ایجاد یک DSL در پایتون

زبانهای اختصاصی دامنه یا DSL، زبانهای برنامهنویسی تخصصی هستند که برای حل مشکلات تخصصی در یک دامنه خاص طراحی شدهاند. DSL ها یک نحو مختصر و گویا را ارائه می دهند که برای رسیدگی به نیازها و چالش های خاص یک مشکل خاص طراحی شده است. DSL ها توسعه دهندگان را قادر می سازند تا مفاهیم و عملیات پیچیده را به شیوه ای بصری بیان کنند. از طریق استفاده از DSL ها، توسعه دهندگان می توانند بدون پرداختن به جزئیات غیر ضروری، روی مشکل موجود تمرکز کنند.
ایجاد یک DSL در پایتون چندین مزیت دارد. انعطاف پذیری و رسا بودن پایتون آن را برای میزبانی یک زبان خاص دامنه ایده آل می کند. این اکوسیستم غنی از کتابخانهها و ابزارها، پایهای محکم برای ایجاد DSLهای منحصربهفرد و تخصصی است که میتوانند به طور یکپارچه با پایگاههای کد موجود ادغام شوند.
در این مقاله به بررسی روند پیاده سازی یک DSL ساده در پایتون می پردازیم. ما مفاهیم اصلی را بررسی میکنیم، اجزای لازم را بررسی میکنیم و شما را از طریق مراحل اساسی پیادهسازی DSL خودتان راهنمایی میکنیم. در پایان، شما درک روشنی از نحوه طراحی، پیاده سازی و استفاده از DSL ها برای بهبود برنامه های پایتون خود خواهید داشت!
DSL ها زبان های تخصصی هستند که برای رسیدگی به مشکلات پیچیده به شیوه ای ساده و شهودی برای دامنه های خاص طراحی شده اند. آنها یک نحو مختصر متناسب با نیازهای خاص برنامه را ارائه می دهند. این رویکرد چندین مزیت از جمله بهبود خوانایی، بیان، گسترش پذیری و سهولت استفاده را به همراه دارد.
DSLهای داخلی در مقابل خارجی
دو دسته DSL وجود دارد، DSL داخلی و DSL خارجی.
DSL های داخلی
DSLهای داخلی در خود یک زبان میزبانی می شوند و از ویژگی های نحوی زبان میزبان آن برای تعریف یک نحو تخصصی استفاده می کنند. این DSL ها از انعطاف پذیری و بیان زبان میزبان خود استفاده می کنند و در نتیجه پیاده سازی نسبتا آسانی دارند.
DSL های خارجی
از سوی دیگر، DSLهای خارجی، نحو و دستور زبان خود را تعریف می کنند که به تنهایی از یک زبان میزبان جدا می شود. اینها به یک تجزیه کننده و مفسر اختصاصی نیاز دارند تا به درستی تجزیه و اجرای زبان را مدیریت کند. مزایای DSL های خارجی شامل کنترل بیشتر بر طراحی و انعطاف پذیری بیشتر است، اما از مضرات آن افزایش پیچیدگی پیاده سازی است.
اصول طراحی در ایجاد DSL
هنگام ایجاد DSL اصول طراحی خاصی وجود دارد که باید رعایت شود. پیروی از این اصول تضمین می کند که یک DSL برای استفاده بصری، گویا و کارآمد در اجرای آن است.
- سادگی: DSL باید یک نحو واضح و مختصر را تعریف کند که به راحتی قابل درک باشد و در حوزه مشکل متناسب باشد.
- بیانگر بودن: یک DSL باید تلاش کند تا دامنه مشکل را به خوبی ثبت کند، عملیات و مفاهیم لازم برای دستیابی به نتایج مشخص شده را تعریف کند.
- خوانایی: DSL ها باید توسط توسعه دهندگانی که پایگاه کد را می نویسند و نگهداری می کنند قابل خواندن باشند. استفاده از کلمات کلیدی معنی دار و قراردادهای نامگذاری ثابت، طراحی واضح و خوانا را تضمین می کند.
- ترکیب بندی: ترکیب ساختارهای DSL امکان ساخت اجزای پیچیده و معنادار از اجزای ساده تر را فراهم می کند، بنابراین استفاده مجدد از کد را ترویج می کند.
- مدیریت خطا: مدیریت صحیح خطا برای اطمینان از یکپارچگی داده ها و اطلاع کاربران از DSL شما در زمان بروز خطا و نحوه پاسخگویی به آنها ضروری است.
برای پیاده سازی DSL خود، لازم است که محیط خود را به درستی راه اندازی کنید تا از توسعه آن پشتیبانی کند. این شامل انتخاب کتابخانه ها و ابزارهای مناسب است که به شما امکان می دهد DSL خود را ایجاد و اجرا کنید.
انتخاب کتابخانه ها
پایتون تعدادی کتابخانه برای ایجاد DSL ارائه می دهد. یک گزینه این است ply
.
ply
پیاده سازی ابزار تجزیه lex و yacc برای پایتون است و امکان ایجاد lexer و تجزیه کننده در پایتون را فراهم می کند. این یک راه روشن برای تعریف قوانین گرامر و مدیریت رمزگذاری و تجزیه کد DSL شما ارائه می دهد. با استفاده از ply
، می توانید به راحتی ساختار و رفتار DSL خود را تعریف کنید.
نصب و راه اندازی
به شرطی که داشته باشی pip
نصب شده، در حال نصب ply
به سادگی اجرای دستور زیر است:
pip install ply
با ply
نصب شده شما اکنون آماده تعریف سینتکس برای ساخت و تفسیر DSL خود هستید!
تعریف سینتکس DSL که می خواهید پیاده سازی کنید، یک گام مهم در پیاده سازی است. شناسایی ساختارهای زبان، کلمات کلیدی و عباراتی که DSL شما را تشکیل میدهند برای درک نحوه پیادهسازی آن ضروری است. با طراحی یک نحو واضح، کاربران خود را قادر میسازید تا اهداف خود را واضح و دقیق بیان کنند.
شناسایی نحو
ابتدا دامنه مشکل را شناسایی کنید و عملیات و مفاهیم لازم برای پشتیبانی از DSL خود را درک کنید. شما باید در نظر بگیرید که کاربر DSL شما می خواهد چه اعمال، شرایط و محاسباتی را انجام دهد. برای مثال، فرض کنید میخواهیم یک DSL ساده برای تعریف بردارها، ماتریسها و انجام عملیات ساده مانند جمع بردار و ضرب ماتریس ایجاد کنیم. می توانید DSL را به صورت زیر تعریف کنید:
vector v1 = [1, 2, 3]
vector v2 = [4, 5, 6]
matrix m1 = [[1, 2], [3, 4]]
matrix m2 = [[5, 6], [7, 8]]
vector v3 = v1 + v2
matrix m3 = m1 * m2
این یک DSL بسیار ساده را برای تعریف بردارها و ماتریس ها و انجام عملیات ساده تعریف می کند. ما محدودیت های خاصی را در طراحی DSL خود با استفاده از آن اعمال خواهیم کرد numpy
زبانی برای مدیریت عملیات جمع و ضرب و اعمال محدودیت های شکل.
ایجاد قواعد گرامر
هنگامی که درک واضحی از نحوی که می خواهید استفاده کنید به دست آوردید، می توانید به تعریف قوانین گرامری در ply
. این قوانین ساختار و معنایی را مشخص می کند که چه چیزی یک عبارت معتبر DSL را تشکیل می دهد.
استفاده كردن ply
، نام نشانه ها و عبارات منظم را برای توکن کردن جریان ورودی ورودی تعریف می کنیم. همچنین میتوانید قواعد گرامری را تعریف کنید که نحوه ترکیب این نشانهها را برای ایجاد عبارات کد معتبر تعریف میکند.
ابتدا اجازه دهید کتابخانه های لازمی را که باید استفاده کنیم وارد کنیم.
import ply.lex as lex
import ply.yacc as yacc
import numpy as np
در مرحله بعد، ما باید توکن های خود را تعریف کنیم
# Token definitions
tokens = (
'IDENTIFIER',
'NUMBER',
'VECTOR_ID',
'VECTOR',
'MATRIX_ID',
'MATRIX',
'PLUS',
'MULTIPLY',
'LPAREN',
'RPAREN',
'LBRACKET',
'RBRACKET',
'COMMA',
'EQUALS',
'PRINT')
# Ignored characters
t_ignore = ' \t'
# Token regular expressions
t_PLUS = r'\+'
t_MULTIPLY = r'\*'
t_LPAREN = r'\('
t_RPAREN = r'\)'
t_EQUALS = r'='
t_LBRACKET = r'\['
t_RBRACKET = r'\]'
t_COMMA = r','
ما یک ذخیره متغیر برای ذخیره متغیرهای خود اعلام می کنیم. در حال حاضر این یک فرهنگ لغت ساده است، اما می تواند به عنوان یک شی پیچیده تر به عنوان یک شیء پیچیده تر در صورت نیاز DSL گسترش یابد.
# Variables
variables = {}
در مرحله بعد، چند نشانه برای چیزهایی مانند خطوط جدید، بیانیه چاپی، اعلان های برداری و ماتریس و شناسه هایمان تعریف می کنیم.
# Token definition for newline, print, vector and
# matrix identifiers, generic identifiers, and numbers
def t_NEWLINE(t):
r'\n+'
t.lexer.lineno += t.value.count('\n')
def t_PRINT(t):
r'print'
t.type = 'PRINT'
return t
def t_VECTOR_ID(t):
r'vector\s+[a-zA-Z_][a-zA-Z_0-9]*'
return t
def t_MATRIX_ID(t):
r'matrix\s+[a-zA-Z_][a-zA-Z_0-9]*'
return t
def t_IDENTIFIER(t):
r'[a-zA-Z_][a-zA-Z_0-9]*'
t.type = 'IDENTIFIER'
return t
def t_NUMBER(t):
r'\d+'
t.value = int(t.value)
return t
ما می خواهیم بتوانیم یک برنامه برای DSL خود تعریف کنیم، اساساً یک سری دستورات که می توانند به ترتیب اجرا شوند. ما ساختار گرامر را به عنوان یک نظر تعریف میکنیم و تعریف میکنیم که چگونه میخواهیم دستور را در کد مدیریت کنیم. اینجا فقط فعلا می گذریم.
# ---- PROGRAM ----
def p_program(p):
'''program : program statement
| statement'''
pass
اکنون میخواهیم روی نحوه مدیریت بردارهای تجزیه تمرکز کنیم. باید بتوانیم یک بردار به یک متغیر اختصاص دهیم و آن متغیر را در جدول متغیر ذخیره کنیم.
ابتدا، بیایید روی بخش تکلیف تمرکز کنیم: vector v1 = <expression>
def p_statement_vector_assignment(p):
'statement : VECTOR_ID EQUALS expression'
variable_name = p[1].split()[1]
variables[variable_name] = p[3]
p[0] = (variable_name, p[3])
در اینجا یک عبارت را تعریف می کنیم که به صورت تعریف شده است VECTOR_ID
نشانه، یک EQUALS
نشانه، و یک <expression>
. ما نشانه را در p[1]، مربوط به VECTOR_ID
و آن را برای بدست آوردن نام متغیر تقسیم کنید. در مثال بالا، اینطور خواهد بود v1
. سپس اختصاص می دهیم variables[variable_name]
به ارزش <expression>
. سپس p را اختصاص می دهیم[0] به عنوان یک تاپلی از (variable name, value)
.
از اینجا، ما می توانیم خود را تعریف کنیم vector
بیان به عنوان یک سری توابع. آنها به شرح زیر تعریف می شوند:
def p_vectordef(p):
'expression : LBRACKET vector_values RBRACKET'
p[0] = np.array(p[2])
def p_vector_values_single(p):
'vector_values : NUMBER'
p[0] = [p[1]]
def p_vector_values_multiple(p):
'vector_values : NUMBER COMMA vector_values'
p[0] = [p[1]] + p[3]
نمای کلی این کد به شرح زیر است. p_vectordef
عبارتی است که به عبارات به شکل [ <vector_values>
] . p_vector_values_single
مقادیر واحد را کنترل می کند، در این مورد، فقط یک عدد. سرانجام، p_vector_values_multiple
چندین مقدار را به شکل 1, 2, 3, 4
. به نحوه ارجاع ما توجه کنید vector_values
از درون P_vector_values_multiple
? این به آن اجازه می دهد تا به صورت بازگشتی خود را فراخوانی کند تا زمانی که در a خاتمه یابد NUMBER
نشانه
با قرار دادن این کد، اکنون میتوانیم عبارات را به شکل زیر تجزیه و ذخیره کنیم vector v1 = [1, 2, 3]
.
ماتریس ها به طور مشابه با چند توابع اضافی برای رسیدگی به سطرها و مقادیر ردیف تعریف می شوند.
# ----- MATRIX -----
def p_statement_matrix_assignment(p):
'statement : MATRIX_ID EQUALS expression'
variable_name = p[1].split()[1]
variables[variable_name] = p[3]
p[0] = (variable_name, p[3])
def p_expression_matrix(p):
'expression : MATRIX'
p[0] = p[1]
def p_matrix(p):
'expression : LBRACKET matrix_rows RBRACKET'
p[0] = np.array(p[2])
def p_matrix_rows_single(p):
'matrix_rows : row'
p[0] =[p[1]]
def p_matrix_rows_multiple(p):
'matrix_rows : row COMMA matrix_rows'
p[0] = [p[1]] + p[3]
def p_row(p):
'row : LBRACKET row_values RBRACKET'
p[0] = p[2]
def p_row_values(p):
'row_values : NUMBER'
p[0] = [p[1]]
def p_row_values_multiple(p):
'row_values : NUMBER COMMA row_values'
p[0] = [p[1]] + p[3]
# ----- END MATRIX -----
کد بالا به ما اجازه می دهد تا ماتریس ها را در قالب تجزیه کنیم matrix m1 = [[a1, b1, c1,....,z1], [a2, b2, c2...,z2], ...., [an, bn, cn, ....zn]]
.
با این کار، اکنون میتوانیم برخی از عملیات مانند جمع و ضرب ماتریس را تعریف کنیم. ما همچنین می توانیم یک دستور چاپ برای چاپ مقادیر متغیرهایمان تعریف کنیم.
ابتدا باید یک عبارت برای بازیابی تعریف کنیم IDENTIFIERS
از جدول متغیر
def p_expression_identifier(p):
'expression : IDENTIFIER'
variable_name = p[1]
if variable_name in variables:
p[0] = variables[variable_name]
else:
print(f"Error: Variable '{variable_name}' not in variable table")
در اینجا اگر با a مواجه شویم IDENTIFIER
نشانه، ما نگاه می کنیم تا ببینیم آیا متغیر توسط آن مشخص شده است IDENTIFIER
در شناسه متغیر قرار دارد. اگر هست، مقدار آن را بازیابی می کنیم و در آن ذخیره می کنیم p[0]
در غیر این صورت یک پیام خطا چاپ می کنیم.
جمع و ضرب نیز مستقیم به جلو هستند، با ضرب با استفاده از numpy matmul
روش:
def p_expression_add(p):
'expression : expression PLUS expression'
p[0] = p[1] + p[3]
def p_expression_multiply(p):
'expression : expression MULTIPLY expression'
p[0] = np.matmul(p[1],p[3])
به همین ترتیب، print
بیانیه را نیز می توان به راحتی تعریف کرد.
def p_statement_print(p):
'statement : PRINT LPAREN IDENTIFIER RPAREN'
variable_name = p[3]
if variable_name in variables:
print(variables[variable_name])
else:
print(f"Error: Variable '{variable_name}' not in variable table")
در اینجا ما عبارات را در قالب تجزیه می کنیم print(<IDENTIFIER>)
. بازیابی می کنیم IDENTIFIER
و آن را در جدول متغیر جستجو کنید. در صورت وجود، مقدار آن متغیر را چاپ می کنیم، در غیر این صورت، یک پیغام خطا چاپ می کنیم.
ما با یک کنترل کننده خطای ساده نتیجه می گیریم:
def p_error(p):
print("Syntax error: ", p)
برای اجرای برنامه، lexer و parser را می سازیم.
# Build the lexer and parser
lexer = lex.lex()
parser = yacc.yacc()
و کد DSL خود را تعریف کنید:
# Parsing and executing DSL code
dsl_code = """
vector v1 = [1, 2, 3]
vector v2 = [4, 5, 6]
print(v1)
print(v2)
matrix m1 = [[1, 2], [3, 4], [5, 6]]
matrix m2 = [[5, 6, 7], [7, 8, 9]]
print(m1)
print(m2)
vector v3 = v1 + v2
matrix m3 = m1 * m2
print(v3)
print(m3)
"""
در نهایت، ما می توانیم تماس بگیرید parse
روش بر روی parser
شیء در dsl_code
برای دیدن خروجی برنامه ما
parser.parse(dsl_code)
با آن، ما یک DSL کاملاً کاربردی در پایتون داریم
در این مقاله، زبانهای مخصوص دامنه را بررسی کردیم. ما دو نوع DSL داخلی و DSL و همچنین معیارهایی را که یک DSL خوب را تعریف می کند، بررسی کردیم. علاوه بر این، ما به مزایای ایجاد یک DSL برای مشکلات خاص دامنه نگاه کردیم. سپس به فرآیند طراحی و پیادهسازی یک DSL با استفاده از کتابخانه Ply پرداختیم که قابلیتهای lexing و تجزیه را در زبان پایتون فراهم میکند.
ما با تعریف نشانه های DSL خود شروع کردیم. اینها بلوکهای اصلی زبان را تشکیل میدهند، مانند اعداد، شناسهها و کلمات کلیدی مانند vector
و matrix
. ما از عبارات منظم برای تعریف قوانین توکن استفاده کردیم و از Ply’s lexer tok tokenize نمونه کد DSL خود استفاده کردیم.
سپس اقدام به طراحی قواعد دستور زبان خود کردیم تا ساختار نحوی و معنایی زبان خود را تعریف کنیم. ما قوانینی را برای اعلام بردارها و ماتریس ها و تخصیص آنها به متغیرها ایجاد کردیم. در ادامه به تعریف عملیات برای انجام جمع و ضرب ماتریس روی متغیرهایی که ذخیره کرده ایم و همچنین عملیاتی برای یک دستور چاپی می پردازیم. از طریق تجزیه کد، ما یک درخت نحو انتزاعی (AST) می سازیم که ساختار کد DSL را نشان می دهد.
با یادگیری نحوه پیاده سازی DSL در پایتون و نحوه استفاده از کتابخانه Ply، می دانید که دانش و ابزار لازم برای ایجاد زبان های خاص دامنه خود را دارید. خواه برای مهندسی داده، سیستمهای مبتنی بر قوانین، طراحی بازی یا سایر موارد استفاده باشد، یک DSL خوب میتواند توسعه نرمافزار و برنامههای کاربردی را تا حد زیادی تسهیل کند، بهرهوری و بیان کد را افزایش دهد.
اکنون زمان آن رسیده است که آنچه را که یاد گرفته اید به کار ببرید و شروع به بررسی امکانات ساخت DSL های خود کنید! با این ابزارهایی که در اختیار دارید، انعطافپذیری و قدرت ایجاد DSLهای قدرتمند و بهبود روش مقابله با مشکلات پیچیده در طیف گستردهای از دامنهها را دارید.
با تشکر از شما، و توسعه DSL مبارک!
کد کامل موجود در: https://github.com/fractalis/devto-articles/blob/main/python-dsl/matrix-dsl.py