نقوم بتنفيذ تحويلات كود Python

مرحبا يا هابر.

نقدم لك اليوم ترجمة لمقال يتطرق إلى موضوع ليس الأكثر مناقشة: تجميع التعليمات البرمجية في Python ، أي: العمل مع شجرة بناء الجملة المجردة (AST) ورمز بايت. في حين أن Python هي لغة مترجمة ، فإن هذه الميزات في غاية الأهمية من وجهة نظر التحسين. سنتحدث عنها اليوم.

هل تساءلت يومًا عن كيفية تحسين المترجم للشفرة الخاصة بك بحيث يعمل بشكل أسرع؟ هل تريد أن تعرف ما هي شجرة البناء المجردة (AST) وماذا يمكن استخدامها؟

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

رمز البرنامج كشجرة


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

عند العمل بلغة برمجة مترجمة (مثل Python) ، من المقبول عمومًا أن المترجم يمر عبر الكود الخاص بك ويقوم بكل شيء يواجهه ، على الفور ، دون تحويل كود Python إلى كود الآلة بأي شكل من الأشكال. ومع ذلك ، من الناحية العملية ، يثير مخطط التنفيذ هذا الكثير من المشاكل ، مما يجعله غير مريح للغاية.
خذ ، على سبيل المثال ، مشكلة بسيطة مثل أولوية المشغلين. في تعبير الرأي 3 + 4 * x ، يتم حساب الجزء أولاً4 * x، وعندها فقط يمكن إضافة 3 إلى نتيجة الضرب. ربما تكون قد تعلمت أسبقية العوامل في صفوف الرياضيات من خلال رسم هذه الأشجار تحت التعبير:



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

BinOp(
  left  = Num(3),
  op    = Add(),
  right = BinOp(
            left  = Num(4),
            op    = Mult(),
            right = Name('x')
          )
)

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

في نظرية المترجمين ولغات البرمجة ، تسمى هذه الشجرة شجرة البناء المجردة ، أو AST للاختصار. يتضمن AST في المثال أعلاه عقدتين BinOp، عقدتين ، Numوعقدة واحدة Name.

هناك ميزة لطيفة في Python - القدرة على عرض وعرض AST مباشرة لأي برنامج Python معين. كل ما هو مطلوب لاستيراد وحدة نمطية قياسيةast، تحليل البرنامج ، ثم عرض النتيجة على الشاشة (بالمناسبة ، التحليل هو عملية تحويل رمز مصدر البرنامج إلى شجرة AST).

import ast
my_tree = ast.parse("3 + 4*x")
print(ast.dump(my_tree))

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

Module(body=[Expr(value=BinOp(left=Num(n=3), op=Add(), right=BinOp(left=Num(n=4), op=Mult(), right=Name(id='x', ctx=Load()))))])

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

Module(body = [
    Expr(
        value = BinOp(
            left  = Num(n=3),
            op    = Add(),
            right = BinOp(
                left  = Num(n=4),
                op    = Mult(),
                right = Name(id='x', ctx=Load())
            )
        )
    )
])

من الواضح أن بايثون "تعتقد" أن الخط الذي أعطيناه للتحليل هو وحدة كاملة. نص الوحدة هو قائمة بجميع التعليمات الواردة فيها. التعليمات الوحيدة في مثالنا هي تعبير Exprيكون معناه بالضبط هو ما ناقشناه أعلاه.

ملاحظة: Nameتحتوي العقدة على حقل إضافي ctx(يُختصر باسم "السياق") ، والذي له قيمة Load(). لذلك تقول Python أننا نستخدم القيمة المخزنة في المتغير x، وليس (إعادة) تعريف أو حذف الاسم x. الآن تحركك ، حاول تحليل شيء مثل del xأو نفسك x = 123، وسترى كيف يتغير الحقل ctxفي العقدة Nameإلى ، Del()أو Store()على التوالي.

بالمناسبة: إذا قمت بتثبيت الوحدةastunparse، ثم يمكن جعل إخراج AST على الشاشة أكثر جمالا ، وحتى تحويل AST مرة أخرى إلى رمز Python مباشر.

عملية التجميع: الباقي


بعد جمع برامج AST ، من الممكن مبدئيًا إكمال البرنامج بالكامل من خلال المرور عبر AST وتنفيذ العمليات بالترتيب الذي يشار إليه به. ومع ذلك ، فإن هذا النهج له عيبان على الأقل. أولاً ، يمكن أن تشغل AST مقدارًا كبيرًا نسبيًا من الذاكرة ، خاصة إذا كانت تحتوي على معلومات زائدة. ثانيًا ، قد يستغرق اجتياز AST وقتًا أطول من اللازم. باختصار: يمكن القيام بذلك ، ولكنه غير فعال.
لا يقوم المترجم بمعالجة AST مباشرة ، لكنه يعد البايت كود ، الذي يتم تنفيذه بعد ذلك على جهاز Python الظاهري. على الرغم من أن مناقشة تفاصيل هذه العملية خارج نطاق هذه المقالة ، فإن المبدأ الأساسي هو أن المترجم يترجم AST إلى تدوين بولندي عكسي (RPN). بدلا من وضع عامل+بين المعاملات اليسرى واليمنى ، نضعها بعد كلتا المعاملات. في المثال 3 + 4*xأعلاه ، نحصل على التسلسل 3 4 x * +(وهذا التدوين جيد بشكل خاص لأنه يمكنك أن ترى على الفور من التسلسل: أولاً تحتاج إلى إجراء الضرب ، وبعد ذلك فقط الإضافة). نظرًا لأنه يمكن من حيث المبدأ تمثيل كل عنصر من العناصر الخمسة في هذا التسلسل على أنه بايت واحد ، يسمى هذا الرمز رمز بايت. ثم تستخدم Python الجهاز الظاهري المكدس لتنفيذ هذا الرمز بكفاءة.

بمعنى آخر ، تتم عملية تجميع برنامج مكتوب بلغة Python على مرحلتين. أولاً ، يتم تحليل البرنامج الذي تم تلقيه بواسطة الإدخال ، والنتيجة هي شجرة بناء جملة مجردة (AST). ثم يمر المحول البرمجي من خلال AST ويولد رمز البايت. بعد ذلك ، يقوم مترجم Python بتنفيذ هذا الرمز الفرعي. بعد تناول التحسين ، يمكن تطبيقه إما على مستوى AST أو على مستوى البايت كود. كل من هذه الخيارات لها مزاياها وعيوبها.

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

نماذج من لغات البرمجة الأخرى


لا تستخدم جميع لغات البرمجة تدوين infix ، كما هو الحال في Python. هناك مثالان جديران بالملاحظة في هذه الحالة هما PostScript ، حيث تتم كتابة البرنامج مباشرة بترميز بولندي عكسي ، و Lisp ، بالطبع ، حيث تتم كتابة البرامج عادة في التدوين البولندي. لذا، فإن تعبيرنا عن المثال أعلاه، في اللثغة تأخذ الشكل التالي: (+ 3 (* 4 x)).

تحويل العقدة ضمن AST


هل لديك برنامج AST ، وكيف يتم تحويل الأجزاء الفردية من هذه الشجرة؟ مع ميزات Python المضمنة والمريحة.

إذا كان لنا أن نلقي نظرة على AST، وعلى سبيل المثال، نجد أن كلا الحقلين leftو rightالعقد BinOpأرقام (العقد Num)، ونحن يمكن أن تؤدي الحسابات المقابلة مسبقا، ومن ثم استبدالها BinOpعقدة العادية Num.

بالطبع ، تحتاج إلى التصرف بعناية فائقة حتى لا تغير سلوك البرنامج ، والقيام بهذه التحولات. على سبيل المثال ، في len([a(), b(), c(), d()])، من الواضح أن النتيجة هي 4. ولكن لا يمكننا استبدال كل تعبير الرقم 4 لأن أربع وظائف a، b، c، dلا تزال تم استدعاؤها بشكل صحيح.

مرة أخرى ، ابدأ بتحسين بسيط. أينما يظهر اسم في شفرة المصدر للبرنامج pi، استبدله بالقيمة 3.14159265. توفر وحدة Python astبالفعل هياكل البيانات اللازمة للقيام بذلك: فئة المحول NodeTransformerالتي تمر عبر جميع ASTs وتتحقق من كل عقدة ما إذا كان يمكن استبدالها. بشكل افتراضي ، ترجع طريقة التحويل ببساطة العقدة المصدر لكل عقدة ، بحيث نحصل على نفس AST التي بدأنا منها. ولكن يمكننا بسهولة تجاوز طريقة العقد Name، على سبيل المثال ، بحيث تتحقق piلمعرفة ما إذا كانت كذلك ، ثم ترجع العقدة Numبدلاً من العقدة بالاسم الأصلي ...

	import ast
 
class MyOptimizer(ast.NodeTransformer):
 
    def visit_Name(self, node: ast.Name):
        if node.id == 'pi':
            return ast.Num(n=3.14159265)
        return node
 
tree = ast.parse("y = 2 * pi")
optimizer = MyOptimizer()
tree = optimizer.visit(tree)
print(ast.dump(tree))

من أجل أن يمر المحول / المحسن عبر شجرتنا ، من الضروري استدعاء طريقته visit، والتي ستعيد شجرة جديدة متغيرة.

لسوء الحظ ، من المستحيل ترجمة وتشغيل AST الناتج ، والسبب في ذلك هو أحد التفاصيل الفنية. هذا ليس مرئيًا بعد ، ولكن (تقريبًا) تحتوي جميع العقد في AST على حقول linenoو col_offset. تشير إلى الموضع الدقيق لعقدة معينة في شفرة المصدر. إذا لم تقم بتثبيتها بشكل صحيح ، فإن المترجم سيقسم ويرفض العمل.

لذا ، لننسخ الحقول المناسبة من العقدة المصدر Nameإلى العقدة الجديدة Num. يمكنك بعد ذلك تجميع وتنفيذ AST الناتج:

import ast
 
class MyOptimizer(ast.NodeTransformer):
 
    def visit_Name(self, node: ast.Name):
        if node.id == 'pi':
            result = ast.Num(n=3.14159265)
            result.lineno = node.lineno
            result.col_offset = node.col_offset
            return result
        return node
 
tree = ast.parse("print(2 * pi)")
optimizer = MyOptimizer()
tree = optimizer.visit(tree)
code = compile(tree, "<string>", "exec")
exec(code)

ملحوظة: لا تتطلب الترجمة الوظيفية كود المصدر فقط (الذي يمكن أن يكون فيه البرنامج نفسه ، أو سطر AST) ، ولكن اسم الملف (كما طلبنا "<string>") ، بالإضافة إلى واحد من ثلاثة: "exec"، "eval"أو "single".

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

def visit_Name(self, node: ast.Name):
        if node.id == 'pi':
            result = ast.Num(n=3.14159265)
            return ast.copy_location(result, node)
        return node

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

class MyVisitor(ast.NodeTransformer):
 
    def visit_BinOp(self, node: ast.BinOp):
        node.left = self.visit(node.left)
        node.right = self.visit(node.right)
        if isinstance(node.left, ast.Num) and isinstance(node.right, ast.Num):
            if isinstance(node.op, ast.Add):
                result = ast.Num(n = node.left.n + node.right.n)
                return ast.copy_location(result, node)
            elif isinstance(node.op, ast.Mult):
                result = ast.Num(n = node.left.n * node.right.n)
                return ast.copy_location(result, node)
        return node
 
    def visit_Name(self, node: ast.Name):
        if node.id == 'pi':
            result = ast.Num(n=3.14159265)
            return ast.copy_location(result, node)
        return node
 
tree = ast.parse("y = 2 * pi + 1")
optimizer = MyOptimizer()
tree = optimizer.visit(tree)
print(ast.dump(tree))

بالمناسبة ، يقوم مترجم CPython بتحسين العقد بالفعل BinOpكما هو موضح هنا. الشفرة المقابلة مكتوبة بالحرف C وهي معطاة في Python / ast_opt.c . يرجى ملاحظة أن: محسن CPython هو أكثر عالمية ولا يعمل فقط مع الأرقام ، كما في المثال الذي نفكر فيه ، ولكن مع أنواع مختلفة من القيم الثابتة.

فحص العقد في AST


كيف نتأكد من أن التحولات التي أجريناها صحيحة؟ تحتاج أولاً إلى تجاوز AST بالكامل وفحص البرنامج بأكمله.

لا يزال المحسن المعروض أعلاه عيبًا خطيرًا. ماذا يحدث إذا قمت بإعادة تعريف مكان ما في البرنامج pi؟ مجرد تصور شيء بسيط ومفهوم pi = 4. سيستبدل محسِّننا ببساطة pi على الجانب الأيسر من التعبير بالقيمة الرقمية 3.14159265 ، ثم ترفض Python الترجمة لأنه لا يمكنها تعيين أي شيء لقيمة حرفية.

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

لذا ماذا نفعل إذا أردنا التمسك بدلالات Python ، ولكن استبدال pi على أي حال ممكن؟ في هذه الحالة ، تحتاج أولاً إلى مراجعة البرنامج بأكمله والتحقق مما إذا تم تعيين القيمة الخاصة به في مكان ما pi. حتى نقوم بتعقيدها: لن نلجأ إلى استبدال pi إذا كانت هناك نقطة واحدة على الأقل في البرنامج لديها أي تعيين لقيمة pi.

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

في حالتنا ، نتحقق مما إذا كانت العقدة تشير Nameإلى piوتقوم بأي شيء بخلاف تحميل القيمةpi(تذكر مجال السياق ctx).

import ast
 
class MyVisitor(ast.NodeVisitor):
 
    def __init__(self):
        self.modify_pi = False
 
    def visit_FunctionDef(self, node: ast.FunctionDef):
        if node.name == 'pi':
            self.modify_pi = True
        self.generic_visit(node)
 
    def visit_Name(self, node: ast.Name):
        if node.id == 'pi' and not isinstance(node.ctx, ast.Load):
            self.modify_pi = True
 
program = """
def pi():
    return 3.1415
print(2 * pi())
"""
tree = ast.parse(program)
my_visitor = MyVisitor()
my_visitor.visit(tree)
print("Pi modified:", my_visitor.modify_pi)

generic_visit(node)يتم استدعاء الطريقة من قبل الزائر لكل عقدة لا نقدم لها طريقة زيارة متخصصة. بمعنى آخر: لا يوجد مثل هذا الأسلوب visit_FunctionDefفي الفصل NodeVisitorالذي يمكن أن نسميه باستخدامه super(). فيما يتعلق بتعريفات الوظائف ، يجب علينا استدعاء زائر عام للتأكد من معالجة نص الوظيفة بالكامل بشكل صحيح. خلاف ذلك ، يمكننا إخفاء التعليمات في الوظيفة global piوتغيير القيمة عالميًا pi، حتى لا يلاحظ المحسن أي شيء.

القيم المحلية في بيثون


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

المثال التالي سينفذ بشكل جيد بدون السطر الرابع. ولكن ، على الرغم x = 0من عدم تنفيذ السطر الرابع مطلقًا ، إلا أنه لا يزال يعتبر مهمةx وبالتالي ، يصبح x متغيرًا محليًا على مقياس الوظيفة بأكملها ، وحتى في السطر 3. ولهذا السبب يقسم بايثون أن المتغير x على السطر الثالث لا يهم حتى الآن.

x = 1
def print_x():
    print(x)
    if False: x = 0
print_x()

إذا كنت مهتمًا بتفاصيل كيفية عمل Python هنا بالضبط ، فراجع Python / symtable.c .

استنتاج


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

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

All Articles