Ciclo de vida do código Python - CPython Runtime Model

Olá pessoal! A primavera chegou, o que significa que resta menos de um mês antes do início do curso Python Developer . É a este curso que a publicação de hoje será dedicada.





Tarefas:

- Aprenda sobre a estrutura interna do Python;
- Compreender os princípios de construção de uma árvore de sintaxe abstrata (AST);
- Escreva código mais eficiente em tempo e memória.

Recomendações preliminares:

- Compreensão básica do intérprete (AST, fichas, etc.).
- Conhecimento de Python.
- Conhecimentos básicos de C.

batuhan@arch-pc  ~/blogs/cpython/exec_model  python --version Python 3.7.0 

Modelo de Execução do CPython

Python é uma linguagem compilada e interpretada. Assim, o compilador Python gera bytecodes e o intérprete os executa. Durante o artigo, consideraremos os seguintes problemas:

  • Análise e tokenização:
  • Transformação em AST;
  • Gráfico de Fluxo de Controle (CFG);
  • Bytecode
  • Executando em uma máquina virtual CPython.

Análise e tokenização.
Gramática.

A gramática define a semântica da linguagem Python. Também é útil para indicar a maneira como o analisador deve interpretar o idioma. A gramática Python usa sintaxe semelhante à forma estendida de Backus-Naur. Possui gramática própria para um tradutor do idioma de origem. Você pode encontrar o arquivo de gramática no diretório "cpython / Grammar / Grammar".

A seguir, um exemplo de gramática,

 batuhan@arch-pc  ~/cpython/Grammar   master  grep "simple_stmt" Grammar | head -n3 | tail -n1 simple_stmt: small_stmt (';' small_stmt)* [';'] NEWLINE 

Expressões simples contêm pequenas expressões e colchetes, e o comando termina com uma nova linha. Expressões pequenas parecem uma lista de expressões que importam ...
Algumas outras expressões :)

 small_stmt: (expr_stmt | del_stmt | pass_stmt | flow_stmt | import_stmt | global_stmt | nonlocal_stmt | assert_stmt) ... del_stmt: 'del' exprlist pass_stmt: 'pass' flow_stmt: break_stmt | continue_stmt | return_stmt | raise_stmt | yield_stmt break_stmt: 'break' continue_stmt: 'continue' 

Tokenização (Lexing)

Tokenização é o processo de obter um fluxo de dados de texto e dividir em tokens de palavras significativas (para o intérprete) com metadados adicionais (por exemplo, onde o token começa e termina e qual é o valor da string desse token).

Tokens

Um token é um arquivo de cabeçalho que contém definições de todos os tokens. Existem 59 tipos de tokens no Python (Include / token.h).

Abaixo está um exemplo de alguns deles,

 #define NAME 1 #define NUMBER 2 #define STRING 3 #define NEWLINE 4 #define INDENT 5 #define DEDENT 6 

Você os verá se você dividir algum código Python em tokens.

Tokenização com CLI

Aqui está o arquivo tests.py

 def greetings(name: str): print(f"Hello {name}") greetings("Batuhan") 

Em seguida, o tokenizamos usando o comando python -m tokenize e o seguinte é gerado:

 batuhan@arch-pc  ~/blogs/cpython/exec_model  python -m tokenize tests.py 0,0-0,0: ENCODING 'utf-8' ... 2,0-2,4: INDENT ' ' 2,4-2,9: NAME 'print' ... 4,0-4,0: DEDENT '' ... 5,0-5,0: ENDMARKER '' 

Aqui, os números (por exemplo, 1.4-1.13) mostram onde o token começou e onde terminou. Um token de codificação é sempre o primeiro token que recebemos. Ele fornece informações sobre a codificação do arquivo de origem e, se houver algum problema, o intérprete gera uma exceção.

Tokenização com tokenize.tokenize

Se você precisar tokenizar o arquivo a partir do seu código, poderá usar o módulo tokenize no stdlib .

 >>> with open('tests.py', 'rb') as f: ... for token in tokenize.tokenize(f.readline): ... print(token) ... TokenInfo(type=57 (ENCODING), string='utf-8', start=(0, 0), end=(0, 0), line='') ... TokenInfo(type=1 (NAME), string='greetings', start=(1, 4), end=(1, 13), line='def greetings(name: str):\n') ... TokenInfo(type=53 (OP), string=':', start=(1, 24), end=(1, 25), line='def greetings(name: str):\n') ... 

O tipo de token é seu ID no arquivo de cabeçalho token.h. String é o valor do token. Início e Fim mostram onde o token começa e termina, respectivamente.

Em outros idiomas, os blocos são indicados entre colchetes ou instruções de início / fim, mas o Python usa recuos e níveis diferentes. Indent e DEDENT tokens e indicam recuo. Esses tokens são necessários para criar árvores de análise relacional e / ou árvores de sintaxe abstrata.

Geração de analisador

Geração de analisador - um processo que gera um analisador de acordo com uma determinada gramática. Os geradores de analisadores são conhecidos como PGen. O Cpython possui dois geradores de analisador. Um é o original ( Parser/pgen ) e um reescrito em python ( /Lib/lib2to3/pgen2 ).

"O gerador original foi o primeiro código que escrevi para Python"
-Guido

A partir da citação acima, fica claro que pgen é a coisa mais importante em uma linguagem de programação. Quando você chama pgen (no Analisador / pgen ), ele gera um arquivo de cabeçalho e um arquivo C (o próprio analisador). Se você olhar para o código C gerado, pode parecer inútil, pois sua aparência significativa é opcional. Ele contém muitos dados e estruturas estáticos. A seguir, tentaremos explicar seus principais componentes.

DFA (Máquina de Estado Definido)

O analisador define um DFA para cada não-terminal. Cada DFA é definido como uma matriz de estados.

 static dfa dfas[87] = { {256, "single_input", 0, 3, states_0, "\004\050\340\000\002\000\000\000\012\076\011\007\262\004\020\002\000\300\220\050\037\202\000"}, ... {342, "yield_arg", 0, 3, states_86, "\000\040\200\000\002\000\000\000\000\040\010\000\000\000\020\002\000\300\220\050\037\000\000"}, }; 

Para cada DFA, há um kit inicial que mostra com quais tokens um terminal não terminal especial pode começar.

Condição

Cada estado é definido por uma matriz de transições / arestas (arcos).

 static state states_86[3] = { {2, arcs_86_0}, {1, arcs_86_1}, {1, arcs_86_2}, }; 

Transições (arcos)

Cada matriz de transição possui dois números. O primeiro número é o nome da transição (etiqueta do arco), refere-se ao número do símbolo. O segundo número é o destino.

 static arc arcs_86_0[2] = { {77, 1}, {47, 2}, }; static arc arcs_86_1[1] = { {26, 2}, }; static arc arcs_86_2[1] = { {0, 2}, }; 

Nomes (etiquetas)

Esta é uma tabela especial que mapeia o ID do caractere para o nome da transição.

 static label labels[177] = { {0, "EMPTY"}, {256, 0}, {4, 0}, ... {1, "async"}, {1, "def"}, ... {1, "yield"}, {342, 0}, }; 

O número 1 corresponde a todos os identificadores. Assim, cada um deles obtém nomes de transição diferentes, mesmo que todos tenham o mesmo ID de caractere.

Um exemplo simples:

Suponha que tenhamos um código que emita "Hi" se 1 - true:

 if 1: print('Hi') 

O analisador considera esse código como single_input.

 single_input: NEWLINE | simple_stmt | compound_stmt NEWLINE 

Em seguida, nossa árvore de análise começa no nó raiz de uma entrada única.



E o valor do nosso DFA (single_input) é 0.

 static dfa dfas[87] = { {256, "single_input", 0, 3, states_0, "\004\050\340\000\002\000\000\000\012\076\011\007\262\004\020\002\000\300\220\050\037\202\000"} ... } 

Ficará assim:

 static arc arcs_0_0[3] = { {2, 1}, {3, 1}, {4, 2}, }; static arc arcs_0_1[1] = { {0, 1}, }; static arc arcs_0_2[1] = { {2, 1}, }; static state states_0[3] = { {3, arcs_0_0}, {1, arcs_0_1}, {1, 
arcs_0_2},
};

Aqui arc_0_0 consiste em três elementos. O primeiro é NEWLINE , o segundo é simple_stmt e o último é compound_stmt .

Dado o conjunto inicial de simple_stmt podemos concluir que simple_stmt não pode começar com if . No entanto, mesmo se não for uma nova linha, o compound_stmt ainda poderá começar com if . Portanto, passamos para o último arc ({4;2}) e adicionamos o nó composite_stmt à árvore de análise e mudamos para o novo DFA antes de prosseguir para o número 2. Temos uma árvore de análise atualizada:



O novo DFA analisa instruções compostas.

 compound_stmt: if_stmt | while_stmt | for_stmt | try_stmt | with_stmt | funcdef | classdef | decorated | async_stmt 

E temos o seguinte:

 static arc arcs_39_0[9] = { {91, 1}, {92, 1}, {93, 1}, {94, 1}, {95, 1}, {19, 1}, {18, 1}, {17, 1}, {96, 1}, }; static arc arcs_39_1[1] = { {0, 1}, }; static state states_39[2] = { {9, arcs_39_0}, {1, arcs_39_1}, }; 

Somente o primeiro salto pode começar com if . Temos uma árvore de análise atualizada.



Em seguida, alteramos novamente o DFA e os próximos ifs do DFA.

 if_stmt: 'if' test ':' suite ('elif' test ':' suite)* ['else' ':' suite] 

Como resultado, obtemos a única transição com a designação 97. Ela coincide com o token if .

 static arc arcs_41_0[1] = { {97, 1}, }; static arc arcs_41_1[1] = { {26, 2}, }; static arc arcs_41_2[1] = { {27, 3}, }; static arc arcs_41_3[1] = { {28, 4}, }; static arc arcs_41_4[3] = { {98, 1}, {99, 5}, {0, 4}, }; static arc arcs_41_5[1] = { {27, 6}, }; static arc arcs_41_6[1] = { {28, 7}, }; ... 

Quando passamos para o próximo estado, permanecendo no mesmo DFA, o próximo arcs_41_1 também possui apenas uma transição. Isso é verdade para o terminal de teste. Ele deve começar com um número (por exemplo, 1).



Existe apenas uma transição no arc_41_2 que obtém o token de dois pontos (:).



Portanto, temos um conjunto que pode começar com um valor de impressão. Por fim, vá para arcs_41_4. A primeira transição no conjunto é uma expressão elif, a segunda é outra, e a terceira é para o último estado. E temos a visão final da árvore de análise.



Interface Python para analisador.


O Python permite editar a árvore de análise de expressões usando um módulo chamado analisador.

 >>> import parser >>> code = "if 1: print('Hi')" 

Você pode gerar uma árvore de sintaxe específica (Árvore de sintaxe concreta, CST) usando o parser.suite.

 >>> parser.suite(code).tolist() [257, [269, [295, [297, [1, 'if'], [305, [309, [310, [311, [312, [315, [316, [317, [318, [319, [320, [321, [322, [323, [324, [2, '1']]]]]]]]]]]]]]]], [11, ':'], [304, [270, [271, [272, [274, [305, [309, [310, [311, [312, [315, [316, [317, [318, [319, [320, [321, [322, [323, [324, [1, 'print']], [326, [7, '('], [334, [335, [305, [309, [310, [311, [312, [315, [316, [317, [318, [319, [320, [321, [322, [323, [324, [3, "'Hi'"]]]]]]]]]]]]]]]]]], [8, ')']]]]]]]]]]]]]]]]]]], [4, '']]]]]], [4, ''], [0, '']] 

O valor de saída será uma lista aninhada. Cada lista na estrutura possui 2 elementos. O primeiro é o ID do caractere (0-256 - terminais, 256+ - não terminais) e o segundo é a sequência de tokens para o terminal.

Árvore de sintaxe abstrata

De fato, uma árvore de sintaxe abstrata (AST) é uma representação estrutural do código-fonte na forma de uma árvore, em que cada vértice denota tipos diferentes de construções de linguagem (expressão, variável, operador, etc.)

O que é uma árvore?

Uma árvore é uma estrutura de dados que possui um vértice raiz como ponto de partida. O vértice raiz é abaixado por ramos (transições) até outros vértices. Cada vértice, exceto a raiz, possui um vértice pai exclusivo.

Há uma certa diferença entre a árvore de sintaxe abstrata e a árvore de análise.



À direita, há uma árvore de análise para a expressão 2 * 7 + 3. Esta é literalmente uma imagem de origem individual sob a forma de uma árvore. Todas as expressões, termos e valores são visíveis nele. No entanto, essa imagem é muito complicada para um simples trecho de código; portanto, podemos simplesmente remover todas as expressões sintáticas que tivemos que definir no código para os cálculos.

Essa árvore simplificada é chamada de árvore de sintaxe abstrata (AST). À esquerda na figura, é mostrado exatamente para o mesmo código. Todas as expressões de sintaxe foram descartadas para facilitar o entendimento, mas lembre-se de que determinadas informações foram perdidas.

Exemplo

O Python oferece um módulo AST embutido para trabalhar com o AST. Para gerar código a partir de árvores AST, você pode usar módulos de terceiros, por exemplo, Astor .

Por exemplo, considere o mesmo código de antes.

 >>> import ast >>> code = "if 1: print('Hi')" 

Para obter o AST, usamos o método ast.parse.

 >>> tree = ast.parse(code) >>> tree <_ast.Module object at 0x7f37651d76d8> 

Tente usar o método ast.dump para obter uma árvore de sintaxe abstrata mais legível.

 >>> ast.dump(tree) "Module(body=[If(test=Num(n=1), body=[Expr(value=Call(func=Name(id='print', ctx=Load()), args=[Str(s='Hi')], keywords=[]))], orelse=[])])" 

O AST é geralmente considerado mais visual e fácil de entender do que a árvore de análise.

Graf de fluxo de controle (CFG)

Os gráficos de fluxo de controle são gráficos direcionais que modelam o fluxo de um programa usando blocos base que contêm uma representação intermediária.

Normalmente, CFG é a etapa anterior antes de obter os valores do código de saída. O código é gerado diretamente a partir dos blocos de base usando uma pesquisa de profundidade para trás no CFG, começando pelos topos.

Bytecode

O bytecode do Python é um conjunto intermediário de instruções que funcionam em uma máquina virtual Python. Quando você executa o código-fonte, o Python cria um diretório chamado __pycache__. Ele armazena o código compilado para economizar tempo em caso de reiniciar a compilação.

Considere uma função Python simples em func.py.

 def say_hello(name: str): print("Hello", name) print(f"How are you {name}?") 

 >>> from func import say_hello >>> say_hello("Batuhan") Hello Batuhan How are you Batuhan? 

O say_hello objeto say_hello é uma função.

 >>> type(say_hello) <class 'function'> 

Possui o atributo __code__ .

 >>> say_hello.__code__ <code object say_hello at 0x7f13777d9150, file "/home/batuhan/blogs/cpython/exec_model/func.py", line 1> 

Objetos de código

Objetos de código são estruturas de dados imutáveis ​​que contêm as instruções ou metadados necessários para executar o código. Simplificando, essas são representações do código Python. Você também pode obter objetos de código compilando árvores de sintaxe abstrata usando o método compile .

 >>> import ast >>> code = """ ... def say_hello(name: str): ... print("Hello", name) ... print(f"How are you {name}?") ... say_hello("Batuhan") ... """ >>> tree = ast.parse(code, mode='exec') >>> code = compile(tree, '<string>', mode='exec') >>> type(code) <class 'code'> 

Cada objeto de código possui atributos que contêm determinadas informações (prefixadas com co_). Aqui mencionamos apenas alguns deles.

co_name

Para começar - um nome. Se uma função existe, ela deve ter um nome, respectivamente, esse nome será o nome da função, uma situação semelhante à da classe. No exemplo AST, usamos módulos e podemos dizer exatamente ao compilador como queremos chamá-los. Deixe que sejam apenas um module .

 >>> code.co_name '<module>' >>> say_hello.__code__.co_name 'say_hello' 

co_varnames

Esta é uma tupla que contém todas as variáveis ​​locais, incluindo argumentos.

 >>> say_hello.__code__.co_varnames ('name',) 

co_conts

Uma tupla contendo todos os literais e valores constantes que acessamos no corpo da função. Aqui vale a pena notar o valor de Nenhum. Não incluímos None no corpo da função, no entanto, o Python indicou, porque se a função começar a executar e terminar sem receber nenhum valor de retorno, ela retornará None.

 >>> say_hello.__code__.co_consts (None, 'Hello', 'How are you ', '?') 

Código de bytes (co_code)

A seguir, é apresentado o bytecode de nossa função.

 >>> bytecode = say_hello.__code__.co_code >>> bytecode b't\x00d\x01|\x00\x83\x02\x01\x00t\x00d\x02|\x00\x9b\x00d\x03\x9d\x03\x83\x01\x01\x00d\x00S\x00' 

Isso não é uma string, é um objeto de byte, ou seja, uma sequência de bytes.

 >>> type(bytecode) <class 'bytes'> 

O primeiro caractere impresso é "t". Se pedirmos seu valor numérico, obteremos o seguinte.

 >>> ord('t') 116 

116 é o mesmo que bytecode [0].

 >>> assert bytecode[0] == ord('t') 

Para nós, um valor de 116 não significa nada. Felizmente, o Python fornece uma biblioteca padrão chamada dis (da desmontagem). É útil ao trabalhar com a lista opname. Esta é uma lista de todas as instruções de bytecode, em que cada índice é o nome da instrução.

 >>> dis.opname[ord('t')] 'LOAD_GLOBAL' 

Vamos criar outra função mais complexa.

 >>> def multiple_it(a: int): ... if a % 2 == 0: ... return 0 ... return a * 2 ... >>> multiple_it(6) 0 >>> multiple_it(7) 14 >>> bytecode = multiple_it.__code__.co_code 

E descobrimos a primeira instrução em bytecode.

 >>> dis.opname[bytecode[0]] 'LOAD_FAST 

A instrução LOAD_FAST consiste em dois elementos.

 >>> dis.opname[bytecode[0]] + ' ' + dis.opname[bytecode[1]] 'LOAD_FAST <0>' 

A instrução Loadfast 0 procura por nomes de variáveis ​​em uma tupla e envia um elemento com um índice zero para uma tupla na pilha de execução.

 >>> multiple_it.__code__.co_varnames[bytecode[1]] 'a' 

Nosso código pode ser desmontado usando dis.dis e converter o bytecode em uma visualização familiar. Aqui os números (2,3,4) são os números de linha. Cada linha de código no Python é expandida como um conjunto de instruções.

 >>> dis.dis(multiple_it) 2 0 LOAD_FAST 0 (a) 2 LOAD_CONST 1 (2) 4 BINARY_MODULO 6 LOAD_CONST 2 (0) 8 COMPARE_OP 2 (==) 10 POP_JUMP_IF_FALSE 16 3 12 LOAD_CONST 2 (0) 14 RETURN_VALUE 4 >> 16 LOAD_FAST 0 (a) 18 LOAD_CONST 1 (2) 20 BINARY_MULTIPLY 22 RETURN_VALUE 

Prazo de execução

CPython é uma máquina virtual orientada a pilha, não uma coleção de registros. Isso significa que o código Python é compilado para uma máquina virtual com uma arquitetura de pilha.

Ao chamar uma função, o Python usa duas pilhas juntas. O primeiro é a pilha de execução e o segundo é a pilha de blocos. A maioria das chamadas ocorre na pilha de execução; a pilha de blocos controla quantos blocos estão ativos no momento, bem como outros parâmetros relacionados a blocos e escopos.

Aguardamos seus comentários sobre o material e convidamos você para um seminário on- line aberto sobre o tema: “Solte-o: aspectos práticos da liberação de software confiável”, que será conduzido por nosso professor, programador do sistema de publicidade no Mail.Ru, Stanislav Stupnikov .

Source: https://habr.com/ru/post/pt442252/


All Articles