Core 모듈은 Byeol 프로그래밍 언어의 핵심 구현을 담당합니다. AST(Abstract Syntax Tree) 구조, 파서, 검증기, 실행기 등 언어의 모든 핵심 기능이 이 모듈에 집중되어 있습니다. Core 모듈의 가장 큰 특징은 Interpreter 패턴을 사용하여 AST를 직접 실행한다는 점이에요. 일반적인 언어와 달리, Byeol 언어는 AST 구조를 유지한 채로 프로그램을 실행합니다. 따라서 타 언어의 AST는 말그대로 문법 구조를 트리로 표현한 중간결과물에 지나지 않지만, Byeol에는 실행가능한 최종 output을 AST가 담당합니다.
다른 모듈 대비 많은 클래스가 있기 때문에 한 다이어그램에 다 표시가 어렵기 때문에 핵심 클래스 몇개와 패키지 위주로 계층도와 시퀸스 다이어그램을 먼저 보여드릴께요. 일반적인 byeol 코드를 interpret할때를 기준으로 시퀸스를 먼저 보시죠.
core 모듈의 핵심 흐름:
외부로 부터 동작의 진입점이 되는 건 interpreter 입니다. interpreter는 parser를 통해서 AST 를 생성하고, by::expander를 통해서 이 AST를 의존성을 고려하여 일종의 링킹을 수행합니다. 그후 verifier를 통해서 올바른 AST인지를 검사하고, 올바른 경우에만 starter를 통해서 AST를 실행합니다.
즉, parsing, expanding, verifying 이 3단계의 프로세스는 좀 머리속에 넣고 가자구요.
core 모듈의 주요 클래스:
AST는 실행 가능해야 하므로 AST 안에 func 과 obj 가 보입니다. scope는 이름 그대로 하나의 scope와 거기에 속한 symbol 들의 라이프 사이클을 관리합니다. frame은 이 많은 scope들을 관리하는 일종의 현재 실행중인 함수에 대한 context를 제공합니다. 이때 scope들은 chain의 link 라는 기능을 통해 매우 유연하게 합쳐지기도, 나눠지기도 합니다. 이 frame은 다시 thread 에 의해서 관리 됩니다. 그리고 starter는 이 thread를 생성하고 프로그램을 실행하는 역할을 담당하죠.
밑에 큰 별도의 섹션으로 다뤄보겠지만, core 모듈의 설계상 핵심 원칙중 하나는 메인 로직은 script 개체인지(예: byeol에서 정의한 객체) native 개체인지를(예: c++에서 정의한 obj 객체) 몰라도 되야 한다는 점에 있습니다. 그래서 obj는 baseObj를 상속하는 것이며, 그래서 mgdType이 존재하는 것이고, 그렇기에 tbridge를 통해서 1줄의 c++ 코드만으로도 c++ 함수를 바로 byeol 코드에서 호출할 수 있도록 할 겁니다.
지금까지 아주 간략하게 core 모듈이 어떤 작업을 하고 어떠한 component나 클래스가 있는지를 잠깐 보여드렸습니다.
첫인상이 어떠셨을까 기대도 되지만, 한편으로는 남아있을 분량에 걱정도 되네요. 매우 아쉽게도 오른 쪽 스크롤바를 잠시 보세요. 보시면 아셨겠지만 위에서 다루지 않은 생략된 내용, 알고리즘이나 클래스가 정말 많아요. 위의 다이어그램에 등장하는 클래스는 전체 클래스의 약 13% 에 불과합니다. 즉 단 한마디 언급조차 못드린 클래스가 87% 나 됩니다. 그게 제가 이렇게 문서 서두에 공을 들이는 이유이기도 하구요.
지금까지의 모듈은 모두 이 core 를 이해하기 위해, core 의 기능을 덜기 위해 만든 모듈이라고 봐도 과언이 아닙니다. 분명 지금 읽고계시는 core 모듈이, 이 프로젝트를 이해하는 데 있어서 가장 peak 입니다. 차근차근 휴식도 가져가면서 따라오세요.
☕ ☕ ☕
사용자는 Byeol 언어 문법으로 객체와 함수를 정의하고, 이들은 interpreter에 의해서 c++의 func 나 obj 클래스의 객체로 생성되는데, 이를 script object나 script func이라고 합니다. 반면, 때로는 c++ 코드 상에서 byeol 에서 사용할 객체나 함수를 위한 class를 정의하고, 이걸 Byeol의 pack으로써 공개하는 경우도 많은데, 이처럼 c++코드로 작성된 object나 func을 native object, native func이라고 합니다.
이렇게 생성된 native symbol (정의된 object or function)과 script symbol은 모두 starter에 의해서 실행되는데, 이때 실행 환경을 Byeol Runtime(특별하게 c++ 언급 없이 그냥 Runtime이라고도 함)이라고 합니다.
core 모듈은 native symbol과 script symbol을 다루고 실행할 때 구체 타입을 몰라도 처리할 수 있도록 여러 컴포넌트에 걸쳐 설계가 되어있습니다.
Byeol의 AST는 실행 가능한 프로그램 트리입니다. 일반적인 AST와 달리 평가(evaluation), 타입 추론(type inference), 프레임 관리 등 실행에 필요한 모든 기능을 포함합니다. 이는 언어 자체의 목적을 위한 핵심 디자인 결정입니다.
node 클래스는 AST의 가장 기본이 되는 클래스예요. 말그대로 AST 상에 존재할 수 있는 무언가가 node 입니다. 이건 함수일 수도 있고, 객체일 수도 있고, 표현식을 수도 있어요.
node는 AST 구조와 관련된 API와 해당 node 를 평가하는 eval(), 자식 node 를 탐색하는 API, 그리고 해당 node 객체를 다른 타입으로 형변환하는 API 등을 제공합니다.
AST 탐색
AST 특성상, node 는 또 다른 node 의 파생클래스의 객체도 가지고 있을 수 있어야 합니다. 때문에 마치 DOM tree처럼 Composite 패턴을 사용해서 설계되어있으며 이 tree를 탐색하는 함수 또한 다양하게 지원하고 있습니다. 그래서 node는 AST를 탐색하기 위한 매우 다양한 API를 제공합니다. AST 탐색을 위해 주로 사용하는 함수는 operator[], sub(), subs(), in(), subAll()입니다. 아, 참고로 AST는 key-value 기반으로 되어있습니다. 물론 iterator를 받아 순차로 탐색도 가능하긴 하죠.
하지만 node 자체가 위 API를 구현하는 것은 아닙니다. 이는 container의 역할이지 node의 역할이 아니죠. core 모듈에서는 기존 STL container에 추가적인 기능이 다수 필요하기 때문에 아주 일부 상황을 제외하고는 STL기반으로 작성된 자체 container를 사용합니다.
예를들어, node에서 가장 핵심 API인 subs()는 scope 객체를 반환하는데, 이는 사실 tnchain 라는 클래스 템플릿을 그대로 typedef 한 겁니다. 즉 container 객체를 밖으로 제공할 뿐인 함수인거죠.
tnchain은 다시 tnmap을 기반으로 하며, tnmap은 multimap을 기반으로 합니다. 뭔가 복잡하죠? 근데 role에 맞게 적절하게 쪼개져서 설계되어있답니다. tnchain은 가장 핵심 container이면서 굉장히 독특한 동작을 가지고 있어서 아무튼 이게 쉽게 설명이 좀 어려워요. 단 걱정마세요. 밑에서 아주 지겹도록 다룰겁니다. 어쨌든 지금은, AST는 scope 기반으로 데이터를 관리하는데, 이 scope라는 건 std::map<node> 과 같은 자체 container다. 정도로만 알고 계세요.
아, 참고로 scope에 포함되는 expr 중에서는 blockExpr 도 있는데, 이건 node 이면서도 내부에 node 뭉치를 array로 가지고 있습니다. 따라서 AST는 전체적으로 보면 map과 array가 혼합된 구조로 구성된 셈이죠.
중복 symbol 처리
byeol 언어는 동일한 scope내 중복 symbol을 허용하지 않습니다. 하지만 이 말은 동일한 key로 2개의 pair가 들어갈 수 없다는 뜻은 아닙니다.
함수의 경우는 이름이 같을 지라도 파라메터의 갯수나 타입이 다르면 다른 symbol이 되기 때문입니다. 이는 함수 오버로딩(Function Overloading)을 지원하기 위한 건데요, 그래서 단순히 string 비교만으로 중복여부를 판단할 수 없기에 scope 클래스는 map 기반이 아니라 multimap 기반으로 되어 있는 거예요. 같은 이름의 함수들을 모두 저장한 후, 호출 시점에 인자 타입을 기준으로 가장 적합한 함수를 선택하는 Overload Resolution 알고리즘을 사용합니다.
eval() - 평가와 실행
node 는 함수일수도 있고, 객체일 수도 있고, 표현식일 수도 있습니다. eval(const args&)은 이러한 node 를 실행한 결과를 반환합니다. byeol에는 클래스란 개념이 없고, 객체가 이를 대신하죠? 그러니 객체 또한 함수처럼 실행할 수 하며, 객체를 실행하면 이는 새로운 객체가 생성되어 생성자가 호출됩니다
infer() - 타입 추론
infer()는 Type Inference 알고리즘을 수행합니다. 런타임의 정확한 값을 반환하는 것이 아니라 verification 단계에서 확정되는 타입을 객체로 반환합니다. 이는 실제 값 계산 없이 타입만 판단하는 알고리즘으로, eval()보다 훨씬 빠르게 동작합니다. 예를 들어 보면,
int + flt을 하게 되면 내부적으로 type promotion이 발생해 flt이 되므로 infered에는 nFlt 객체가 들어갑니다. 각 표현식 클래스는 반드시 타입을 유추 요청을 들어왔을때 현재 가지고 있는 자식 node들에게 재귀적으로 타입유추를 호출해서 계산해야 합니다.
Type Inference 알고리즘은 어디까지나 타입을 빨리 아는 데 집중하므로 값의 계산은 수행하지 않습니다. 값을 정확하게 알고 싶다면 eval()을 해야만 합니다.
eval() vs infer() 차이 비교
| 항목 | eval() | infer() |
|---|---|---|
| 목적 | 실제 값을 계산하고 반환 | 결과 타입을 추론하여 반환 |
| 주로 실행하는 시점 | 런타임 | Verification 단계 |
| 반환값 | 실제 계산된 값 (str) | 타입 객체 (str) |
| 값 계산 | O | X |
| 성능 | 느림 (실제 연산 수행) | 빠름 (타입만 판단) |
| 사용 사례 | 프로그램 실행, 실제 결과 필요 | 타입 체크, 컴파일 타임 검증 |
예시:
타입 변환 - as()와 is()
node 는 Runtime 환경에서 명시적 타입 변환을 위한 as()와 타입 변환이 가능한지 체크하는 is()를 제공합니다.
형변환이 동작하는 흐름을 알고 싶다면 ases 와 asable 을 참조하세요.
Native vs Script 타입 변환
앞서 설명한 as(), is()는 byeol 언어 환경에서의 타입 변환입니다. 이와 별도로 C++ native 환경에서의 타입 변환은 cast()가 담당합니다.
이처럼 Core 모듈에는 native와 script 별로 동일한 개념을 각각 구현한 경우가 계속 나올겁니다.
Runtime 환경에서의 묵시적 형변환과 명시적 형변환
묵시적 형변환은 impliAs(), impliIs()로 제공됩니다. 이를 외부에서 직접 호출하는 일은 적습니다. 대부분은 expr이나 baseFunc 등 AST 클래스들에 의해서 호출됩니다.
참고로, byeol 사용자가 정의한 타입 변환은 당연히 묵시적 형변환에 포함되지 않습니다. 그래서 이 명시적인것과 묵시적인 것을 구분하는 것입니다.
Node는 Visitable Class
AST를 순회하는 동작 자체는 매우 다양한 목적으로 발생합니다. 단순히 eval()을 위해서만 아니라 디버깅 정보를 위해 AST를 출력할때도 사용됩니다.
순회 방식과 node 에 대한 처리를 분리시키기 위해 Visitor 패턴이 node 에 반드시 적용되어야 합니다. node의 accept()는 바로 이 visitor 클래스에 의해 사용되며, Double Dispatch를 통해 컴파일 타임에는 알 수 없는 구체 타입을 런타임에 올바르게 처리합니다. 대신 visitor에 의해서 어떻게 멤버변수를 순회해야 하는지 등을 override 하기 위해서는 각 클래스 헤더에 VISIT 매크로가 추가로 필요합니다. 예를들면, 이렇게요.
물론, Visitor 동작 자체는 visitor가 처리합니다. 밑에서 아주 자세하게 다룰테니, 기억만 살짝 해두세요.
Node는 Frame Interaction을 지원합니다.
frame 은 현재 실행 중인 scope와 안에 등록된 symbol 등을 관리합니다. node 는 AST의 기본을 구성하므로 node 가 객체이거나 함수일 경우, frame 에 자신이 소유한 함수나 property 등을 등록하거나 해제하는 작업을 해줘야 합니다.
node에는 함수나 객체가 있을 수 있으므로, node는 기본적으로 frame interactable 하게 설계되어야 하는 거죠. 대부분의 node는 물론 frame과 아무런 동작을 하지 않지만요.
이걸 위해 node에는 inFrame()과 outFrame()이 함수가 존재하게 됩니다. 자세한 내용은 frame 을 참조하세요.
Overload Resolution 알고리즘
byeol은 함수 오버로딩을 지원하므로 어떤 객체에는 같은 이름을 가졌으되, 파라메터가 다른 함수가 여럿 있을 수 있습니다. 어떤 함수는 인자에 대해서 정확하게 일치할 것이고, 어떤 함수는 약간의 타입변환이 필요할 지도 모릅니다.
node 는 자식 node 들에 대해서 주어진 args에 대해 얼마나 잘 일치하는 가를 prioritize() 함수를 통해 판단결과를 받습니다. 이 알고리즘은 C++의 Overload Resolution과 유사하게 동작하며, 다음 우선순위로 함수를 선택합니다:
1순위: EXACT_MATCH: 인자 타입이 정확히 일치 2순위: IMPLICIT_MATCH: 묵시적 타입 변환으로 일치 가능 3순위: NO_MATCH: 일치하지 않음
각 node 의 파생클래스는 args가 정확하게 일치하면 EXACT_MATCH로 반환하며, 일치하지 않으면 NO_MATCH로 반환됩니다.
정확한 판단기준이나 알고리즘은 tprior 과 priorType 을 참고하세요.
prioritize() 사용 예제
srcFile 은 AST의 출처가 되는 소스코드 파일에 대한 정보를 담습니다. src 는 생성된 AST node 가 어떠한 srcFile 안의 어느 위치에서 생성된 것인지를 담고있습니다.
이 정보는 byeol 언어의 Context 기반 REPL의 핵심 기능에 사용됩니다. src 정보는 script symbol에 국한하며, native에서 생성된 객체는 src 에 더미 값이 들어갑니다.
함수나 객체 eval()을 위해 전달하는 인자를 표현합니다. narr 을 상속하므로 narr 의 모든 API를 사용할 수 있습니다.
me 객체
args는 me 객체가 포함되어 전달되는 게 특징입니다. 함수는 현재 런타임의 어떤 객체로부터 eval() 요청이 왔는지 기본적으로 알 수 없습니다. 별도로 setMe()를 통해 args를 사용하는 함수나 property는 어떤 객체로부터 이 args가 전달되었는지를 알 수 있습니다.
참고로, baseObj 는 eval(name, args)를 호출받으면 setMe(this)를 호출해 자신을 args에 넣습니다.
args 사용 예제
param은 말 그대로 파라메터를 표현합니다. 이름과 타입을 표현하기 위한 origin 객체에 대한 참조가 있습니다.
params 사용 예제
Byeol의 객체 모델은 native(C++로 작성)와 scripted(Byeol 언어로 작성) symbol을 모두 지원하도록 설계되었습니다. 두 환경의 객체가 동일한 인터페이스로 사용될 수 있으며, 이를 통해 seamless한 상호작용이 가능합니다.
baseObj 는 byeol의 객체를 표현하는 데 있어서 기반 클래스입니다. Core 모듈은 byeol 언어로 작성된 script 객체(obj)와 C++ 코드로 작성된 native 객체(baseObj) 모두를 구분하지 않고 사용합니다.
둘은 모두 node 임과 동시에 baseObj 일 뿐입니다. 이를 위해서 obj 를 baseObj 에서 상속받게 함으로써 둘을 같은 클래스 계통으로 묶었습니다. 따라서 baseObj 는 native 객체와 script 객체의 공통된 기능만 갖습니다.
origin 객체
byeol 언어로 사용자가 정의한, 원본이 되는 타입을 origin 객체라고 합니다. 생성자 호출을 하면 모두 origin 객체를 복사 생성해서 만들어집니다.
getOrigin() override
C++ 코드로 새로운 baseObj 를 정의해서 byeol 코드로 사용하고 싶다면 baseObj 를 상속한 C++ 클래스를 만들고 getOrigin()을 override해서 적절한 baseObj 의 origin 객체를 반환하면 됩니다.
tbridger 활용 예시
tbridger 를 사용하면 native 함수를 매우 간단하게 Byeol Runtime으로 노출시킬 수 있습니다. nStr 에 좋은 예시가 있습니다:
이는 Adapter 패턴 또는 Bridge 패턴의 실제 활용 사례입니다. C++의 인터페이스를 byeol runtime 인터페이스로 변환하여 두 환경을 연결합니다.
이제 위 클래스를 pack으로써 노출하게 되면 byeol 코드에서도 len() 이나 getSeqFunc을 사용할 수 있습니다! 어떻게 pack으로 내보내는지는 packLoading 이나 autoslot 등을 참조하세요.
byeol 코드에서는 다음과 같이 사용할 수 있게 됩니다:
이처럼 tbridger 는 native로 작성된 함수를 매우 간단하게 runtime으로 노출시킬 수 있습니다. 좀 더 자세한 tbridger 사용 방법은 해당 클래스를 참조하세요.
obj 클래스는 runtime에서의 객체를 표현합니다. baseObj 의 기능을 확장해서 runtime에서만 필요한 scope에 대한 처리나 shares, owns에 대한 개념을 추가합니다.
타입으로써의 obj
C++에서 타입은 클래스로 표현되지만 byeol은 클래스란 존재하지 않아요. 객체와 클래스의 구분이 없으므로 obj 자체가 타입인 셈입니다.
runtime에서는 def로 정의된 obj와 obj로부터 복제된 obj 2가지 경로로만 생성되며, 이 둘은 모두 타입으로써 사용되므로 구분이 무의미합니다.
shares, owns
baseObj 의 경우는 C++ native 클래스 기반이므로 new와 생성자를 통해 만들어집니다. 하지만 byeol 코드로 작성하는 runtime 환경은 다릅니다.
runtime환경에서 객체를 정의하는 것은 origin 객체로부터 객체를 복제하는 것입니다. 이때 함수는 시스템 내 한 개만 있으면 되므로 얇은 복사면 충분하지만, property는 인스턴스마다 다른 값이 들어가야 하므로 깊은 복사가 되어야 합니다.
이를 효율적으로 하기 위해, 같은 타입의 obj 끼리 공유되는 부분들을 shares, 깊은 복사가 되는 부분들을 owns로 구분합니다.
obj의 clone()이 발생하면:
이렇게 여러 인스턴스 간에 공유 가능한 데이터(함수)와 고유한 데이터(property)를 분리하여 메모리 효율성을 극대화합니다.
shares vs owns 예제
이러한 구조 덕분에 메모리 효율성이 크게 향상됩니다. 수천 개의 인스턴스가 생성되더라도 함수는 단 하나만 메모리에 존재하게 됩니다.
immutable type
str, int 등 scalar 타입은 모두 immutable 타입입니다. 이는 byeol의 호출 전략이 by object를 따르기 때문입니다 (java, C# 등과 유사: 객체는 얇은 복사, raw 타입은 깊은 복사). 객체에 할당을 시도할 경우 이를 복사해야하는지, 아니면 참조만 변경하면 되는지는 immutableTactic 을 통해서 판단합니다.
밑에서 더 자세히 다룰꼐요.
byeol 언어로 사용자가 정의한, 원본이 되는 타입을 origin 객체라고 합니다. 사용자가 byeol 코드로 객체를 생성하면, 내부적으로 해당 타입의 origin 객체를 복사 생성(clone)하여 새 인스턴스를 만듭니다.
이는 Prototype 패턴의 전형적인 구현입니다. 원본(prototype) 객체를 복제하여 새로운 인스턴스를 생성하므로, 매번 처음부터 객체를 구성할 필요가 없습니다.
origin에 대해 meta 타입 사용 금지
origin 객체는 obj 에서 상속받았으며 생성시점을 제외하고는 obj 타입으로써 사용되는 것을 전제로 작성되어 있습니다. 따라서 함부로 Meta 모듈을 사용하면 안 됩니다:
왜 new1->cast<origin>(); 은 nullptr를 반환할까요? 사용자가 byeol 코드로 다음과 같이 작성했다고 가정합시다:
MyObj는 script object이므로 C++ 코드상에서는 obj의 인스턴스로 생성됩니다. 만약 별다른 추가 코드 없이 MyObj를 표현하는 obj 인스턴스에 getType()을 하면 어떻게 될까요? 당연히 ttype<obj>()가 나오게 됩니다.
문제는 YourObj를 표현하는 obj 인스턴스에 호출시에도 같은 ttype<obj>()를 반환할 것이라는 점입니다. 결과, 프로그램을 실행하는 starter는 YourObj와 MyObj를 같은 타입이라고 생각하게 됩니다.
이를 해결하기 위해 C++ 로 정의된 obj 클래스는 멤버변수로 mgdType 변수를 갖습니다. mgdType 은 runtime 환경에서의 byeol 타입에 대한 타입정보이며, 부모타입이 뭔지 생성자에서 입력할 수 있습니다. parser는 부모타입을 계산해서 적절하게 mgdType을 생성해서 obj 객체를 생성하기 때문에 위의 MyObj나 YourObj 타입은 Runtime 환경내에서 ttype<obj>를 상속한 것으로 표현됩니다.
그래서 C++의 MyObj 객체에 대해 getType()을 하면 obj::getType()에 의해 mgdType이 나오고, 이 타입은 부모클래스가 obj 타입이며 YourObj와는 다른 타입이라고 구분할 수 있게 되는 거죠. 그러니 obj 타입으로의 형변환이나 cast<obj>() 같은 것은 아무런 문제가 되지 않습니다.
만약 이런 방식을 하지 않고, getType()과 getMgdType() 2개를 구분해서 API를 만들면 어떻게 될까요? type을 사용하는 모든 함수들에서, node*를 받았을때 이게 native 객체인가 script 객체인가를 구분하고 script로 생성된 객체라면 getMgdType()을 호출해서 처리를 해야 했을 것입니다.
눈치가 빠른 사람이라면 이제 왜 cast<origin>()을 하면 안되는지 알았을지도 모릅니다. cast<T>() 는 아무런 타입 인자를 넘기지 않기 때문에 cast(ttype<origin>()) 로 redirection 할 수 밖에 없습니다.
하지만 위의 예제처럼 byeol 코드의 MyObj를 표현하기 위해서 생성된 obj 인스턴스는 mgdType을 반환해야 합니다. 당연히 ttype<origin>은 방금 생성된 mgdType 인스턴스와 아무런 상속 관계가 아니므로 cast<origin>을 호출하면 내부에서 타입체킹에 실패하게 됩니다.
따라서 핵심은 obj c++ 클래스를 기점으로, 기존의 C++ 클래스별로 메타 타입을 제공하던 것이, C++ 인스턴스별로 메타타입을 제공하는 mgdType 형태로 전환된다는 것입니다.
그러니 origin 객체에 대해서 함부로 Meta 모듈을 사용하려고 하면 안됩니다. origin 클래스의 getType()은 C++의 origin 타입과 아무런 관련이 없는 타입을 반환합니다.
중요한 유의사항
origin 자체가 obj 타입으로 사용될 것을 전제로 한 것이기에 거의 모든 public 함수는 obj 타입의 API와 동일한 것입니다. 대부분은 origin 타입으로 써야할 필요가 없습니다.
물론 origin 객체에 대해 멤버함수 호출 자체는 아무런 C++ 컴파일러 제약이 없습니다. dynamic_cast를 사용한다면 안전하게 형변환이 가능하며, 위의 제약사항을 잘 이해하고 있다면 얼마든지 origin으로부터 getType() 호출해서 cast나 isSub등을 사용해도 상관없습니다. 요약하면, 클래스 설계 의도를 잘 이해하고 사용하자는 것입니다.
baseObj 에 대한 origin 객체를 쉽게 정의하기 위해서 사용하는 클래스 템플릿입니다.
먼저 origin 와 baseObj 를 보고 오는 것이 좋습니다. 구조나 컨셉은 origin 클래스와 동일합니다. 자신의 타입을 shadow하며, baseObj 타입으로 다뤄지도록 의도합니다.
단 사용방법에서 차이가 발생합니다. 이는 baseObj 클래스의 컨셉이 obj 와 다르기 때문입니다.
obj와 달리 baseObj의 origin 객체는 static으로 존재한다
obj 는 byeol 언어로 작성된 객체를 표현합니다. 이 중에서도 def 키워드로 작성된 origin 객체는 origin 클래스의 인스턴스로 표현됩니다.
반면 baseObj 는 그 자체로 사용할 수 없으며 이를 상속한 C++의 클래스가 존재합니다. 둘의 차이를 잘 이해해보겠습니다.
C++ 코드로 봤을때 baseObj 의 origin 은 정적이지만 obj 의 origin 은 동적이라는 얘기입니다.
예를들어, baseObj의 origin를 정의할때는 C++ 클래스는 컴파일 타임에 정의되므로 origin 객체를 static으로 선언할 수 있습니다. 반면, 사용자가 byeol 코드로 다음과 같이 작성했다고 해보겠습니다:
MyObj라는 타입은 C++에서 봤을 때는 동적입니다. 런타임에 parser가 이 코드를 읽기 전까지는 class MyObj라는 것을 컴파일 타임에 만들 수 없습니다.
MyObj 자체를 byeol에서는 origin 객체라고 하며 이는 origin 클래스의 인스턴스로 표현됩니다:
타입에 속한 함수나 property에 대해 접근제한자를 표현합니다. byeol 언어에는 public/protected/override 3종류의 modifier만 존재합니다.
runtime 환경에서 byeol 타입에 대해 사용자가 복사 생성자를 정의하지 않은 경우 parser에 의해 추가되는 기본 복사생성자입니다. 복사 생성자 호출 시 args 의 첫 번째 인자로 복사 대상 객체가 전달되며, 이 객체의 property들을 얇은 복사를 수행합니다.
참고로 scalar는 immutable 객체이므로 얇은 복사를 시도해도 깊은 복사처럼 동작합니다.
defaultCopyCtor 동작 예제
str, int 등 scalar타입은 모두 immutable 타입입니다. 이는 byeol의 호출 전략이 by object를 따르기 때문입니다.
단 이를 책임져야 하는 부분은 node 나 obj 와 같이 컨테이너를 소유한 객체가 아니라 tnmap 과 같은 컨테이너 클래스 자체가 되어야 합니다.
obj 입장에서는 node 는 그냥 node 일 뿐이며, 자신이 소유한 container에 넣어야할 새로운 node 가 immutable 타입인지 아닌지를 알아야할 필요가 있어서는 안됩니다. 이게 깨지는 순간 다형성도 같이 깨집니다.
그래서 객체가 들어왔는데 이를 복사해야하는지, 아니면 참조만 가리키면 되는지를 immutableTactic 을 통해서 판단합니다.
알고리즘
판단은 단순하게 ntype 에 정의된 isImmutable() 함수로 판단합니다. scalar type을 구현한 nInt, nStr 등은 자신의 타입을 내보낼때 isImmutable()이 true로 나오는 타입으로 내보냅니다. immutable 타입으로 판단되면 인자를 바로 넣지 않고 clone()을 호출합니다.
Stela 언어로 작성된, pack을 로딩하기 위한 기본정보를 담고 있는 객체입니다. manifest.stela 파일로부터 Stela 모듈을 사용해 파싱됩니다.
주어진 타입 파라메터 T에 대한 일종의 proxy 클래스입니다.
만약 생성시 T*로 원본이 될 인스턴스를 넣지 않으면 아무런 동작을 하지 않는 dummy 객체로 동작합니다. 최초에는 검증 시간을 최적화 하기 위해 고안되었습니다.
예를들어 verifier 가 특정 symbol이 scope에 있는지 없는지 코드를 검증하려면 실제로 그 객체를 만들기 보다 타입 정보만 redirection하는 tmock 을 대신 생성하기 위해서였습니다.
현재는 해당 기능은 사용되지 않으며 dummy 객체로 대신 사용중입니다.
Byeol은 대부분이 표현식으로 구성된 언어입니다. 블록문조차도 표현식이며, 마지막 줄의 evaluation 결과를 반환합니다. 이 섹션에서는 Byeol의 다양한 표현식 클래스들을 살펴봅니다.
FBOExpr는 Binary Operator 표현식을 담당합니다. lhs, rhs에 각각 피연산자 표현식이 들어가며, 둘은 모두 scalar 타입이어야 합니다.
FUOExpr는 Unary Operator 표현식을 담당합니다. args에 피연산자가 1개 들어가며 scalar 타입이어야 합니다.
할당 표현식을 담당합니다.
단, 이 표현식은 scope에 등록된 참조를 바꾸는 것이지 객체 자체에 대해 operator=()를 호출하는 것이 아닙니다. obj::operator=()를 호출하게 되면 일종의 깊은 복사처럼 동작하니 주의해야 합니다. 예제로 차이를 비교해보죠.
블록 표현식을 담당합니다. frameInteractable 하며, 외부에서 frame 에 등록을 요청하면 scope를 하나 생성해 등록합니다. 이 scope는 이 블록문에서만 유효한 것으로, 흔히들 말하는 local scope입니다.
이는 RAII 패턴 (Resource Acquisition Is Initialization)을 따릅니다. scope가 블록의 lifetime과 함께 생성되고 소멸되므로, 블록 종료시 자동으로 리소스(scope)가 정리됩니다.
최적화: blockExpr 은 eval() 함수 안에서 local scope를 생성하지 않습니다. 이는 특정 context에서는 불필요한 scope 생성을 막아 최적화하려는 것입니다.
byeol 언어는 대부분 표현식으로 구성되며, block문도 예외가 아닙니다. block문은 마지막 줄의 evaluation 결과를 반환합니다.
배열 리터럴 표현식을 담당합니다. 어떠한 타입의 배열인지는 배열의 원소로 적은 리터럴 상수들의 타입들을 type promotion을 통해 추론됩니다. Type promotion은 여러 타입 중 가장 넓은 범위의 타입으로 승격하는 과정입니다 (예: [1, 2.5, 3]이라는 배열이 있다면 int와 flt 타입이 섞여있으므로 flt 배열로 승격됩니다).
tnarr 은 native 환경에서 배열을 담당하는 클래스입니다. arr 은 tnarr 을 byeol runtime 환경에 맞게 확장한 것입니다. 이렇게 함으로써 C++에서도 byeol에서도 서로 유사한 API를 사용 가능하게 됩니다.
tnarr 사용 예제
byeol 언어의 중첩 함수인 nestedFunc 을 생성합니다. 중첩함수는 정의할때는 이름을 생략할 수 있으며, closure로도 활용될 수 있습니다.
시퀀스(범위) 표현식을 담당합니다.
nseq 은 native 환경에서 시퀀스를 담당하는 클래스입니다. seq 은 nseq 를 runtime 환경에 맞게 확장한 것입니다. 이렇게 함으로써 C++에서도 byeol에서도 서로 유사한 API를 사용 가능하게 됩니다.
end 키워드를 구현합니다. endExpr은 단순히 블록문을 가지고 있을 뿐입니다. func이 end를 실행시킵니다.
blockExpr에서 벗어나는 키워드들의 기반클래스입니다.
블록문을 바로 종료하는 알고리즘
블록문 안에서 ret 등을 하여 바로 블록문을 종료해야 하는 경우, thread에 setRet()로 값을 넣어야 합니다. 각 blockExpr은 구문을 한줄 한줄 실행하다가 thread에 setRet()에 일정한 값이 발견될 경우, 모든 동작을 중단하고 해당 값을 블록문의 호출자에게 올려보냅니다.
이렇게 해서 thread에 상태를 설정하여 중첩된 블록 구조에서도 즉시 탈출할 수 있도록 합니다.
Byeol의 컨테이너 시스템은 c++ runtime환경과 byeol runtime환경에서 동일한 API를 제공하도록 설계되었습니다. 특히 tnchain은 AST 데이터를 보관하는 가장 중요한 컨테이너입니다.
byeol 언어에는 기본적으로 array, map, seq를 지원합니다.
tucontainable 클래스는 array와 같은 index를 입력받아 임의의 타입 T를 반환하는 container 클래스의 기본 인터페이스를 정의합니다.
tbicontainable 클래스는 map과 같은 key를 입력받아 임의의 타입 T를 반환하는 container 클래스의 기본 인터페이스를 정의합니다.
Native 타입을 Script 타입이 상속한다
byeol 언어는 AST를 그대로 프로그램 실행으로 이용하는 구조이기 때문에 C++ native 클래스를 그대로 script 타입으로 노출시키면, native API와 script API가 동일해지므로 이점이 있습니다.
이를 원활하게 하기 위해서, 기본 인터페이스를 tucontainable로 정의하고, 이걸 구현한 C++ native 클래스를 정의합니다. byeol에 사용할 script 타입은 이 native 클래스를 상속한 후, bridger를 사용해 쉽게 노출시키는 전략을 사용합니다.
Native 타입에는 이름 앞에 n이 붙는다
n은 native의 첫글자를 딴 것입니다. 컨테이너 클래스는 native 클래스와 그걸 상속한 script 클래스가 나오게 됩니다. native 클래스는 임의의 타입에 대해서도 동작해야 하므로 클래스 템플릿으로 정의됩니다.
그래서 native 환경에서 사용하는 tnarr과 runtime 환경에서 사용할 arr과 같이 하나의 컨테이너에 대해 2벌씩 클래스가 나옵니다.
t prefix
컨벤션 규칙에 따라, 클래스 템플릿인 경우는 prefix t가 붙습니다. 그리고 t 빠진 타입을 typedef로 선언하는 경우가 있는데 이는 해당 클래스 템플릿의 기본 파라메터를 넣은 것을 의미합니다.
예를들어 클래스 템플릿인 tnarr의 기본 타입 파라메터인 node를 넣은걸 narr이라고 합니다. tstr도 마찬가지로 tstr<node>를 str이라고 선언하고 있습니다.
컨테이너 기본 제공 API
모든 컨테이너는 다음 API를 제공합니다:
add()del()begin(), end(), iterate()rend(), rbegin(), riterate()set()in()이는 Iterator 패턴을 따릅니다. begin(), end(), iterate() 등의 메서드를 통해 컨테이너의 내부 구조를 노출하지 않고 순회할 수 있습니다.
R과 RSquare
tucontainable은 본래 index에 대해 타입파라메터 T를 원소로 반환하는 컨테이너입니다. 그러니 타입 파라메터가 T만 있어도 충분할거라 생각하기 쉽지만 실제로는 R과 RSquare라는 타입을 추가로 갖고 있습니다.
T*가 될 것입니다.T&가 될 것입니다.이렇게 2가지 타입을 추가로 정의하는 이유는 nseq 때문입니다. nseq는 그 기본 형태가 index를 기반으로 하는 int 배열과 유사합니다.
다만 반환 타입은 참조가 될 수 없습니다. seq의 범위가 몇 억에 가깝다면 사용하지도 않을 몇 억개의 int 원소를 다 가지고 있을 필요가 없습니다.
그래서 nseq는 99% API가 ucontainable과 동일하나 반환형이 nInt*가 아니라 nInt 여야 한다는 차이만 있을 뿐입니다.
node가 AST의 근간을 이루는 가장 중요한 클래스라고 한다면, tnchain은 AST의 데이터를 보관하는 컨테이너로써 가장 중요한 클래스라 할 수 있습니다.
tnchain은 말그대로 컨테이너를 chain하면서 관리하는 클래스로, 여러 원소들을 하나의 그룹으로 묶어서 참조하거나, 순회하거나, 다른 곳에 있는 컨테이너를 여기에 참조만 chain해서 겉으로는 마치 하나의 flatten된 map처럼 보이도록 하는 기능을 가지고 있습니다.
쉽게 비유하면 linked list의 각 node를 배열로 정의한 컨테이너를 떠올리면 좋습니다.
tbicontainable로도 대부분의 기능을 사용할 수 있다
tnchain의 가장 중요한 컨셉으로, 외부 사용자는 bicontainer를 하나 받았을 뿐, 이것이 실체가 tnmap인지, tnchain인지는 몰라도 상관없어야 합니다.
물론 이것은 tnchain을 bicontainable로써 add(), del(), len() 등을 할때의 얘기이며, link()와 같이 tnchain의 고유 기능을 사용하려면 tnchain이라는 걸 알아야만 합니다.
b>link() - tnchain의 존재의의
tnchain은 내부적으로는 타입 파라메터로 선언한 defaultContainer를 한개 가지고 있으며, next, prev라고 하는 tnchain에 대한 참조를 갖는 일종의 linked list의 node와 비슷한 구조로 되어 있습니다.
link(const tnchain<T>&)로 새로운 chain 객체를 넘기면 this의 container의 next에 새로운 chain이 연결됩니다. 이때 탐색을 하면 tnchain은 자신의 defaultContainer를 모두 탐색한 후 바로 다음 next chain의 defaultContainer로 순회합니다.
따라서 외부에서 봤을때는 this container에 next chain의 원소들이 복사되어 들어간 것처럼 코드를 작성할 수 있습니다.
이제 chn1을 순회하면 {0, 1, 6, 5, 2, 3} 순서로 모든 원소에 접근할 수 있습니다. 하지만 실제로는 어떠한 복사도 일어나지 않았습니다. 단지 참조를 chain으로 연결했을 뿐입니다.
tnchain link() 실전 사용 예제
순회 예제
Native API를 byeol 환경에서도 그대로 사용
byeol 언어는 AST를 그대로 프로그램 실행으로 이용하는 구조이기 때문에 C++ native 클래스를 그대로 script 타입으로 노출시키면, native API와 script API가 동일해지므로 이점이 있습니다.
노출할때는 tbridger를 사용해서 몇 줄만으로도 간단하게 노출시킬 수 있습니다.
seq의 기반이 되는 ucontainable를 구현한 클래스로, int를 받아서 적절한 int를 반환하는 범위형 컨테이너입니다.
예를들어 byeol 코드로 3..5라고 정의한 seq가 있다면 해당 seq[0]은 3, seq.len()은 2가 나오게 됩니다.
사용 예제
byeol에서는 AST에서 node를 구성할때 단순하게 map을 사용할 순 없으며 multimap을 사용해야 합니다. (함수 오버로딩 때문입니다. 자세한 내용은 node를 참조하세요.)
다만 여기서 중요한 점은 원소의 삽입 순서가 scope의 검색 우선순위를 결정한다는 것입니다. scope에서 같은 이름의 여러 심볼(예: 오버로딩된 함수들)을 찾을 때, 먼저 삽입된 것을 우선적으로 검색하고 매칭을 시도합니다. 따라서 삽입된 순서를 기억하고 유지할 필요가 있어 삽입 순서를 기억하는 multimap을 별도로 구현해 사용하고 있습니다.
API는 STL 라이브러리를 최대한 비슷하게 구성했습니다. 이는 tnarr또한 내부적으로는 vector를 사용하므로 같은 상황에 같은 컨벤션이 되도록 의도적으로 구성한 것입니다.
삽입 순서를 기억해야 하는 또 다른 이유: Closure의 Scope Capture
중첩 함수가 closure로 export될 때, 그 시점의 local scope를 capture하고 유지해야 하는 요구사항이 있습니다. 하지만 capture 시점 이후에 발생한 local 변수들은 중첩 함수와 아무런 관련이 없어야 합니다.
이러한 이유로 closure는 전체 scope를 capture하지 않고, closure가 생성되는 시점까지의 local scope에 속한 변수들만 capture합니다. 이를 구현하려면 scope의 얕은 복사가 필요하다고 생각하기 쉽지만, tnchain과 link()를 사용해서 복사 없이 이 문제를 해결합니다.
하지만 이를 위해서는 tnchain이 반복자가 삽입 순서대로 순회하는 것을 보장해야 하므로, STL의 multimap을 직접 사용할 수 없어서 smultimap을 별도로 구현했습니다.
삽입 순서 보장 예제
Byeol은 C++의 템플릿과 유사한 generic 타입 시스템을 제공합니다. 하지만 C++ 템플릿과 달리, byeol의 generic은 지연 인스턴스화(lazy instantiation) 방식으로 동작합니다. 즉, generic 타입이 실제로 사용될 때만 해당 타입의 구체화된 인스턴스가 생성됩니다.
사용된 디자인 패턴: Lazy Instantiation 패턴 (설계상 lazy, 실제로는 verifier가 eager 수행), AST Transformation (generalizer가 타입 파라메터를 구체 타입으로 교체), Cache 패턴 (map으로 생성된 origin 재사용)
Generic 타입 생성 흐름도:
generic 클래스의 참조를 담당하며 generic 타입 생성의 진입점을 담당합니다. 사용자가 SomeGeneric<MyObj>()처럼 generic 타입을 사용하면, 이 표현식이 getGenericExpr로 표현됩니다.
내부적으로는 genericOrigin에게 generic 타입을 줄 것을 요청하며, genericOrigin은 해당 타입에 대한 generic이 있다면 그걸 바로 주고, 없다면 generalizer를 통해 generic 타입을 생성 후, 그 새로운 인스턴스를 반환합니다.
genericOrigin은 설계상 lazy instantiation을 지원합니다. 즉, eval() 호출 시점에 주어진 type parameter로 generic 타입을 생성합니다. 하지만 verifier가 프로그램 실행 전에 모든 타입을 검증하면서 필요한 generic 인스턴스를 미리 생성하므로, 실제로는 eager instantiation처럼 동작합니다.
기본적으로 origin과 같은 역할을 수행하는 클래스이지만, generic을 지원한다는 점과 이 인스턴스 자체가 origin을 담당하는 게 아니라 필요에 의해 origin을 생성/관리한다는 점이 다릅니다.
동작 방식
genericOrigin은 내부적으로 map을 사용해서 타입 파라메터별로 구체화된 origin 객체들을 관리합니다. 예를 들어, SomeGeneric<MyObj>가 요청되면:
MyObj를 key로 찾습니다_org)를 deepClone합니다generalizer를 사용해서 타입 파라메터 T를 모두 MyObj로 교체합니다이는 Lazy Instantiation 패턴과 Cache 패턴의 조합입니다. 요청이 있을 때만 origin을 생성(lazy)하고, 한번 생성된 origin은 map에 저장하여 재사용(cache)합니다.
다음은 generic 타입이 어떻게 관리되는지 보여주는 AST 덤프 예시입니다:
가장 중요한 부분은 첫 줄로, SomeGeneric이라는 genericOrigin 인스턴스 안에 myObj라는 key로 SomeGeneric<myObj>이라는 origin 객체가 들어있다는 것입니다.
visitor 기반으로 동작하며, generic 타입을 구체화하는 실질적인 작업을 담당합니다. 동작은 매우 단순합니다:
AST를 순회하면서 타입 파라메터를 찾아 교체합니다.
예를 들어, SomeGeneric<T>에서 SomeGeneric<myObj>를 만들 때:
getExpr(args = T) 형태의 노드를 찾으면getExpr(args = myObj)로 교체합니다이 과정이 모든 node에 걸쳐서 수행되면, 원본 generic 타입의 복사본에서 타입 파라메터가 모두 구체 타입으로 교체된 새로운 origin이 완성됩니다.
Generic 타입 생성 흐름
전체 흐름을 정리하면 다음과 같습니다:
SomeGeneric<MyObj>().foo()getGenericExpr가 genericOrigin에게 MyObj 타입 요청genericOrigin이 map에서 MyObj 검색generalizer 실행generalizer가 AST를 순회하며 T를 MyObj로 교체Byeol은 C++로 작성된 native 코드와 byeol 언어로 작성된 script 코드가 서로 상호작용할 수 있는 bridge 시스템을 제공합니다. 이 시스템의 핵심은 C++ 클래스와 함수를 간단한 선언만으로 byeol 언어에서 사용 가능하도록 노출하는 것입니다.
tbridger 는 다음 컴포넌트들의 조합으로 동작합니다:
func() 등으로 함수를 static subs()에 저장동작 흐름
이 과정에서 Marshaling 이 핵심입니다. tmarshaling이 script 타입(nInt)을 native 타입(int)으로, 반환값을 다시 script 타입으로 자동 변환하여 두 환경 간의 타입 불일치를 해결합니다.
tbridger는 bridge 시스템의 facade 역할을 합니다. C++ 클래스를 타입 파라메터로 받는 클래스 템플릿이며, monostate 패턴으로 설계되어 있습니다. 기본 사용법을 먼저 보시죠. C++ 구조체를 byeol에서 사용 가능하도록 만드는 예시입니다:
위 코드가 pack으로 배포되면, byeol 코드에서는 다음과 같이 사용할 수 있습니다:
tbridge 는 tbridger 에 등록된 정보를 바탕으로 생성되는 baseObj 입니다. 내부적으로 tbaseObjOrigin<tbridger<T>> 형태로 origin 을 정의하고 있어서, tbridger 를 통해 채워진 subs()를 자신의 origin으로 사용합니다.
따라서 tbridge 객체는 tbridger 에 등록된 함수들을 마치 자신의 멤버 함수처럼 소유하게 됩니다.
tmock은 주어진 타입 파라메터 T에 대한 proxy 클래스입니다. 생성시 원본 인스턴스를 넣지 않으면 아무 동작도 하지 않는 dummy 객체로 동작합니다.
원래는 verifier가 symbol이 scope에 있는지 검증할 때, 실제 객체를 만들지 않고 타입 정보만 redirect하는 용도로 고안되었습니다. 현재는 해당 기능은 사용되지 않으며, 주로 dummy 객체 용도로 사용됩니다.
이는 Proxy 패턴의 구현입니다. 원본 객체에 대한 대리자(proxy) 역할을 하며, 원본이 없을 때는 dummy로 동작합니다.
Byeol의 코드 실행은 scope, frame, frames, thread 가 유기적으로 협력하여 이루어집니다. 이 시스템은 symbol 탐색, 함수 호출, 실행 흐름 관리를 담당합니다.
클래스 계층도
scope는 tnchain을 기반으로 설계되어 있습니다. byeol에서 scope는 만든 주체에 따라 5가지로 분류됩니다:
이는 Chain of Responsibility 패턴을 활용합니다. scope들이 chain으로 연결되어, symbol 탐색 요청이 현재 scope에서 처리되지 않으면 다음 scope로 전달됩니다.
Scope의 종류
blockExpr에 의해 생성, 블록 종료시 소멸아, 참고로 localScope 이라는 별도의 클래스가 있다는 얘기가 아닙니다. 개념적으로 분류하고 있을 뿐, 위의 모든 scope는 다 똑같은 scope 객체예요.
byeol 코드로 보는 Scope 분류 예시
Local scope는 함수 내 블록문이 실행될 때 생성됩니다. 최적화를 위해 blockExpr이 직접 생성/해제하지 않고, frameInteract를 통해 생성됩니다:
이는 RAII 패턴의 전형적인 예입니다. frameInteract 객체의 생성자가 scope를 frame에 등록하고, 소멸자가 자동으로 정리하므로 리소스 누수를 방지합니다.
Func scope는 함수가 소유한 symbol들이 저장됩니다. 주의할 점은 매 함수 호출마다 새로 생성되는 게 아니라, 시스템 내 유일하게 존재하는 func 객체가 소유한 sub node들이라는 것입니다.
File scope와 pack scope는 밀접한 관계가 있습니다:
IS_DBG는 file scope과 pack scope에 각각 1개씩 정의됩니다. 중요한 점은 file scope는 parser 에 의해 항상 pack scope를 chain한다는 것입니다. Symbol을 찾을 때 file scope를 먼저 검색하고, 없을 경우 pack scope를 검색하므로, file scope에 선언된 값이 우선됩니다.
frame은 현재 실행중인 코드 블록에서 접근 가능한 symbol을 관리합니다. 여러 개의 scope를 동적으로 chain하는 방식으로 최적화된 symbol 탐색을 제공합니다.
Frame 생성 과정
baseFunc이 eval() 되면 다음 순서로 frame이 구성됩니다:
baseObj: 새 frame 객체를 만들어 frames에 추가, subs()와 me 참조 등록obj: baseObj 동작에 더해 현재 file scope 추가 (pack scope도 함께)func: 자신의 subs()와 args를 frame에 등록blockExpr: local scope으로 사용할 빈 scope 생성이 과정을 거쳐 함수 호출시 최소 5개의 scope가 frame에 등록됩니다.
실제 예시: yourObj.foo(10) 호출 시 frame 구조
앞서 본 Calculator 예시에서 calc.add(5) 호출 시 생성되는 frame은 다음과 같은 순서로 scope가 적층됩니다:
Symbol 탐색은 위에서 아래로 순차 진행됩니다:
temp 검색: [1] local scope에서 발견val 검색: [3] args scope에서 발견result 검색: [4] obj scope에서 발견PI 검색: [6] pack scope에서 발견Scope 탐색의 효율성
frame은 node를 상속하므로 subs()를 제공합니다. 하지만 subs()는 vector<scope>가 아니라 여러 scope이 chain으로 연결된 하나의 scope 객체를 반환합니다. 따라서 symbol을 찾을 때:
이는 scope이 tnchain으로 구현되어 있기 때문에 가능합니다.
frames는 하나의 thread에 속한 여러 frame을 관리합니다. byeol에서는 최상위 코드도 암묵적으로 초기화 함수 내에서 실행되며, main()도 함수이므로, 모든 코드 실행은 함수 컨텍스트 안에서 이루어집니다. 따라서 코드 실행 = 함수 실행이며, 함수 실행시 frame 객체가 생성되어 적절히 scope를 적층시킵니다.
Frame 적층의 필요성
다음 byeol 코드를 봅시다:
main() 안에서 yourObj.foo()를 호출하면 frame은 다음과 같이 구성됩니다:
frame #2를 보면 yourObj 아래에 file/pack scope이 다시 나타납니다. 왜 그럴까요?
Symbol 탐색은 위에서 아래로 순차 탐색합니다. 만약 frame #2에서 pack/file scope을 제거한다면, yourObj.foo() 안에서 name을 참조할 때 main()의 local scope에 있는 "unknown"이 먼저 발견됩니다. 이는 잘못된 결과입니다 (file scope의 "kniz"가 나와야 함).
올바른 결과를 위해서는 frame #2에 pack/file scope를 끼워넣어야 합니다. if-else로 scope 탐색 장소를 분리하는 것보다, 하나의 큰 배열을 위에서 아래로 순차 탐색하는 것이 훨씬 간결합니다.
게다가 tnchain의 link()를 활용하면, parser가 obj를 생성할 때 pack과 file을 미리 chain으로 연결해두어서, obj의 scope를 frames에 추가하는 것만으로도 obj, file, pack scope이 한번에 연결됩니다. 이 과정에서 어떠한 복사도 일어나지 않습니다.
thread는 하나의 프로그램 실행 흐름을 표현합니다 (현재는 단일 스레드만 지원). thread는 frames와 errReport를 소유하여 프로그램 실행시 frame을 구성하고 에러를 수집합니다.
Builtin pack
builtin pack은 byeol 언어에서 기본 제공하는 타입/함수들입니다. manifest 선언 없이도 항상 접근 가능하며, int, str, arr, err, print() 등이 여기 속합니다.
standard pack과 비슷하지만 엄연히 구분되는 pack이며, builtin은 전부 C++ native 클래스를 tbridger를 활용해 노출한 형태입니다.
싱글톤 속성
thread는 static thread& get()을 제공합니다. scope나 expr들은 이를 통해 현재 활성화된 thread에 접근합니다.
기본 thread를 사용하지 않고 직접 thread 인스턴스를 만들 때는, thread 객체를 등록하고 종료시 원본으로 교체하는 작업이 필요합니다. 이를 위해 threadUse를 사용합니다:
threadUse는 생성과 동시에 내부에서 thread 객체를 자동 생성까지 해주므로 편리합니다.
threadUse RAII 패턴 예제
Byeol은 pack이라는 단위로 라이브러리를 배포합니다. 패키지 시스템은 pack을 동적으로 lazy 로딩하며, 종속성 관리와 검증을 담당합니다.
stela 언어로 작성된, pack을 로딩하기 위한 기본정보를 담고 있는 객체입니다. manifest.stela 파일로부터 Stela 모듈을 사용해 파싱됩니다.
manifest 는 pack의 entrypoint, 종속성 정보 등을 포함합니다. entrypoint는 pack이 어떠한 종류의 라이브러리를 포함하고 있는지를 나타냅니다 (예: cpp, byeol).
byeol 언어는 pack이라는 일종의 압축파일 단위로 라이브러리를 배포하는데, pack 파일에는 최상위 obj 객체와 manifest, 종속하는 pack 목록이 포함됩니다.
(byeol 언어에서 내 코드에서 다른 pack을 사용하려면 manifest 에 종속관계에 있다는 걸 선언해야 합니다)
slot 은 pack 파일로부터 만들어지는 결과물이지, pack을 불러오는 걸 담당하지 않습니다. pack 로딩에 대해서는 packLoading이나 slotLoader를 참조하세요.
byeol 언어는 pack을 lazy하게 동적으로 불러옵니다. autoslot 은 이 기능을 구현한 것으로, slotLoader가 pack 파일을 찾으면 packLoading 객체를 적절히 생성해서 autoslot 에 넣어둡니다.
이후 autoslot 에 접근해서 안에 포함된 symbol을 가져오려는 시도를 하면 lazy하게 packLoading 이 동작해 symbol을 파일로부터 불러옵니다.
이는 Lazy Loading 패턴의 핵심 구현입니다. 필요할 때까지 리소스(pack) 로딩을 미루어 초기 로딩 시간과 메모리 사용량을 최소화합니다.
복수의 packLoading
packLoading 은 native 환경에서 가져올 수도 있고(dll 혹은 so 파일), runtime 환경에서 가져올 수도 있습니다(.byeol 파일). 또는 2개가 모두 하나의 pack에 있는 경우도 있을 수 있습니다.
따라서 autoslot 은 항상 1개의 packLoading 만 가지지 않고, 배열로 처리합니다.
autoslot 상태 (State)
총 4개의 상태를 가지며 다음과 같은 흐름으로 로딩 파이프라인을 갖습니다:
이는 State Machine 패턴을 응용한 부분입니다. autoslot은 정의된 상태들(RELEASED → PARSED → VERIFIED → LINKED) 사이를 전이하며, 각 상태에서 허용되는 동작이 다릅니다.
왜 이런 상태 관리가 필요할까요?
동적 검증과 의존성 문제
모든 pack이 검증이 완료된, 완전무결한 상태라고 전제하고 그냥 로딩만 해서는 안될 수 있습니다. 때로는 pack이 올바른지 한번 더 검증할 필요가 있기에, autoslot 중 일부는 symbol을 불러올때 사전에 파싱이나 검증을 해야 합니다.
문제는 어떠한 pack은 다른 pack에 종속되는 경우가 매우 많이 발생한다는 점으로, 종속한 pack이 검증에 실패하게 되면, 그 사실을 전파해서 종속된 pack들도 모두 사용이 불가능해야 합니다. 이를 위의 4가지 상태를 제어하는 알고리즘으로 해결합니다.
재귀적 로딩
pack이 다른 pack에 종속되는 경우는 부지기수로 많습니다. autoslot 이 lazy하게 동작하기 때문에 어떠한 slot 을 loading하다가 다른 autoslot 에 접근함으로써 해당 autoslot 도 재귀적으로 로딩 시퀸스에 들어가는 일도 많습니다.
이때 중복으로 초기화 되거나 아직 완전히 파이프라인을 통과하지 않는지 체크합니다.
RAII autoslot 은 slot 에 정의된 _pack 객체를 가리킵니다. 이 객체는 외부 파일인 pack 을 로딩함으로써 읽어온 심볼들인데, autoslot 은 packLoading 을 통한 pack의 symbol 생성을 책임지므로, pack의 소멸 또한 책임집니다. 그래서 RAII를 사용해, autoslot 객체가 소멸될때 모든 심볼을 먼저 없애고 packLoading 객체 또한 없앰으로써 so 파일을 close 하는 등의 작업을 수행합니다. 자세한 내용은 packLoading 을 참조하세요.
이는 RAII 패턴의 전형적인 활용으로, autoslot 객체의 lifetime에 pack 리소스의 lifetime을 바인딩하여 리소스 누수를 방지합니다.
slotLoader 는 외부 pack을 로딩하는 역할을 담당합니다. addPath()로 탐색 경로를 추가하고, load()를 호출하면 pack을 불러올 수 있습니다.
기본 사용법
메서드 체이닝(setReport().setBaseSlots().addPath().load())으로 객체 구성 과정을 명확하고 읽기 쉽게 표현합니다.
manifest와 entrypoint
pack 로딩 중에는 필연적으로 manifest 를 파싱합니다. manifest를 통해 가장 중요한 정보는 entrypoint입니다. entrypoint는 pack이 어떠한 종류의 라이브러리를 포함하고 있는지를 나타냅니다.
예를들어 pack에 C++ 동적 라이브러리가 포함되어 있다면 entrypoint는 cpp가 되며, byeol 라이브러리라면 byeol이 됩니다.
slotLoader 는 manifest를 읽은 후 autoslot 을 생성하고 entrypoint에 따라 적절한 packLoading 인스턴스를 autoslot에 추가합니다.
주의사항:
slotLoader에 의해 패키지를 로딩할 때 사용되는 추상 클래스입니다. packMakable 인터페이스가 핵심 API를 제공합니다.
packLoading은 rel(), parse(), verify() 함수를 제공하며, 이는 autoslot의 상태와 깊은 관련이 있습니다.
slotLoader에 의해 cpp 패키지를 로딩할 때 사용되는 packLoading 중 하나입니다. 이름 그대로 C++ pack을 동적 라이브러리 파일에서 로딩하는 역할을 합니다.
Entrypoint
Indep 모듈의 dlib 을 사용해서 동적 라이브러리를 로딩합니다. entrypoint는 이미 정의되어 있는 ENTRYPOINT_NAME 의 값을 사용합니다.
Byeol에서는 AST를 중점적으로 다루기 때문에 visitor 를 자주 사용하게 됩니다. 순회하는 방법과 순회시 node를 visit했을 때의 동작을 서로 분리하기 위해 visitor 가 적극적으로 활용됩니다.
visitor 는 항상 전위 순회(pre-order traversal)를 따릅니다. 후위 순회를 하도록 변경은 불가능합니다.
visit()은 다음 3개의 단계로 이뤄져 있습니다:
onVisit())onTraverse())onLeave())visit()이 알고리즘의 골격(3단계 구조)을 정의하고, 각 단계(onVisit, onTraverse, onLeave)는 하위 클래스가 override하여 구체적인 동작을 정의합니다.
accept를 통한 Downcasting
visitor 는 onVisit(T&)과 같이 많은 구체 타입에 대한 방문을 표현하는 virtual function을 다수 가지고 있습니다.
반면 onTraverse에서 탐색시에는 주로 node의 subs()를 통해서 tbicontainable 인터페이스를 사용하기 때문에 node 타입을 사용하게 됩니다. 그래서 어디선가는 node 타입을 nInt나 defNestedFuncExpr과 같이 구체타입으로 downcasting을 해야합니다.
이를 위해 node 의 accept()라는 virtual 함수를 호출합니다:
가상함수 accept()가 호출되면 안에서 *this를 통해 구체타입으로써 역으로 visitor의 visit()을 호출하는 식입니다.
이를 위해 visitation에 참여하는 모든 node 의 파생클래스는 accept()라는 virtual 함수를 override 해야 하는데, 이 과정을 쉽게 하기 위해서 VISIT 매크로를 사용합니다:
만약 node 파생클래스 임에도 accept()를 override 하지 않았다면, onTraverse(node&)가 대신 사용되며, subs()를 호출해서 자식 node 를 순회하도록 기본동작을 수행합니다.
중복 방문 제거
AST는 참조가 서로 순환하는 경우도 종종 발생합니다 (예: A가 B를 참조하고 B가 다시 A를 참조). 이 경우 아무런 예외처리 없이 순회하면 이미 방문했던 node 를 다시 방문하면서 무한 순회에 빠집니다.
visitor 는 _visited라는 map을 소유하고 있습니다. 이를 통해서 visit()이 호출 되었을 때 이미 방문한 node 인지를 판단해서 예외처리를 해주고 있습니다.
이 방문 기록 정보는 매번 visitor 가 방문을 시작하기 직전에 초기화됩니다. 만약 재방문이 가능하도록 하고 싶다면 setReturnable(true)로 값을 변경하면 됩니다.
visitor 구현 예제
visitor 기반의 AST 로깅 클래스로, 주어진 root node를 순회하면서 상세 정보를 모두 로깅합니다.
다음과 같은 상황에서 사용됩니다:
interpreter에 의해서 디버그 빌드에서 사용defaultSigZone에 의해서 crash가 발생하면 출력logStructureFlag에 의해서 interpreter나 starter로 하여금 graphVisitor 를 사용하도록 flag가 set됨graphVisitor 는 AST 구조를 트리 형태로 시각화하여 출력하므로, 디버깅시 AST의 구조를 파악하는 데 매우 유용합니다.
byeol 언어의 파싱 시스템은 Flex와 Bison을 사용하는 전통적인 파서 구조를 따르지만, byeol 언어의 특성인 offside rule을 지원하기 위해 정교한 메커니즘을 갖추고 있습니다.
parser는 byeol 파싱 컴포넌트의 진입점 역할을 하며 worker를 상속합니다. work()을 통해서 파싱된 결과가 slot으로 반환됩니다.
Scanner - Bison - Parser 구조
Flex와 Bison을 사용하고 있으며 flex는 lowscanner로, bison은 lowparser로 각각 명명합니다. 이 low level인 scanner, parser 는 parser 컴포넌트 안에만 존재하는 것으로 외부에서는 일절 노출되지 않습니다.
parser::work()가 실행되면 다음과 같은 흐름으로 동작합니다:
그러므로 parser 의 on으로 시작하는 함수들(예: onPack(), onCompilationUnit())은 그러한 이벤트를 handling하는 함수로, 실제로 어떻게 node 를 생성해서 AST를 구축하는지를 정의합니다.
lowparser.y의 Lifecycle 관리
각 rule에서 parser::onXXXX() 함수들을 호출하면, 해당 함수 내에서는 new 키워드로 새로운 객체를 heap에 생성해서 반환하는 경우가 많습니다. 이렇게 받은 인자를 그대로 tbicontainer 등에 직접 넣으면 괜찮지만, 그렇지 않은 경우는 미리 tstr 등으로 binding을 하지 않으면 메모리 릭이 발생하기 딱 좋습니다:
이 점은 onXXXX() 같은 함수에 국한되지 않고, 문자열 token을 받는 경우도 동일하게 적용됩니다:
byeol 파서의 smartDedent와 tokenScan은 stela 모듈의 stelaSmartDedent, stelaTokenScan과 동일한 원리로 동작합니다. Indentation을 배열로 관리하고, scan mode를 동적으로 전환하는 방식은 완전히 같습니다.
상세한 동작 원리와 예제는 Stela 모듈의 해당 클래스들을 참조하세요. byeol 파서의 경우 더 복잡한 문법을 가지므로 명령 토큰(SCAN_AGAIN, SCAN_EXIT 등)이 더 다양합니다
자세한 명령 토큰 목록은 lowparser.y의 // mode: 단락을 참조하세요.
parser에서 사용되는 클래스로, parser 에 입력되는 코드를 어떠한 형태로 제공할지를 추상화합니다.
예를 들어 파일로써 제공하는 방법도 있지만, 버퍼에 담아서 줄 수도 있고 stream으로 주는 방법도 있을 것입니다. srcSupply는 이러한 다양한 소스 제공 방식을 추상화하여 parser 가 일관된 인터페이스로 소스 코드를 읽을 수 있도록 합니다.
parser에서 명시한 것처럼 현재 사전 타입 추론 알고리즘은 종속성 그래프를 만들지 않고 모든 사전 타입 추론 표현식을 모아둔 후, 반복적으로 타입 추론을 시도하는 방식입니다.
이러한 방식을 사용하기 때문에 반복적으로 표현식을 순회하며 사전 타입 추론을 시도하는 동작을 verifier 안에서 한번에 수행하기에는 적합하지 않습니다. 따라서 verifier 로 검증하기 전에 expander 로 사전 타입 추론을 완료해둘 필요가 있습니다.
기본 동작은 visitor에 기반하므로, 사전에 visitor 를 숙지하고 오는 게 좋습니다.
defBlock과 Expand
어떠한 byeol 코드는 타입 추론시 순서를 요구합니다:
위와 같은 코드를 타입추론을 위해 위에서부터 아래로 코드를 평가한다고 해봅시다:
msg는 name과 age를 참조하는데 name과 age의 타입을 모르므로 실패name은 age를 참조하는데 age를 모르므로 실패age는 심지어 외부 모듈의 값을 참조하므로 해당 모듈에 접근해야 함만약 해당 모듈에서 값을 가져왔다고 가정한다면 age는 int 타입임을 알 수 있지만 name과 msg는 타입 확정에 실패한 상태로 진행됩니다.
byeol은 이러한 타입추론 표현식들을 한곳에 모아둔 후, parsing이 종료된 후 1줄이라도 타입추론이 성공한다면 무한루프를 도는 방법을 사용합니다. 가지고 있는 모든 표현식이 타입추론에 실패하거나, 타입추론 표현식이 바닥나면 루프를 종료합니다.
이 방식은 추론 순서를 따지지 않아도 되므로 구현이 간단하지만 속도가 느리므로, 향후 종속성 그래프를 구축하여 타입 추론 순서를 최적화하는 방법으로 개선할 여지가 있습니다.
defBlock은 이러한 역할을 돕습니다. defBlock 에는 expand, common, scope 3종류의 데이터가 있는데:
verifier 동작 전에 expander로 넘길 부분파싱이 완료된 AST는 실행되기 전에 검증 과정을 거쳐야 합니다. byeol 언어는 인터프리터 구조를 띄고 있지만 강형 타입을 사용하며, 컴파일 언어처럼 사전에 에러를 도출합니다.
tworker는 대량의 배치 작업을 수행하는 데 최적화되어 있습니다. errReport로 이 과정에서 발생하는 에러를 수집할 수 있으며 다양한 flag들을 지정해서 작업 중 일부 동작을 변경시킬 수도 있습니다.
verifier, visitor, parser 등 큰 작업을 수행하는 클래스들은 tworker 기반으로 돌아갑니다.
work와 task
tworker 는 말 그대로 work()를 하기 위해 존재합니다. 이때 작업의 대상이 되는 input을 task라고 합니다.
작업 전에 _prepare()가 호출되며 작업이 종료되면 _onEndWork()가 호출됩니다.
errReport 통합
대량의 작업을 깊은 함수 depth를 동반하면서 수행해야 하므로, 콜스택 깊은 곳에 있는 에러를 충분히 탐지하기 위해서는 errReport 를 통해서 이를 수집할 수 있어야 합니다.
tworker 는 setReport()를 통해서 외부로부터 errReport 객체를 넣을 수 있습니다. errReport 를 할당하지 않을 경우 dummyErrReport가 대신 들어갑니다.
Log Flag
tworker 는 작업 도중에 로깅을 위한 다양한 flag를 가지고 있습니다:
LOG_ON_EX: 에러 발생시 한줄 로그를 남김DUMP_ON_EX: 에러 발생시 callstack을 포함한 err 객체를 dumpGUARD: 함수가 시작되고 끝날 때 로깅INTERNAL: 에러나 GUARD를 제외한 로그를 로깅LOG_ON_END: 작업이 모두 완료되면, 그간 수집한 err를 모두 log()로 한줄 로그를 남김DUMP_ON_END: 작업이 모두 완료되면, 그간 수집한 err를 모두 dump()visitor 기반의 코드 검증을 담당합니다. byeol 언어는 인터프리터의 구조를 띄고 있지만 언어 자체는 강형 타입을 사용하고 컴파일을 실행하는 것처럼 사전에 에러를 도출합니다. verifier 는 그 기능의 핵심을 구현합니다.
parser와 expander로 최종 생성된 AST를 실행하기 전에 에러는 없는지 확인합니다.
verifier 는 다양한 모듈을 종합적으로 사용하는 고급 클래스이므로, 다음 개념들을 먼저 학습한 후 접근하는 것을 권장합니다:
node: AST의 기본 구조visitor: AST 순회 패턴expr: 표현식 클래스들visitor 기반
onTraverse는 visitor 에서 알아서 채워주므로, verifier 는 onVisit과 onLeave시 어떻게 해야 하는지에 대해서만 정의해두고 있습니다.
eval과 infer
프로그램의 실행은 결국 각 node의 eval()로 이뤄지지만, verifier 는 실행한 런타임 값은 관심이 없습니다. 오직 실행이 가능한가, 지정한 표현식의 결과가 정의된 property의 타입과 묵시적 변환이 허용되는가와 같은 타입 매칭에만 관심이 있습니다.
node의 infer()는 타입 추론 기능을 수행하는 것으로 실행하면 값은 모르지만 결과 타입을 origin 객체로 반환하는 함수입니다. 값을 계산하지 않으므로 eval()보다 더 빠릅니다.
이제 감이 왔겠지만, verifier 는 eval()보다는 infer()를 중점적으로 사용합니다:
위의 예시처럼 eval() 대신 infer()를 주로 사용합니다.
단계별 상세 로깅
visitor 는 타입당 1개의 onVisit() 함수만 가질 수 있습니다. 그러나 verifier 는 하나의 타입이 주어졌을 때 검증해야 하는 케이스가 대부분 2개 이상입니다. 그러니 잘못하면 하나의 onVisit() 함수는 여러 개의 검증 로직으로 뒤범벅되어 구분이 어려워집니다.
단계별 상세 로깅이란, 이를 도와주는 코드로, 검증 타겟이 되는 객체에 대해 진행하게 될 검증 로직이 무엇인지를 로그를 남기는 역할을 합니다:
이렇게 단계별로 로깅을 남기면 복잡한 검증 로직을 추적하기가 훨씬 쉬워집니다.
worker의 일종으로, verifier에 의해 검증이 완료된 AST를 실행합니다. 실행 결과 thread에게 exception이 발생되었다면 로그를 덤프하고 exception을 반환합니다.
main 함수 Look up
starter가 메인 함수를 찾을 때 사용하는 조건:
main이어야 함void 또는 intstarter 는 interpreter 와 함께 사용하면 byeol 코드를 파싱해서 실행할 수 있습니다.
interpreter와 starter를 사용한 코드 실행 예제
signaler에 RAII를 적용한 클래스입니다.
sigZone 인스턴스가 정의된 블록문 안의 코드를 실행할 때 signal이 오면 람다를 수행하도록 signaler 를 사용합니다. RAII 패턴을 통해 블록을 벗어나면 자동으로 signal handler가 해제됩니다.
byeol 언어는 에러 처리를 위한 정교한 시스템을 갖추고 있습니다. 에러는 단순히 발생하고 끝나는 것이 아니라, callstack 정보와 함께 수집되며, 검증 단계에서부터 실행 단계까지 체계적으로 관리됩니다.
byeol 언어에서의 err 클래스 계통의 가장 base 클래스입니다. 에러 처리에 대한 공통 로직과 인터페이스를 포함합니다.
byeol Error의 문법적 구분과 내부 구현
byeol 문법상으로는 에러는 2종류로 구분됩니다:
?로 표현되며, errorable 타입으로 명시하지만 구현상으로는 둘은 완전히 동일한 에러 객체입니다. 에러는 parser나 verifier가 발생시킨, native 상에서 발생한 nerr와 byeol 코드로 인해 발생한 err, 2개가 에러의 발생원에 의해 구분되고 있을 뿐입니다.
이 frame 을 사용해서 callstack 정보를 제공합니다. frame에서 설명한 것처럼 frame 은 함수 호출시 baseObj 에 의해서 생성됩니다.
이후 frame 은 frames에 의해서 del() 되는데 이때 객체의 강한 참조를 잃어버릴 뿐 heap에서 바로 삭제가 되진 않습니다. (이 프로젝트에서 memlite 모듈의 tstr에 의한 레퍼런스 카운팅 없이 직접 heap에서 new/delete를 하는 경우는 극히 드뭅니다.)
그러므로 err 가 살아있는 한 참조하는 frame 데이터 또한 유지됩니다.
이는 Reference Counting 메모리 관리 기법입니다. tstr이 참조 카운트를 관리하여, err가 frame을 참조하는 동안 frame이 메모리에서 해제되지 않도록 보장합니다. 마지막 참조가 사라질 때 자동으로 메모리가 해제됩니다.
nerr 생성
nerr 은 주로 core 모듈의 __core_when__에 의해서 만들어집니다. 다음과 같은 코드로 이뤄집니다:
위 코드는 stmt가 nullptr일 경우 IS_NUL이라는 에러가 getReport()로 반환되는 errReport 객체에 추가되고 blk를 반환하면서 함수가 종료됩니다.
dumpable과 log
log(): 해당 에러를 clog 모듈을 사용해서 로깅dump(): callstack을 포함해서 최대한 많은 정보를 노출errCode
관리를 위해서 nerr 는 errCode 로 관리됩니다. nerr는 주로 parser나 verifier에 의해서 생성되는데, 이는 문법 에러가 발생했을 때 Err2203과 같이 code 2203를 같이 줌으로써 어떤 에러인지 상세 정보를 쉽게 검색하도록 도움을 주기 위해서입니다.
errCode 는 리터럴 상수로 간단하게 description 문자열도 정의하고 있으니 참고하세요.
baseErr들을 담아두는 클래스입니다.
시간이 오래 걸리거나 복잡한 동작을 수행하는 로직이 있는 경우 함수의 depth가 매우 깊어집니다. 안쪽에서 특정 함수에서 에러가 발생한 경우, 에러가 발생했다는 사실을 기록하고 작업은 최대한 진행하고 싶을 때 errReport 를 사용해서 에러를 기록합니다.
컨테이너
errReport 는 거의 모든 기능이 baseErr 객체에 대한 관리이기 때문에 tucontainable과 유사한 인터페이스를 가지고 있습니다.
noisy
어떠한 errReport 는 새로운 err 객체가 add될 때마다 자동으로 log()를 수행하고 싶을 때가 있습니다. 이때 setNoisy(true)를 지정합니다. 이렇게 하면 에러가 추가되는 즉시 로그가 남아 디버깅이 더 쉬워집니다.
사용 예제
정리하면 다음과 같은 흐름으로 에러처리가 이뤄집니다.
양이 많아서 평소하던대로 모든 클래스가 포함된 다이어그램을 적는 건 생략할께요. 아마 문서만으로 모든 부분이 이해되는 건 어려우셨을 거라 생각됩니다. 실제 코드와 병행해서 다시 읽어보시면 좀 더 수월할거라 예상됩니다.
다음 문서: frontend 모듈 - CLI 인터페이스