CPython 구조와 실행 방법이 요즘 뜨거운 주제라서 찾아보았다. 이후 이어지는 강의도 확인하고
언제나 그렇듯이 원문은 이 링크를 따라가면 만날 수 있습니다.
Python 비하인드 스토리 #1: CPython VM의 작동 방식
작성자: Victor Skvortsov
태그: Python 비하인드 스토리 Python CPython
소개
프로그램을 실행할 때 Python이 무엇을 하는지 궁금한 적이 있나요?
$ python script.py
이 글은 바로 이 질문에 답하고자 하는 시리즈를 시작합니다. Python의 가장 인기 있는 구현체인 CPython의 내부 구조를 살펴보겠습니다. 그렇게 하면 언어 자체를 더 깊이 이해할 수 있습니다. 이것이 이 시리즈의 주요 목표입니다. Python에 익숙하고 C를 읽는 데 익숙하지만 CPython의 소스 코드로 작업한 경험이 많지 않다면 이 글이 흥미로울 가능성이 큽니다.
CPython이 무엇이고 왜 공부하고 싶어 할까요?
잘 알려진 사실 몇 가지를 언급하면서 시작해 보겠습니다. CPython은 C로 작성된 Python 인터프리터입니다. PyPy, Jython, IronPython 등과 함께 Python 구현 중 하나입니다. CPython은 독창적이고 가장 많이 유지 관리되고 가장 인기 있는 것으로 구별됩니다.
CPython은 Python을 구현하지만 Python은 무엇일까요? 간단히 대답할 수 있습니다. Python은 프로그래밍 언어입니다. 같은 질문을 제대로 하면 대답은 훨씬 더 미묘해집니다. Python을 정의하는 것은 무엇일까요? C와 같은 언어와 달리 Python에는 공식적인 사양이 없습니다. 가장 가까운 것은 다음 단어로 시작하는 Python 언어 참조입니다.
가능한 한 정확하게 하려고 노력하지만 구문과 어휘 분석을 제외한 모든 것에 대해 공식적인 사양 대신 영어를 사용하기로 했습니다. 이렇게 하면 일반 독자가 문서를 더 이해하기 쉽지만 모호함이 생길 여지가 있습니다. 따라서 화성에서 이 문서만으로 Python을 다시 구현하려고 한다면, 추측해야 할 수도 있고, 사실 상당히 다른 언어를 구현하게 될 것입니다. 반면에 Python을 사용하고 언어의 특정 영역에 대한 정확한 규칙이 무엇인지 궁금하다면, 여기에서 확실히 찾을 수 있을 것입니다.
따라서 Python은 언어 참조로만 정의되지 않습니다. Python이 참조 구현인 CPython으로 정의된다고 말하는 것도 잘못된 것입니다. 언어의 일부가 아닌 구현 세부 사항이 있기 때문입니다. 참조 계산에 의존하는 가비지 수집기가 한 가지 예입니다. 진실의 단일 소스가 없기 때문에 Python은 부분적으로 Python 언어 참조와 부분적으로 주요 구현인 CPython으로 정의된다고 말할 수 있습니다.
이러한 추론은 고지식하게 들릴 수 있지만, 우리가 연구할 주제의 핵심 역할을 명확히 하는 것이 중요하다고 생각합니다. 하지만 왜 그것을 연구해야 하는지 여전히 궁금할 수 있습니다. 단순한 호기심 외에도 다음과 같은 이유가 있습니다.
- 전체적인 그림을 보면 언어에 대한 이해가 깊어집니다. Python의 구현 세부 사항을 알고 있다면 Python의 특성을 파악하기가 훨씬 더 쉽습니다.
- 구현 세부 사항은 실제로 중요합니다. 객체가 저장되는 방식, 가비지 수집기가 작동하는 방식, 여러 스레드가 조정되는 방식은 언어의 적용 가능성과 한계를 이해하고 성능을 추정하거나 비효율성을 감지할 때 매우 중요한 주제입니다.
- CPython은 Python을 C로 확장하고 Python을 C 내부에 임베드할 수 있는 Python/C API를 제공합니다. 이 API를 효과적으로 사용하려면 프로그래머가 CPython의 작동 방식을 잘 이해해야 합니다.
CPython의 작동 방식을 이해하는 데 필요한 것
CPython은 유지 관리하기 쉽게 설계되었습니다. 초보자는 소스 코드를 읽고 그 기능을 이해할 수 있을 것입니다. 그러나 시간이 다소 걸릴 수 있습니다. 이 시리즈를 작성하여 시리즈를 단축하는 데 도움이 되기를 바랍니다.
이 시리즈의 구성 방식
저는 상향식 접근 방식을 선택했습니다. 이 부분에서는 CPython 가상 머신(VM)의 핵심 개념을 살펴보겠습니다. 다음으로, CPython이 어떻게 프로그램을 VM이 실행할 수 있는 것으로 컴파일하는지 살펴보겠습니다. 그 후, 소스 코드에 익숙해지고 프로그램 실행을 단계별로 진행하면서 인터프리터의 주요 부분을 연구합니다. 결국, 언어의 여러 측면을 하나씩 골라내고 구현 방법을 살펴볼 수 있을 것입니다. 이는 결코 엄격한 계획이 아니라 저의 대략적인 생각입니다.
참고: 이 게시물에서는 CPython 3.9를 언급하고 있습니다. 일부 구현 세부 사항은 CPython이 발전함에 따라 확실히 변경될 것입니다. 중요한 변경 사항을 추적하고 업데이트 노트를 추가하려고 노력하겠습니다.
전반적인 그림
Python 프로그램 실행은 대략 세 단계로 구성됩니다.
1. 초기화
2. 컴파일
3. 해석
초기화 단계에서 CPython은 Python을 실행하는 데 필요한 데이터 구조를 초기화합니다. 또한 내장 유형과 같은 것을 준비하고, 내장 모듈을 구성하고 로드하고, 가져오기 시스템을 설정하고, 그 밖의 많은 작업을 수행합니다. 이는 서비스 특성 때문에 CPython 탐색기가 종종 간과하는 매우 중요한 단계입니다.
다음은 컴파일 단계입니다. CPython은 기계 코드를 생성하지 않는다는 의미에서 컴파일러가 아니라 인터프리터입니다. 그러나 인터프리터는 일반적으로 소스 코드를 실행하기 전에 중간 표현으로 변환합니다. CPython도 마찬가지입니다. 이 변환 단계는 일반적인 컴파일러가 하는 것과 동일한 작업을 수행합니다. 소스 코드를 구문 분석하고 AST(추상 구문 트리)를 빌드하고, AST에서 바이트코드를 생성하고, 심지어 일부 바이트코드 최적화를 수행합니다.
다음 단계를 살펴보기 전에 바이트코드가 무엇인지 이해해야 합니다. 바이트코드는 일련의 명령입니다. 각 명령어는 두 바이트로 구성됩니다. 하나는 명령어 코드용이고 다른 하나는 인수용입니다. 예를 들어 보겠습니다.
def g(x):
return x + 3
CPython은 함수 g()의 본문을 다음 바이트 시퀀스로 변환합니다. [124, 0, 100, 1, 23, 0, 83, 0]. 표준 dis 모듈을 실행하여 분해하면 다음과 같은 결과가 나옵니다.
$ python -m dis example1.py
...
2 0 LOAD_FAST 0 (x)
2 LOAD_CONST 1 (3)
4 BINARY_ADD
6 RETURN_VALUE
LOAD_FAST 명령어는 바이트 124에 해당하고 인수는 0입니다. LOAD_CONST 명령어는 바이트 100에 해당하고 인수는 1입니다. BINARY_ADD 및 RETURN_VALUE 명령어는 인수가 필요 없으므로 항상 각각 (23, 0) 및 (83, 0)으로 인코딩 됩니다.
CPython의 핵심은 바이트 코드를 실행하는 가상 머신입니다. 이전 예를 살펴보면 작동 방식을 짐작할 수 있을 것입니다. CPython의 VM은 스택 기반입니다. 즉, 스택을 사용하여 데이터를 저장하고 검색하는 명령어를 실행합니다. LOAD_FAST 명령어는 로컬 변수를 스택에 푸시합니다. LOAD_CONST는 상수를 푸시합니다. BINARY_ADD는 스택에서 두 객체를 팝 하여 더한 다음 결과를 다시 푸시합니다. 마지막으로 RETURN_VALUE는 스택에 있는 모든 것을 팝하여 호출자에게 결과를 반환합니다.
바이트코드 실행은 실행할 명령어가 있는 동안 실행되는 거대한 평가 루프에서 발생합니다. 값을 생성하거나 오류가 발생하면 중지됩니다.
이렇게 간략하게 살펴보면 많은 질문이 생깁니다.
LOAD_FAST 및 LOAD_CONST 명령어에 대한 인수는 무엇을 의미합니까? 인덱스입니까? 무엇을 인덱싱 합니까?
VM은 스택에 있는 객체에 대한 값 또는 참조를 배치합니까?
CPython은 x가 로컬 변수라는 것을 어떻게 알 수 있습니까?
인수가 너무 커서 단일 바이트에 맞지 않으면 어떻게 합니까?
두 숫자를 더하는 명령이 두 문자열을 연결하는 명령과 동일합니까? 그렇다면 VM은 이러한 작업을 어떻게 구별합니까?
이러한 질문과 다른 흥미로운 질문에 답하기 위해 CPython VM의 핵심 개념을 살펴봐야 합니다.
코드 객체, 함수 객체, 프레임
코드 객체
우리는 간단한 함수의 바이트코드가 어떤지 보았습니다. 하지만 일반적인 Python 프로그램은 더 복잡합니다. VM은 함수 정의를 포함하고 함수 호출을 하는 모듈을 어떻게 실행합니까?
다음 프로그램을 고려해 보세요.
def f(x):
return x + 1
print(f(1))
바이트코드는 어떻게 생겼습니까? 이 질문에 답하기 위해 프로그램이 무엇을 하는지 분석해 보겠습니다. 함수 f()를 정의하고 인수로 1을 사용하여 f()를 호출하고 호출 결과를 출력합니다. 함수 f()가 무엇을 하든 모듈의 바이트코드의 일부가 아닙니다. 디스어셈블러를 실행하면 확인할 수 있습니다.
$ python -m dis example2.py
1 0 LOAD_CONST 0 (<code object f at 0x10bffd1e0, file "example.py", line 1>)
2 LOAD_CONST 1 ('f')
4 MAKE_FUNCTION 0
6 STORE_NAME 0 (f)
4 8 LOAD_NAME 1 (print)
10 LOAD_NAME 0 (f)
12 LOAD_CONST 2 (1)
14 CALL_FUNCTION 1
16 CALL_FUNCTION 1
18 POP_TOP
20 LOAD_CONST 3 (None)
22 RETURN_VALUE
...
1번째 줄에서 우리는 코드 객체라는 것에서 함수를 만들고 이름 f를 바인딩하여 함수 f()를 정의합니다. 증가된 인수를 반환하는 함수 f()의 바이트코드는 보이지 않습니다.
모듈이나 함수 본문과 같이 단일 단위로 실행되는 코드 조각을 코드 블록이라고 합니다. CPython은 코드 블록이 수행하는 작업에 대한 정보를 코드 객체라는 구조에 저장합니다. 여기에는 바이트코드와 블록 내에서 사용되는 변수 이름 목록과 같은 항목이 포함됩니다. 모듈을 실행하거나 함수를 호출한다는 것은 해당 코드 객체를 평가하기 시작한다는 것을 의미합니다.
함수 객체
그러나 함수는 단순한 코드 객체가 아닙니다. 함수 이름, docstring, 기본 인수 및 둘러싼 범위에 정의된 변수 값과 같은 추가 정보를 포함해야 합니다. 이 정보는 코드 객체와 함께 함수 객체 내부에 저장됩니다. MAKE_FUNCTION 명령어를 사용하여 이를 만듭니다. CPython 소스 코드의 함수 객체 구조 정의는 다음 주석으로 시작합니다.
함수 객체와 코드 객체는 서로 혼동해서는 안 됩니다.
함수 객체는 'def' 명령문을 실행하여 생성됩니다. 이들은 __code__ 속성에서 코드 객체를 참조하는데, 이는 순전히 구문적 객체, 즉 일부 소스 코드 줄의 컴파일된 버전에 불과합니다. 소스 코드 "조각"당 하나의 코드 객체가 있지만, 각 코드 객체는 지금까지 소스의 'def' 명령문이 실행된 횟수에 따라서만 0개 또는 여러 개의 함수 객체에 의해 참조될 수 있습니다.
여러 함수 객체가 단일 코드 객체를 참조하는 이유는 무엇일까요? 다음은 한 가지 예입니다.
def make_add_x(x):
def add_x(y):
return x + y
return add_x
add_4 = make_add_x(4)
add_5 = make_add_x(5)
make_add_x() 함수의 바이트 코드에는 MAKE_FUNCTION 명령문이 포함되어 있습니다. add_4() 및 add_5() 함수는 인수로 동일한 코드 객체를 사용하여 이 명령을 호출한 결과입니다. 하지만 x의 값이라는 하나의 인수가 다릅니다. 각 함수는 add_4() 및 add_5()와 같은 클로저를 만들 수 있는 셀 변수 메커니즘을 통해 자체 인수를 얻습니다.
다음 개념으로 넘어가기 전에 코드와 함수 객체의 정의를 살펴보고 그것들이 무엇인지 더 잘 이해해 보세요.
struct PyCodeObject {
PyObject_HEAD
int co_argcount; /* #arguments, except *args */
int co_posonlyargcount; /* #positional only arguments */
int co_kwonlyargcount; /* #keyword only arguments */
int co_nlocals; /* #local variables */
int co_stacksize; /* #entries needed for evaluation stack */
int co_flags; /* CO_..., see below */
int co_firstlineno; /* first source line number */
PyObject *co_code; /* instruction opcodes */
PyObject *co_consts; /* list (constants used) */
PyObject *co_names; /* list of strings (names used) */
PyObject *co_varnames; /* tuple of strings (local variable names) */
PyObject *co_freevars; /* tuple of strings (free variable names) */
PyObject *co_cellvars; /* tuple of strings (cell variable names) */
Py_ssize_t *co_cell2arg; /* Maps cell vars which are arguments. */
PyObject *co_filename; /* unicode (where it was loaded from) */
PyObject *co_name; /* unicode (name, for reference) */
/* ... more members ... */
};
typedef struct {
PyObject_HEAD
PyObject *func_code; /* A code object, the __code__ attribute */
PyObject *func_globals; /* A dictionary (other mappings won't do) */
PyObject *func_defaults; /* NULL or a tuple */
PyObject *func_kwdefaults; /* NULL or a dict */
PyObject *func_closure; /* NULL or a tuple of cell objects */
PyObject *func_doc; /* The __doc__ attribute, can be anything */
PyObject *func_name; /* The __name__ attribute, a string object */
PyObject *func_dict; /* The __dict__ attribute, a dict or NULL */
PyObject *func_weakreflist; /* List of weak references */
PyObject *func_module; /* The __module__ attribute, can be anything */
PyObject *func_annotations; /* Annotations, a dict or NULL */
PyObject *func_qualname; /* The qualified name */
vectorcallfunc vectorcall;
} PyFunctionObject;
프레임 객체
VM이 코드 객체를 실행할 때 변수의 값과 끊임없이 변화하는 값 스택을 추적해야 합니다. 또한 현재 코드 객체의 실행을 중단한 곳에서 다른 코드 객체를 실행하고 반환 시 어디로 가야 하는지 기억해야 합니다. CPython은 이 정보를 프레임 객체 또는 단순히 프레임 내부에 저장합니다. 프레임은 코드 객체를 실행할 수 있는 상태를 제공합니다. 소스 코드에 더 익숙해지고 있으므로 프레임 객체의 정의도 여기에 남겨둡니다.
struct _frame {
PyObject_VAR_HEAD
struct _frame *f_back; /* previous frame, or NULL */
PyCodeObject *f_code; /* code segment */
PyObject *f_builtins; /* builtin symbol table (PyDictObject) */
PyObject *f_globals; /* global symbol table (PyDictObject) */
PyObject *f_locals; /* local symbol table (any mapping) */
PyObject **f_valuestack; /* points after the last local */
PyObject **f_stacktop; /* Next free slot in f_valuestack. ... */
PyObject *f_trace; /* Trace function */
char f_trace_lines; /* Emit per-line trace events? */
char f_trace_opcodes; /* Emit per-opcode trace events? */
/* Borrowed reference to a generator, or NULL */
PyObject *f_gen;
int f_lasti; /* Last instruction if called */
/* ... */
int f_lineno; /* Current line number */
int f_iblock; /* index in f_blockstack */
char f_executing; /* whether the frame is still executing */
PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* for try and loop blocks */
PyObject *f_localsplus[1]; /* locals+stack, dynamically sized */
};
첫 번째 프레임은 모듈의 코드 객체를 실행하기 위해 생성됩니다. CPython은 다른 코드 객체를 실행해야 할 때마다 새 프레임을 만듭니다. 각 프레임에는 이전 프레임에 대한 참조가 있습니다. 따라서 프레임은 프레임 스택을 형성하는데, 이를 호출 스택이라고도 하며 현재 프레임이 맨 위에 있습니다. 함수가 호출되면 새 프레임이 스택에 푸시됩니다. 현재 실행 중인 프레임에서 돌아오면 CPython은 마지막으로 처리된 명령어를 기억하여 이전 프레임의 실행을 계속합니다. 어떤 의미에서 CPython VM은 프레임을 구성하고 실행하는 것 외에는 아무것도 하지 않습니다. 그러나 곧 알게 되겠지만, 이 요약은 완곡하게 말해서 일부 세부 사항을 숨깁니다.
스레드, 인터프리터, 런타임
우리는 이미 세 가지 중요한 개념을 살펴보았습니다.
- 코드 객체
- 함수 객체; 및
- 프레임 객체.
CPython에는 세 가지가 더 있습니다.
- 스레드 상태
- 인터프리터 상태; 및
- 런타임 상태.
스레드 상태
스레드 상태는 호출 스택, 예외 상태 및 디버깅 설정을 포함한 스레드별 데이터가 포함된 데이터 구조입니다. OS 스레드와 혼동해서는 안 됩니다. 하지만 이 둘은 긴밀하게 연결되어 있습니다. 표준 스레딩 모듈을 사용하여 별도의 스레드에서 함수를 실행할 때 어떤 일이 발생하는지 생각해 보세요.
from threading import Thread
def f():
"""Perform an I/O-bound task"""
pass
t = Thread(target=f)
t.start()
t.join()
t.start()는 실제로 OS 함수(UNIX 유사 시스템의 경우 pthread_create(), Windows의 경우 _beginthreadex())를 호출하여 새 OS 스레드를 만듭니다. 새로 만든 스레드는 대상을 호출하는 역할을 하는 _thread 모듈에서 함수를 호출합니다. 이 함수는 대상과 대상의 인수뿐만 아니라 새 OS 스레드 내에서 사용할 새 스레드 상태도 받습니다. OS 스레드는 자체 스레드 상태로 평가 루프에 들어가므로 항상 손쉽게 사용할 수 있습니다.
여기서 여러 스레드가 동시에 평가 루프에 들어가는 것을 방지하는 유명한 GIL(Global Interpreter Lock)을 기억할 수 있습니다. 그 주된 이유는 더 세분화된 잠금을 도입하지 않고도 CPython의 상태를 손상으로부터 보호하기 위해서입니다. Python/C API 참조에서 GIL을 명확하게 설명합니다.
Python 인터프리터는 완전히 스레드로부터 안전하지 않습니다. 멀티스레드 Python 프로그램을 지원하기 위해 글로벌 인터프리터 잠금 또는 GIL이라고 하는 글로벌 잠금이 있는데, 현재 스레드가 Python 객체에 안전하게 액세스 하기 전에 이 잠금을 유지해야 합니다. 잠금이 없으면 가장 간단한 작업조차도 멀티스레드 프로그램에서 문제를 일으킬 수 있습니다. 예를 들어 두 스레드가 동시에 동일한 객체의 참조 카운트를 증가시키면 참조 카운트가 두 번이 아니라 한 번만 증가할 수 있습니다.
여러 스레드를 관리하려면 스레드 상태보다 더 높은 수준의 데이터 구조가 필요합니다.
인터프리터 및 런타임 상태
사실, 두 가지가 있습니다. 인터프리터 상태와 런타임 상태입니다. 둘 다 필요한지 당장은 명확하지 않을 수 있습니다. 그러나 모든 프로그램을 실행하면 각각 적어도 하나의 인스턴스가 있으며 그럴 만한 이유가 있습니다.
인터프리터 상태는 이 그룹에 특정한 데이터와 함께 스레드 그룹입니다. 스레드는 로드된 모듈(sys.modules), 내장 모듈(builtins.__dict__) 및 가져오기 시스템(importlib)과 같은 것을 공유합니다.
런타임 상태는 전역 변수입니다. 프로세스에 특정한 데이터를 저장합니다. 여기에는 CPython의 상태(예: 초기화되었는지 여부)와 GIL 메커니즘이 포함됩니다.
일반적으로 프로세스의 모든 스레드는 동일한 인터프리터에 속합니다. 그러나 스레드 그룹을 격리하기 위해 하위 인터프리터를 만들어야 하는 드문 경우가 있습니다. WSGI 애플리케이션을 실행하기 위해 별도의 인터프리터를 사용하는 mod_wsgi가 한 가지 예입니다. 격리의 가장 명백한 효과는 각 스레드 그룹이 전역 네임스페이스인 __main__을 포함한 모든 모듈의 자체 버전을 얻는다는 것입니다.
CPython은 threading 모듈과 유사한 새로운 인터프리터를 만드는 쉬운 방법을 제공하지 않습니다. 이 기능은 Python/C API를 통해서만 지원되지만 언젠가 변경될 수 있습니다.
아키텍처 요약
CPython의 아키텍처를 간략하게 요약하여 모든 것이 어떻게 조화를 이루는지 살펴보겠습니다. 인터프리터는 계층 구조로 볼 수 있습니다. 다음은 계층이 무엇인지 요약한 것입니다.
- 런타임: 프로세스의 전역 상태. 여기에는 GIL과 메모리 할당 메커니즘이 포함됩니다.
- 인터프리터: 스레드 그룹과 가져온 모듈과 같은 공유하는 일부 데이터.
- 스레드: 단일 OS 스레드에 대한 특정 데이터. 여기에는 호출 스택이 포함됩니다.
- 프레임: 호출 스택의 요소. 프레임에는 코드 개체가 포함되어 있으며 이를 실행할 상태를 제공합니다.
- 평가 루프: 프레임 개체가 실행되는 위치.
계층은 이미 살펴본 해당 데이터 구조로 표현됩니다. 그러나 어떤 경우에는 동일하지 않습니다. 예를 들어, 메모리 할당 메커니즘은 전역 변수를 사용하여 구현됩니다. 런타임 상태의 일부는 아니지만 확실히 런타임 계층의 일부입니다.
결론
이 부분에서는 파이썬이 파이썬 프로그램을 실행하기 위해 수행하는 작업을 설명했습니다. 세 단계로 작동하는 것을 보았습니다.
- CPython 초기화
- 소스 코드를 모듈의 코드 개체로 컴파일
- 코드 개체의 바이트코드 실행
바이트코드 실행을 담당하는 인터프리터 부분을 가상 머신이라고 합니다. CPython VM에는 코드 객체, 프레임 객체, 스레드 상태, 인터프리터 상태 및 런타임이라는 몇 가지 특히 중요한 개념이 있습니다. 이러한 데이터 구조는 CPython 아키텍처의 핵심을 형성합니다.
많은 것을 다루지 않았습니다. 소스 코드를 파헤치는 것을 피했습니다. 초기화 및 컴파일 단계는 완전히 우리의 범위를 벗어났습니다. 대신 VM의 광범위한 개요로 시작했습니다. 이런 식으로 각 단계의 책임을 더 잘 볼 수 있다고 생각합니다. 이제 CPython이 소스 코드를 무엇으로 컴파일하는지 알게 되었습니다. 코드 객체로 말입니다. 다음 시간에는 어떻게 그렇게 하는지 알아보겠습니다.
질문, 의견 또는 제안 사항이 있으면 victor@tenthousandmeters.com으로 언제든지 문의하세요.
2020년 9월 4일 업데이트: CPython 내부 구조를 배우는 데 사용한 리소스 목록을 만들었습니다.
'개발자 > 파이썬 Python' 카테고리의 다른 글
소스 코드가 포함된 18개의 자바스크립트 프로젝트 (9) | 2024.09.29 |
---|---|
파이토치(PyTorch) - 이수안컴퓨터연구소 (7강, 동영상) (0) | 2024.07.08 |
파이썬 기초문법 핵심정리 - 파이스탁 (16강, 동영상) (0) | 2024.07.03 |
초보자를 위한 파이썬 300제 - 파이스탁 (31강, 동영상) (1) | 2024.06.28 |
Happy New Year 2024 하트 표시 만드는 파이선 코드 (2) | 2024.01.05 |
아스키코드는 0~127입니다. (1) | 2023.11.27 |
Tiny Python Projects 강의 영상과 소스코드 (0) | 2023.08.14 |
파이썬에서 가장 많이 사용하는 상위 40개 라이브러리 (1) | 2023.06.26 |
더욱 좋은 정보를 제공하겠습니다.~ ^^