Metaprogramming

أقوى إرث للغة Lisp في لغة Julia هو دعمها للبرمجة الميتا. مثل Lisp، تمثل Julia كودها الخاص كهيكل بيانات من اللغة نفسها. نظرًا لأن الكود يتم تمثيله بواسطة كائنات يمكن إنشاؤها والتلاعب بها من داخل اللغة، فمن الممكن لبرنامج ما أن يحول ويولد كوده الخاص. وهذا يسمح بتوليد كود متقدم دون خطوات بناء إضافية، كما يسمح أيضًا باستخدام ماكروهات على نمط Lisp تعمل على مستوى abstract syntax trees. بالمقابل، تقوم أنظمة "الماكرو" في المعالج المسبق، مثل تلك الموجودة في C وC++، بإجراء تلاعب نصي واستبدال قبل حدوث أي تحليل أو تفسير فعلي. نظرًا لأن جميع أنواع البيانات والكود في Julia ممثلة بهياكل بيانات Julia، فإن قدرات reflection القوية متاحة لاستكشاف تفاصيل البرنامج وأنواعه تمامًا مثل أي بيانات أخرى.

Warning

البرمجة الميتا هي أداة قوية، لكنها تقدم تعقيدًا يمكن أن يجعل الكود أكثر صعوبة في الفهم. على سبيل المثال، قد يكون من الصعب بشكل مدهش الحصول على قواعد النطاق بشكل صحيح. يجب استخدام البرمجة الميتا عادةً فقط عندما لا يمكن تطبيق أساليب أخرى مثل higher order functions و closures.

يجب استخدام eval وتعريف الماكرو الجديدة عادة كملاذ أخير. من النادر أن تكون فكرة جيدة استخدام Meta.parse أو تحويل سلسلة عشوائية إلى كود جوليا. للتلاعب بكود جوليا، استخدم بنية البيانات Expr مباشرة لتجنب تعقيد كيفية تحليل بناء جملة جوليا.

أفضل استخدامات البرمجة الميتا غالبًا ما تنفذ معظم وظائفها في دوال المساعدة في وقت التشغيل، ساعيةً لتقليل كمية الشيفرة التي تولدها.

Program representation

تبدأ كل برنامج جوليا كخيط:

julia> prog = "1 + 1"
"1 + 1"

ماذا يحدث بعد ذلك؟

الخطوة التالية هي parse تحويل كل سلسلة إلى كائن يسمى تعبير، ممثلاً بنوع جوليا Expr:

julia> ex1 = Meta.parse(prog)
:(1 + 1)

julia> typeof(ex1)
Expr

تحتوي كائنات Expr على جزئين:

  • a Symbol تحديد نوع التعبير. الرمز هو interned string معرف (مزيد من المناقشة أدناه).
julia> ex1.head
:call
  • وسائط التعبير، التي قد تكون رموزًا، أو تعبيرات أخرى، أو قيم حرفية:
julia> ex1.args
3-element Vector{Any}:
  :+
 1
 1

يمكن أيضًا بناء التعبيرات مباشرة في prefix notation:

julia> ex2 = Expr(:call, :+, 1, 1)
:(1 + 1)

التعبيران اللذان تم إنشاؤهما أعلاه - من خلال التحليل ومن خلال البناء المباشر - متساويان:

julia> ex1 == ex2
true

النقطة الرئيسية هنا هي أن كود جوليا يتم تمثيله داخليًا كهيكل بيانات يمكن الوصول إليه من اللغة نفسها.

تقوم دالة dump بتوفير عرض متداخل ومشروح لكائنات Expr:

julia> dump(ex2)
Expr
  head: Symbol call
  args: Array{Any}((3,))
    1: Symbol +
    2: Int64 1
    3: Int64 1

يمكن أيضًا تداخل كائنات Expr:

julia> ex3 = Meta.parse("(4 + 4) / 2")
:((4 + 4) / 2)

طريقة أخرى لعرض التعبيرات هي باستخدام Meta.show_sexpr، والذي يعرض الشكل S-expression لتعبير معين Expr، والذي قد يبدو مألوفًا جدًا لمستخدمي Lisp. إليك مثال يوضح العرض على تعبير متداخل Expr:

julia> Meta.show_sexpr(ex3)
(:call, :/, (:call, :+, 4, 4), 2)

Symbols

يحتوي حرف : على غرضين نحويين في جوليا. الشكل الأول ينشئ Symbol، وهو interned string يُستخدم كأحد اللبنات الأساسية للتعبيرات، من معرفات صالحة:

julia> s = :foo
:foo

julia> typeof(s)
Symbol

يأخذ المُنشئ Symbol أي عدد من الوسائط وينشئ رمزًا جديدًا عن طريق دمج تمثيلات السلسلة الخاصة بهم معًا:

julia> :foo === Symbol("foo")
true

julia> Symbol("1foo") # `:1foo` would not work, as `1foo` is not a valid identifier
Symbol("1foo")

julia> Symbol("func",10)
:func10

julia> Symbol(:var,'_',"sym")
:var_sym

في سياق تعبير، تُستخدم الرموز للإشارة إلى الوصول إلى المتغيرات؛ عندما يتم تقييم تعبير، يتم استبدال رمز بالقيمة المرتبطة بتلك الرمز في scope المناسبة.

أحيانًا تكون الأقواس الإضافية حول الوسيطة لـ : ضرورية لتجنب الغموض في التحليل:

julia> :(:)
:(:)

julia> :(::)
:(::)

Expressions and evaluation

Quoting

الغرض النحوي الثاني من حرف : هو إنشاء كائنات تعبيرية دون استخدام المُنشئ الصريح Expr. يُشار إلى ذلك باسم الاقتباس. حرف :, متبوعًا بأقواس متزاوجة حول عبارة واحدة من كود جوليا، ينتج كائن Expr بناءً على الكود المحاط. إليك مثال على الشكل القصير المستخدم لاقتباس تعبير رياضي:

julia> ex = :(a+b*c+1)
:(a + b * c + 1)

julia> typeof(ex)
Expr

(لعرض هيكل هذا التعبير، حاول ex.head و ex.args، أو استخدم dump كما هو أعلاه أو Meta.@dump)

لاحظ أنه يمكن بناء تعبيرات مكافئة باستخدام Meta.parse أو الشكل المباشر Expr:

julia>      :(a + b*c + 1)       ==
       Meta.parse("a + b*c + 1") ==
       Expr(:call, :+, :a, Expr(:call, :*, :b, :c), 1)
true

تعبيرات التي يوفرها المحلل عادةً ما تحتوي فقط على رموز، وتعبيرات أخرى، وقيم حرفية كوسائط لها، بينما التعبيرات التي يتم إنشاؤها بواسطة كود جوليا يمكن أن تحتوي على قيم زمن التشغيل عشوائية بدون أشكال حرفية كوسائط. في هذا المثال المحدد، + و a هما رموز، و *(b,c) هو تعبير فرعي، و 1 هو عدد صحيح موقع 64 بت حرفي.

هناك شكل ثانٍ نحوي للاقتباس لعدة تعبيرات: كتل من الشيفرة محاطة بـ quote ... end.

julia> ex = quote
           x = 1
           y = 2
           x + y
       end
quote
    #= none:2 =#
    x = 1
    #= none:3 =#
    y = 2
    #= none:4 =#
    x + y
end

julia> typeof(ex)
Expr

Interpolation

البناء المباشر لكائنات Expr مع وسائط القيم قوي، لكن مُنشئات Expr يمكن أن تكون مملة مقارنةً بصياغة جوليا "العادية". كبديل، تسمح جوليا بتداخل الثوابت أو التعبيرات في التعبيرات المقتبسة. يتم الإشارة إلى التداخل بواسطة بادئة $.

في هذا المثال، يتم إدراج قيمة المتغير a:

julia> a = 1;

julia> ex = :($a + b)
:(1 + b)

لا يُدعم إدراج تعبير غير مُقتبس وسيتسبب في حدوث خطأ في وقت الترجمة:

julia> $a + b
ERROR: syntax: "$" expression outside quote

في هذا المثال، يتم إدخال الزوج المرتب (1,2,3) كتعبير في اختبار شرطي:

julia> ex = :(a in $:((1,2,3)) )
:(a in (1, 2, 3))

استخدام $ للتداخل التعبيري هو عن عمد تذكير بـ string interpolation و command interpolation. يسمح التداخل التعبيري بإنشاء تعبيرات جوليا المعقدة بطريقة مريحة وقابلة للقراءة.

Splatting interpolation

لاحظ أن بناء جملة الاستبدال $ يسمح بإدراج تعبير واحد فقط في تعبير محيط. في بعض الأحيان، لديك مصفوفة من التعبيرات وتحتاج إلى أن تصبح جميعها وسائط للتعبير المحيط. يمكن القيام بذلك باستخدام بناء الجملة $(xs...). على سبيل المثال، الكود التالي يولد استدعاء دالة حيث يتم تحديد عدد الوسائط برمجياً:

julia> args = [:x, :y, :z];

julia> :(f(1, $(args...)))
:(f(1, x, y, z))

Nested quote

من الطبيعي أن تحتوي تعبيرات الاقتباس على تعبيرات اقتباس أخرى. يمكن أن يكون فهم كيفية عمل الاستبدال في هذه الحالات أمرًا معقدًا بعض الشيء. اعتبر هذا المثال:

julia> x = :(1 + 2);

julia> e = quote quote $x end end
quote
    #= none:1 =#
    $(Expr(:quote, quote
    #= none:1 =#
    $(Expr(:$, :x))
end))
end

لاحظ أن النتيجة تحتوي على $x، مما يعني أن x لم يتم تقييمه بعد. بعبارة أخرى، فإن تعبير $ "ينتمي إلى" تعبير الاقتباس الداخلي، وبالتالي يتم تقييم حجته فقط عندما يكون تعبير الاقتباس الداخلي هو:

julia> eval(e)
quote
    #= none:1 =#
    1 + 2
end

ومع ذلك، فإن تعبير quote الخارجي قادر على إدراج القيم داخل $ في الاقتباس الداخلي. يتم ذلك باستخدام عدة $s:

julia> e = quote quote $$x end end
quote
    #= none:1 =#
    $(Expr(:quote, quote
    #= none:1 =#
    $(Expr(:$, :(1 + 2)))
end))
end

لاحظ أن (1 + 2) يظهر الآن في النتيجة بدلاً من الرمز x. تقييم هذه التعبير يعطي 3 متداخلة:

julia> eval(e)
quote
    #= none:1 =#
    3
end

الحدس وراء هذا السلوك هو أن x يتم تقييمه مرة واحدة لكل $: يعمل $ واحد بشكل مشابه لـ eval(:x)، مما يعطي قيمة x، بينما يعمل اثنان من $ كمعادل لـ eval(eval(:x)).

QuoteNode

التعبير المعتاد لشكل quote في شجرة التحليل (AST) هو Expr مع رأس :quote:

julia> dump(Meta.parse(":(1+2)"))
Expr
  head: Symbol quote
  args: Array{Any}((1,))
    1: Expr
      head: Symbol call
      args: Array{Any}((3,))
        1: Symbol +
        2: Int64 1
        3: Int64 2

كما رأينا، تدعم مثل هذه التعبيرات الاستبدال باستخدام $. ومع ذلك، في بعض الحالات، من الضروري اقتباس الكود دون إجراء الاستبدال. هذا النوع من الاقتباس لا يزال ليس له بناء جملة، ولكنه يتم تمثيله داخليًا ككائن من نوع QuoteNode:

julia> eval(Meta.quot(Expr(:$, :(1+2))))
3

julia> eval(QuoteNode(Expr(:$, :(1+2))))
:($(Expr(:$, :(1 + 2))))

يولد المحلل QuoteNodes للعناصر المقتبسة البسيطة مثل الرموز:

julia> dump(Meta.parse(":x"))
QuoteNode
  value: Symbol x

يمكن أيضًا استخدام QuoteNode لبعض مهام البرمجة الميتا المتقدمة.

Evaluating expressions

نظرًا لوجود كائن تعبير، يمكن للمرء أن يجعل جوليا تقيم (تنفذ) ذلك في النطاق العالمي باستخدام eval:

julia> ex1 = :(1 + 2)
:(1 + 2)

julia> eval(ex1)
3

julia> ex = :(a + b)
:(a + b)

julia> eval(ex)
ERROR: UndefVarError: `b` not defined in `Main`
[...]

julia> a = 1; b = 2;

julia> eval(ex)
3

كل module له دالة eval الخاصة به التي تقيم التعبيرات في نطاقها العالمي. التعبيرات المرسلة إلى 4d61726b646f776e2e436f64652822222c20226576616c2229_40726566 ليست محدودة بإرجاع القيم - يمكن أن يكون لها أيضًا آثار جانبية تغير حالة بيئة الوحدة المحيطة:

julia> ex = :(x = 1)
:(x = 1)

julia> x
ERROR: UndefVarError: `x` not defined in `Main`

julia> eval(ex)
1

julia> x
1

هنا، يؤدي تقييم كائن التعبير إلى تعيين قيمة للمتغير العالمي x.

نظرًا لأن التعبيرات هي مجرد كائنات Expr يمكن إنشاؤها برمجيًا ثم تقييمها، فمن الممكن إنشاء كود عشوائي ديناميكيًا يمكن تشغيله باستخدام eval. إليك مثال بسيط:

julia> a = 1;

julia> ex = Expr(:call, :+, a, :b)
:(1 + b)

julia> a = 0; b = 2;

julia> eval(ex)
3

تُستخدم قيمة a لبناء التعبير ex الذي يطبق دالة + على القيمة 1 والمتغير b. لاحظ التمييز المهم بين الطريقة التي تُستخدم بها a و b:

  • تُستخدم قيمة المتغير a في وقت إنشاء التعبير كقيمة فورية في التعبير. وبالتالي، فإن قيمة a عند تقييم التعبير لم تعد مهمة: القيمة في التعبير هي بالفعل 1، بغض النظر عن أي قيمة قد تكون لـ a.
  • من ناحية أخرى، يتم استخدام الرمز :b في بناء التعبير، لذا فإن قيمة المتغير b في ذلك الوقت غير ذات صلة - :b هو مجرد رمز ولا يحتاج المتغير b حتى أن يكون معرفًا. ومع ذلك، في وقت تقييم التعبير، يتم حل قيمة الرمز :b من خلال البحث عن قيمة المتغير b.

Functions on Expressions

كما تم الإشارة أعلاه، فإن إحدى الميزات المفيدة للغاية في جوليا هي القدرة على توليد والتلاعب بشيفرة جوليا داخل جوليا نفسها. لقد رأينا بالفعل مثالاً واحدًا على دالة تعيد Expr كائنات: دالة Meta.parse، التي تأخذ سلسلة من شيفرة جوليا وتعيد الـ Expr المقابل. يمكن أن تأخذ الدالة أيضًا واحدًا أو أكثر من كائنات Expr كوسائط، وتعيد Expr آخر. إليك مثال بسيط ومحفز:

julia> function math_expr(op, op1, op2)
           expr = Expr(:call, op, op1, op2)
           return expr
       end
math_expr (generic function with 1 method)

julia>  ex = math_expr(:+, 1, Expr(:call, :*, 4, 5))
:(1 + 4 * 5)

julia> eval(ex)
21

كمثال آخر، هنا دالة تضاعف أي وسيط رقمي، لكنها تترك التعبيرات كما هي:

julia> function make_expr2(op, opr1, opr2)
           opr1f, opr2f = map(x -> isa(x, Number) ? 2*x : x, (opr1, opr2))
           retexpr = Expr(:call, op, opr1f, opr2f)
           return retexpr
       end
make_expr2 (generic function with 1 method)

julia> make_expr2(:+, 1, 2)
:(2 + 4)

julia> ex = make_expr2(:+, 1, Expr(:call, :*, 5, 8))
:(2 + 5 * 8)

julia> eval(ex)
42

Macros

توفر الماكروز آلية لتضمين الشيفرة المولدة في الجسم النهائي لبرنامج. يقوم الماكرو بربط مجموعة من المعاملات بتعبير مرتجع، ويتم تجميع التعبير الناتج مباشرة بدلاً من الحاجة إلى استدعاء وقت التشغيل eval. قد تشمل معاملات الماكرو تعبيرات، قيم حرفية، ورموز.

Basics

هنا ماكرو بسيط للغاية:

julia> macro sayhello()
           return :( println("Hello, world!") )
       end
@sayhello (macro with 1 method)

تحتوي الماكروز على حرف مخصص في بناء جملة جوليا: @ (علامة @)، تليها الاسم الفريد المعلن عنه في كتلة macro NAME ... end. في هذا المثال، سيقوم المترجم باستبدال جميع حالات @sayhello بـ:

:( println("Hello, world!") )

عند إدخال @sayhello في REPL، يتم تنفيذ التعبير على الفور، وبالتالي نرى فقط نتيجة التقييم:

julia> @sayhello()
Hello, world!

الآن، اعتبر ماكرو أكثر تعقيدًا قليلاً:

julia> macro sayhello(name)
           return :( println("Hello, ", $name) )
       end
@sayhello (macro with 1 method)

تأخذ هذه الماكرو حجة واحدة: name. عندما يتم العثور على @sayhello، يتم توسيع التعبير المقتبس ليتضمن قيمة الحجة في التعبير النهائي:

julia> @sayhello("human")
Hello, human

يمكننا عرض تعبير الإرجاع المقتبس باستخدام الدالة macroexpand (ملاحظة مهمة: هذه أداة مفيدة للغاية لتصحيح أخطاء الماكرو):

julia> ex = macroexpand(Main, :(@sayhello("human")) )
:(Main.println("Hello, ", "human"))

julia> typeof(ex)
Expr

يمكننا أن نرى أن النص "human" قد تم إدخاله في التعبير.

يوجد أيضًا ماكرو @macroexpand الذي قد يكون أكثر ملاءمة من دالة macroexpand:

julia> @macroexpand @sayhello "human"
:(println("Hello, ", "human"))

Hold up: why macros?

لقد رأينا بالفعل دالة f(::Expr...) -> Expr في قسم سابق. في الواقع، macroexpand هي أيضًا مثل هذه الدالة. إذن، لماذا توجد الماكرو؟

تعتبر الماكروز ضرورية لأنها تنفذ عند تحليل الشيفرة، وبالتالي، تتيح للبرمجي توليد وإدراج أجزاء من الشيفرة المخصصة قبل تشغيل البرنامج بالكامل. لتوضيح الفرق، اعتبر المثال التالي:

julia> macro twostep(arg)
           println("I execute at parse time. The argument is: ", arg)
           return :(println("I execute at runtime. The argument is: ", $arg))
       end
@twostep (macro with 1 method)

julia> ex = macroexpand(Main, :(@twostep :(1, 2, 3)) );
I execute at parse time. The argument is: :((1, 2, 3))

يتم تنفيذ المكالمة الأولى إلى println عندما يتم استدعاء macroexpand. تحتوي التعبير الناتج على فقط println الثاني:

julia> typeof(ex)
Expr

julia> ex
:(println("I execute at runtime. The argument is: ", $(Expr(:copyast, :($(QuoteNode(:((1, 2, 3)))))))))

julia> eval(ex)
I execute at runtime. The argument is: (1, 2, 3)

Macro invocation

يتم استدعاء الماكرو باستخدام الصيغة العامة التالية:

@name expr1 expr2 ...
@name(expr1, expr2, ...)

لاحظ التمييز @ قبل اسم الماكرو وغياب الفواصل بين تعبيرات الوسائط في الشكل الأول، وغياب المسافة البيضاء بعد @name في الشكل الثاني. يجب عدم خلط الأسلوبين. على سبيل المثال، التركيب التالي يختلف عن الأمثلة أعلاه؛ حيث يمرر الزوج (expr1, expr2, ...) كوسيط واحد إلى الماكرو:

@name (expr1, expr2, ...)

طريقة بديلة لاستدعاء ماكرو على مصفوفة حرفية (أو فهم) هي وضع كلاهما بجانب بعضهما دون استخدام الأقواس. في هذه الحالة، ستكون المصفوفة هي التعبير الوحيد المقدم للماكرو. الصياغة التالية متكافئة (ومختلفة عن @name [a b] * v):

@name[a b] * v
@name([a b]) * v

من المهم التأكيد على أن الماكرو يتلقى وسائطه كتعابير أو ثوابت أو رموز. إحدى الطرق لاستكشاف وسائط الماكرو هي استدعاء الدالة show داخل جسم الماكرو:

julia> macro showarg(x)
           show(x)
           # ... remainder of macro, returning an expression
       end
@showarg (macro with 1 method)

julia> @showarg(a)
:a

julia> @showarg(1+1)
:(1 + 1)

julia> @showarg(println("Yo!"))
:(println("Yo!"))

julia> @showarg(1)        # Numeric literal
1

julia> @showarg("Yo!")    # String literal
"Yo!"

julia> @showarg("Yo! $("hello")")    # String with interpolation is an Expr rather than a String
:("Yo! $("hello")")

بالإضافة إلى قائمة الحجج المعطاة، يتم تمرير كل ماكرو مع حجج إضافية تُسمى __source__ و __module__.

الحجة __source__ توفر معلومات (على شكل كائن LineNumberNode) حول موقع محلل الشيفرة لعلامة @ من استدعاء الماكرو. هذا يسمح للماكرو بتضمين معلومات تشخيص أخطاء أفضل، وغالبًا ما يستخدمه تسجيل الدخول، وماكرو تحليل السلاسل، والوثائق، على سبيل المثال، بالإضافة إلى تنفيذ الماكرو @__LINE__، @__FILE__، و @__DIR__.

يمكن الوصول إلى معلومات الموقع من خلال الإشارة إلى __source__.line و __source__.file:

julia> macro __LOCATION__(); return QuoteNode(__source__); end
@__LOCATION__ (macro with 1 method)

julia> dump(
            @__LOCATION__(
       ))
LineNumberNode
  line: Int64 2
  file: Symbol none

الحجة __module__ توفر معلومات (في شكل كائن Module) حول سياق توسيع استدعاء الماكرو. هذا يسمح للماكرو بالبحث عن معلومات سياقية، مثل الربط الموجود، أو لإدراج القيمة كحجة إضافية لاستدعاء دالة في وقت التشغيل تقوم بالتأمل الذاتي في الوحدة الحالية.

Building an advanced macro

هنا تعريف مبسط لماكرو @assert في جوليا:

julia> macro assert(ex)
           return :( $ex ? nothing : throw(AssertionError($(string(ex)))) )
       end
@assert (macro with 1 method)

يمكن استخدام هذا الماكرو بهذه الطريقة:

julia> @assert 1 == 1.0

julia> @assert 1 == 0
ERROR: AssertionError: 1 == 0

بدلاً من بناء الجملة المكتوبة، يتم توسيع استدعاء الماكرو في وقت التحليل إلى نتائجه المرجعة. هذا يعادل كتابة:

1 == 1.0 ? nothing : throw(AssertionError("1 == 1.0"))
1 == 0 ? nothing : throw(AssertionError("1 == 0"))

هذا يعني أنه في الاستدعاء الأول، يتم إدخال التعبير :(1 == 1.0) في مكان شرط الاختبار، بينما يتم إدخال قيمة string(:(1 == 1.0)) في مكان رسالة التأكيد. يتم وضع التعبير بالكامل، الذي تم بناؤه بهذه الطريقة، في شجرة التركيب حيث يحدث استدعاء الماكرو @assert. ثم في وقت التنفيذ، إذا كانت نتيجة تعبير الاختبار صحيحة، يتم إرجاع nothing، بينما إذا كان الاختبار خاطئًا، يتم رفع خطأ يشير إلى التعبير الذي تم التأكيد عليه والذي كان خاطئًا. لاحظ أنه لن يكون من الممكن كتابة هذا كدالة، حيث أن قيمة الشرط فقط متاحة، وسيكون من المستحيل عرض التعبير الذي تم حسابه في رسالة الخطأ.

التعريف الفعلي لـ @assert في قاعدة جوليا أكثر تعقيدًا. يسمح للمستخدم بتحديد رسالة خطأ خاصة به بشكل اختياري، بدلاً من مجرد طباعة التعبير الفاشل. تمامًا كما في الدوال التي تحتوي على عدد متغير من الوسائط (Varargs Functions)، يتم تحديد ذلك باستخدام ثلاث نقاط بعد الوسيط الأخير:

julia> macro assert(ex, msgs...)
           msg_body = isempty(msgs) ? ex : msgs[1]
           msg = string(msg_body)
           return :($ex ? nothing : throw(AssertionError($msg)))
       end
@assert (macro with 1 method)

الآن @assert لديه وضعين للتشغيل، اعتمادًا على عدد الوسائط التي يتلقاها! إذا كان هناك وسيط واحد فقط، فإن مجموعة التعبيرات الملتقطة بواسطة msgs ستكون فارغة وسيتصرف بنفس الطريقة كما في التعريف الأبسط أعلاه. ولكن الآن إذا حدد المستخدم وسيطًا ثانيًا، فسيتم طباعته في نص الرسالة بدلاً من التعبير الفاشل. يمكنك فحص نتيجة توسيع الماكرو باستخدام الماكرو المسمى بشكل مناسب @macroexpand:

julia> @macroexpand @assert a == b
:(if Main.a == Main.b
        Main.nothing
    else
        Main.throw(Main.AssertionError("a == b"))
    end)

julia> @macroexpand @assert a==b "a should equal b!"
:(if Main.a == Main.b
        Main.nothing
    else
        Main.throw(Main.AssertionError("a should equal b!"))
    end)

هناك حالة أخرى يتعامل معها ماكرو @assert: ماذا لو، بالإضافة إلى طباعة "a يجب أن تساوي b"، أردنا طباعة قيمهما؟ قد يحاول المرء بشكل ساذج استخدام تداخل السلاسل في الرسالة المخصصة، على سبيل المثال، @assert a==b "a ($a) should equal b ($b)!"، لكن هذا لن يعمل كما هو متوقع مع الماكرو أعلاه. هل يمكنك أن ترى لماذا؟ تذكر من string interpolation أن سلسلة متداخلة يتم إعادة كتابتها إلى استدعاء لـ string. قارن:

julia> typeof(:("a should equal b"))
String

julia> typeof(:("a ($a) should equal b ($b)!"))
Expr

julia> dump(:("a ($a) should equal b ($b)!"))
Expr
  head: Symbol string
  args: Array{Any}((5,))
    1: String "a ("
    2: Symbol a
    3: String ") should equal b ("
    4: Symbol b
    5: String ")!"

لذا الآن بدلاً من الحصول على سلسلة بسيطة في msg_body، فإن الماكرو يتلقى تعبيرًا كاملًا يحتاج إلى التقييم من أجل العرض كما هو متوقع. يمكن دمجه مباشرة في التعبير المعاد كوسيط إلى استدعاء string؛ انظر error.jl للحصول على التنفيذ الكامل.

تستخدم ماكرو @assert بشكل كبير التقطيع في التعبيرات المقتبسة لتبسيط معالجة التعبيرات داخل جسم الماكرو.

Hygiene

تظهر مشكلة في الماكروهات الأكثر تعقيدًا وهي hygiene. باختصار، يجب على الماكروهات التأكد من أن المتغيرات التي تقدمها في تعبيراتها المعادة لا تتعارض عن غير قصد مع المتغيرات الموجودة في الكود المحيط الذي تتوسع فيه. على العكس، فإن التعبيرات التي يتم تمريرها إلى الماكرو كوسائط غالبًا ما يُتوقع أن تُقيّم في سياق الكود المحيط، متفاعلة مع المتغيرات الموجودة وتعديلها. تظهر مشكلة أخرى من حقيقة أن الماكرو قد يتم استدعاؤه في وحدة مختلفة عن تلك التي تم تعريفه فيها. في هذه الحالة، نحتاج إلى التأكد من أن جميع المتغيرات العالمية يتم حلها إلى الوحدة الصحيحة. تتمتع جوليا بالفعل بميزة كبيرة على اللغات التي تستخدم توسيع الماكرو النصي (مثل C) لأنها تحتاج فقط إلى النظر في التعبير المعاد. جميع المتغيرات الأخرى (مثل msg في @assert أعلاه) تتبع normal scoping block behavior.

لإظهار هذه القضايا، دعنا نفكر في كتابة ماكرو @time الذي يأخذ تعبيرًا كوسيط له، يسجل الوقت، يقيم التعبير، يسجل الوقت مرة أخرى، يطبع الفرق بين الأوقات السابقة واللاحقة، ثم يكون له قيمة التعبير كقيمته النهائية. قد يبدو الماكرو كما يلي:

macro time(ex)
    return quote
        local t0 = time_ns()
        local val = $ex
        local t1 = time_ns()
        println("elapsed time: ", (t1-t0)/1e9, " seconds")
        val
    end
end

هنا، نريد أن تكون t0 و t1 و val متغيرات مؤقتة خاصة، ونريد أن تشير time_ns إلى دالة time_ns في قاعدة جوليا، وليس إلى أي متغير time_ns قد يمتلكه المستخدم (ينطبق نفس الشيء على println). تخيل المشاكل التي قد تحدث إذا كانت تعبير المستخدم ex يحتوي أيضًا على تعيينات لمتغير يسمى t0، أو عرف متغيره الخاص time_ns. قد نحصل على أخطاء، أو سلوك غير صحيح بشكل غامض.

يحل موسع الماكرو في جوليا هذه المشكلات بالطريقة التالية. أولاً، يتم تصنيف المتغيرات داخل نتيجة الماكرو على أنها محلية أو عالمية. يُعتبر المتغير محليًا إذا تم تعيينه (ولم يتم الإعلان عنه كعالمي)، أو تم الإعلان عنه كمتغير محلي، أو تم استخدامه كاسم لوسيط دالة. بخلاف ذلك، يُعتبر عالميًا. ثم يتم إعادة تسمية المتغيرات المحلية لتكون فريدة (باستخدام دالة gensym، التي تولد رموزًا جديدة)، ويتم حل المتغيرات العالمية ضمن بيئة تعريف الماكرو. لذلك، يتم التعامل مع كلا القلقين أعلاه؛ لن تتعارض المتغيرات المحلية للماكرو مع أي متغيرات للمستخدم، وtime_ns وprintln ستشير إلى تعريفات جوليا الأساسية.

ومع ذلك، تبقى مشكلة واحدة. اعتبر الاستخدام التالي لهذه الماكرو:

module MyModule
import Base.@time

time_ns() = ... # compute something

@time time_ns()
end

هنا تعبير المستخدم ex هو استدعاء لـ time_ns، لكنه ليس نفس دالة time_ns التي تستخدمها الماكرو. إنه يشير بوضوح إلى MyModule.time_ns. لذلك يجب علينا ترتيب الأمر بحيث يتم حل الكود في ex في بيئة استدعاء الماكرو. يتم ذلك عن طريق "الهروب" من التعبير باستخدام esc:

macro time(ex)
    ...
    local val = $(esc(ex))
    ...
end

تُترك التعبيرات المغلفة بهذه الطريقة دون تغيير بواسطة موسع الماكرو وتُلصق ببساطة في المخرجات كما هي. لذلك سيتم حلها في بيئة استدعاء الماكرو.

يمكن استخدام آلية الهروب هذه "لانتهاك" النظافة عند الضرورة، من أجل إدخال أو تعديل متغيرات المستخدم. على سبيل المثال، تقوم الماكرو التالية بتعيين x إلى صفر في بيئة الاستدعاء:

julia> macro zerox()
           return esc(:(x = 0))
       end
@zerox (macro with 1 method)

julia> function foo()
           x = 1
           @zerox
           return x # is zero
       end
foo (generic function with 1 method)

julia> foo()
0

يجب استخدام هذا النوع من التلاعب بالمتغيرات بحذر، ولكنه في بعض الأحيان يكون مفيدًا جدًا.

يمكن أن يكون الحصول على قواعد النظافة الصحيحة تحديًا هائلًا. قبل استخدام ماكرو، قد ترغب في التفكير فيما إذا كانت إغلاق الدالة سيكون كافيًا. استراتيجية مفيدة أخرى هي تأجيل أكبر قدر ممكن من العمل إلى وقت التشغيل. على سبيل المثال، العديد من الماكرو ببساطة تغلف حججها في QuoteNode أو Expr مشابهة أخرى. تشمل بعض الأمثلة على ذلك @task body الذي يعيد ببساطة schedule(Task(() -> $body))، و @eval expr، الذي يعيد ببساطة eval(QuoteNode(expr)).

لإظهار ذلك، يمكننا إعادة كتابة مثال @time أعلاه كالتالي:

macro time(expr)
    return :(timeit(() -> $(esc(expr))))
end
function timeit(f)
    t0 = time_ns()
    val = f()
    t1 = time_ns()
    println("elapsed time: ", (t1-t0)/1e9, " seconds")
    return val
end

ومع ذلك، لا نقوم بذلك لسبب وجيه: تغليف expr في كتلة نطاق جديدة (الدالة المجهولة) يغير أيضًا قليلاً معنى التعبير (نطاق أي متغيرات فيه)، بينما نريد أن يكون @time قابلاً للاستخدام مع الحد الأدنى من التأثير على الكود المغلف.

Macros and dispatch

الماكروز، تمامًا مثل دوال جوليا، هي عامة. هذا يعني أنه يمكن أن يكون لها أيضًا تعريفات طرق متعددة، بفضل التوزيع المتعدد:

julia> macro m end
@m (macro with 0 methods)

julia> macro m(args...)
           println("$(length(args)) arguments")
       end
@m (macro with 1 method)

julia> macro m(x,y)
           println("Two arguments")
       end
@m (macro with 2 methods)

julia> @m "asd"
1 arguments

julia> @m 1 2
Two arguments

ومع ذلك، يجب أن نأخذ في الاعتبار أن توجيه الماكرو يعتمد على أنواع AST التي تُعطى للماكرو، وليس على الأنواع التي تقيمها AST في وقت التشغيل:

julia> macro m(::Int)
           println("An Integer")
       end
@m (macro with 3 methods)

julia> @m 2
An Integer

julia> x = 2
2

julia> @m x
1 arguments

Code Generation

عندما يتطلب الأمر كمية كبيرة من كود boilerplate المتكرر، من الشائع توليده برمجيًا لتجنب التكرار. في معظم اللغات، يتطلب ذلك خطوة بناء إضافية، وبرنامجًا منفصلًا لتوليد الكود المتكرر. في جوليا، يسمح دمج التعبيرات و eval بتوليد مثل هذا الكود في السياق العادي لتنفيذ البرنامج. على سبيل المثال، اعتبر النوع المخصص التالي

struct MyNumber
    x::Float64
end
# output

لإضافة عدد من الطرق لذلك. يمكننا القيام بذلك برمجياً في الحلقة التالية:

for op = (:sin, :cos, :tan, :log, :exp)
    eval(quote
        Base.$op(a::MyNumber) = MyNumber($op(a.x))
    end)
end
# output

ويمكننا الآن استخدام تلك الدوال مع نوعنا المخصص:

julia> x = MyNumber(π)
MyNumber(3.141592653589793)

julia> sin(x)
MyNumber(1.2246467991473532e-16)

julia> cos(x)
MyNumber(-1.0)

به این ترتیب، جولیا به عنوان preprocessor عمل می‌کند و اجازه تولید کد از داخل زبان را می‌دهد. کد فوق می‌تواند به طور کمی مختصرتر با استفاده از فرم نقل قول پیشوند : نوشته شود:

for op = (:sin, :cos, :tan, :log, :exp)
    eval(:(Base.$op(a::MyNumber) = MyNumber($op(a.x))))
end

هذا النوع من توليد الشيفرة داخل اللغة، ومع ذلك، باستخدام نمط eval(quote(...))، شائع بما فيه الكفاية بحيث تأتي جوليا مع ماكرو لتبسيط هذا النمط:

for op = (:sin, :cos, :tan, :log, :exp)
    @eval Base.$op(a::MyNumber) = MyNumber($op(a.x))
end

الماكرو @eval يعيد كتابة هذا الاستدعاء ليكون متطابقًا تمامًا مع النسخ الأطول المذكورة أعلاه. بالنسبة لكتل الشيفرة المولدة الأطول، يمكن أن يكون الوسيط التعبيري المقدم إلى 4d61726b646f776e2e436f64652822222c2022406576616c2229_40726566 كتلة:

@eval begin
    # multiple lines
end

Non-Standard String Literals

تذكر من Strings أن السلاسل النصية التي تسبقها معرف تُسمى سلاسل نصية غير قياسية، ويمكن أن يكون لها دلالات مختلفة عن السلاسل النصية غير المسبوقة. على سبيل المثال:

ربما من المفاجئ أن هذه السلوكيات ليست مشفرة بشكل ثابت في محلل جوليا أو المترجم. بدلاً من ذلك، فهي سلوكيات مخصصة مقدمة من خلال آلية عامة يمكن لأي شخص استخدامها: يتم تحليل السلاسل النصية المسبوقة كاستدعاءات لماكروهات ذات أسماء خاصة. على سبيل المثال، ماكرو التعبير العادي هو مجرد ما يلي:

macro r_str(p)
    Regex(p)
end

هذا كل شيء. تقول هذه الماكرو أن المحتويات الحرفية لسلسلة النص الحرفية r"^\s*(?:#|$)" يجب أن تُمرر إلى الماكرو @r_str ويجب وضع نتيجة هذا التوسع في شجرة التركيب حيث تحدث سلسلة النص. بعبارة أخرى، فإن التعبير r"^\s*(?:#|$)" يعادل وضع الكائن التالي مباشرة في شجرة التركيب:

Regex("^\\s*(?:#|\$)")

ليس فقط أن شكل السلسلة النصية أقصر وأكثر ملاءمة، ولكنه أيضًا أكثر كفاءة: نظرًا لأن التعبير العادي يتم تجميعه و يتم إنشاء كائن Regex فعليًا عندما يتم تجميع الكود، فإن التجميع يحدث مرة واحدة فقط، بدلاً من كل مرة يتم فيها تنفيذ الكود. اعتبر إذا كان التعبير العادي يحدث في حلقة:

for line = lines
    m = match(r"^\s*(?:#|$)", line)
    if m === nothing
        # non-comment
    else
        # comment
    end
end

نظرًا لأن التعبير النمطي r"^\s*(?:#|$)" يتم تجميعه وإدراجه في شجرة التركيب عند تحليل هذا الرمز، فإن التعبير يتم تجميعه مرة واحدة فقط بدلاً من كل مرة يتم فيها تنفيذ الحلقة. من أجل تحقيق ذلك دون استخدام الماكرو، سيتعين على المرء كتابة هذه الحلقة على النحو التالي:

re = Regex("^\\s*(?:#|\$)")
for line = lines
    m = match(re, line)
    if m === nothing
        # non-comment
    else
        # comment
    end
end

علاوة على ذلك، إذا لم يتمكن المترجم من تحديد أن كائن التعبير العادي كان ثابتًا على مدار جميع الحلقات، فقد لا تكون بعض التحسينات ممكنة، مما يجعل هذه النسخة أقل كفاءة من الشكل الأدبي الأكثر ملاءمة أعلاه. بالطبع، لا تزال هناك حالات يكون فيها الشكل غير الأدبي أكثر ملاءمة: إذا كان المرء بحاجة إلى إدخال متغير في التعبير العادي، يجب اتخاذ هذا النهج الأكثر تفصيلاً؛ في الحالات التي يكون فيها نمط التعبير العادي نفسه ديناميكيًا، ويتغير محتملًا في كل تكرار حلقة، يجب إنشاء كائن تعبير عادي جديد في كل تكرار. ومع ذلك، في الغالبية العظمى من حالات الاستخدام، لا يتم إنشاء التعبيرات العادية بناءً على بيانات وقت التشغيل. في هذه الغالبية من الحالات، فإن القدرة على كتابة التعبيرات العادية كقيم وقت الترجمة لا تقدر بثمن.

آلية الأدب النصي المحدد من قبل المستخدم قوية بشكل عميق وعميق. ليس فقط أن الأدب غير القياسي في جوليا يتم تنفيذه باستخدامه، ولكن أيضًا يتم تنفيذ بناء جملة الأدب الأمر (`echo "Hello, $person"`) باستخدام الماكرو الذي يبدو غير ضار التالي:

macro cmd(str)
    :(cmd_gen($(shell_parse(str)[1])))
end

بالطبع، هناك كمية كبيرة من التعقيد مخفية في الدوال المستخدمة في تعريف هذا الماكرو، لكنها مجرد دوال، مكتوبة بالكامل بلغة جوليا. يمكنك قراءة مصدرها ورؤية ما تفعله بالضبط - وكل ما تفعله هو بناء كائنات تعبير ليتم إدراجها في شجرة بناء جملة برنامجك.

مثل السلاسل النصية، يمكن أيضًا تمييز الأوامر النصية بواسطة معرف لتشكيل ما يسمى بالأوامر النصية غير القياسية. يتم تحليل هذه الأوامر النصية كاستدعاءات لماكروهات ذات أسماء خاصة. على سبيل المثال، يتم تحليل الصيغة custom`literal` كـ @custom_cmd "literal". لا تحتوي جوليا نفسها على أي أوامر نصية غير قياسية، ولكن يمكن للحزم الاستفادة من هذه الصيغة. بخلاف الصيغة المختلفة واللاحقة _cmd بدلاً من اللاحقة _str، تتصرف الأوامر النصية غير القياسية تمامًا مثل السلاسل النصية غير القياسية.

في حالة تقديم وحدتين لرموز نصية أو أوامر غير قياسية بنفس الاسم، من الممكن تأهيل الرمز النصي أو الأمر باسم الوحدة. على سبيل المثال، إذا كانت كل من Foo و Bar تقدم رموز نصية غير قياسية @x_str، فيمكن كتابة Foo.x"literal" أو Bar.x"literal" لتفريق بين الاثنين.

طريقة أخرى لتعريف ماكرو ستكون كالتالي:

macro foo_str(str, flag)
    # do stuff
end

يمكن استدعاء هذا الماكرو بعد ذلك باستخدام الصيغة التالية:

foo"str"flag

نوع العلم في الصياغة المذكورة أعلاه سيكون String بمحتويات أي شيء يتبع السلسلة النصية.

Generated functions

ماكرو خاص جدًا هو @generated، والذي يتيح لك تعريف ما يُعرف بـ الدوال المُولّدة. هذه الدوال لديها القدرة على توليد كود متخصص اعتمادًا على أنواع وسائطها مع مزيد من المرونة و/أو أقل من الكود مما يمكن تحقيقه مع التوزيع المتعدد. بينما تعمل الماكروز مع التعبيرات في وقت التحليل ولا يمكنها الوصول إلى أنواع مدخلاتها، يتم توسيع الدالة المُولّدة في وقت تكون فيه أنواع الوسائط معروفة، ولكن الدالة لم تُجمع بعد.

بدلاً من إجراء بعض الحسابات أو الإجراءات، تعيد عبارة إعلان الوظيفة المولدة تعبيرًا مقتبسًا يشكل بعد ذلك جسم الطريقة المقابلة لأنواع المعاملات. عندما يتم استدعاء وظيفة مولدة، يتم تجميع التعبير الذي تعيده ثم تشغيله. لجعل ذلك فعالًا، يتم عادةً تخزين النتيجة في الذاكرة المؤقتة. ولجعل ذلك قابلًا للاستنتاج، يتم استخدام مجموعة محدودة فقط من اللغة. وبالتالي، توفر الوظائف المولدة وسيلة مرنة لنقل العمل من وقت التشغيل إلى وقت التجميع، على حساب قيود أكبر على البنى المسموح بها.

عند تعريف الدوال المولدة، هناك خمسة اختلافات رئيسية عن الدوال العادية:

  1. تقوم بتعليق إعلان الدالة باستخدام ماكرو @generated. يضيف هذا بعض المعلومات إلى شجرة التحليل المجرد (AST) التي تخبر المترجم أن هذه دالة تم إنشاؤها.
  2. في جسم الدالة المُولَّدة، لديك وصول فقط إلى أنواع المعاملات - وليس إلى قيمها.
  3. بدلاً من حساب شيء ما أو تنفيذ إجراء ما، تعيد عبارة مقتبسة والتي، عند تقييمها، تفعل ما تريده.
  4. تُسمح الوظائف المُولّدة فقط باستدعاء الوظائف التي تم تعريفها قبل تعريف الوظيفة المُولّدة. (قد يؤدي عدم اتباع ذلك إلى الحصول على MethodErrors تشير إلى وظائف من عصر عالمي مستقبلي.)
  5. يجب ألا تقوم الدوال المُولَّدة بـ تغيير أو مراقبة أي حالة عالمية غير ثابتة (بما في ذلك، على سبيل المثال، الإدخال/الإخراج، الأقفال، القواميس غير المحلية، أو استخدام hasmethod). هذا يعني أنه يمكنها فقط قراءة الثوابت العالمية، ولا يمكن أن يكون لها أي آثار جانبية. بعبارة أخرى، يجب أن تكون نقية تمامًا. بسبب قيود التنفيذ، يعني هذا أيضًا أنها لا يمكنها حاليًا تعريف إغلاق أو مولد.

من الأسهل توضيح ذلك بمثال. يمكننا إعلان دالة مولدة foo كـ

julia> @generated function foo(x)
           Core.println(x)
           return :(x * x)
       end
foo (generic function with 1 method)

لاحظ أن الجسم يعيد تعبيرًا مقتبسًا، وهو :(x * x)، بدلاً من مجرد قيمة x * x.

من منظور المتصل، هذا مشابه تمامًا لدالة عادية؛ في الواقع، لا تحتاج إلى معرفة ما إذا كنت تستدعي دالة عادية أو دالة تم إنشاؤها. دعنا نرى كيف تتصرف foo:

julia> x = foo(2); # note: output is from println() statement in the body
Int64

julia> x           # now we print x
4

julia> y = foo("bar");
String

julia> y
"barbar"

لذا، نرى أنه في جسم الدالة المولدة، x هو نوع الوسيطة الممررة، والقيمة التي تعيدها الدالة المولدة هي نتيجة تقييم التعبير المقتبس الذي أعدناه من التعريف، الآن مع قيمة x.

ماذا يحدث إذا قمنا بتقييم foo مرة أخرى مع نوع قد استخدمناه بالفعل؟

julia> foo(4)
16

لاحظ أنه لا يوجد طباعة لـ Int64. يمكننا أن نرى أن جسم الدالة المولدة تم تنفيذه مرة واحدة فقط هنا، لمجموعة محددة من أنواع المعاملات، وتم تخزين النتيجة. بعد ذلك، في هذا المثال، تم إعادة استخدام التعبير الذي تم إرجاعه من الدالة المولدة في الاستدعاء الأول كجسم الطريقة. ومع ذلك، فإن سلوك التخزين المؤقت الفعلي هو تحسين أداء محدد بالتنفيذ، لذا فإنه من غير الصحيح الاعتماد بشكل وثيق على هذا السلوك.

عدد المرات التي يتم فيها توليد دالة قد تكون مرة واحدة فقط، ولكنها قد تكون أيضًا أكثر تكرارًا، أو قد لا تحدث على الإطلاق. ونتيجة لذلك، يجب عليك ألا تكتب دالة مولدة تحتوي على آثار جانبية - متى، وكم مرة، تحدث الآثار الجانبية غير محدد. (هذا صحيح أيضًا بالنسبة للماكرو - ومثلما هو الحال مع الماكرو، فإن استخدام eval في دالة مولدة هو علامة على أنك تفعل شيئًا بطريقة خاطئة.) ومع ذلك، على عكس الماكرو، لا يمكن لنظام التشغيل التعامل بشكل صحيح مع استدعاء 4d61726b646f776e2e436f64652822222c20226576616c2229_40726566، لذا فهو غير مسموح به.

من المهم أيضًا أن نرى كيف تتفاعل دوال @generated مع إعادة تعريف الطرق. وفقًا للمبدأ القائل بأن دالة @generated الصحيحة يجب ألا تلاحظ أي حالة قابلة للتغيير أو تسبب أي تغيير في الحالة العالمية، نرى السلوك التالي. لاحظ أن الدالة المولدة لا يمكنها استدعاء أي طريقة لم يتم تعريفها قبل تعريف الدالة المولدة نفسها.

في البداية، f(x) لها تعريف واحد

julia> f(x) = "original definition";

حدد عمليات أخرى تستخدم f(x):

julia> g(x) = f(x);

julia> @generated gen1(x) = f(x);

julia> @generated gen2(x) = :(f(x));

نحن الآن نضيف بعض التعريفات الجديدة لـ f(x):

julia> f(x::Int) = "definition for Int";

julia> f(x::Type{Int}) = "definition for Type{Int}";

وقارن كيف تختلف هذه النتائج:

julia> f(1)
"definition for Int"

julia> g(1)
"definition for Int"

julia> gen1(1)
"original definition"

julia> gen2(1)
"definition for Int"

كل طريقة من دالة مولدة لها رؤيتها الخاصة للدوال المعرفة:

julia> @generated gen1(x::Real) = f(x);

julia> gen1(1)
"definition for Type{Int}"

لم تقم الدالة المولدة foo المذكورة أعلاه بأي شيء لا يمكن أن تفعله دالة عادية foo(x) = x * x (باستثناء طباعة النوع عند الاستدعاء الأول، وتحمل تكلفة أعلى). ومع ذلك، تكمن قوة الدالة المولدة في قدرتها على حساب تعبيرات مقتبسة مختلفة اعتمادًا على الأنواع الممررة إليها:

julia> @generated function bar(x)
           if x <: Integer
               return :(x ^ 2)
           else
               return :(x)
           end
       end
bar (generic function with 1 method)

julia> bar(4)
16

julia> bar("baz")
"baz"

(على الرغم من أن هذا المثال المصطنع سيكون من الأسهل تنفيذه باستخدام التوزيع المتعدد...)

سوف يؤدي إساءة استخدام هذا إلى فساد نظام التشغيل وإحداث سلوك غير محدد:

julia> @generated function baz(x)
           if rand() < .9
               return :(x^2)
           else
               return :("boo!")
           end
       end
baz (generic function with 1 method)

نظرًا لأن جسم الدالة المُولَّدة غير حتمي، فإن سلوكها، وسلوك جميع التعليمات البرمجية اللاحقة غير محدد.

لا تنسخ هذه الأمثلة!

هذه الأمثلة تأمل أن تكون مفيدة لتوضيح كيفية عمل الدوال المولدة، سواء في نهاية التعريف أو في موقع الاستدعاء؛ ومع ذلك، لا تنسخها، للأسباب التالية:

  • تحتوي دالة foo على آثار جانبية (الاستدعاء إلى Core.println)، وهي غير محددة بالضبط متى، أو كم مرة، أو عدد المرات التي ستحدث فيها هذه الآثار الجانبية.
  • تقوم دالة bar بحل مشكلة يتم حلها بشكل أفضل باستخدام التوزيع المتعدد - تعريف bar(x) = x و bar(x::Integer) = x ^ 2 سيفعل نفس الشيء، لكنه أبسط وأسرع.
  • الدالة baz هي مرضية

لاحظ أن مجموعة العمليات التي يجب عدم محاولة تنفيذها في دالة تم إنشاؤها غير محدودة، ونظام التشغيل الحالي يمكنه فقط اكتشاف مجموعة فرعية من العمليات غير الصالحة. هناك العديد من العمليات الأخرى التي ستؤدي ببساطة إلى فساد نظام التشغيل دون إشعار، عادة بطرق دقيقة ليست مرتبطة بوضوح بالتعريف السيئ. نظرًا لأن مولد الدالة يتم تشغيله أثناء الاستدلال، يجب أن يحترم جميع قيود هذا الرمز.

بعض العمليات التي لا ينبغي محاولة القيام بها تشمل:

  1. تخزين مؤشرات الأصل.

  2. التفاعل مع محتويات أو طرق Core.Compiler بأي شكل من الأشكال.

  3. مراقبة أي حالة قابلة للتغيير.

    • يمكن إجراء الاستدلال على الدالة المُولَّدة في أي وقت، بما في ذلك أثناء محاولة كودك مراقبة أو تغيير هذه الحالة.
  4. أخذ أي أقفال: قد يستخدم كود C الذي تستدعيه أقفالًا داخليًا، (على سبيل المثال، ليس من المشكل استدعاء malloc، على الرغم من أن معظم التنفيذات تتطلب أقفالًا داخليًا) ولكن لا تحاول الاحتفاظ أو الحصول على أي منها أثناء تنفيذ كود جوليا.

  5. استدعاء أي دالة تم تعريفها بعد جسم الدالة المولدة. يتم تخفيف هذا الشرط لوحدات ما قبل التجميع المحملة بشكل تدريجي للسماح باستدعاء أي دالة في الوحدة.

حسنًا، الآن بعد أن أصبح لدينا فهم أفضل لكيفية عمل الدوال المُولَّدة، دعنا نستخدمها لبناء بعض الوظائف الأكثر تقدمًا (وصحيحة)...

An advanced example

تحتوي مكتبة جوليا الأساسية على دالة داخلية sub2ind لحساب فهرس خطي في مصفوفة متعددة الأبعاد، استنادًا إلى مجموعة من الفهارس متعددة الخطوط - بعبارة أخرى، لحساب الفهرس i الذي يمكن استخدامه للفهرسة في مصفوفة A باستخدام A[i]، بدلاً من A[x,y,z,...]. واحدة من الممكنات للتنفيذ هي كما يلي:

julia> function sub2ind_loop(dims::NTuple{N}, I::Integer...) where N
           ind = I[N] - 1
           for i = N-1:-1:1
               ind = I[i]-1 + dims[i]*ind
           end
           return ind + 1
       end;

julia> sub2ind_loop((3, 5), 1, 2)
4

يمكن القيام بنفس الشيء باستخدام الاستدعاء الذاتي:

julia> sub2ind_rec(dims::Tuple{}) = 1;

julia> sub2ind_rec(dims::Tuple{}, i1::Integer, I::Integer...) =
           i1 == 1 ? sub2ind_rec(dims, I...) : throw(BoundsError());

julia> sub2ind_rec(dims::Tuple{Integer, Vararg{Integer}}, i1::Integer) = i1;

julia> sub2ind_rec(dims::Tuple{Integer, Vararg{Integer}}, i1::Integer, I::Integer...) =
           i1 + dims[1] * (sub2ind_rec(Base.tail(dims), I...) - 1);

julia> sub2ind_rec((3, 5), 1, 2)
4

كلا التنفيذين هذين، على الرغم من اختلافهما، يقومان في الأساس بنفس الشيء: حلقة وقت تشغيل عبر أبعاد المصفوفة، تجمع الإزاحة في كل بعد إلى الفهرس النهائي.

ومع ذلك، فإن جميع المعلومات التي نحتاجها للحلقة مضمنة في معلومات نوع المعاملات. وهذا يسمح للمجمع بنقل التكرار إلى وقت التجميع وإزالة الحلقات في وقت التشغيل تمامًا. يمكننا استخدام الدوال المولدة لتحقيق تأثير مشابه؛ في مصطلحات المجمع، نستخدم الدوال المولدة لفك الحلقة يدويًا. يصبح الجسم شبه متطابق، ولكن بدلاً من حساب الفهرس الخطي، نبني تعبيرًا يحسب الفهرس:

julia> @generated function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
           ex = :(I[$N] - 1)
           for i = (N - 1):-1:1
               ex = :(I[$i] - 1 + dims[$i] * $ex)
           end
           return :($ex + 1)
       end;

julia> sub2ind_gen((3, 5), 1, 2)
4

ما هو الكود الذي ستنتجه هذه؟

طريقة سهلة لمعرفة ذلك هي استخراج الجسم إلى دالة أخرى (عادية):

julia> function sub2ind_gen_impl(dims::Type{T}, I...) where T <: NTuple{N,Any} where N
           length(I) == N || return :(error("partial indexing is unsupported"))
           ex = :(I[$N] - 1)
           for i = (N - 1):-1:1
               ex = :(I[$i] - 1 + dims[$i] * $ex)
           end
           return :($ex + 1)
       end;

julia> @generated function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
           return sub2ind_gen_impl(dims, I...)
       end;

julia> sub2ind_gen((3, 5), 1, 2)
4

يمكننا الآن تنفيذ sub2ind_gen_impl وفحص التعبير الذي يعيده:

julia> sub2ind_gen_impl(Tuple{Int,Int}, Int, Int)
:(((I[1] - 1) + dims[1] * (I[2] - 1)) + 1)

لذا، فإن جسم الطريقة التي سيتم استخدامها هنا لا يتضمن حلقة على الإطلاق - فقط الفهرسة في التوائم الاثنين، الضرب والجمع/الطرح. يتم تنفيذ جميع الحلقات في وقت الترجمة، ونتجنب الحلقات أثناء التنفيذ تمامًا. وبالتالي، نحن نقوم بالتكرار مرة واحدة لكل نوع، في هذه الحالة مرة واحدة لكل N (باستثناء الحالات الحدودية حيث يتم توليد الدالة أكثر من مرة - انظر إخلاء المسؤولية أعلاه).

Optionally-generated functions

يمكن أن تحقق الدوال المولدة كفاءة عالية في وقت التشغيل، ولكنها تأتي بتكلفة في وقت الترجمة: يجب توليد جسم دالة جديدة لكل مجموعة من أنواع الوسائط المحددة. عادةً، يمكن لجوليا تجميع إصدارات "عامة" من الدوال التي ستعمل مع أي وسائط، ولكن مع الدوال المولدة، فإن ذلك مستحيل. وهذا يعني أن البرامج التي تستخدم بشكل مكثف الدوال المولدة قد تكون مستحيلة الترجمة بشكل ثابت.

لحل هذه المشكلة، توفر اللغة بناءً نحويًا لكتابة تنفيذات بديلة عادية، غير مولدة، للدوال المولدة. عند تطبيق ذلك على مثال sub2ind أعلاه، سيبدو الأمر كما يلي:

julia> function sub2ind_gen_impl(dims::Type{T}, I...) where T <: NTuple{N,Any} where N
           ex = :(I[$N] - 1)
           for i = (N - 1):-1:1
               ex = :(I[$i] - 1 + dims[$i] * $ex)
           end
           return :($ex + 1)
       end;

julia> function sub2ind_gen_fallback(dims::NTuple{N}, I) where N
           ind = I[N] - 1
           for i = (N - 1):-1:1
               ind = I[i] - 1 + dims[i]*ind
           end
           return ind + 1
       end;

julia> function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
           length(I) == N || error("partial indexing is unsupported")
           if @generated
               return sub2ind_gen_impl(dims, I...)
           else
               return sub2ind_gen_fallback(dims, I)
           end
       end;

julia> sub2ind_gen((3, 5), 1, 2)
4

داخليًا، يقوم هذا الرمز بإنشاء تنفيذين للدالة: واحد مُولد حيث يتم استخدام الكتلة الأولى في if @generated، وآخر عادي حيث يتم استخدام كتلة else. داخل الجزء then من كتلة if @generated، يكون للرمز نفس الدلالات مثل الدوال المُولدة الأخرى: أسماء المعاملات تشير إلى الأنواع، ويجب أن يُرجع الرمز تعبيرًا. قد تحدث عدة كتل if @generated، وفي هذه الحالة يستخدم التنفيذ المُولد جميع كتل then ويستخدم التنفيذ البديل جميع كتل else.

لاحظ أننا أضفنا فحص خطأ إلى أعلى الدالة. سيكون هذا الرمز شائعًا بين النسختين، ويتم تشغيله كود وقت التشغيل في كلا النسختين (سيتم اقتباسه وإرجاعه كتعبيير من النسخة المولدة). وهذا يعني أن القيم وأنواع المتغيرات المحلية غير متاحة في وقت توليد الكود - يمكن أن ترى كود توليد الكود أنواع المعاملات فقط.

في هذا النمط من التعريف، تعتبر ميزة توليد الشيفرة في الأساس تحسينًا اختياريًا. سيستخدم المترجم هذه الميزة إذا كانت مريحة، ولكنه قد يختار خلاف ذلك استخدام التنفيذ العادي بدلاً من ذلك. يُفضل هذا النمط، لأنه يسمح للمترجم باتخاذ المزيد من القرارات وتجميع البرامج بطرق أكثر، ولأن الشيفرة العادية أكثر قابلية للقراءة من الشيفرة التي تولد الشيفرة. ومع ذلك، يعتمد التنفيذ المستخدم على تفاصيل تنفيذ المترجم، لذا من الضروري أن تتصرف التنفيذات الاثنين بشكل متطابق.