Self-modifying python code (2)

W poprzednim wpisie dotyczącym pisania programów, które potrafią modyfikować swój własny kod przedstawiłem bardzo prosty przykład. Program w trakcie działania podmienił swój kod na inna wersję. Odczytywał on nową wersję modułu i z niej korzystał - nowa “wersja” musiała zostać napisana jednak przez człowieka. Czy nie ciekawiej by było jakby program potrafił sam modyfikować swój kod, całkowicie bez udziału nas? Dlatego w tym wpisie chciałbym kontynuować ten temat i przedstawić wam czym jest AST i jak pomoże nam osiągnąć ten cel.

Ponieważ jest to pojęcie dość mocno związane z procesem kompilacji i tym jak są budowane obecne kompilatory, nie udało mi uniknąć paru informacji na temat tego jak działa interpreter cpythona. Czasem warto zajrzeć ‘pod maskę’ i zrozumieć co tam mniej więcej się dzieje. Nie sa to rzeczy bardzo trudne, acz wymagają troszkę wiedzy na temat struktur danych (wypadało by wiedzieć czym np. jest drzewo)

Jak więc przebiega proces kompilacji kodu źródłowego do bajtkodu kiedy uruchamiamy program napisany w pythonie? Obecnie implementacja pythona wykona cztery następujące kroki po sobie:

  1. Pierwszym etapem jest stworzenie z naszego kodu tzw. parse tree
  2. Posiadając parse tree kompilator przekształci to drzewo do postaci AST
  3. Z AST kompilator tworzy Control Flow Graph
  4. I na samym końcu z CFG dostaniemy bytecode, który może zostać uruchomiony.

Jak widać głównym celem tych kroków jest zmienie naszego kodu źródłowego do formy z której można łatwo stworzyć bajt kod. Dzięki rozbiciu tego etapu na tyle kroków można uprościć samo pisanie kompilatora i co nas bardziej interesuje zmodyfikować program w trakcie jego działania. Z tej możliwości korzysta np. pylint który pozwala nam sprawdzić kod pod względem najbardziej popularnych błędów.

Zacznijmy może od pierwszego kroku. Czym jest parse tree? Taka ‘sucha” definicja mówi że parse tree jest wynikiem przeprowadzenia analizy składniowej zgodnie z gramatyka danego jezyka zapisanym w formie drzewa.

Wydaje mi się że ta definicja nie jest zbyt jasna (zwłaszcza dla kogoś kto teorie języków i automatów ma już daaaawno za sobą). Spójrzmy może lepiej na przykład który powinien nam to rozjaśnić.

Python posiada wbudowany moduł parser który umożliwia nam stworzenie takiego właśnie drzewa. Używam pprint żeby wynik działania był bardziej czytelny (co jednak aż tak wiele nam nie pomoże). Wynik działania tego programu możemy zobaczyć tutaj

Jak można zauważyć drzewo to zawiera parę interesujących elementów. Zwróćmy uwagę na ten fragment:

Widzimy tutaj część drzewa która trzyma informację na temat printa. O ile samo wywołanie funkcji i podanie argumentu do niej jest dość niezbędne w działaniu programu, to jednak trzymanie informacji na temat otwarcia nawiasu, czy jego zamknięciu nie jest już aż tak potrzebne. Zwłaszcza żeby zrozumieć logikę stojącej za działaniem programu. Czym jest więc parse tree? To nic innego jak zapisane naszego programu w formie drzewa. Z uwzględnieniem wszystkich elementów języka jak np. średniki, spację, otwarcie nawiasów itp.

Mając to już za sobą - możemy teraz przejść do tego czym jest więc drzewo ast. A AST to nic innego jak uproszczona wersja parse tree. Znowu przykład powie nam więcej niż definicja:

Wynik wygląda tak.

“Module(body=[Assign(targets=[Name(id=’a’, ctx=Store())], value=Num(n=10)), Assign(targets=[Name(id=’b’, ctx=Store())], value=Num(n=0)), While(test=Compare(left=Name(id=’a’, ctx=Load()), ops=[Gt()], comparators=[Num(n=0)]), body=[AugAssign(target=Name(id=’b’, ctx=Store()), op=Add(), value=Num(n=1)), If(test=Compare(left=BinOp(left=Name(id=’a’, ctx=Load()), op=Mod(), right=Num(n=2)), ops=[Eq()], comparators=[Num(n=0)]), body=[AugAssign(target=Name(id=’a’, ctx=Store()), op=Sub(), value=Name(id=’b’, ctx=Load()))], orelse=[AugAssign(target=Name(id=’a’, ctx=Store()), op=Sub(), value=BinOp(left=Name(id=’b’, ctx=Load()), op=Add(), right=Num(n=1)))])], orelse=[]), Print(dest=None, values=[Name(id=’b’, ctx=Load())], nl=True)])”

Na pierwszy rzut oka może się wydawać że wygląda to bardziej skomplikowanie niż nasza poprzednia wersja, jednak wystarczy tylko się przyjrzeć i od razu zrozumiemy co robi nasz program. AST jest bytem bardziej abstrakcyjnym niż nasze pierwsze drzewo. W momencie kiedy nie interesuje nas już sama gramatyka, a tylko bardziej logika naszego programu o wiele lepiej jest się posługiwać drzewem AST. Cecha ta pozwala nam w przyszłości móc modyfikować program w trakcie jego działania.

Teraz na koniec chciałbym przedstawić prosty przykład zmodyfikowania programu poprzez inny program. W naszym przykładzie umieścimy kod pod postacią stringa, ale równie dobrze moglibyśmy otworzyć jakiś inny plik źródłowy i przeprowadzić na nim tą samą operację co przeprowadzimy na naszym przykładzie.

Nie wdając się w zbędne szczegóły - ast.Transformer jest klasa umożliwiająca przechodzenie naszego drzewa i dokonywania zmian w nim. My w naszym prostym przykładzie podmienimy operacje mnożenia na operacje odejmowania. Jak widać kod ktory to robi jest dość prosty i nie wymaga chyba żadnego komentarza. Dodam jedynie że w linii 16 dokonuje się podmiana operacji mnożenia na odejmowanie. Operacje te są zdefiniowane w module ast. Jak mozna sie spodziewac - wynikiem działania tego programu będzie 5 (zamiast 50 które mogłoby wynikać z kodu)

Poniewaz temat jest dosc ciekawy i tak naprawde dotknelismy dopiero podstaw w kolejnym poście chciałabym przedstawić troszke wiecej zastosowan modyfikacji kodu za pomocą AST.

Więcej informacji można znaleźć pod linkami:

  1. Python AST
  2. PEP 339
  3. Parse python source code
  4. Drzewo wyprowadzenia
  5. AST vs CST
  6. Python compiler
  7. About AST
  8. AST