나도 CPython 파헤치기2

이주환·2023년 8월 12일
0

안녕 씨파이썬?

목록 보기
2/3

파이썬 언어와 문법

👏 원리 설명

파이썬도 컴파일을 하다

컴파일러를 선택 할 때 고려 해야 하는 조건 중 하나는 이식성이다. 어떤 컴파일러는 시스템에서 바로 실행 할 수 있는 저수준 기계어로 컴파일 하지만, 어떤 컴파일러는 가상 머신에서 실행 하기 위한 중간 언어로 컴파일한다.

  • 중간 언어 컴파일: Java, .NET 등
  • 바이너리 컴파일: C, C++, Go, Pascal 등

이 중, 파이썬은 소스 코드 형태로 배포 되는 형태이다. 파이썬의 인터프리터는 소스 코드를 변환 한 후 한줄씩 실행 한다.
CPython 런타임이 첫번째 실행 될 때 코드를 컴파일 하지만 이 단계는 일반 개발자에게 노출 되지 않는다.

파이썬 코드는 기계어 대신 바이트 코드라는 저수준 중간 언어로 컴파일 된다. 바이트 코드는 .pyc 파일에 저장 되고 실행을 위해 캐싱된다. 코드가 수정 되지 않는 파이썬 파일은 매번 컴파일 하지 않기 때문에 빠르게 실행 되는 것을 확인 할 수 있다.

👏 원리 설명

4.1 CPython이 파이썬이 아니라 C로 작성된 이유: @book p.40

네트워크 소켓, 파일 시스템 조작, 윈도우와 리눅스 커널 API는 모두 C로 작성 되었기 때문에 파이썬 또한 확장계층을 C에 집중하는게 합리적이었다.

CPython 외 다른 컴파일러

- PyPy: 파이썬으로 작성 된 컴파일러
- Jython: 파이썬 크로스 컴파일러로 자바로 작성 되었고 파이썬 코드를
		  자바 바이트코드로 컴파일 하며 자바 모듈과 클래스 참조가 가능함

파이썬 언어 레퍼런스

책에서 간단한 예시로 with문을 예시로 들었다. 해당 파일에 접근 하여 어떻게 정의 되어 있는지 찾아보았다.

with 문은 컨텍스트 관리자가 정의한 메서드로 블록 실행을 감싸는 데 사용됩니다.
메서드로 블록 실행을 래핑하는 데 사용됩니다(context-managers 섹션 참조)

이를 통해 일반적인 try...\ except...\ finally 사용 패턴을 캡슐화하여 편리하게 재사용할 수 있습니다.

파이썬 문법
with_stmt: "with" with_item ("," with_item)* ":" suite
with_item: expression ["as" target]

하나의 item이 포함된 with 문 실행은 다음과 같이 진행됩니다

  1. 컨텍스트 표현식(with_item에 주어진 표현식)이 평가되어 컨텍스트 관리자를 가져옵니다.

  2. 컨텍스트 관리자의 __enter__가 나중에 사용할 수 있도록 로드됩니다.

  3. 컨텍스트 관리자의 __exit__가 나중에 사용할 수 있도록 로드됩니다.

  4. 컨텍스트 관리자의 __enter__ 메서드가 호출됩니다.

  5. 대상이with 문에 포함된 경우, 반환 값인 __enter__가 할당됩니다.

with 문은 __enter__ 메서드가 오류 없이 반환될 경우 항상 __exit__가 호출됩니다.

Python with 문법

    with EXPRESSION as TARGET:
        SUITE

with 문법이 실행 되면 아래와 같이 코드가 실행 됩니다

    manager = (EXPRESSION)
    enter = type(manager).__enter__
    exit = type(manager).__exit__
    value = enter(manager)
    hit_except = False

    try:
        TARGET = value
        SUITE
    except:
        hit_except = True
        if not exit(manager, *sys.exc_info()):
            raise
    finally:
        if not hit_except:
            exit(manager, None, None, None)

예제 만들어보기


위 코드를 실행 하고 난 후 과연 with문이 끝나면 정말 메모리를 소멸 시키는게 맞을까? 내가 이 것을 증명 할 수 있을까 라는 의문을 품으며 tracemallocsys.getrefcount를 사용 하여 레퍼런스 카운트가 감소 하는지, 메모리에 할당 된 크기가 줄어드는지 등 확인을 해 보았는데 가비지 컬렉션이 회수 하는 시점이 달라서 그런지 확인하기 어려웠다. CPython을 공부하고 난 후 가비지 컬렉션의 메모리 수거 시점을 나중에 눈으로 보고 증명 할 수 있으면 좋겠다.

문법 파일

Grammar > python.gram

문법 파일은 아래와 같은 표기법을 사용 할 수 있다.

  • *: 반복
  • +: 최소 1회 반복
  • []: 선택적인 부분 표시
  • |: 대안
  • (): 그룹

예시
커피 한 잔을 문서에 정의 해보자.

  • 컵이 있어야 한다
  • 최소 에스프레소 한 샷 또는 여러샷이 필요하다
  • 우유나 물은 선택사항
  • 우유를 사용 했다면 두유, 저지방 우유 등 옵션이 존재함

문서

coffee: 'cup' ('espresso')+ ['water'] [milk]
milk: 'full-fat' | 'skimmed' | 'soy'

다이어그램

파이썬 문법에서 예시

while:

문서

while_stmt[stmt_ty]:
	| 'while' a=named_expression ':' b=block c=[else_block]...
  • 따옴표로 둘러싸인 부분은 문자열 리터럴이다.
  • block은 한 개 이상의 문장이 있는 코드 블록이다.
  • named_expressions은 간단한 표현식 또는 대입 표현식이다.

try:

이렇게 Grammer.python.gram 에 들어가보면 실제 파이썬에서 사용 하는 문법의 정의가 되어있다. 그 중 try문은 복잡한 문법의 좋은 예시이다.

3.9버전 부터 CPython은 파서 테이블 생성기(pgen 모듈) 대신, 문맥 의존 문법 파서를 사용한다고 한다.
기존 파서는 -X oldparser 플래그를 활성화 해 사용 할 수 있지만 파이썬 3.10버전에서 완전히 제거 되었다. 그렇기 때문에 이 책에서는 3.9의 새로운 PEG 파서를 다룬다고 한다.

💻 실습

4.4 문법 다시 생성하기: @book p.46

CPython 3.9 버전부터 새로 도입 된 pegen을 테스트해 보기 위해 문법 일부를 변경 해보자.

Grammar> python.gram: [lineno] 60

[lineno] 66 에 위치 해 있는 pass 줄에 |proceed를 붙혀서 pass 또는 proceed를 사용하면 구문을 사용 할 수 있는지 테스트 해보자.

❗️꼭 따라 해보며 흐름을 익히자

수정 된 66번 라인

| ( 'pass' | 'proceed' ) { _Py_Pass(EXTRA) }

pass | proceed를 변경 해도 명령어를 찾을 수 없다고 나오는 경우

make -j2 -s

다시 책에서 안내 하는 방식으로 파이썬 파일을 생성하면 proceed가 작동 되는 것을 확인 할 수 있다.

def hello_cpython():
     proceed
 
hello_cpython()

파일 수정 후 적용 방법 정리

  1. Grammar > python.gram 에서 문서 수정
  2. 수정 된 문법 파일 빌드
    make regen-pegen
    • Parser > pegen > parse.c 파일이 생성 되었다는 출력문 확인
  3. 운영체제 별 컴파일 실행
    macOS 기준
    make -j2 -s

한글로 문법을 한 번 적어 봤는데 안된다. 혹시 이 글을 읽는 누군가는 시도 해 볼 것 같아서 찾아보았다.
스택오버플로를 보면 다른 언어로 시도 하려는 존재가 분명 있다!

스택오버플로 질문 내용

이에 대한 답변:

오류 메시지를 정확히 찾아내세요. .\Tools\peg_generator\pegen\c_generator.py에 있습니다. 
파일을 열면 AssertionError 위에 re.match 줄을 볼 수 있습니다. 필요에 따라 일치 패턴을 수정합니다.

make regen-pegen을 실행하여 Parser/parser.c를 다시 생성합니다. 
또한 이 파일의 11번째 줄부터 reserved_keywords[]를 약간 변경해야 할 수도 있습니다. 
제 경우에는 '函'('def'에 해당)을 예로 들면 수정 전에는 두 번째 { } 안에 나타나지만,
중국어 문자 하나는 utf-8에서 3바이트를 사용하므로 'def'와 함께 네 번째 { } 안에 들어가야 합니다. 순서에도 유의하세요.

저도 시도 해 봤지만, 밑에 설명하는 부분을 해결 하지 못해서 실패 했는데 성공 사례를 보면 다시 해보고 싶네요.

제가 시도 해 본 부분:
정규표현식 부분을 수정하지 않으면 literal 에러가 떨어졌습니다.

  • re.match([가-힣az-A-Z_]) 기존, [a-zA-Z_]를 옆에와 같이 수정
  • 문법 파일에 아까와 같이 ('pass' | '패스') 로 수정
  • 파서 생성 및 컴파일러 빌드
    찾을 수 없다고 나왔습니다ㅠㅠ.. 한글도 3bytes가 적용 되니까, 밑에 수정한 부분을 건들어야 되겠다 라는 판단은 했지만 c_generator.py 를 아무리 만져봐도 해결이 안됐네요.. 😱

토큰

실제 적용 되어 있는 토큰 사례

샘플 코드를 작성하고, 코드에서 사용되는 토큰을 알아보자

def my_function():
    proceed

폴더 구조를 cpython root와 tutorial code 폴더를 나눴기 때문에 아래와 같은 명령어로 tokenize를 확인 할 수 있지만, 루트 폴더에 바로 만들었다면 현재 본인 폴더의 상대경로 python.exe를 잡고 명령어를 실행 하면 된다.

../cpython/python.exe -m tokenize -e ./test_tokens.py

  • ENCODING: utf-8 인코딩 토큰
  • DEDENT: 함수 정의를 마치는 토큰
  • ENDMARKER: 파일의 끝을 알리는 토큰
  • 끝은 공백

test_tokens.py 가 파싱 되는 과정

../cpython/python.exe -d ./test_tokens.py

아래 출력문 보다 훨씬 많이 나온다.

       > _tmp_14[14-14]: 'import'
       - _tmp_14[14-14]: 'import' failed!
       > _tmp_14[14-14]: 'from'
       - _tmp_14[14-14]: 'from' failed!
      - small_stmt[14-14]: &('import' | 'from') import_stmt failed!
      > small_stmt[14-14]: &'raise' raise_stmt
      - small_stmt[14-14]: &'raise' raise_stmt failed!
      > small_stmt[14-14]: ('pass' | 'proceed')
       > _tmp_15[14-14]: 'pass'
       - _tmp_15[14-14]: 'pass' failed!
       > _tmp_15[14-14]: 'proceed'
       - _tmp_15[14-14]: 'proceed' failed!
      - small_stmt[14-14]: ('pass' | 'proceed') failed!
      > small_stmt[14-14]: &'del' del_stmt
      - small_stmt[14-14]: &'del' del_stmt failed!
      > small_stmt[14-14]: &'yield' yield_stmt
      - small_stmt[14-14]: &'yield' yield_stmt failed!
      > small_stmt[14-14]: &'assert' assert_stmt
      - small_stmt[14-14]: &'assert' assert_stmt failed!
      > small_stmt[14-14]: 'break'
      - small_stmt[14-14]: 'break' failed!
      > small_stmt[14-14]: 'continue'
      - small_stmt[14-14]: 'continue' failed!
      > small_stmt[14-14]: &'global' global_stmt
      - small_stmt[14-14]: &'global' global_stmt failed!
      > small_stmt[14-14]: &'nonlocal' nonlocal_stmt
      - small_stmt[14-14]: &'nonlocal' nonlocal_stmt failed!
     - simple_stmt[14-14]: small_stmt !';' NEWLINE failed!
     > simple_stmt[14-14]: ';'.small_stmt+ ';'? NEWLINE
      > _gather_12[14-14]: small_stmt _loop0_13
      - _gather_12[14-14]: small_stmt _loop0_13 failed!
     - simple_stmt[14-14]: ';'.small_stmt+ ';'? NEWLINE failed!
    - statement[14-14]: simple_stmt failed!
   - _loop1_11[14-14]: statement failed!
  + statements[0-14]: statement+ succeeded!
 + file[0-15]: statements? $ succeeded!

이제 CPython의 흐름을 이해하는데 도움을 준 테스트를 마치고, 다시 원상복구 한 다음 컴파일 하여 코드를 정리하자.

$ git checkout -- Grammar/python.gram
$ make regen-pegen
$ make -j2 -s

만약 위에서 pegen/c_generoate.py 를 건들었다면 원래대로 복구 하고 빌드 하면 된다.

profile
안녕하새우

0개의 댓글