-
리스프에서 멋진 3가지.프로그래밍/설계 2024. 11. 3. 13:32
이 글을 쓰게 된 계기는 Racket 공식 계정의 좋아요 때문.
그리고 plwiki에 조금 기여해볼까 생각 중.
리스프라 하면 고전언어라고만 생각할지도 모르겠으나,
선진적으로 구현된 부분들도 많다.
이 중 Code as data, data as code 혹은 Homoiconicity처럼 식상한 것을 제외하고, 알아보도록 합시다.
- Racket - 매크로: 본디 리스프에서 매크로는 유명하다. 그 중에서도 Racket의 매크로는 조금 특별하다.
- Racket - parameterize: Thread와 함께 사용할 수 있는 동적 바인딩.
- Common Lisp - CLOS: 매우 동적이고 유연한 객체지향 시스템.
이 글을 보게되면 제가 가끔씩 Racket 기습찬양하는 이유를 알게될지도 모르겠네요.
1. Racket - 가장 강력하고 안전한 매크로
매크로는 코드를 생성하거나 변환할 때 사용하는 강력한 도구입니다.
하지만 위험하고, 작성하기 어렵기로 유명합니다.
1.1 안전한 매크로
1.1.1 C vs Racket: 매크로가 구문이어야 하는 이유
먼저 안전한 매크로란 무엇인가에 대해 알아봅시다.
C와 Racket의 매크로를 간단히 만들어 비교해보면 다음과 같습니다.
#include <stdio.h> #define MAX(a, b) (a > b ? a : b) #define DEBUG_PRINT(x) printf("%s = %d", #x, x) // 사용 예 int main() { int x = 5, y = 10; int max = MAX(x, y); DEBUG_PRINT(max); return 0; }
Racket의 방식이 전위식이라서 익숙하지 않은 것을 제외하고는 어렵지 않을 것이다.
#lang racket (define-syntax-rule (max-of a b) (if (> a b) a b)) (define-syntax-rule (debug-print x) (printf "~a = ~a" 'x x)) ;; 사용 예 (let ([x 5] [y 10]) (let ([max (max-of x y)]) (debug-print max)))
결과는
max = 10
이라는 출력.max라는 심볼과 10이라는 값을 잘 활용했음을 볼 수 있습니다.
여기까지는 별 차이가 없어보인다.
그러나 C의 매크로와 Racket의 매크로에는 근본적인 차이가 있습니다.
C 매크로는 단순한 텍스트 치환에 가까운 반면,
Racket의 매크로는 함수를 정의할 때처럼 구문처럼 취급합니다.
이를 확인하기 위해 변수의 값을 바꿔주는 swap 매크로를 구현해보았다.
#define SWAP(a, b) \ typeof(a) temp = a; \ a = b; \ b = temp; // 사용 예 int main() { int x = 1, y = 2; SWAP(x, y); DEBUG_PRINT(x); DEBUG_PRINT(y); return 0; }
(define-syntax-rule (swap a b) (let ([temp a]) (set! a b) (set! b temp))) (let ([x 1] [y 2]) (swap x y) (debug-print x) (debug-print y))
실행해보면
x = 2
이고,y = 1
로 잘 변환됨을 알 수 있습니다.그렇다면
temp = 3
이라고 추가적인 변수를 미리 선언해놓고 실행해보면 어떨까요?int main() { int x = 1, y = 2, temp = 3; SWAP(x, y); DEBUG_PRINT(x); DEBUG_PRINT(y); return 0; }
(let ([x 1] [y 2] [temp 3]) (swap x y) (debug-print x) (debug-print y))
Racket과 달리 C에서는 temp가 이미 선언되었다며 에러가 났음을 확인할 수 있다.
C는 매크로를 확장할 때 문자열의 치환이므로, 변수 캡쳐나 문법등을 고려하여 조심히 구현해야 합니다.
#define swap(a, b) do { \ typeof(a) temp = a; \ a = b; \ b = temp; \ } while(0)
왜 일반 블록 뿐만이 아니라
do ~ while(0)
을 넣었느냐.일반 블록을 넣게 되면 다음과 같은 경우에
if (x > y) { swap 내용 }; else 내용
으로 해석되게 되므로,main.c:24:5: error: ‘else’ without a previous ‘if’
와 같은 에러가 나오게 되기 때문입니다.#define SWAP(a, b) { \ typeof(a) temp = a; \ a = b; \ b = temp; \ } int main() { int x = 1, y = 2, temp = 3; if (x > y) SWAP(x, y); else printf("not SWAP!!\n"); DEBUG_PRINT(x); DEBUG_PRINT(y); return 0; }
C 매크로의 이상한 예를 하나 더 들어봅시다.
다음은 10을 곱하는 매크로 정의입니다.
그러나 문자열 치환과 연산자 우선순위에 의해서 각각의 결과가 모두 다릅니다.
#include <stdio.h> #define MULTIPLY1(x) x * 10 #define MULTIPLY2(x) (x * 10) #define MULTIPLY3(x) ((x) * 10) // 사용 예 int main() { printf("1: %d\n", MULTIPLY1(1 + 2) * 3); // 1 + 2 * 10 * 3 = 61 printf("2: %d\n", MULTIPLY2(1 + 2) * 3); // (1 + 2 * 10) * 3 = 63 printf("3: %d\n", MULTIPLY3(1 + 2) * 3); // ((1 + 2) * 10) * 3 = 90 return 0; }
C에서 매크로를 안전하게 사용하기 위해서는 인자 참조시 괄호를 넣어줘야 합니다.
(위에 있는 버전에서는 가독성을 위해 의도적으로 넣지 않았다)
이외에도 많은 예상치 못한 에러들이 쏟아질 수도 있죠.
반면 Rakcet의 매크로는 구문이며, 위생적 매크로이기 때문에 문법에러나 변수캡쳐등을 고려할 필요가 없이 안전하게 매크로를 작성할 수 있습니다.
음.. 그렇다면 다른 Lisp의 매크로와는 어떻게 비교될까요?
1.1.2 Common Lisp vs Racket: 위생적 매크로여야 하는 이유
Common Lisp의 매크로는 구문이지만 '위생적(Hygienic)'이라고 하지 않습니다.
위생적 매크로는 의도하지 않은 변수 캡쳐나 이름 충돌을 방지합니다.
바로 예를 들어보죠.
다음 Common Lisp 매크로의 결과는
6
입니다.의도치 않게 사용 예의
temp
가 캡쳐되어(+ temp 1)
의 값으로setf
된겁니다.(defmacro without-gensym (num &body body) `(let ((temp ,num)) (setf temp (+ temp 1)) ,@body)) ;; 사용 예 (let ((temp 5)) (without-gensym temp (print temp))) ;; 결과: 6
의도치 않은 변수 캡쳐를 방지하려면 어떻게 해야 할까?
gensym(generate symbol)을 사용해 고유한 심볼을 생성하도록 해야합니다.
이렇게 하면 매크로 내부의
temp
와 외부의temp
가 서로 다른 변수로 취급되어 충돌을 방지할 수 있습니다.(defmacro with-gensym (num &body body) (let ((temp (gensym))) `(let ((,temp ,num)) (setf ,temp (+ ,temp 1)) ,@body))) ;; 사용 예 (let ((temp 5)) (with-gensym temp (print temp))) ;; 결과: 5
규칙 자체는 간단하지만, 조심해야 한다는 점은 여전합니다.
반면 Racket은 위생적인 매크로이기 때문에 일반적인 lexical scope처럼 사용할 수 있습니다.
(define-syntax-rule (without-gensym num body ...) (let ([temp num]) (set! temp (+ temp 1)) body ...)) ;; 사용 예 (let ([temp 5]) (without-gensym temp (print temp))) ;; 결과: 5
반대로 Racket에서 Common Lisp처럼 영향을 미치려면 어떻게 해야 할까요?
다음과 같이 param에 대입을 해주면 되겠습니다.
(define-syntax-rule (without-gensym num body ...) (let ([num (+ num 1)]) body ...)) ;; 사용 예 (let ([temp 5]) (without-gensym temp (print temp))) ;; 결과: 6
Racket의 매크로가 확실히 쉽고 명확한 편이죠?
두려움에 떨지 않고 매크로를 만들기 위해서 매크로는 구문이어야 하며, 위생적이어야 합니다.
1.2 강력한 매크로
define-syntax-rule
을 써보았을때 안전한 매크로구나!!라는 생각은 들었지만 그렇다고 저것만으로 강력한 DSL을 정의할 수 있다는 생각이 들지는 않습니다.
어쩌면 당연한 것이
define-syntax
에서 제약된 형태 중 일부인데다, 활용법을 모르기 때문입니다.1.2.1 바닥부터 톺아보기
앞서 매크로는 코드를 생성하거나 변환하는 용도라고 했습니다.
그리고 이와 비슷한 용도로 빌드나 컴파일 레벨에서 동작하는 도구가 있습니다.
Babel, SWC와 같은 transform이 예시죠.
Babel 플러그인을 만들때처럼, AST 대신 Syntax Object가 존재하며 input-output 값으로 사용됩니다.
define-syntax
에서 transformer-expression에는 syntax 객체가 input으로 들어오는 callback이며, syntax 객체로 return 해야합니다.(define-syntax macro-name transformer-expression)
먼저 들어오는 syntax 객체는 신경쓰지 않고, 항상
"Hello, world!!"
를 출력하는 매크로를 만들어봅시다.hello-macro
는 확장되어, 컴파일타임에"Hello, world!!"
문자열로 대체됩니다.(define-syntax hello-macro (lambda (stx) (syntax "Hello, world!!")))
(hello-macro)
로 실행해보면"Hello, world!!"
가 출력되었을 것입니다.람다 콜백은 함수정의와 마찬가지로
lambda
는 짧게 줄여서 할당해 간결히 만들어봅시다.syntax 함수는
#'
로 줄일수 있다.#'
는 Lisp계열에서 함수 심볼이라고 표시하는 용도로 많이 쓰이나, Racket에서는 syntax 객체를 나타내기 위해 사용된다.(define-syntax (hello-macro stx) #'"Hello, world!!")
Racket에서 Macro stepper를 활용해 확장된 모습을 보거나,
expand
를 이용해 확장된 모습과 정보를 확인해볼 수 있다.(expand #'hello-macro)
정의된 라인이나 컬럼등 메타 정보들도 눈에 띕니다.
1.2.2 Syntax 객체와 리스트 다루기
다만 syntax는 뒤에 값들을 아예 평가하지 않고, syntax 객체로 만들기 때문에 의도와 달라질 수도 있습니다.
예를 들어
(syntax (+ 1 2))
는'(+ 1 2)'
자체가 syntax 객체가 되며, 미리 계산이 되지 않지요.변수도 마찬가지로
(syntax (+ 1 num))
을 하게되면 변수가 바인딩되지 않고, 평가될때 비로소 바인딩 됩니다.(define num 2) (define my-syntax (syntax (+ 1 num))) ;; 사용 (expand my-syntax) ;; (#%app + '1 num) 로 확장됨 (eval my-syntax) ;; 이때 바인딩 및 평가
따라서 미리 함수를 평가하고 싶거나, 현재 컨텍스트의 값을 바인딩하고 싶다면 다음과 같이 사용해야 합니다.
(quasisyntax (unsyntax (+ 1 2))) ;; 3으로 평가됨 (quasisyntax (+ 1 (unsyntax num))) ;; (+ 1 2)로 num이 바인딩 됨
역시 길다면
quasisyntax
는#`
으로,unsyntax
는#,
으로 줄여서 쓸 수 있습니다.#`#,(+ 1 2) #`(+ 1 #,num)
Syntax 객체를 데이터로 바꾸는 것도 가능합니다.
데이터로의 변환이 아니라 메타 데이터를 가져오고 싶다면
syntax-line
이나syntax-column
과 같은 연산자를 활용하면 된다.데이터로의 변환이 아니라 syntax를 분해하여 syntax list로 만들고 싶다면
syntax->list
를 활용하자.(syntax->datum #'(+ 1 2))
위 결과는
'(+ 1 2)
라는 리스트다.여기서 Lisp의 리스트와 조작법에 대해 잠시 알아봅시다.
Lisp가 LISt Processor의 준말임을 생각하면 리스트와 조작에 대한 이해가 없다면 어려움이 생길 수 있습니다.
안다면 바로 다음으로 넘어가고, 모른다면 설명을 읽어보면 도움이 될 것이다.
Lisp의 리스트는 Cons cell라는 2개의 포인터를 이용한 링크드 리스트로 되어 있습니다.
예를 들어 아래 이미지는 중첩리스트인
(list a (r t) b)
를 나타낸 것이다.마지막의
nil
혹은'()
는 null을 나타낸다. 리스프에서는 비어있는 리스트와 null이 동치인 표현입니다.물론 cons cell이 꼭 null로 끝날 필요는 없습니다.
(cons 1 2)
를 해보면 1과 2를 가르키는 pair 구조를 만들어 낼 수 있죠.여기서도 앞에서 그랬듯이 축약 표현이 있습니다.
(cons 1 2)
는'(1 . 2)
(cons 1 (cons 2 (cons 3 '())))
은(list 1 2 3)
(list 1 2 3)
은'(1 2 3)
으로 나타내는 식입니다.
그럼
'(1 2 3)
을 어떻게 조작해야 할까요?가장 간단한 것은
car
과cdr
입니다.(car '(1 2 3))
: 첫번째 참조값인 1을 리턴(cdr '(1 2 3))
: 두번째 참조값인 2를 리턴(car (cdr '(1 2 3)))
: 2를 리턴.- 축약하여
(cadr '(1 2 3))
처럼 사용할 수 있음 - caar, cddr, cddar과 같은 방식
- 축약하여
이름이 car, cdr인게 이상해 보일 수 있으나. 나름의 역사적인 이유가 존재합니다.
많이 쓰일만한 함수로는 다음이 있습니다.
(length '(1 2 3))
: 길이인 3(list-ref '(1 2 3) 2)
: index에 따른 접근(append '(1 2 3) '(4 5))
: 두 리스트를 이어'(1 2 3 4 5)
를 만듦(reverse '(1 2 3))
: 뒤집어'(3 2 1)
을 출력
함수형 언어답게 고차함수도 있으니 문서를 확인해보자.
https://black7375.tistory.com/38
추가적으로 보고 싶으시다면 예전에 써놨던 글을 참고해보셔도 좋을 것 같습니다.
이제 준비되었다.
본격적으로 매크로를 다루어봅시다.
1.2.3 define-syntax 사용해보기
그럼 아주 간단하게 컴파일 타임에 리스트의 갯수를 반환해주는 매크로를 만들어볼까요?
#`
로 평가가 가능한 Syntax 객체로 만들고#,
로 평가를 진행했습니다.(define-syntax (length-of stx) #`#,(length (syntax->datum stx))) (legth-of '(1 2 3)) ;; 결과는?
결과가 3을 예상하지만..!
실제로 나온 결과는 2입니다.
왜 일까요?
일단 input으로 들어온 syntax 객체를 프린트 해봅니다.
(define-syntax (length-of stx) (println (syntax->datum stx)) #`#,(length (syntax->datum stx)))
'(length-of '(1 2 3))
가 출력됩니다.그러니까 다음과 같은 상태인 겁니다.
함수 이름부터 포함해서 변환에 필요한 정보가 모두 들어온다고 생각할 수 있습니다.
그림을 보고 침착하게
'(1 2 3)
만 나오도록 추출을 해봅시다.(car (cdr (car (cdr '(length-of '(1 2 3))))))
줄이면
cadadr
이 되겠죠?(define-syntax (length-of stx) #`#,(length (cadadr (syntax->datum stx)))) (legth-of '(1 2 3)) ;; 결과: 3
좋습니다.
방금전에는 컴파일 타임 계산이었지만, 리스프 매크로 특유의 강력함과 명성을 표현하기에는 약해보입니다.
그래서 두가지 정도의 예를 더 들어보도록 하겠습니다.
- 다른 패러다임 문법 구현
- 함수/변수 자동 생성
다른 도구 없이 Syntax 객체만 조작하여 만들기 때문에 다소 어렵게 느껴질 수도 있습니다.
단, 전자는 이 파트에서 다루며, 후자는 매크로가 복잡해지기 때문에 다음 파트에서 구현해보겠습니다.
다른 패러다임 문법 구현으로는 명령형 패러다임의 대표적인 문법인 while문을 만들어봅시다.
(while 조건 ...본문)
이어야 하므로 최소 3개 이상으로 이루어져 있어야 함- 조건과 본문 부분을 뽑아냄
본문은 여러개의 표현식으로 이루어 질 수 있으므로 javascript에서 spread 연산자를 쓰듯,@
을 추가로 붙여줘야 한다. - 조건이 true일 경우 반복하는 재귀함수
(define-syntax (while stx) (let ([parts (syntax->datum stx)]) (if (< (length parts) 3) (raise-syntax-error 'while "올바른 형식이 아닙니다: (while condition body ...)" stx) (let ([condition (cadr parts)] [body (cddr parts)]) #`(let loop () (when #,condition #,@body (loop))))))) ;; 사용 (define counter 0) (while (< counter 5) (println counter) (set! counter (+ counter 1)))
0, 1, 2, 3, 4가 순서대로 잘 실행됩니다.
그러나 버그가 있습니다.
syntax->datum
은 모든 syntax 메타 정보를 날려버리기 때문에 글로벌 변수가 아니라 지역변수인let
을 사용하면 작동하지 않습니다.따라서 syntax 정보를 보존하는
syntax->list
로 변환하여 사용할 필요가 있다.(define-syntax (while stx) (let ([parts (syntax->list stx)]) (if (< (length parts) 3) (raise-syntax-error 'while "올바른 형식이 아닙니다: (while condition body ...)" stx) (let ([condition (cadr parts)] [body (cddr parts)]) #`(let loop () (when #,condition #,@body (loop))))))) ;; 사용 (let ([counter 0]) (while (< counter 5) (println counter) (set! counter (+ counter 1))))
확실히 수동적으로 조작하려니 힘들지요?
갯수를 세거나,
cadr
,cddr
따위로 각 파트를 분리해내는 것, 바인딩이 말입니다.바인딩은
with-syntax
를 이용해 보다 간단히 만들 수 있습니다.(define-syntax (while stx) (let ([parts (syntax->list stx)]) (if (< (length parts) 3) (raise-syntax-error 'while "올바른 형식이 아닙니다: (while condition body ...)" stx) (let ([condition (cadr parts)] [body (cddr parts)]) (with-syntax ([condition condition] [(body ...) body]) #'(let loop () (when condition body ... (loop))))))))
...
는 body를 rest parameter로 인식하도록 합니다.사용할 때는 함께 써야 해요.
1.2.4 매턴매칭 매크로: 선언적으로 작성하기
그렇다면 어떻게 만드는게 좋을까요?
제목에 있듯, 패턴 매칭을 해봅시다.
syntax-case
를 이용해서요.(define-syntax (while stx) (syntax-case stx () [(_ condition body ...) #'(let loop () (when condition body ... (loop)))] [_ (raise-syntax-error 'while "올바른 형식이 아닙니다: (while condition body ...)" stx)]))
훨씬 간단해졌죠?
사실
with-syntax
도 패턴 매칭을 할 수 있으므로 사용해도 상관없습니다만,syntax-case
가 더 선언적일거에요.단, rest parameter는 0개일때도 작동하므로, 반드시 1개이상의 표현식을 가지도록 body와 body-rest를 만들어줍시다.
그리고 패턴 매칭에 실패하면 알아서 에러를 띄우므로 굳이 에러처리를 해야할 필요도 없습니다.
(define-syntax (while stx) (syntax-case stx () [(_ condition body1 body-others ...) #'(let loop () (when condition body1 body-others ... (loop)))]))
#'도 조금 거슬리지요?
syntax-rules
를 사용하여 해결해봅시다.단,
syntax-rules
는 콜백을 리턴해주니 변수에 정의하도록 바꿉니다.(define-syntax while (syntax-rules () [(_ condition body1 body-others ...) (let loop () (when condition body1 body-others ... (loop)))]))
여기에서는 패턴이 단 하나이니, 처음에 나왔던
define-syntax-rule
로 줄여서 사용할 수도 있겠죠?처음과 비교해보면 말도 안되게 편리해집니다 ㅋㅋㅋㅋ
(define-syntax-rule (while condition body1 body-others ...) (let loop () (when condition body1 body-others ... (loop))))
Rust를 이끌던 Dave Herman이 매크로 애호가이며, Rust의 매크로 설계에 Racket이 큰 영향을 끼치기도 했습니다.
실제로 Rust 매크로에서
macro_rules!
는syntax-rules
와 비슷하고,proc_macro
는define-syntax
와 비슷합니다.또한
set!
처럼 사이드이펙트가 있는 경우 Racket에서는 !를 마지막에 붙이는 관습이 있는데, Rust에서는 선언적 매크로에 !를 붙이도록 하는 작은 변형도...// 선언적 매크로 #[macro_export] macro_rules! vec { ( $( $x:expr ),* ) => { { let mut temp_vec = Vec::new(); $( temp_vec.push($x); )* temp_vec } }; } // 절차적 매크로 use proc_macro; #[some_attribute] pub fn some_name(input: TokenStream) -> TokenStream { }
물론 Rust는 중위식이라 전위식 기반인 Racket보다 훨씬 복잡한데다가 타입등의 이유로 매크로 자체를 짜는 난이도가 높을수밖에 없습니다.
Lisp는 사실상 그 자체로 AST나 다름없다보니... 훨씬 쉬운편이지요.
1.2.5 syntax-case vs syntax-rules: 컴파일타임과 런타임
아직은
syntax-case
와syntax-rules
가 헷갈릴겁니다.앞서 본 차이로는
syntax-case
에서 있던 Syntax 객체 생성 문법인#'
가 사라진 것,syntax-rules
에는stx
매개변수가 없다는 거죠.syntax-case
는 기본적으로 컴파일 타임에 실행되고#'
내부에 있는 요소들이 런타임에 실행되는 반면,syntax-rules
는 컴파일 타임(phase 1)에 변환만 일어나고 런타임(phase 0)때 실행됩니다.즉,
syntax-case
는 컴파일 타임에 Syntax 객체의 메타데이터 활용, 입력값 검증이나 조건부처리, 각종 계산 평가들을 할 수 있는 능력을 갖춘 반면,syntax-rules
는 런타임에 처리해야 하는거죠.syntax-case
를 잘 활용할 수 있는 예를 하나 더 봅시다.다음은 입력값 유효성을 컴파일타임에 검증하여 에러를 발생시킵니다.
(define-syntax (simple-op stx) (syntax-case stx () [(_ op a b) (and (number? (syntax->datum #'a)) (number? (syntax->datum #'b))) (case (syntax->datum #'op) [(add) #'(+ a b)] [(sub) #'(- a b)] [(mul) #'(* a b)] [(div) (if (= 0 (syntax->datum #'b)) (raise-syntax-error 'simple-op "0으로 나눌 수 없습니다" #'b) #'(/ a b))] [else (raise-syntax-error 'simple-op "지원하지 않는 연산자" #'op)])] [(_ op a b) (raise-syntax-error 'simple-op "숫자만 연산이 가능합니다" stx)])) ;; 사용 (simple-op add 1 2) ;; 결과: 3 (simple-op sub 1 2) ;; 결과: -1 (simple-op mul 1 2) ;; 결과: 2 (simple-op div 1 2) ;; 결과: 0.5 (simple-op div 1 0) ;; 에러: 0으로 나눌 수 없음 (simple-op div 1 "abc") ;; 에러: 숫자만 연산이 가능 (simple-op other 1 2) ;; 에러: 지원하지 않는 연산자
역시 번잡해 보인다는 생각을 지울 수 없습니다.
바로
add
,sub
를 다루거나 타입을 검증하는 등 더 복잡한 패턴매칭이 가능할까요?다행히
syntax/parse
라는 내장 라이브러리를 이용하면 가능합니다.;; syntax/parse라는 라이브러리 사용 - 매크로를 import할 경우 for-syntax를 붙여줘야 한다 (require (for-syntax syntax/parse)) (define-syntax (simple-op stx) (syntax-parse stx [(_ (~literal add) a:number b:number) #'(+ a b)] [(_ (~literal sub) a:number b:number) #'(- a b)] [(_ (~literal mul) a:number b:number) #'(* a b)] [(_ (~literal div) a:number b:number) (when (= (syntax->datum #'b) 0) (raise-syntax-error 'simple-op "0으로 나눌 수 없습니다" #'b)) #'(/ a b)] [(_ op:id a:number b:number) (raise-syntax-error 'simple-op "지원하지 않는 연산자입니다" #'op)]))
한층 Rust의
macro_rules!
와 더 가까워졌죠?body:expr
과 같이 익숙한 패턴도 쓸 수 있습니다.1.2.6 함수와 변수를 정의하는 매크로
지금까지 Racket 매크로의 강점을 알아보고, 튜토리얼을 해보았습니다.
마지막으로 더 많은 활용을 위해 매크로에서 함수를 정의해봅시다.
저희가 만들어볼 것은 define-struct라 하여 구조체 관련 함수를 만들어주는 매크로입니다.
(define-struct posn (x y)) ;; 사용 (define p1 (make-posn 1 2)) (posn? p1) (posn-y p1)
define-struct
를 이용하여 구조체 모양을 정의하면 3가지 종류의 함수를 자동으로 생성합니다.make-[이름]
: 구조체 생성자[이름]?
: 구조체의 인스턴스인지 확인[이름]-[필드]
: 인스턴스의 필드값을 반환
구현한 매크로는 조금 어려워 보이겠지만ㅠㅠ 다음과 같습니다.
여기에서
format-id
는 컴파일 타임에 실행되어야 하는 함수이기 때문에begin-for-syntax
로 컴파일 타임에 실행 가능하도록 만들어두었어요.- 구조체의 구조는 심플하게
'(구조체_이름 arg1 arg2)
와 같은 리스트로 생성 (실제 Racket struct와는 다르겠지만요) - 술어 함수는
car
로 구조체 이름이 같은지 체크 - 접근자 함수는 arg이름과 index에 따라
list-ref
로 참조함
(require (for-syntax syntax/parse)) (begin-for-syntax (define (format-id stx format-str . base-ids) (datum->syntax stx (string->symbol (apply format format-str (map syntax->datum base-ids)))))) (define-syntax (my-define-struct stx) (syntax-parse stx [(_ name:id (field:id ...)) #`(begin ; 생성자 함수 정의 (define (#,(format-id stx "make-~a" #'name) field ...) (cons 'name (list field ...))) ; 술어 함수 정의 (define (#,(format-id stx "~a?" #'name) x) (and (list? x) (not (empty? x)) (eq? (car x) 'name))) ; 접근자 함수들 정의 #,@(for/list ([f (syntax->list #'(field ...))] [i (in-naturals)]) #`(define (#,(format-id stx "~a-~a" #'name f) x) (list-ref (cdr x) #,i))))])) ;; 사용 (my-define-struct person (name age)) (define p1 (make-person "이름" 20)) ; '(person "이름" 20) 처럼 저장 (person? p1) ; #true (person-name p1) ; "이름" (person-age p1) ; 20
성공적으로 만들어진것을 확인했습니다.
본문에 직접 바인딩 되는게 불만이라면 앞서 나오는
with-syntax
를 활용하면 되요.(define-syntax (my-define-struct stx) (syntax-parse stx [(_ name:id (field:id ...)) (with-syntax ([fields (syntax->list #'(field ...))] [constructor (format-id stx "make-~a" #'name)] [predicate (format-id stx "~a?" #'name)] [(accessor ...) (for/list ([f (syntax->list #'(field ...))]) (format-id stx "~a-~a" #'name f))] [(index ...) (for/list ([i (in-range (length (syntax->list #'(field ...))))]) #`#,i)]) #'(begin ; 생성자 함수 정의 (define (constructor field ...) (cons 'name (list field ...))) ; 술어 함수 정의 (define (predicate x) (and (list? x) (not (empty? x)) (eq? (car x) 'name))) ; 접근자 함수들 정의 (define (accessor x) (list-ref (cdr x) index)) ...))]))
또는
syntax-parse
에서 제공하는#:with
를 사용해 블럭을 한단계 줄이는 것도 방법입니다.(define-syntax (my-define-struct stx) (syntax-parse stx [(_ name:id (field:id ...)) #:with constructor (format-id stx "make-~a" #'name) #:with predicate (format-id stx "~a?" #'name) #:with fields (syntax->list #'(field ...)) #:with (accessor ...) (for/list ([f (attribute fields)]) (format-id stx "~a-~a" #'name f)) #:with (index ...) (for/list ([i (in-range (length (attribute fields)))]) #`#,i) #'(begin ; 생성자 함수 정의 (define (constructor field ...) (cons 'name (list field ...))) ; 술어 함수 정의 (define (predicate x) (and (list? x) (not (empty? x)) (eq? (car x) 'name))) ; 접근자 함수들 정의 (define (accessor x) (list-ref (cdr x) index)) ...)]))
마지막에는 난이도가 좀 있었지만, 접근자처럼 복잡한 대상을 구현해서 그렇습니다. 일반적인 경우는 쉽게 만들 수 있죠.
이제 강력하고 안전한 Racket 매크로의 멋짐을 다들 알아주셨으면 하면서 매크로 파트를 마치겠습니다 ㅎㅎ
2. Racket - Thread와 함께하는 동적바인딩
2.1 동적 바인딩이란?
일전에 lexical scope에서 다루었지만, 다시 한번 살펴봅시다.
https://black7375.tistory.com/62
다음은 Emacs Lisp의 코드입니다.
setq
는 전역변수,defun
은 함수정의,let
은 앞서 보았던 지역변수라고 할때 최종적으로 출력되는 값은 무엇일까요?(setq sample-var 5) (defun sample-print () (print sample-var)) (let ((sample-var 8)) (sample-print))
정답은 환경에 따라 다르다..이다.
- lexical scope: 컴파일 타임에 결정이 나므로,
5
가 출력 - dynamic scope: 런타임에 결정이 나므로,
8
이 출력
물론 정답을 굳이 고르자면, Emacs Lisp는 dynamic scope를 기본적으로 사용하므로
8
이 출력됩니다.경험을 해보고 싶다면 lexical-binding에 대한 기본값을 조절할 수 있으므로 쉽게 테스트할 수 있을 것 입니다.
(setq lexical-binding t) ;; 활성화 (setq lexical-binding nil) ;; 비활성화
이맥스에서는 보통은 파일단위로 lexical scope를 활성화해서 사용하는 편입니다.
다음을 첫번째 라인에 두면 해당 파일은 lexical binding을 사용하는 식.
;;; -*- lexical-binding: t -*-
실제로 Doom Emacs라던가 다양한 설정파일, 그리고 패키지들을 보면 lexical binding을 사용하기 위해 첫 라인에 두는 것을 볼 수 있어요.
2.2 왜 동적 바인딩인가?
동적 바인딩 나쁜거 아닌가요?
라고 물을 수 있습니다.
실제로 알려진것처럼 기본값으로 동적바인딩이 안좋습니다.
그렇다면 왜 고전적인 Lisp들은 동적 바인딩을 사용하고 있을까요?
말 그대로 역사적인 이유 때문입니다.
실제로 John McCarthy가 50년대에 Alonzo Church의 람다 계산을 이해하지 못했기 때문에 어휘 범위 대신 동적 범위를 구현했다는 것입니다. 그리고 elisp가 만들어지고 나서야 어휘 범위가 Lisp 세계의 표준이 되었습니다. . GNU Emacs가 개발되면서 동적 범위에는 Emacs와 같은 프로그램에 대한 몇 가지 유용한 속성이 있다는 것이 밝혀졌습니다. 비록 대부분의 경우 그것이 잘못된 것이기 때문에 멈춰 있었습니다.
그래도 몇가지 경우에는 동적바인딩이 유용하게 문제를 해결할 수 있습니다.
기본값으로 나쁠뿐이지, 의도적으로 특정 케이스들을 위해서는 유용합니다.
2.2.1 전역변수: 불필요한 전역 오염을 방지
전역 변수는 모든 코드에서 접근 가능해 예측하지 못한 수정 위험이 존재합니다.
따라서 여러 모듈이 동일한 전역 변수를 사용하면 충돌 가능성이 높으며, 비슷한 이유로 싱글톤 패턴이 안티패턴으로 분류되곤 하지요.
하지만 동적 바인딩을 사용하면,
- 변수의 생명주기가 실행 컨텍스트에 한정
- 명시적인 범위 내에서만 변수 접근 가능
- 실행 종료 후 자동으로 정리됨
특성을 가지기 때문에 보다 명시적이고, 안전하게 사용할 수 있습니다.
예를 들어 Javascript라 할때, 다음과 같이 전역 변수를 활용하는 테스트코드는 복원등의 실수를 유발하기 쉽습니다.
let baseUrl = "https://api.production.com"; async function getUser(id) { const result = await fetch(`${baseUrl}/user/${id}`); return await result.json(); } describe("테스트", () => { const bakupUrl = baseUrl; beforeEach(() => { baseUrl = "https://api.test.com"; }); afterEach(() => { baseUrl = backupUrl; }); test("테스트유져 확인", async () => { const user1 = await getUser(1); expect(user1.name).toBe("test 유저1"); }); // ... });
만약 let 키워드가 블럭 단위의 동적 바인딩을 제공한다고 가정해봅시다.
그렇다면
beforeEach
나afterEach
와 같은 것을 사용하지 않아도 자동적으로 복원이 가능합니다.let baseUrl = "https://api.production.com"; async function getUser(id) { const result = await fetch(`${baseUrl}/user/${id}`); return await result.json(); } describe("테스트", () => { let baseUrl = "https://api.test.com"; test("테스트유져 확인", async () => { const user1 = await getUser(1); expect(user1.name).toBe("test 유저1"); }); // ... });
let이 거부감이 느껴진다면
with
문과 해당 블럭이 동적 바인딩을 제공한다고 가정해보자. (키워드가 겹침은 무시합시다)let baseUrl = "https://api.production.com"; async function getUser(id) { const result = await fetch(`${baseUrl}/user/${id}`); return await result.json(); } describe("테스트", () => { with(baseUrl as "https://api.test.com") { test("테스트유져 확인", async () => { const user1 = await getUser(1); expect(user1.name).toBe("test 유저1"); }); // ... } });
또 다른 예로 로깅의 컨텍스트에 DEBUG, INFO, WARNING, ERROR, CRITICAL과 같은 로그 레벨을 제공해주는 것도 좋은 생각아닐까.
결론적으로 동적 바인딩은 전역변수의 오염을 방지해주는 좋은 방법입니다.
2.2.2 컨텍스트: 불필요한 매개변수 제거
동적 바인딩이 유용한 두번째 경우는 불필요한 context 매개변수를 계속 전해줘야하거나, 이를 극복하기 위한 라이브러리 차원의 노력이 있습니다.
아마 프론트 개발자들에게는 익숙한 개념일 것 입니다.
여기서는 리엑트의 예제를 변형하여, 예외처리 없이 최대한 간단하게 만들어진 코드를 이해해보도록 합시다.
목표는 다음 코드를 간단하게 만들어보기.
function Page() { return ( <Section> <Heading level={1}>Title</Heading> <Section> <Heading level={2}>Heading</Heading> <Heading level={2}>Heading</Heading> <Heading level={2}>Heading</Heading> <Section> <Heading level={3}>Sub-heading</Heading> <Heading level={3}>Sub-heading</Heading> <Heading level={3}>Sub-heading</Heading> <Section> <Heading level={4}>Sub-sub-heading</Heading> <Heading level={4}>Sub-sub-heading</Heading> <Heading level={4}>Sub-sub-heading</Heading> </Section> </Section> </Section> </Section> ); } function Section({ children }) { return ( <section className="section"> {children} </section> ); } function Heading({ level, children }) { const SizedHeader = `h${ level }`; return <SizedHeader>{children}</SizedHeader>; }
Heading
에 넘겨주는level
값에 따라 크기가 달라지며, 섹션이 한단계 증가할 수록level
의 값이 커지는 것을 확인할 수 있습니다.어떻게 하면 깔끔하게 만들 수 있을까요?
리엑트에서 정답은 createContext와 useContext를 사용하는 것입니다.
import { createContext, useContext } from "react"; function Page() { return ( <Section> <Heading>Title</Heading> <Section> <Heading>Heading</Heading> <Heading>Heading</Heading> <Heading>Heading</Heading> <Section> <Heading>Sub-heading</Heading> <Heading>Sub-heading</Heading> <Heading>Sub-heading</Heading> <Section> <Heading>Sub-sub-heading</Heading> <Heading>Sub-sub-heading</Heading> <Heading>Sub-sub-heading</Heading> </Section> </Section> </Section> </Section> ); } const LevelContext = createContext(0); function Section({ children }) { const level = useContext(LevelContext); return ( <section className="section"> <LevelContext.Provider value={level + 1}> {children} </LevelContext.Provider> </section> ); } function Heading({ children }) { const level = useContext(LevelContext); const SizedHeader = `h${ level }`; return <SizedHeader>{children}</SizedHeader>; }
context 덕분에 매우 깔끔하게 구현해집니다.
앞서 보았던 동적 바인딩의 속성과 매우 유사하죠?
- 변수의 생명주기가 실행 컨텍스트에 한정
- 명시적인 범위 내에서만 변수 접근 가능
- 실행 종료 후 자동으로 정리됨
Context 변화에 따른 re-render를 요구하는 것 빼고는 사실상 동적 스코프와 거의 같다시피 합니다.
언어 차원에서 동적 스코프를 제공했다면, React 팀이나 기타 프레임워크들에서 해야할 일이 많이 줄어들어들 겁니다.
한가지 유용한 예제를 더 살펴봅시다.
방금전에는 최상위에 flat하게 정의되어 있기 좋은 예제를 사용했지만,
테마처럼 때에 따라서 특정한 값을 계속 하위 컴포넌트로 넘겨주어야 할 수도 있습니다.
import { useState } from "react"; const themes = { light: { background: "#ffffff", text: "#000000", primary: "#007bff" }, dark: { background: "#222222", text: "#ffffff", primary: "#0056b3" } }; function App() { const [currentTheme, setCurrentTheme] = useState("light"); const toggleTheme = () => { setCurrentTheme(prevTheme => prevTheme === "light" ? "dark" : "light"); }; return ( <div> <h1>테마 예제</h1> <ParentComponent theme={themes[currentTheme]} toggleTheme={toggleTheme} currentTheme={currentTheme} /> </div> ); } function ParentComponent({ theme, toggleTheme, currentTheme }) { return ( <div> <ChildComponent theme={theme} toggleTheme={toggleTheme} currentTheme={currentTheme} /> <AnotherChildComponent theme={theme} toggleTheme={toggleTheme} currentTheme={currentTheme} /> </div> ); } function ChildComponent({ theme, toggleTheme, currentTheme }) { return ( <div style={{ background: theme.background, color: theme.text, padding: "20px", transition: "all 0.3s ease" }}> <h2>현재 테마: {currentTheme}</h2> <button onClick={toggleTheme} style={{ background: theme.primary, color: theme.text, padding: "10px 20px", borderRadius: "5px" }} > 테마 변경 </button> <p>이것은 테마가 적용된 컴포넌트입니다.</p> </div> ); } function AnotherChildComponent({ theme }) { return ( <div style={{ background: theme.background, color: theme.text, padding: "20px", marginTop: "10px" }}> <p>다른 자식 컴포넌트</p> </div> ); }
이럴때 context를 사용하면 자연스럽게 개선을 할 수 있습니다.
// == 테마 제공 =================================================================== import { createContext, useState, useContext } from "react"; // 테마 정의 const themes = { light: { background: "#ffffff", text: "#000000", primary: "#007bff" }, dark: { background: "#222222", text: "#ffffff", primary: "#0056b3" } }; // Context 생성 const ThemeContext = createContext(); // Provider 컴포넌트 function ThemeProvider({ children }) { const [theme, setTheme] = useState("light"); const toggleTheme = () => { setTheme(prevTheme => prevTheme === "light" ? "dark" : "light"); }; const value = { theme: themes[theme], toggleTheme, currentTheme: theme }; return ( <ThemeContext.Provider value={value}> {children} </ThemeContext.Provider> ); } // 커스텀 훅 function useTheme() { const context = useContext(ThemeContext); if (context === undefined) { throw new Error("useTheme must be used within a ThemeProvider"); } return context; } // == 테마 사용 =================================================================== function App() { return ( <ThemeProvider> <div> <h1>테마 예제</h1> <ParentComponent /> </div> </ThemeProvider> ); } function ParentComponent() { return ( <ChildComponent /> <AnotherChildComponent /> ); } function ChildComponent() { const { theme, toggleTheme, currentTheme } = useTheme(); return ( <div style={{ background: theme.background, color: theme.text, padding: "20px", transition: "all 0.3s ease" }}> <h2>현재 테마: {currentTheme}</h2> <button onClick={toggleTheme} style={{ background: theme.primary, color: theme.text, padding: "10px 20px", borderRadius: "5px" }} > 테마 변경 </button> <p>이것은 테마가 적용된 컴포넌트입니다.</p> </div> ); } function AnotherChildComponent() { const { theme } = useTheme(); return ( <div style={{ background: theme.background, color: theme.text, padding: "20px", marginTop: "10px" }}> <p>다른 자식 컴포넌트</p> </div> ); }
자, 그렇다면 프론트에만 유용할까요?
첫번째 View 함수 인자로 HttpRequest를 전달하는 Django와 달리 Flask는 Request Context를 통해 각 요청을 처리하며,
옛날에는 자체적으로 구현되어 있었으나 최신에는 Python 3.7에 도입된 contextvars를 이용해 구현되어 있습니다.
from flask import Flask, request, g from flask import current_app app = Flask(__name__) # 요청 전에 실행되는 before_request 함수 @app.before_request def before_request_func(): g.user = None # g 객체에 사용자 정보 저장 if "user_id" in request.headers: g.user = request.headers["user_id"] # 기본 라우트 @app.route("/") def index(): # request 객체 사용 user_agent = request.headers.get("User-Agent") # g 객체 사용 current_user = g.user or "Anonymous" return f"Hello {current_user}! Your browser is: {user_agent}" # request context를 수동으로 사용하는 예제 def process_request_outside_context(): with app.test_request_context("/hello", method="POST"): # 이제 request context 내부입니다 assert request.path == "/hello" assert request.method == "POST" # current_app 접근 가능 assert current_app.name == "app"
contextvars
는 저도 FastAPI와 SQLModel 환경 구축을 위해 transactional을 구현하는 도중 사용한 경험이 있는데,NDA 때문에 직접적인 코드를 보여주지는 못하지만, How to implement a transactional decorator in FastAPI + SQLAlchemy라는 글과 유사하게 구현했었고 비동기시 상태공유에서 무척 좋은 방법이었습니다.
전통적인 언어에서는 그럼 어떻게 처리했느냐.
위의 Django처럼 첫번째 인자로 context를 넘기는 방향으로 처리해왔습니다.
// SQLite // https://www.sqlite.org/c3ref/prepare.html int sqlite3_prepare_v2( sqlite3 *db, /* Database handle */ const char *zSql, /* SQL statement, UTF-8 encoded */ int nByte, /* Maximum length of zSql in bytes. */ sqlite3_stmt **ppStmt, /* OUT: Statement handle */ const char **pzTail /* OUT: Pointer to unused portion of zSql */ ); // libcurl // https://curl.se/libcurl/c/curl_easy_setopt.html CURLcode curl_easy_setopt( CURL *handle, // context CURLoption option, parameter ); // FFmpeg // https://ffmpeg.org/doxygen/6.1/group__lavc__decoding.html int avcodec_default_get_buffer2( AVCodecContext *s, AVFrame *frame, int flags );
이 전통에 따라 Go도 context를 사용할때 첫번째 인자는 항상 context를 넘기도록 되어있습니다.
Go는 에러처리에서도 명시적으로 처리하기 좋아하는 언어라서 그렇다쳐도
일반적인 언어들은 다이나믹 바인딩을 구현하는게 더 좋지 않을까요?
불필요한 매개변수를 제거하고, 비동기 처리시 상태공유를 하기 좋은 방법이다.
2.2.3 의존성 주입: 보일러 플레이트 제거
의존성 주입은 보일러 플레이트가 많기로 유명합니다.
그러나 개념 자체는 생각보다 간단하며, DI 프레임워크들 그리고 언어 자체가 장황한 Java의 문제가 크지 않을까..
예를 들어 Android의 DI예제를 살펴보면 Kotlin과 Java의 차이는 상당합니다.
모두 합쳐 코틀린이 43라인인 반면 자바는 59라인입니다(약 1.4배).
코틀린:
// DI 적용 전 class Car { private val engine = Engine() fun start() { engine.start() } } fun main(args: Array) { val car = Car() car.start() } // 생성자 삽입 DI class Car(private val engine: Engine) { fun start() { engine.start() } } fun main(args: Array) { val engine = Engine() val car = Car(engine) car.start() } // 필드 삽입 DI class Car { lateinit var engine: Engine fun start() { engine.start() } } fun main(args: Array) { val car = Car() car.engine = Engine() car.start() }
자바:
// DI 적용 전 class Car { private Engine engine = new Engine(); public void start() { engine.start(); } } class MyApp { public static void main(String[] args) { Car car = new Car(); car.start(); } } // 생성자 삽입 DI class Car { private final Engine engine; public Car(Engine engine) { this.engine = engine; } public void start() { engine.start(); } } class MyApp { public static void main(String[] args) { Engine engine = new Engine(); Car car = new Car(engine); car.start(); } } // 필드 삽입 DI class Car { private Engine engine; public void setEngine(Engine engine) { this.engine = engine; } public void start() { engine.start(); } } class MyApp { public static void main(String[] args) { Car car = new Car(); car.setEngine(new Engine()); car.start(); } }
예제로 본 DI 개념 자체는 무척이나 간단한게, 외부에서 생성자나 필드로 값을 주입해 제어를 역전하기 입니다.
그러나 프레임워크를 거치며 처음본 사람이라면 다소 어려워집니다.
여기서는 Dagger라는 프레임워크를 기준으로 설명하고자 합니다.
import javax.inject.Inject import dagger.Component import dagger.Module import dagger.Provides // 생성자 주입 // 1. 의존성이 필요한 클래스 (@Inject) class Car @Inject constructor(private val engine: Engine) { fun start() { engine.start() } } // 2. 의존성을 제공하는 모듈 (@Module) @Module class EngineModule { @Provides fun provideEngine() = Engine() } // 3. 둘을 연결하는 컴포넌트 (@Component) @Component(modules = [EngineModule::class]) interface CarComponent { fun getCar(): Car } // 실제 사용 // @Component 사용으로, 앞에 Dagger가 붙은 DaggerCarComponent가 자동생성됨 fun main() { val carComponent = DaggerCarComponent.create() val car = carComponent.getCar() car.start() }
눈에 띄는 것은 의존성을 제공하고 연결하는
@Module
,@Component
이며이로인해 사용하는 곳에서는 별다른 인자없이,
DaggerCarComponent.create()
로 의존성 주입 관리자 생성carComponent.getCar()
로 객체 생성과 의존성 주입
로 사용가능하다는 것입니다.
필드 삽입 DI의 예까지만 보고 DI 프레임워크를 사용했을때 장점을 알아봅시다.
// 필드 주입 // 1. 의존성이 필요한 클래스 (@Inject) class Car { @Inject lateinit var engine: Engine fun start() { engine.start() } } // 2. 의존성을 제공하는 모듈 (@Module) @Module class CarModule { @Provides fun provideEngine() = Engine() } // 3. 둘을 연결하는 컴포넌트 (@Component) @Component(modules = [CarModule::class]) interface CarComponent { fun inject(car: Car) } // 실제 사용 // @Component 사용으로, 앞에 Dagger가 붙은 DaggerCarComponent가 자동생성됨 fun main() { val car = Car() val carComponent = DaggerCarComponent.create() carComponent.inject(car) car.start() }
만약 의존성 구성이 복잡하다고 가정해봅시다.
예를 들어 엔진을 구성할때
FuelInjector
,EngineSensor
,EngineLogger
가 필요할테다.그리고 바퀴는 4개가 있어야 하며 역시 센서가 있을수도 있습니다.
이런 하위의존성은 어떻게 처리할 수 있을까요?
// 1. 센서 및 모니터링 인터페이스 구현 interface EngineSensor { fun getCurrentStatus(): EngineStatus fun getTemperature(): Float } class EngineTemperatureSensor @Inject constructor() : EngineSensor { override fun getCurrentStatus(): EngineStatus = EngineStatus.RUNNING override fun getTemperature(): Float = 90.0f } interface TirePressureSensor { fun getCurrentPressure(): Float } class StandardTirePressureSensor @Inject constructor() : TirePressureSensor { override fun getCurrentPressure(): Float = 32.0f } interface EngineLogger { fun log(message: String) } class ConsoleEngineLogger @Inject constructor() : EngineLogger { override fun log(message: String) { println("Engine: $message") } } interface CarMonitor { fun logEvent(event: CarEvent) } class ConsoleCarMonitor @Inject constructor() : CarMonitor { override fun logEvent(event: CarEvent) { println("Car Event: $event") } } // 2. FuelInjector 구현 class FuelInjector @Inject constructor() { fun inject() { println("Fuel injected") } fun stop() { println("Fuel injection stopped") } } // 3. 핵심 컴포넌트 구현 interface Engine { fun start() fun stop() fun getStatus(): EngineStatus fun getTemperature(): Float } class GasolineEngine @Inject constructor( private val fuelInjector: FuelInjector, private val engineSensor: EngineSensor, private val engineLogger: EngineLogger ) : Engine { override fun start() { fuelInjector.inject() engineLogger.log("Engine started") } override fun stop() { fuelInjector.stop() engineLogger.log("Engine stopped") } override fun getStatus(): EngineStatus = engineSensor.getCurrentStatus() override fun getTemperature(): Float = engineSensor.getTemperature() } interface Wheel { fun getPressure(): Float fun checkCondition(): WheelCondition } class StandardWheel @Inject constructor( private val tirePressureSensor: TirePressureSensor ) : Wheel { override fun getPressure(): Float = tirePressureSensor.getCurrentPressure() override fun checkCondition(): WheelCondition = WheelCondition.GOOD } // 4. Car 구현 interface Car { fun start() fun stop() fun drive() fun getStatus(): CarStatus } class StandardCar @Inject constructor( private val engine: Engine, @Named("wheels") private val wheels: List<Wheel>, private val carMonitor: CarMonitor ) : Car { override fun start() { engine.start() carMonitor.logEvent(CarEvent.START) } override fun stop() { engine.stop() carMonitor.logEvent(CarEvent.STOP) } override fun drive() { if (engine.getStatus() == EngineStatus.RUNNING) { wheels.forEach { it.rotate() } carMonitor.logEvent(CarEvent.DRIVING) } } override fun getStatus(): CarStatus = CarStatus( engineStatus = engine.getStatus(), engineTemperature = engine.getTemperature(), wheelsPressure = wheels.map { it.getPressure() } ) } // 5. 데이터 클래스 및 열거형 data class CarStatus( val engineStatus: EngineStatus, val engineTemperature: Float, val wheelsPressure: List<Float> ) enum class EngineStatus { STOPPED, RUNNING, ERROR } enum class WheelCondition { GOOD, WORN, CRITICAL } enum class CarEvent { START, STOP, DRIVING } // 6. Dagger 모듈 및 컴포넌트 @Module class CarModule { @Provides fun provideEngineSensor(sensor: EngineTemperatureSensor): EngineSensor = sensor @Provides fun provideTirePressureSensor(sensor: StandardTirePressureSensor): TirePressureSensor = sensor @Provides fun provideEngineLogger(logger: ConsoleEngineLogger): EngineLogger = logger @Provides fun provideCarMonitor(monitor: ConsoleCarMonitor): CarMonitor = monitor @Provides fun provideEngine(engine: GasolineEngine): Engine = engine @Provides @Named("wheels") fun provideWheels( tirePressureSensor: Provider<TirePressureSensor>, ): List<Wheel> = List(4) { StandardWheel(tirePressureSensor.get()) } @Provides fun provideCar(car: StandardCar): Car = car } @Component(modules = [CarModule::class]) interface CarComponent { fun getCar(): Car } // 7. 실제 사용 fun main() { val carComponent = DaggerCarComponent.create() val car = carComponent.getCar() car.start() car.drive() println(car.getStatus()) car.stop() }
@Module
에서 의존성 제공을 모두 처리해주니 확실히 깔끔해보입니다.dagger는 명시적인 의존성 그래프로 컴파일타임에 빠진 의존성에 대한 체크를 하고, 자동화된 의존성 주입을 가능하게 만들어줍니다.
방금은 의존하는 양이 많고 구조화가 필요할 때의 이야기였다면,
이번에는 더 많은 조건과 복잡성이 들어가면 어떻게 될까요?
예를 들어 가솔린 엔진, 전기 모터, 하이브리드를 골라서 사용이 필요하고 네트워크나 보안 모듈이 필요하다면 말입니다.
enum class EngineType { GASOLINE, ELECTRIC, HYBRID } @Module class NetworkModule { @Provides @Singleton fun provideNetworkClient(): NetworkClient { return NetworkClientImpl() } } @Module class CarModule(private val engineType: EngineType) { @Provides fun provideEngineSensor(sensor: EngineTemperatureSensor): EngineSensor = sensor @Provides fun provideTirePressureSensor(sensor: StandardTirePressureSensor): TirePressureSensor = sensor @Provides fun provideEngineLogger(logger: ConsoleEngineLogger): EngineLogger = logger @Provides fun provideCarMonitor(monitor: ConsoleCarMonitor): CarMonitor = monitor @Provides fun provideEngine( gasolineEngine: Provider<GasolineEngine>, electricEngine: Provider<ElectricEngine>, hybridEngine: Provider<HybridEngine> ): Engine { return when (engineType) { EngineType.GASOLINE -> gasolineEngine.get() EngineType.ELECTRIC -> electricEngine.get() EngineType.HYBRID -> hybridEngine.get() } } @Provides @Named("wheels") fun provideWheels( tirePressureSensor: Provider<TirePressureSensor>, ): List<Wheel> = List(4) { StandardWheel(tirePressureSensor.get()) } @Provides fun provideCar(car: StandardCar): Car = car } @Component(modules = [ CarModule::class, NetworkModule::class, SecureModule::class ]) interface CarComponent { fun getCar(): Car @Component.Builder interface Builder { @BindsInstance fun carModule(carModule: CarModule): Builder fun networkModule(networkModule: NetworkModule): Builder fun build(): CarComponent } } fun main() { val carComponent = DaggerCarComponent.builder() .carModule(CarModule(EngineType.HYBRID)) .networkModule(NetworkModule(baseUrl = "https://car-api.example.com")) .build() val car = carComponent.getCar() car.start() car.drive() println(car.getStatus()) car.stop() }
살짝 더 복잡해 보이긴 하지만..
의존성이 복잡하게 얽혀있고, 이런저런 커스텀이 필요할때 DI 프레임워크를 사용하면 도움이 되리란 것을 체감할 수 있습니다.
이 글을 읽는 여러분은 지금쯤 "그래서 동적 바인딩과는 대체 무슨 상관이지?" 라는 생각이 스물스물 올라오고 있을테지요.
DI 프레임워크의 필요성만 설파할 뿐이지, 동적 바인딩에 대해서는 언급하지 않았으니까 말입니다.
하지만 동적 바인딩의 대체가능성을 탐색하기 위해 어떠한 기능이 필요한가를 탐색하기 위해 중요한 과정이었습니다.
본격적으로 들어가기 앞서 코틀린에 동적 바인딩이 들어간다면 어떤 모습일지에 대해 가정하고 넘어갑시다.
DynamicParameter
로 초기값을 산정currentLogLevel
로 참조withDynamicScope
로 블록 내부에서 얻어지는 값이 바뀌게끔함
val currentLogLevel = DynamicParameter<LogLevel>(LogLevel.INFO) fun log(level: LogLevel, msg: String) { if (level.priority >= currentLogLevel.priority) { println("[$level] $msg") } } fun main() { log(LogLevel.DEBUG, "Debug message") // 출력 안됨 withDynamicScope(currentLogLevel to LogLevel.DEBUG) { log(LogLevel.DEBUG, "Debug message") // 출력됨 } }
동적 바인딩을 사용하자는게 이상하다 느껴질 수도 있겠지만 다행히 다른 사람도 주장한 흔적들이 있다.
앞선 Context도 일종의 의존성 주입이었죠.
그럼 동적 바인딩을 사용할때로 포팅을 해봅시다.
단, 의존성 제공에만 집중해보자.
CarContext
:@Module CarModule
처럼 사용될 동적 변수들 모음createCar
: 의존성을 주입하여 제공
// 1. 사용될 Context 정의 object CarContext { val currentEngineSensor = DynamicParameter<EngineSensor>() val currentTirePressureSensor = DynamicParameter<TirePressureSensor>() val currentEngineLogger = DynamicParameter<EngineLogger>() val currentCarMonitor = DynamicParameter<CarMonitor>() val currentEngine = DynamicParameter<Engine>() val currentWheels = DynamicParameter<List<Wheel>>() val currentCar = DynamicParameter<Car>() } // 2. Context 사용 class GasolineEngine: Engine { private val fuelInjector: FuelInjector = FuelInjector() private val engineSensor: EngineSensor get() = CarContext.currentEngineSensor private val engineLogger: EngineLogger get() = CarContext.currentEngineLogger override fun start() { fuelInjector.inject() engineLogger.log("Engine started") } override fun stop() { fuelInjector.stop() engineLogger.log("Engine stopped") } override fun getStatus(): EngineStatus = engineSensor.getCurrentStatus() override fun getTemperature(): Float = engineSensor.getTemperature() } class StandardWheel: Wheel { private val tirePressureSensor: TirePressureSensor get() = CarContext.currentTirePressureSensor override fun getPressure(): Float = tirePressureSensor.getCurrentPressure() override fun checkCondition(): WheelCondition = WheelCondition.GOOD } class StandardCar : Car { private val engine: Engine get() = CarContext.currentEngine private val wheels: List<Wheel> get() = CarContext.currentWheels private val carMonitor: CarMonitor get() = CarContext.currentCarMonitor override fun start() { engine.start() carMonitor.logEvent(CarEvent.START) } override fun stop() { engine.stop() carMonitor.logEvent(CarEvent.STOP) } override fun drive() { if (engine.getStatus() == EngineStatus.RUNNING) { wheels.forEach { it.rotate() } carMonitor.logEvent(CarEvent.DRIVING) } } override fun getStatus(): CarStatus = CarStatus( engineStatus = engine.getStatus(), engineTemperature = engine.getTemperature(), wheelsPressure = wheels.map { it.getPressure() } ) } // 3. Context 제공 fun createEngine(engineType: EngineType) { withDynamicScope( CarContext.currentEngineSensor to EngineTemperatureSensor(), CarContext.currentEngineLogger to ConsoleEngineLogger(), CarContext.currentEngine to when (engineType) { EngineType.GASOLINE -> GasolineEngine() EngineType.ELECTRIC -> ElectricEngine() EngineType.HYBRID -> HybridEngine() } ) { return CarContext.currentEngine } } fun createCar( engineType: EngineType, networkModule: NetworkModule, ) { withDynamicScope( CarContext.currentTirePressureSensor to StandardTirePressureSensor, CarContext.currentWheels to List(4) { StandardWheel() }, CarContext.currentEngine to createEngine(engineType), CarContext.currentCar to StandardCar() NetworkContext.currentNetworkModule to networkModule, SecureContext.currentSecureModule to createSecure() ) { return CarContext.currentCar } } // 4. 실제 사용 fun main() { val car = createCar( engineType=EngineType.HYBRID, networkModule=createNetwork(baseUrl = "https://car-api.example.com") ) car.start() car.drive() println(car.getStatus()) car.stop() withDynamicScope( CarComponents.currentEngineSensor to MockEngineSensor(), CarComponents.currentCarMonitor to MockCarMonitor() ) { car.start() car.drive() println(car.getStatus()) car.stop() } }
분명 처음보는 문법임에도, Dagger를 사용했을때보다 간결해보입니다.
하지만 의심되는 부분이 있습니다. Dagger처럼 의존성이 제공이 컴파일 타임에 체크가 가능한가입니다.
컴파일타임에 의존성 체크만 성공적으로 이루어진다면 의존성을 제공하는 부분은 간결하게
createCar
과 같은 함수만 만들면 되기 때문에 무척 쉬워지죠.저에게 의존성이 제공되었음을 보장하는 두가지 방안이 있습니다.
첫번째.
컴파일러의 Null Safety의 확장으로서 생각하기.
- 함수를 호출하거나, 생성자를 호출하는 곳 기준 Null 안전성을 갖춰야 한다
- 함수 본문이나 클래스 정의에서는 의존성을 갖추었는지를 알 방법이 없으므로, 호출하는 곳이 기준점이어야 함
- 초기값을 모두 가지고 있다면 안전하게 함수나 생성자 호출 가능
withDynamicScope
로 모든 동적 바인딩 변수나 프로퍼티가 제공되었다면 함수나 생성자 호출 가능
fun useCar() { // 동적 바인딩을 사용하는 함수 본문이나 클래스 프로퍼티에서는 알 수 없고, // 호출지점에서 Scope가 있는지 결정나므로 생성자가 실행되는 지점에서 에러체크 val car1 = StandardCar() // 에러: currentEngine, currentWheels, currentCarMonitor의 초기값이 없음 withDynamicScope( CarContext.currentTirePressureSensor to StandardTirePressureSensor, CarContext.currentEngineLogger to ConsoleEngineLogger(), CarContext.currentCarMonitor to ConsoleCarMonitor(), CarContext.currentEngine to GasolineEngine(), CarContext.currentWheels to List(4) { StandardWheel() }, ) { val car2 = StandardCar() // 에러: GasolineEngine에서 사용되는 currentEngineSensor가 제공되지 않음 } withDynamicScope( CarContext.currentEngineSensor to EngineTemperatureSensor(), CarContext.currentTirePressureSensor to StandardTirePressureSensor, CarContext.currentEngineLogger to ConsoleEngineLogger(), CarContext.currentCarMonitor to ConsoleCarMonitor(), CarContext.currentEngine to GasolineEngine(), CarContext.currentWheels to List(4) { StandardWheel() }, ) { val car3 = StandardCar() // 성공 } }
두번째.
함께 제공하는 Context 묶음에 대해 Dagger처럼 의존성 그래프가 만족하는지 체크할 수 있습니다.
// 마치 Dagger의 @Module처럼 의존성이 모두 만족하는지 체크 @Context object CarContext { val currentEngineSensor = DynamicParameter<EngineSensor>() val currentTirePressureSensor = DynamicParameter<TirePressureSensor>() val currentEngineLogger = DynamicParameter<EngineLogger>() val currentCarMonitor = DynamicParameter<CarMonitor>() val currentEngine = DynamicParameter<Engine>() val currentWheels = DynamicParameter<List<Wheel>>() val currentCar = DynamicParameter<Car>() }
물론 복잡한 케이스에서 Bunshi나 Bunja와 같은 Scope관리는 추가로 필요할수도 있습니다.
2.3 Thread 안전한 동적 바인딩
Racket에는 fluid-let과 parameterize 두가지의 구현이 존재합니다.
fluid-let
: 일반 변수를 동적 스코프처럼 쓸 수 있게 해줌parameterize
:make-parameter
와 쌍으로 쓰이며, 쓰레드 안전함
;; fluid-let 예제 (define x 10) (fluid-let ([x 20]) (print x)) ; 20 출력 (print x) ; 10 출력 ;; parameterize 예제 (define current-x (make-parameter 10)) (parameterize ([current-x 20]) (print (current-x))) ; 20 출력 (print (current-x)) ; 10 출력
당연히(?)
parameterize
가 현재 Racket의 표준입니다.저도 동적 스코프는 명시적으로 관리하는 것이 좋으며, 쓰레드 안전함을 위해서 동의하는 바 입니다.
좋다. 그럼 어떤 방식으로 만들어진걸까요?
Racket의 문서를 읽어보면 짐작할 수 있습니다.
쓰레드 및 컨티뉴에이션 친화적인 방식으로 코드를 매개변수화하려면 parameterize를 사용하세요. parameterize 형식은 본문 표현의 동적 범위를 위해 새로운 쓰레드 셀을 도입합니다.
새로운 쓰레드가 생성될 때, 새 쓰레드의 초기 컨티뉴에이션에 대한 매개변수화는 생성자 쓰레드의 매개변수화를 따릅니다. 각 매개변수의 쓰레드 셀이 보존되므로, 새 쓰레드는 이를 생성한 스레드의 매개변수 값들을 "상속"받게 됩니다. 컨티뉴에이션이 한 쓰레드에서 다른 쓰레드로 이동할 때, parameterize를 통해 도입된 설정들은 쓰레드과 함께 효과적으로 이동합니다.- Racket에는 Thread local storage의 프리미티브 형태인 쓰레드 셀(Thread cell)이 있음
- 새로운 쓰레드가 생기면 현재 매개변수 값들(make-parameter 값들)을 상속받음
- 컨티뉴에이션이 다른 쓰레드로 이동할때도 현재 매개변수 값들이 이동
보다 자세한 설명을 보고싶다면 다음 글들이 도움이 될 것이다.
- Continuation Marks, part I: Dynamic Wind
- Continuation Marks, part II: Parameters
- Continuation Marks, part III: Marks themselves
언어적으로 다이나믹 바인딩을 지원하면 전역변수, 컨텍스트, DI등 다양한 방면에서 커다란 도움이 되는 해법이라 생각합니다.
사람들의 편견하에 지나치게 기피되고 있는 기능이 아닐까..
저는 명시적으로 다이나믹 바인딩을 위한 명시적인 플레이스홀더를 정의하고, Thread safe, 컴파일타임 의존성 체크가 가능하다면 분명 유용하게 쓰일수 있을거라 봅니다.
모든것을 함수의 인수로 명시적으로 넘기자는 주장도 있을 수 있지만, 더 이상 리엑트 hooks와 context처럼 쓰이는게 안티패턴은 아니잖아요?
3. Common Lisp - 유연하고 확장가능한 객체지향 시스템
* Common Lisp의 구현체는 다양하나, 이 글은 SBCL을 기준으로 하고 있습니다.
3.1 다양한 객체지향 구현
객체지향을 구현하는 방법에는 여러가지 방식들이 있습니다.
대표적인게 예전에도 언급했던 클래스와 프로토타입.
https://black7375.tistory.com/86
이외에도 interface, trait, protocol, mixin 등의 개념이 객체지향 구현을 위해 쓰입니다.
각각의 미세한 차이는 이 글이 지나치게 길어질테니 간략한 차이만을 다루고, 추후 따로 다룰 수 있도록 하겠습니다. (언젠가?)
- Interface: 구현해야 하는 메서드들의 명세
- Trait: 메서드를 재사용할 수 있게 해주는 수평적 재사용 매커니즘 (메서드 명세와 메서드 기본 구현)
- Protocol: 객체가 따라야하는 메서드와 프로퍼티를 정의하는 규약(프로퍼티와 메서드 명세, 메서드 기본 구현)
- Mixin: 재사용 가능한 메서드와 프로퍼티을 정의하는 수평적 재사용(프로퍼티와 메서드 구현)
- Class: 객체를 생성하기 위한 템플릿으로, 프로퍼티와 메서드를 캡슐화(Mixin과 달리 수직적 재사용인 상속 가능)
- Prototype: 객체를 복제하여 새로운 객체를 생성하는 방식의 상속 방식
- CLOS: 다중 디스패치와 메서드 조합을 지원하는 동적 객체 시스템
위에서 아래로 올수록 구체적이고, 동적인 객체지향 구현방법입니다.
그럼 CLOS(Common Lisp Object System)을 함께 살펴보도록 합시다.
3.2 유연한 메서드 시스템
3.2.1 확장 가능한 메서드: 클래스 정의를 수정하지 않고 추가
먼저 간단한 클래스를 만들어 봅시다.
;; 클래스 구조 (defclass 클래스명 (부모클래스들) ((슬롯명1 :옵션키워드1 값1 :옵션키워드2 값2 ...) (슬롯명2 :옵션키워드1 값1 ...)) ;; 옵션 키워드 :initarg ; 초기화 시 사용할 키워드 :accessor ; getter/setter 함수 자동 생성 :reader ; getter 함수만 생성 :writer ; setter 함수만 생성 :initform ; 초기값 설정 :type ; 타입 지정 :allocation ; :class(클래스 변수) 또는 :instance(인스턴스 변수) ;; 메서드 구조 (defmethod 메소드이름 ((변수명 클래스명)) ; 메서드 본문 )
실제로 써보면 그리 어렵지 않아요.
;; 클래스 정의 (defclass person () ((name :initarg :name :accessor person-name) (age :initarg :age :accessor person-age))) ;; 메서드 정의 - 포맷 방식 ;; ~A: (Aesthetic) 읽기 좋은 형태로 출력 ;; ~D: (Decimal) 십진수로 출력 ;; ~%: 줄바꿈 (defmethod display-info ((p person)) (format t "이름: ~A, 나이: ~D~%" (person-name p) (person-age p))) ;; 사용 (let ((person1 (make-instance 'person :name "John" :age 20))) (display-info person1))
살펴보면 클래스가 정의된 이후에 메서드들을 계속 추가할 수 있는 형태죠?
저 이후에도 메서드를 추가할 수 있습니다.
(defmethod increase-age ((p person)) (setf (person-age p) (+ 1 (person-age p)))) ;; 다시 사용해보기 (let ((person1 (make-instance 'person :name "John" :age 20))) (display-info person1) (increase-age person1) (display-info person1))
클래스를 정의할 때만 메서드를 정의할 수 있는 Java, C++과 달리 그 이후 자유롭게 확장에 열려있다는 점은 큰 매력입니다.
3.2.2 다중 디스패치: 런타임 객체 타입에 따른 함수 사용
이번에는 다중 디스패치를 해봅시다.
;; 클래스 (Person 상속) (defclass student (person) ((grade :initarg :grade :accessor student-grade))) (defclass teacher (person) ((subject :initarg :subject :accessor teacher-subject))) ;; 제네릭 메서드 (defgeneric greet (person1 person2)) ;; 다중 디스패치 (defmethod greet ((person1 person) (person2 person)) (format t "person이 person에게 인사")) (defmethod greet ((person1 teacher) (person2 student)) (format t "teacher가 student에게 인사")) ;; 사용 (let ((p1 (make-instance 'teacher :name "John" :age 20 :subject "math")) (p2 (make-instance 'student :name "Jane" :age 21 :grade 100))) (greet p1 p2)) ;; "teacher가 student에게 인사"
네. 여러분이 상상하는 그대로 동작하죠?
메서드 오버로딩과 비슷해 보이지만 약간의 차이가 있습니다.
public class Main { public static void greet(Person a, Person b) { System.out.println("Person이 Person에게 인사"); } public static void greet(Teacher a, Student b) { System.out.println("Teacher가 Student에게 인사"); } public static void main(String[] args) { Person teacher = new Teacher(); Person student = new Student(); greet(teacher, student); // Person이 Person에게 인사 } } class Person {} class Student extends Person {} class Teacher extends Person {}
- 다중 디스패치: 런타임의 객체 타입에 따라 결정
- 오버로딩: 컴파일 타임의 객체 타입에 따라 결정
어떨때 다중 디스패치가 유용할까요?
예를 들어
student
와teacher
가 랜덤으로 섞인 리스트에서 각각의 정보들을 앞서 정의한display-info
로 출력해줄때student
는grade
도 출력을 해주고,teacher
는subject
를 추가로 출력해주는 것은 어떨까요?이처럼 컴파일 타임에 각 객체의 타입을 정확히 알 수 없는 경우들이 있습니다.
정말 간단한 예로, 다중 디스패치를 지원한다면 런타임에서 임의의
Shape
객체에 대한 구역 구하기가 쉬워지겠죠?특히 수치해석쪽은 정수/부동소수이냐, SparseVector/DenseVector이냐등 타입을 판별하는데 드는 시간보다 수행하는 최적화가 크기 때문에 Julia와 같은 언어는 다중 디스패치를 사용한다고 알려져있습니다.
;; 클래스 정의 (defclass circle () ((radius :initarg :radius :accessor circle-radius))) (defclass rectangle () ((width :initarg :width :accessor rectangle-width) (height :initarg :height :accessor rectangle-height))) (defclass triangle () ((base :initarg :base :accessor triangle-base) (height :initarg :height :accessor triangle-height))) ;; 다중 디스패치가 없을경우 (일반함수 조건문) (defun calculate-area (shape) (cond ((typep shape 'circle) (* pi (expt (circle-radius shape) 2))) ((typep shape 'rectangle) (* (rectangle-width shape) (rectangle-height shape))) ((typep shape 'triangle) (/ (* (triangle-base shape) (triangle-height shape)) 2)) (t (error "지원하지 않는 도형입니다.")))) ;; 다중 디스패치가 있을경우 (defgeneric calculate-area (shape) (:documentation "도형의 면적을 계산합니다.")) ; 각 도형별 메소드 구현 (defmethod calculate-area ((shape circle)) (* pi (expt (circle-radius shape) 2))) (defmethod calculate-area ((shape rectangle)) (* (rectangle-width shape) (rectangle-height shape))) (defmethod calculate-area ((shape triangle)) (/ (* (triangle-base shape) (triangle-height shape)) 2)) ;;; 사용 예제 (defun print-areas (shapes) (format t "도형 면적 계산 결과:~%") (format t "==================~%") (loop for shape in shapes for i from 1 do (format t "~A. ~A의 면적: ~,2f~%" i (class-name (class-of shape)) (calculate-area shape))) (format t "==================~%")) (print-areas(list (make-instance 'circle :radius 5) (make-instance 'rectangle :width 4 :height 6) (make-instance 'triangle :base 3 :height 4) (make-instance 'circle :radius 3) (make-instance 'rectangle :width 2 :height 8))) ;;; 결과 ; 도형 면적 계산 결과: ; ================== ; 1. CIRCLE의 면적: 78.54 ; 2. RECTANGLE의 면적: 24.00 ; 3. TRIANGLE의 면적: 6.00 ; 4. CIRCLE의 면적: 28.27 ; 5. RECTANGLE의 면적: 16.00 ; ==================
혹시 리스프 코드라 눈에 안들어오나요?
다음 형태라 생각해보세요.
// 다중 디스패치가 없을경우 type Shape = | { kind: "circle"; radius: number } | { kind: "rectangle"; width: number; height: number } | { kind: "triangle"; base: number; height: number }; function calculateArea(shape: Shape): number { switch (shape.kind) { case "circle": return Math.PI * shape.radius ** 2; case "rectangle": return shape.width * shape.height; case "triangle": return (shape.base * shape.height) / 2; } } // 다중 디스패치가 있을경우 type Shape = Circle | Rectange | Triangle; type Circle = { radius: number }; type Rectangle = { width: number; height: number }; type Triangle = { base: number; height: number }; function calculateArea(shape: Circle): number { return Math.PI * shape.radius ** 2; } function calculateArea(shape: Rectange): number { return shape.width * shape.height; } function calculateArea(shape: Triangle): number { return (shape.base * shape.height) / 2; }
혼동이 올수도 있으므로 가능하다면 Switch나 패턴매칭을 우선적으로 사용하는게 좋을테지만,
강력한 패턴임은 분명해 보이네요.
3.2.3 메서드 조합: 횡단 관심사 제어
메서드 조합(Method combination)은 AOP(Aspect-oriented programing)에서 쓰이던 before, after, around가 메서드에 적용되는 것이다.
예를 들어 다음 코드는 "계산 중..."의 앞 뒤로 시작과 완료 메세지가 뜨게 된다.
;; 메소드 결합 (defmethod calculate-area :before ((shape t)) (format t "면적 계산을 시작합니다.~%")) (defmethod calculate-area :after ((shape t)) (format t "면적 계산이 완료되었습니다.~%")) ;; 메소드 재정의 (defmethod calculate-area ((shape circle)) (format t "계산 중...~%") (* pi (expt (circle-radius shape) 2))) ;; 사용 (calculate-area (make-instance 'circle :radius 5))
AOP와 마찬가지로 횡단 관심사(Cross-cutting concerns)인 로깅, 트랜잭션, 유효성 검증등에 유용합니다.
3.3 동적인 객체
이건 장점이라 보기에는 힘들지만, 프로토타입과 마찬가지로 인터프리터 언어의 특성이라고도 생각할 수 있습니다.
예를 들어 프로토타입 언어도 메서드를 CLOS처럼 동적으로 추가가능하며, 프로퍼티나 생성자 역시 동적으로 추가/변경할 수 있죠.
// 1. 프로토타입 생성자 function Person(name, age) { this.name = name; this.age = age; } // 프로토타입에 메서드 추가 Person.prototype.greet = function() { console.log(`Hello, ${this.name}!`); }; // 인스턴스 생성 const person1 = new Person("John", 30); const person2 = new Person("Jane", 25); // 2. 동적 속성 추가 방법들 // 2.1 특정 인스턴스에만 속성 추가 person1.address = "Seoul"; console.log(person1.address); // "Seoul" console.log(person2.address); // undefined // 2.2 프로토타입에 속성 추가 (모든 인스턴스에 영향) Person.prototype.address = "Default Address"; const person3 = new Person("Bob", 35); console.log(person2.address); // "Default Address" console.log(person3.address); // "Default Address" // 2.3 생성자 함수 수정 function Person(name, age, address) { this.name = name; this.age = age; this.address = address; } // 새로운 인스턴스는 수정된 생성자 사용 const person4 = new Person("Alice", 28, "Busan");
CLOS 역시 앞선 Javascript처럼 프로퍼티(슬롯)을 추가 가능합니다.
다만, 엄연히 객체에 기반한 프로토타입 체인을 수정하는 자바스크립트에 비해 정의 자체를 재정의할 수 있다.
(defvar person1 (make-instance 'person :name "John" :age 20)) ;; 클래스 재정의 (defclass person () ((name :initarg :name :accessor person-name) (age :initarg :age :accessor person-age) (address :initarg :address :accessor person-address :initform "Default Address"))) ;; 메서드 재정의 (defmethod display-info ((p person)) (format t "이름: ~A, 나이: ~D, 주소: ~A~%" (person-name p) (person-age p) (person-address p))) (person-address person1) ;; Default Address (display-info person1)
그렇다면
address
의 기본값을 바꿀 수 있을까요?여기에서 프로토타입 객체지향과는 다른 면이 드러납니다.
어찌되었건 CLOS는 클래스의 확장이기 때문에, 이미 인스턴스의 프로퍼티가 초기화되어 이전에 있던 값을 반환합니다.
;; address의 기본 값을 변경 (defclass person () ((name :initarg :name :accessor person-name) (age :initarg :age :accessor person-age) (address :initarg :address :accessor person-address :initform "Other Default Address"))) (person-address person1) ;; 하지만 "Default Address"
그렇다면 기존 인스턴스들을 명시적으로 마이그레이션을 해야 합니다.
기존에 사용하던
"Default Address"
만 새로운 것으로 변경해봅시다.;; 인스턴스 마이그레이션 (defmethod update-instance-for-redefined-class :after ((instance person) added-slots discarded-slots property-list &rest initargs) (when (equal (person-address instance) "Default Address") (setf (person-address instance) "Other Default Address"))) (make-instances-obsolete 'person) ;; 체크해보기 (person-address person1) (display-info person1)
그렇다면 혹시 특정 메서드를 제거하는 것이 가능할까요?
fmakunbound
를 통해 일괄적으로 제거할 수도 있고,remove-method
를 통해 특정 메서드만 선택해 제거할 수도 있습니다.;; 모든 메서드 일괄 삭제 (fmakunbound 'display-info) ;; calculate-area의 :after로 나오는 로그 메서드 제거 (remove-method #'calculate-area (find-method #'calculate-area '(:after) (list (find-class 't)))) ;; calculate-area의 circle 메서드 제거 (remove-method #'calculate-area (find-method #'calculate-area '() (list (find-class 'circle))))
3.4 MOP(Meta-Object Protocol)
MOP는 객체 지향 시스템의 구현을 사용자가 확장할 수 있게 해주는 인터페이스입니다.
Python의 metaclass와 비슷하지만 더 강력합니다.
파이썬의 metaclass가 생성 시점의 동작에 대해서 제어를 한다면
MOP는 클래스 정의, 슬롯 접근, 메소드 디스패치 등 거의 모든 부분에서 확장 가능합니다.
먼저 표준 메타 클래스는
standard-class
입니다.(class-of person1) ;; #<STANDARD-CLASS #:PERSON>
이제 메타 클래스를 사용해보겠습니다.
몇가지 유용한 예를 만들어봤습니다.
3.4.1 인스턴스 카운터: 생성시 상태제어
인스턴스가 생길때
counter
가 증가하도록 만들어봤어요.이런 기능은 로그를 남길때 딱이겠죠?
;; 1. 에러를 피하기 위해서.. ;; MOP 관련 패키지를 import함 - 추후 sb-mop:validate-superclass 와 같이 네임스페이스를 지정하지 않기위해 ; https://koji-kojiro.github.io/sb-docs/build/html/sb-mop/ (use-package :sb-mop) ;; Meta class를 변경하기 위한 class의 symbol 제거 ; https://stackoverflow.com/questions/38811931/how-to-change-classs-metaclass#38812140 (unintern 'person) ;; 2. 메타 클래스 정의 (defclass counted-class (standard-class) ((counter :initform 0))) (defmethod make-instance :after ((class counted-class) &rest initargs) (declare (ignore initargs)) (incf (slot-value class 'counter))) (defmethod get-instance-count ((class counted-class)) (slot-value class 'counter)) ; 메타 클래스끼리 호환이 되는지 여부 - 필수로 구현요구 ; true면 상속, false면 상속 금지 (defmethod validate-superclass ((class counted-class) (superclass standard-class)) t) ;; 3. 클래스 재정의 (defclass person () ((name :initarg :name :accessor person-name) (age :initarg :age :accessor person-age) (address :initarg :address :accessor person-address :initform "Default Address")) (:metaclass counted-class)) ;; 4. 사용 (get-instance-count (find-class 'person)) ;; 0 (make-instance 'person :name "Jane" :age 21) (get-instance-count (find-class 'person)) ;; 1
3.4.2 타입 검사기: 기존 기능 확장
Common Lisp에는 분명 타입 선언을 할 수 있지만,
파이썬의 타입힌트처럼 런타임에서 무시하고 실행됩니다.
;; 재정의 (unintern 'person) (defclass person () ((name :type string :initarg :name :accessor person-name) (age :type integer :initarg :age :accessor person-age))) ;; 실행 (make-instance 'person :name 1 :age "John") ;; 잘못된 타입이지만 성공적으로 실행됨
Common Lisp의 타입명시는 최적화를 위해서 존재하기 때문이다. (Python은 IDE와 문서화 역할이 더 큼)
그럼 어떻게 하면 될까요?
메타 클래스를 이용해서 set 하기 전에 검사를 하면 됩니다.
;; 메타 클래스 정의 (defclass type-checking-class (standard-class) ()) (defmethod validate-superclass ((class type-checking-class) (superclass standard-class)) t) (defmethod (setf slot-value-using-class) :before (new-value (class type-checking-class) instance slot) (let ((type (slot-definition-type slot))) (when type ; type이 지정된 경우에만 검증 (unless (typep new-value type) (error "Value ~S is not of type ~S" new-value type))))) ;; 클래스 재정의 (unintern 'person) (defclass person () ((name :type string :initarg :name :accessor person-name) (age :type integer :initarg :age :accessor person-age)) (:metaclass type-checking-class)) ;; 실행 (make-instance 'person :name 1 :age "John") ;; 에러!! (defvar person1 (make-instance 'person :name "John" :age 20)) ;; 성공 (setf (person-age person1) 21) ;; 성공 (setf (person-age person1) "22") ;; 에러!!
결과는 이런식으로 나와요.
확실히 자바스크립트의 Proxy처럼 쓰일 수 있겠죠?
3.4.3 값 검증하기: 클래스 정의시 키워드 추가
이번에는 타입을 넘어 defclass를 할때 검증하는 함수를 넣어봅시다.
:validator
에 함수를 사용해 검증할 수 있도록 할거에요.슬롯의 검증에는 직접 쓸때와 상속해서 쓸때 두 경우를 모두 다루어야 해요.
직접 사용하는 슬롯을
direct-slot
이라 부르고요,(defclass person () ((name :initarg :name) ; 직접 슬롯 (age :initarg :age))) ; 직접 슬롯
상속까지 고려했을때 유효한 슬롯을
effective-slot
이라 불러요.(defclass animal () ((name :initarg :name))) (defclass dog (animal) ((breed :initarg :breed))) ;; dog 클래스의 유효 슬롯: ;; - name (animal로부터 상속) ;; - breed (직접 정의)
이걸 감안해도 조금 어렵습니다만, 구현을 한번 살펴보도록 합시다!!
난이도는 흑마법이라 어쩔수가 없어요 ㅠㅠ
;; 클래스 정의 (defclass validated-class (standard-class) ()) (defclass validated-slot-definition (standard-slot-definition) ((validator :initarg :validator :initform nil :accessor slot-validator))) (defclass validated-direct-slot-definition (validated-slot-definition standard-direct-slot-definition) ()) (defclass validated-effective-slot-definition (validated-slot-definition standard-effective-slot-definition) ()) ;; 검증 함수 (defun validate-slot (value validator slot-name) (when (and validator (not (funcall (if (functionp validator) validator (coerce validator 'function)) value))) (error "Validation failed for slot ~A with value ~A" slot-name value))) ;; 메타클래스 메소드들 (defmethod validate-superclass ((class validated-class) (superclass standard-class)) t) (defmethod direct-slot-definition-class ((class validated-class) &rest initargs) (declare (ignore initargs)) (find-class 'validated-direct-slot-definition)) (defmethod effective-slot-definition-class ((class validated-class) &rest args) (declare (ignore args)) (find-class 'validated-effective-slot-definition)) (defmethod compute-effective-slot-definition ((class validated-class) name direct-slots) (let* ((effective-slot (call-next-method)) (validator (and (first direct-slots) (slot-validator (first direct-slots))))) (when validator (setf (slot-validator effective-slot) validator)) effective-slot)) (defmethod (setf slot-value-using-class) :before (new-value (class validated-class) object (slot validated-effective-slot-definition)) (validate-slot new-value (slot-validator slot) (slot-definition-name slot))) (defmethod shared-initialize :after ((instance standard-object) slot-names &rest args) (declare (ignore args slot-names)) (when (typep (class-of instance) 'validated-class) (dolist (slot (class-slots (class-of instance))) (when (slot-boundp instance (slot-definition-name slot)) (validate-slot (slot-value instance (slot-definition-name slot)) (slot-validator slot) (slot-definition-name slot)))))) ;; 클래스 재정의 (unintern 'person) (defclass person () ((name :initarg :name :accessor person-name :validator (lambda (x) (and (stringp x) (> (length x) 5)))) (age :initarg :age :accessor person-age :validator (lambda (x) (and (numberp x) (> x 5))))) (:metaclass validated-class)) ;; 실행 (make-instance 'person :name "John Doe" :age 20) ;; 성공 (make-instance 'person :name "John" :age 20) ;; 실패 (make-instance 'person :name "John Doe" :age 3) ;; 실패
그럼 앞서했던 타입 검증과 검증을 차례대로 진행할 수 있다면 어떨까요?
현재 :validator에 있는 검증 로직이 줄어들 것 같네요.
구현은 간단하게 다중 상속을 하면 해결됩니다?!
;; 결합된 메타클래스 정의 (defclass type-and-validated-class (type-checking-class validated-class) ()) ;; 상위클래스 검증 (defmethod validate-superclass ((class type-and-validated-class) (superclass standard-class)) t) ;; 클래스 재정의 (unintern 'person) (defclass person () ((name :type string :initarg :name :accessor person-name :validator (lambda (x) (> (length x) 5))) (age :type integer :initarg :age :accessor person-age :validator (lambda (x) (> x 5)))) (:metaclass type-and-validated-class))
이외에도 MOP를 잘 활용하면 각종 로깅, 캐싱은 물론 완전한 AOP 구현에도 쓰일수 있겠죠?
MOP는 단순한 리플렉션을 넘어 객체지향 시스템 자체를 확장할 수 있다는 점에서 커다란 유연성을 제공합니다.
3.5 다중상속
다중 상속.. 말만 들어도 문제가 생길 것 같습니다.
사실 이런 문제들 때문에 저는 Trait이나 Protocol 방식을 좋아하나,
그래도 만약 Java나 C++과 같은 Class 방식을 써야 한다면 차라리 CLOS방식이 낫지 않나..
3.5.1 C++의 다중상속 문제
C++의 다중상속은 엉망이기로 유명합니다.
예를 들어 유명한 다이아몬드 상속을 생각해봅시다.
class A {}; class B: public A {}; class C: public A {}; class D: public B, public C {}; int main() { D d{}; return 0; }
우리는 다이어몬드 모양으로 상속이 될거라 생각하지만, 실제모양은 오른쪽과 같습니다. [그림출처]
안믿긴다면 생성자를 사용해보세요.
#include <iostream> class A { public: A() { std::cout << "A constructor" << std::endl; } }; class B : public A { public: B() { std::cout << "B constructor" << std::endl; } }; class C : public A { public: C() { std::cout << "C constructor" << std::endl; } }; class D : public B, public C { public: D() { std::cout << "D constructor" << std::endl; } }; int main() { D d{}; return 0; } /* 결과 A constructor B constructor A constructor C constructor D constructor */
그래서 앞선 코드는 컴파일이 되었겠지만 실제로 메서드를 사용해보면,
보다 쉽게 문제를 확인할 수 있습니다.
#include <iostream> class A { public: void foo() { std::cout << "Message" << std::endl; } }; class B: public A {}; class C: public A {}; class D: public B, public C {}; int main() { D d{}; d.foo(); // 에러: request for member ‘foo’ is ambiguous d.A::foo(); // 에러: ‘A’ is an ambiguous base of ‘D’ d.B::foo(); // 가능 d.C::foo(); // 가능 return 0; }
이는
A
의 인스턴스를 하나만 쓰게 하면 해결됩니다.B
와C
에서A
를 가상 상속하게 되면 말이지요.class A { public: void foo() { std::cout << "Message" << std::endl; } }; class B: virtual public A {}; class C: virtual public A {}; class D: public B, public C {}; int main() { D d{}; d.foo(); d.A::foo(); d.B::foo(); d.C::foo(); return 0; }
생성자 예제에
virtual
을 붙여 테스트해보세요.A
클래스가 한번만 생성될 겁니다.이게 끝이었다면 말을 꺼내지 않았을 겁니다.
이름 충돌 문제가 그대로 존재하기 때문에 다음은 에러가 생기게 됩니다.
class A { public: void foo() { std::cout << "Message1" << std::endl; } }; class B: virtual public A { public: void foo() { std::cout << "Message2" << std::endl; } }; class C: virtual public A { public: void foo() { std::cout << "Message3" << std::endl; } }; class D: public B, public C {}; int main() { D d{}; d.foo(); // 에러!! d.A::foo(); // Message1 출력 d.B::foo(); // Message2 출력 d.C::foo(); // Message3 출력 return 0; }
이를 해결하기 위해서는 명시적으로 해결해줘야 하지요.
class D: public B, public C { public: void foo() { B::foo(); } };
이외에도 C++에서 다중상속은 초기화 및 생성자/소멸자 순서 같은 관리의 모호함과 메모리 레이아웃, 가상상속 오버헤드등의 성능 문제들이 일어나기로 유명합니다.
3.5.2 CPL(Class Precedence List) 규칙
다행히도 Common Lisp에서는 CPL(Class Precedence List)이란 규칙을 사용해 결정적으로 적용됩니다.
참고로 다중 상속시 메타클래스와 일반 클래스는 동작이 다른데, 메타클래스는 결합되어 둘 다 실행되는 반면, 일반 클래스는 하나의 결정적인 메서드만 실행됩니다.
그래서 MOP에서는 타입과 검증을 함께 적용할 수 있었죠.
적용되었던 순서를 살펴봅시다.
type-and-validated-class
->type-checking-class
->validated-class
...순으로 적용된 것을 볼 수 있지요.(compute-class-precedence-list (find-class 'type-and-validated-class)) ;; 결과 (#<STANDARD-CLASS COMMON-LISP-USER::TYPE-AND-VALIDATED-CLASS> #<STANDARD-CLASS COMMON-LISP-USER::TYPE-CHECKING-CLASS> #<STANDARD-CLASS COMMON-LISP-USER::VALIDATED-CLASS> #<STANDARD-CLASS COMMON-LISP:STANDARD-CLASS> #<STANDARD-CLASS SB-PCL::STD-CLASS> #<STANDARD-CLASS SB-PCL::SLOT-CLASS> #<STANDARD-CLASS SB-PCL::PCL-CLASS> #<STANDARD-CLASS COMMON-LISP:CLASS> #<STANDARD-CLASS SB-PCL::DEPENDENT-UPDATE-MIXIN> #<STANDARD-CLASS SB-PCL::PLIST-MIXIN> #<STANDARD-CLASS SB-PCL::DEFINITION-SOURCE-MIXIN> #<STANDARD-CLASS SB-PCL::STANDARD-SPECIALIZER> ...)
CPL 규칙은 C3 선형화 알고리즘이므로 파이썬의 MRO(Method Resolution Order)와 거의 동일합니다.
- 클래스 자신이 가장 먼저
- 왼쪽에서 오른쪽으로 선언된 부모 순서
- 공통 조상은 한 번만 나타남 [위상 정렬(Topological Sorting)을 사용함]
standard-object
와t
는 항상 마지막 [메타 클래스가 아닌 일반 클래스 기준]
그래서 다이아몬드 상속에서는 우선 순위가 어떻게 될까요?
;; 리스트대로 보여주기 (defun print-cpl (class-name) (format t "CPL for ~A: ~A~%" class-name (mapcar #'class-name (compute-class-precedence-list (find-class class-name))))) ;; 다이아몬드 상속 (defclass A () ()) (defclass B (A) ()) (defclass C (A) ()) (defclass D (B C) ()) ;; 결과 (print-cpl 'D) ; CPL for D: (D B C A STANDARD-OBJECT SLOT-OBJECT T)
CPL 대로라면 메서드를 선언했을때
D
에 없을 경우B
의 메서드가 실행될테죠.한번 실험해봅시다!!
;; 메소드 선언 (defgeneric foo (obj)) (defmethod foo ((obj A)) (format t "Message1~%")) (defmethod foo ((obj B)) (format t "Message2~%")) (defmethod foo ((obj C)) (format t "Message3~%")) ;; 예시 (let ((d1 (make-instance 'D))) (foo d1) ; Message2 (foo (change-class d1 'A)) ; Message1 (foo (change-class d1 'B)) ; Message2 (foo (change-class d1 'C))) ; Message3
다행히 잘 되는 모습을 볼 수 있네요.
그리고 CPL로 더 복잡한 경우도 잘 다루는 모습도 확인 가능합니다.
(defclass A () ()) (defclass B () ()) (defclass C () ()) (defclass D (A B) ()) (defclass E (B C) ()) (defclass F (D E) ()) ; 결과 (print-cpl 'F) ; CPL for F: (F D A E B C STANDARD-OBJECT SLOT-OBJECT T)
단, 교차 상속에서는 문제가 생기지요.
;; 교차 상속 (defclass A () ()) (defclass B () ()) (defclass C (A B) ()) ; C는 A > B 순서를 요구 (defclass D (B A) ()) ; D는 B > A 순서를 요구 (defclass E (C D) ()) ; 모순 발생! ;; 에러 ; While computing the class precedence list of the class named COMMON-LISP-USER::E. ; It is not possible to compute the class precedence list because ; there is a circularity in the local precedence relations. ; This arises because: ; The class named COMMON-LISP-USER::A follows the class named COMMON-LISP-USER::B in the supers of the class named COMMON-LISP-USER::D. ; The class named COMMON-LISP-USER::B follows the class named COMMON-LISP-USER::A in the supers of the class named COMMON-LISP-USER::C.. ;; 시각화 ; E ; ├─ C ; │ ├─ A ──┐ ; │ └─ B <─┤ ; └─ D │ ; ├─ B ──┤ ; └─ A <─┘ ; ; 순환 발생: A > B > A
CLOS의 상속 시스템이 완전히 마음에 드는 것은 아니지만,
다중 상속이 필요하다면 적어도 C++의 다중 상속방식보다 나은 것으로 보입니다.
이제 Lisp가 강력하다는 의미를 체감했으리라 믿는다.
Homoiconicity말고도 안전하면서 쉬운 패턴매칭 매크로, 다양한 용도를 제공하는 동적 스코프, 객체지향 시스템 자체를 확장하는 CLOS 모두 메타적이고, 동적이고, 유연하게 만들어주는 리스프 특징을 잘 보여준다고 생각한다.
조심히 사용해야할 고오급 사용법 측면에선 꽤나 발전되어있다.
CLOS는 상당히 호불호가 갈릴수 있겠지만 나머지는 안전하면서도 다양한 활용법을 제공해주기 때문에 다른 언어들에서도 많이 참고했으면.
댓글