Metaprogramming
أقوى إرث للغة Lisp في لغة Julia هو دعمها للبرمجة الميتا. مثل Lisp، تمثل Julia كودها الخاص كهيكل بيانات من اللغة نفسها. نظرًا لأن الكود يتم تمثيله بواسطة كائنات يمكن إنشاؤها والتلاعب بها من داخل اللغة، فمن الممكن لبرنامج ما أن يحول ويولد كوده الخاص. وهذا يسمح بتوليد كود متقدم دون خطوات بناء إضافية، كما يسمح أيضًا باستخدام ماكروهات على نمط Lisp تعمل على مستوى abstract syntax trees. بالمقابل، تقوم أنظمة "الماكرو" في المعالج المسبق، مثل تلك الموجودة في C وC++، بإجراء تلاعب نصي واستبدال قبل حدوث أي تحليل أو تفسير فعلي. نظرًا لأن جميع أنواع البيانات والكود في Julia ممثلة بهياكل بيانات Julia، فإن قدرات reflection القوية متاحة لاستكشاف تفاصيل البرنامج وأنواعه تمامًا مثل أي بيانات أخرى.
البرمجة الميتا هي أداة قوية، لكنها تقدم تعقيدًا يمكن أن يجعل الكود أكثر صعوبة في الفهم. على سبيل المثال، قد يكون من الصعب بشكل مدهش الحصول على قواعد النطاق بشكل صحيح. يجب استخدام البرمجة الميتا عادةً فقط عندما لا يمكن تطبيق أساليب أخرى مثل 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))))
يولد المحلل QuoteNode
s للعناصر المقتبسة البسيطة مثل الرموز:
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 Expr
essions
كما تم الإشارة أعلاه، فإن إحدى الميزات المفيدة للغاية في جوليا هي القدرة على توليد والتلاعب بشيفرة جوليا داخل جوليا نفسها. لقد رأينا بالفعل مثالاً واحدًا على دالة تعيد 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 أن السلاسل النصية التي تسبقها معرف تُسمى سلاسل نصية غير قياسية، ويمكن أن يكون لها دلالات مختلفة عن السلاسل النصية غير المسبوقة. على سبيل المثال:
r"^\s*(?:#|$)"
ينتج regular expression object بدلاً من سلسلة.b"DATA\xff\u2200"
هو byte array literal لـ[68,65,84,65,255,226,136,128]
.
ربما من المفاجئ أن هذه السلوكيات ليست مشفرة بشكل ثابت في محلل جوليا أو المترجم. بدلاً من ذلك، فهي سلوكيات مخصصة مقدمة من خلال آلية عامة يمكن لأي شخص استخدامها: يتم تحليل السلاسل النصية المسبوقة كاستدعاءات لماكروهات ذات أسماء خاصة. على سبيل المثال، ماكرو التعبير العادي هو مجرد ما يلي:
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
، والذي يتيح لك تعريف ما يُعرف بـ الدوال المُولّدة. هذه الدوال لديها القدرة على توليد كود متخصص اعتمادًا على أنواع وسائطها مع مزيد من المرونة و/أو أقل من الكود مما يمكن تحقيقه مع التوزيع المتعدد. بينما تعمل الماكروز مع التعبيرات في وقت التحليل ولا يمكنها الوصول إلى أنواع مدخلاتها، يتم توسيع الدالة المُولّدة في وقت تكون فيه أنواع الوسائط معروفة، ولكن الدالة لم تُجمع بعد.
بدلاً من إجراء بعض الحسابات أو الإجراءات، تعيد عبارة إعلان الوظيفة المولدة تعبيرًا مقتبسًا يشكل بعد ذلك جسم الطريقة المقابلة لأنواع المعاملات. عندما يتم استدعاء وظيفة مولدة، يتم تجميع التعبير الذي تعيده ثم تشغيله. لجعل ذلك فعالًا، يتم عادةً تخزين النتيجة في الذاكرة المؤقتة. ولجعل ذلك قابلًا للاستنتاج، يتم استخدام مجموعة محدودة فقط من اللغة. وبالتالي، توفر الوظائف المولدة وسيلة مرنة لنقل العمل من وقت التشغيل إلى وقت التجميع، على حساب قيود أكبر على البنى المسموح بها.
عند تعريف الدوال المولدة، هناك خمسة اختلافات رئيسية عن الدوال العادية:
- تقوم بتعليق إعلان الدالة باستخدام ماكرو
@generated
. يضيف هذا بعض المعلومات إلى شجرة التحليل المجرد (AST) التي تخبر المترجم أن هذه دالة تم إنشاؤها. - في جسم الدالة المُولَّدة، لديك وصول فقط إلى أنواع المعاملات - وليس إلى قيمها.
- بدلاً من حساب شيء ما أو تنفيذ إجراء ما، تعيد عبارة مقتبسة والتي، عند تقييمها، تفعل ما تريده.
- تُسمح الوظائف المُولّدة فقط باستدعاء الوظائف التي تم تعريفها قبل تعريف الوظيفة المُولّدة. (قد يؤدي عدم اتباع ذلك إلى الحصول على
MethodErrors
تشير إلى وظائف من عصر عالمي مستقبلي.) - يجب ألا تقوم الدوال المُولَّدة بـ تغيير أو مراقبة أي حالة عالمية غير ثابتة (بما في ذلك، على سبيل المثال، الإدخال/الإخراج، الأقفال، القواميس غير المحلية، أو استخدام
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
هي مرضية
لاحظ أن مجموعة العمليات التي يجب عدم محاولة تنفيذها في دالة تم إنشاؤها غير محدودة، ونظام التشغيل الحالي يمكنه فقط اكتشاف مجموعة فرعية من العمليات غير الصالحة. هناك العديد من العمليات الأخرى التي ستؤدي ببساطة إلى فساد نظام التشغيل دون إشعار، عادة بطرق دقيقة ليست مرتبطة بوضوح بالتعريف السيئ. نظرًا لأن مولد الدالة يتم تشغيله أثناء الاستدلال، يجب أن يحترم جميع قيود هذا الرمز.
بعض العمليات التي لا ينبغي محاولة القيام بها تشمل:
تخزين مؤشرات الأصل.
التفاعل مع محتويات أو طرق
Core.Compiler
بأي شكل من الأشكال.مراقبة أي حالة قابلة للتغيير.
- يمكن إجراء الاستدلال على الدالة المُولَّدة في أي وقت، بما في ذلك أثناء محاولة كودك مراقبة أو تغيير هذه الحالة.
أخذ أي أقفال: قد يستخدم كود C الذي تستدعيه أقفالًا داخليًا، (على سبيل المثال، ليس من المشكل استدعاء
malloc
، على الرغم من أن معظم التنفيذات تتطلب أقفالًا داخليًا) ولكن لا تحاول الاحتفاظ أو الحصول على أي منها أثناء تنفيذ كود جوليا.استدعاء أي دالة تم تعريفها بعد جسم الدالة المولدة. يتم تخفيف هذا الشرط لوحدات ما قبل التجميع المحملة بشكل تدريجي للسماح باستدعاء أي دالة في الوحدة.
حسنًا، الآن بعد أن أصبح لدينا فهم أفضل لكيفية عمل الدوال المُولَّدة، دعنا نستخدمها لبناء بعض الوظائف الأكثر تقدمًا (وصحيحة)...
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
.
لاحظ أننا أضفنا فحص خطأ إلى أعلى الدالة. سيكون هذا الرمز شائعًا بين النسختين، ويتم تشغيله كود وقت التشغيل في كلا النسختين (سيتم اقتباسه وإرجاعه كتعبيير من النسخة المولدة). وهذا يعني أن القيم وأنواع المتغيرات المحلية غير متاحة في وقت توليد الكود - يمكن أن ترى كود توليد الكود أنواع المعاملات فقط.
في هذا النمط من التعريف، تعتبر ميزة توليد الشيفرة في الأساس تحسينًا اختياريًا. سيستخدم المترجم هذه الميزة إذا كانت مريحة، ولكنه قد يختار خلاف ذلك استخدام التنفيذ العادي بدلاً من ذلك. يُفضل هذا النمط، لأنه يسمح للمترجم باتخاذ المزيد من القرارات وتجميع البرامج بطرق أكثر، ولأن الشيفرة العادية أكثر قابلية للقراءة من الشيفرة التي تولد الشيفرة. ومع ذلك، يعتمد التنفيذ المستخدم على تفاصيل تنفيذ المترجم، لذا من الضروري أن تتصرف التنفيذات الاثنين بشكل متطابق.