이 글은 Claude Opus 5 를 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.


Part 4에서 Cons·Car·Cdr 세 개로 리스트와 트리를 만들었습니다. 이번 편은 그 위에서 두 가지를 합니다.

앞쪽은 2.2.3 관례적 인터페이스 입니다. map, filter, accumulate 세 개로 프로그램의 모양을 바꾸는 이야기이고, 이 책에서 실무에 가장 직접적으로 쓸모 있는 절입니다. Go 1.23 이 iter.Seq 를 넣은 이유와 정확히 같은 이야기이기도 합니다.

뒤쪽은 2.3 기호 데이터 입니다. 여기서 Part 1 에 예고한 마찰 두 번째 — Go 에는 quote 가 없다 — 를 처음 만납니다.


1. 2.2.3 — 같은 뼈대가 자꾸 나온다#

원서는 두 프로그램을 나란히 놓습니다.

하나는 트리에서 홀수인 잎을 찾아 제곱해 더하는 것, 다른 하나는 피보나치 수 중 짝수만 모아 리스트로 만드는 것입니다. 하는 일이 전혀 다릅니다. 그런데 원서는 둘이 같은 모양 이라고 말합니다.

flowchart LR
    E1["열거<br/>enumerate"] --> F1["거르기<br/>filter: 홀수"] --> M1["변환<br/>map: 제곱"] --> A1["모으기<br/>accumulate: +"]
    E2["열거<br/>enumerate"] --> M2["변환<br/>map: fib"] --> F2["거르기<br/>filter: 짝수"] --> A2["모으기<br/>accumulate: cons"]

    style E1 fill:#90EE90,color:#000000
    style E2 fill:#90EE90,color:#000000
    style F1 fill:#FFD700,color:#000000
    style F2 fill:#FFD700,color:#000000
    style M1 fill:#87CEEB,color:#000000
    style M2 fill:#87CEEB,color:#000000
    style A1 fill:#FFA07A,color:#000000
    style A2 fill:#FFA07A,color:#000000

원서의 비유가 신호 처리 파이프라인 입니다. 신호가 단계를 거치며 흘러가고, 각 단계는 자기 일만 합니다. 그런데 보통 이렇게 짜지 않습니다. 재귀 함수 하나에 열거·거르기·변환·모으기가 전부 뒤엉켜 들어갑니다. 구조가 있는데 코드에 안 보이는 상태 가 됩니다.

원서의 처방은 각 단계를 실제 함수로 만드는 것입니다.

func Map(f func(any) any, l any) any {
	if l == nil { return nil }
	return Cons(f(Car(l)), Map(f, Cdr(l)))
}

func Filter(pred func(any) bool, l any) any {
	switch {
	case l == nil:
		return nil
	case pred(Car(l)):
		return Cons(Car(l), Filter(pred, Cdr(l)))
	default:
		return Filter(pred, Cdr(l))
	}
}

// 오른쪽 접기
func Accumulate(op func(a, b any) any, initial any, l any) any {
	if l == nil { return initial }
	return op(Car(l), Accumulate(op, initial, Cdr(l)))
}

그리고 열거자 두 개.

func EnumerateInterval(lo, hi int) any { /* (lo lo+1 ... hi) */ }

func EnumerateTree(t any) any {
	switch v := t.(type) {
	case nil:
		return nil
	case *Pair:
		return Append(EnumerateTree(v.Car), EnumerateTree(v.Cdr))
	default:
		return List(t) // 잎
	}
}

이제 두 프로그램이 파이프라인이 됩니다.

func sumOddSquares(tree any) int {
	return Accumulate(
		func(a, b any) any { return a.(int) + b.(int) }, 0,
		Map(func(x any) any { return square(x.(int)) },
			Filter(func(x any) bool { return x.(int)%2 == 1 },
				EnumerateTree(tree)))).(int)
}
tree          = ((1 (2 3)) (4 (5 (6 7))))
EnumerateTree = (1 2 3 4 5 6 7)
sumOddSquares = 84        (1² + 3² + 5² + 7² = 84)
evenFibs(10)  = (0 2 8 34)

타입 단언(.(int))이 눈에 거슬립니다. Part 4 에서 Pair 를 any 로 만들기로 한 대가가 여기서 청구됩니다. 원서의 Scheme 코드에는 이 잡음이 없습니다.

원서가 이 절에서 강조하는 것은 재사용성입니다. 파이프라인 부품이 표준화되면 조각을 바꿔 끼워서 새 프로그램을 만들 수 있습니다. 실제로 원서는 같은 부품으로 여러 프로그램을 조립해 보입니다. 이 아이디어가 30년 뒤에 Stream, LINQ, RxJS, iterator 라는 이름으로 거의 모든 언어에 들어왔습니다.


2. 같은 것을 Go 1.23 방식으로 쓰면#

Go 는 오랫동안 이 이야기에 냉담했습니다. for 문이 있는데 왜 map 이 필요하냐는 입장이었습니다.

그런데 Go 1.23 이 range-over-func 을 넣으면서 상황이 바뀌었습니다. iter.Seq[T] 는 사실 func(yield func(T) bool) 일 뿐이고, 이걸로 파이프라인을 만들 수 있습니다.

func MapSeq[A, B any](f func(A) B, s iter.Seq[A]) iter.Seq[B] {
	return func(yield func(B) bool) {
		for v := range s {
			if !yield(f(v)) { return }
		}
	}
}

func FilterSeq[A any](pred func(A) bool, s iter.Seq[A]) iter.Seq[A] {
	return func(yield func(A) bool) {
		for v := range s {
			if pred(v) && !yield(v) { return }
		}
	}
}

같은 계산을 이렇게 씁니다.

nums := Seq(1, 2, 3, 4, 5, 6, 7)
odds := FilterSeq(func(x int) bool { return x%2 == 1 }, nums)
squares := MapSeq(func(x int) int { return x * x }, odds)
Fold(func(a, b int) int { return a + b }, 0, squares) // 84
합: 84
라벨: [#1 #2 #3]
무한 수열 앞 5개: [1 2 3 4 5]

원서의 리스트 판과 결과가 같습니다. 그런데 성질이 셋 다릅니다.

SICP 리스트 파이프라인Go iter.Seq 파이프라인
타입any 뿐. 단언 필요제네릭. iter.Seq[int] → iter.Seq[string] 이 타입 검사됨
중간 결과단계마다 새 리스트 전부 생성생성하지 않음. 값이 하나씩 흘러감
무한 수열불가 (3장의 스트림이 필요)가능. yield 가 false 를 돌려주면 멈춤

두 번째가 원서도 인정하는 약점입니다. 2.2.3 의 파이프라인은 단계마다 리스트를 통째로 만듭니다. 원소 100만 개를 걸러서 변환하면 100만 개짜리 리스트가 두 번 더 생깁니다. 원서는 이 문제를 3.5 절의 스트림 으로 풀고, 그게 Part 9 의 주제입니다.

iter.Seq 는 그 문제를 처음부터 겪지 않습니다. 소비자가 값을 하나 요구하면 생산자가 하나 만듭니다. SICP 3.5 가 손으로 만드는 것을 Go 1.23 이 언어에 넣은 셈 입니다. 다만 완전히 같지는 않은데, 그 차이가 Part 9 의 핵심입니다.


3. 중첩된 매핑, 그리고 여덟 퀸#

2.2.3 의 마지막은 중첩 매핑 입니다. 리스트 안에서 리스트를 만들고 평평하게 펴는 패턴입니다. 다른 언어에서 flatMap 이라고 부르는 것입니다.

func FlatMap(f func(any) any, l any) any {
	return Accumulate(func(a, b any) any { return Append(a, b) }, nil, Map(f, l))
}

원서의 첫 예제는 1 ≤ j < i ≤ n 인 쌍 중 i+j 가 소수인 것을 찾는 것입니다.

primeSumPairs(6) = ((2 1 3) (3 2 5) (4 1 5) (4 3 7) (5 2 7) (6 1 7) (6 5 11))

그리고 연습문제 2.42, 여덟 퀸 문제 가 나옵니다. 8×8 체스판에 퀸 여덟 개를 서로 공격하지 않게 놓는 방법을 전부 찾는 문제입니다.

원서의 해법이 아름다운 이유는 백트래킹을 명시적으로 짜지 않기 때문입니다. k-1 열까지의 모든 안전한 배치를 구해 놓고, 각각에 대해 k 열의 모든 행을 시도하고, 안전한 것만 거릅니다. 그게 전부입니다.

func queens(boardSize int) any {
	var queenCols func(k int) any
	queenCols = func(k int) any {
		if k == 0 {
			return List(nil) // 빈 배치 하나
		}
		return Filter(func(positions any) bool { return safe(k, positions) },
			FlatMap(func(rest any) any {
				return Map(func(newRow any) any { return Cons(newRow, rest) },
					EnumerateInterval(1, boardSize))
			}, queenCols(k-1)))
	}
	return queenCols(boardSize)
}

k == 0 일 때 빈 리스트가 아니라 “빈 배치 하나를 담은 리스트” 를 돌려주는 게 요령입니다. List(nil) 입니다. 빈 리스트를 돌려주면 FlatMap 이 아무것도 못 만들어서 전부 사라집니다. 원서 연습문제를 처음 푸는 사람이 가장 많이 걸리는 곳입니다.

queens(4) 해의 개수 = 2
queens(5) 해의 개수 = 10
queens(6) 해의 개수 = 4
queens(7) 해의 개수 = 40
queens(8) 해의 개수 = 92
queens(8) 첫 해 = (4 2 7 3 6 8 5 1)

92개. 여덟 퀸 문제의 해가 92개라는 건 잘 알려진 값입니다. 4×4가 2개, 6×6이 4개로 뚝 떨어지는 것도 맞습니다.

여기서 짚을 것이 있습니다. 이 코드에는 “되돌아가라” 는 명령이 한 줄도 없습니다. 백트래킹처럼 보이는 동작이 filter 에서 저절로 나옵니다. 조건에 안 맞는 후보는 다음 단계로 넘어가지 않을 뿐입니다.

이 발상이 4장의 amb 평가기로 직행합니다. 4.3 절이 하는 일은 이 “생성하고 거른다” 패턴을 언어 기능으로 승격시키는 것입니다. Part 12 에서 다시 만납니다.


4. 2.3 — 기호 데이터, 그리고 quote 라는 마찰#

이제 후반부입니다.

지금까지 리스트에 넣은 것은 숫자였습니다. 2.3 은 심볼 을 넣습니다. Scheme 에서 심볼을 만드는 것은 따옴표 하나입니다.

(define a 1)
(define b 2)
(list a b)     ; => (1 2)
(list 'a 'b)   ; => (a b)

'a 는 “a 라는 이름이 가리키는 값” 이 아니라 "a 라는 이름 자체" 입니다. quote 는 평가를 한 단계 멈추는 장치입니다.

Go 에는 이런 게 없습니다. Go 소스의 x 는 항상 변수 x 의 값을 뜻하고, 그 이름 자체를 값으로 얻을 방법이 문법에 없습니다.

우회는 어렵지 않습니다. 심볼 타입을 하나 만들면 됩니다.

// 문자열과 구별하기 위해 별도 타입으로 둔다
type Symbol string

Symbol("a") 가 'a 를 대신합니다. 그런데 이 우회가 잃는 것이 하나 있습니다. quote 는 심볼만 만드는 게 아니라 리스트 전체를 만듭니다.

'(* (* x y) (+ x 3))

Scheme 에서는 이 한 줄이 곧 식이자 데이터입니다. Go 에서는 이렇게 써야 합니다.

List(Symbol("*"), List(Symbol("*"), Symbol("x"), Symbol("y")),
	List(Symbol("+"), Symbol("x"), 3))

이 차이가 4장에서 결정적으로 커집니다. 원서의 메타순환 평가기가 짧은 이유는 프로그램을 quote 로 그냥 받을 수 있기 때문입니다. Go 에서는 문자열을 파싱해야 합니다. Part 10 에서 렉서와 파서를 손으로 만듭니다.

지금 단계에서는 List(Symbol(...), ...) 이 장황할 뿐 막히지는 않습니다. 그래서 2.3 은 무사히 통과합니다.


5. 2.3.2 — 기호 미분#

d/dx (x·y·(x+3)) 을 계산하는 프로그램입니다. 수식을 데이터로 다룹니다.

미분 규칙은 재귀적으로 정의됩니다. 상수의 미분은 0, 변수의 미분은 자기 자신이면 1 아니면 0, 합의 미분은 미분의 합, 곱의 미분은 곱셈 규칙. 그대로 옮기면 됩니다.

func deriv(exp, v any) any {
	switch {
	case isNumber(exp):
		return 0
	case isVariable(exp):
		if sameVariable(exp, v) { return 1 }
		return 0
	case isSum(exp):
		return makeSum(deriv(Cadr(exp), v), deriv(Caddr(exp), v))
	case isProduct(exp):
		return makeSum(
			makeProduct(Cadr(exp), deriv(Caddr(exp), v)),
			makeProduct(deriv(Cadr(exp), v), Caddr(exp)))
	}
	panic("알 수 없는 식: " + Write(exp))
}

여기서 원서의 설계가 빛나는 지점 이 있습니다. deriv 는 식이 리스트로 표현된다는 사실을 모릅니다. isSum, Cadr, makeSum 같은 이름만 씁니다. 표현을 바꿔도 deriv 는 그대로입니다. Part 4 의 추상화 장벽이 여기서 실제로 값을 합니다.

그리고 단순화가 생성자 안 에 들어 있습니다.

func makeSum(a, b any) any {
	switch {
	case isSameNumber(a, 0):
		return b          // 0 + b = b
	case isSameNumber(b, 0):
		return a
	case isNumber(a) && isNumber(b):
		return a.(int) + b.(int)
	}
	return List(Symbol("+"), a, b)
}

deriv 는 단순화를 전혀 모릅니다. 그냥 makeSum 을 부를 뿐입니다.

d/dx (+ x 3)             = 1
d/dx (* x y)             = y
d/dx (* (* x y) (+ x 3)) = (+ (* x y) (* y (+ x 3)))

세 번째 줄을 손으로 확인해 보면, (xy)·1 + y·(x+3) = xy + y(x+3) 입니다. 맞습니다. 그리고 (* (* x y) 1) 이 (* x y) 로 줄어든 것이 makeProduct 의 단순화 덕분입니다.

원서는 여기서 정직하게 한계를 인정합니다. 이 단순화는 형편없습니다. xy + xy + 3y 로 더 줄일 수 있는데 안 합니다. 그러려면 대수 시스템이 필요하고, 그게 2.5.3 절의 주제입니다.

Go 로 옮기면서 눈에 띄는 것은 switch { case ... } 가 Scheme 의 cond 와 거의 1:1 이라는 점입니다. Go 의 태그 없는 switch 는 사실상 cond 입니다. 이 대응 덕분에 2~4장의 술어 분기 코드가 거의 기계적으로 옮겨집니다.


6. 2.3.4 — Huffman 부호화 트리#

2장의 마지막 큰 예제입니다. 자주 나오는 심볼에 짧은 부호를, 드문 심볼에 긴 부호를 주는 가변 길이 부호화입니다.

핵심 제약은 접두 부호(prefix code) 여야 한다는 것입니다. 어떤 부호도 다른 부호의 접두사가 되면 안 됩니다. 그래야 구분자 없이 붙여 써도 해독됩니다. 트리로 표현하면 이 제약이 저절로 만족됩니다. 심볼이 전부 잎에 있으면 되기 때문입니다.

Go 로는 두 타입으로 표현합니다.

type Leaf struct {
	Sym    Symbol
	Weight int
}
type Node struct {
	Left, Right any
	Syms        []Symbol
	Weight      int
}

원서는 이것도 리스트로 표현합니다((leaf symbol weight) 같은 태그된 리스트). Go 에서는 구조체 두 개와 타입 스위치가 훨씬 자연스럽고, 표현만 바뀌었지 인터페이스는 원서와 같습니다. symbolsOf, weightOf, makeCodeTree 세 개면 위층이 돌아갑니다. 추상화 장벽이 실제로 작동한다는 증거입니다.

트리 생성은 Huffman 의 원래 알고리즘 그대로입니다. 가장 가벼운 둘을 합치기를 반복합니다.

for len(set) > 1 {
	merged := makeCodeTree(set[0], set[1]) // 가장 가벼운 둘
	set = append([]any{merged}, set[2:]...)
	sortSet()
}

A=8, B=3, 나머지 여섯 개가 각각 1인 빈도로 만들어 봤습니다.

  A -> [0]
  B -> [1 1 1]
  C -> [1 1 0 0]
  D -> [1 1 0 1]
  E -> [1 0 1 0]
  F -> [1 0 1 1]
  G -> [1 0 0 0]
  H -> [1 0 0 1]

메시지    : [B A C A D A E A F A B B A A A G A H]
부호화    : 총 42 비트
고정길이면: 54 비트 (심볼 8종 -> 3비트)
복호화 일치: true

A 는 1비트, B 는 3비트, 나머지는 4비트입니다. 18개 심볼을 42비트 로 담았습니다. 고정 길이라면 54비트가 필요합니다. 22% 절약입니다.

한 가지 밝혀 둘 것이 있습니다. 위 부호는 원서 그림 2.18 의 트리와 모양이 다릅니다. 원서는 A=0, B=100, C=1010 … 을 얻습니다. 부호 길이는 같습니다(A 1비트, B 3비트, 나머지 4비트). 같은 무게를 가진 노드가 여러 개일 때 어느 쪽을 먼저 합치느냐에 따라 트리 모양이 갈리고, 어느 쪽이든 총 비트 수는 같습니다. Huffman 알고리즘의 최적성은 트리 모양이 아니라 부호 길이 분포에 대한 것입니다.

원서 연습문제 2.71 이 이 구조를 일반화합니다. 빈도가 1, 2, 4, 8, …, 2ⁿ⁻¹ 처럼 배치되면 가장 흔한 심볼은 몇 비트가 되는가. n=2 부터 12 까지 실제로 트리를 만들어 재 봤습니다.

n= 2  가장 흔한 심볼 1비트, 가장 드문 심볼 1비트 (n-1=1)
n= 5  가장 흔한 심볼 1비트, 가장 드문 심볼 4비트 (n-1=4)
n=10  가장 흔한 심볼 1비트, 가장 드문 심볼 9비트 (n-1=9)
n=12  가장 흔한 심볼 1비트, 가장 드문 심볼 11비트 (n-1=11)

가장 흔한 심볼은 n 과 무관하게 항상 1비트, 가장 드문 심볼은 n−1비트 입니다. 트리가 완전히 한쪽으로 늘어진 모양이 되기 때문입니다. 위 A=8 예제에서 A 가 1비트인 것도 같은 현상의 약한 버전입니다.


7. 2.3.3 집합 — 표현을 바꾸면 복잡도가 바뀐다#

지면상 코드는 생략하지만, 2.3.3 절의 요점만 정리해 둘 값어치가 있습니다. 원서는 같은 “집합” 을 세 가지로 표현하고 복잡도를 비교합니다.

표현elementOfSet?adjoinSetintersectionSet
정렬 안 된 리스트Θ(n)Θ(n)Θ(n²)
정렬된 리스트Θ(n) (평균 n/2)Θ(n)Θ(n)
이진 트리 (균형 잡힌 경우)Θ(log n)Θ(log n)리스트로 변환 후 Θ(n)

그리고 원서는 마지막에 함정을 짚습니다. 이진 트리의 Θ(log n) 은 트리가 균형 잡혀 있을 때만 성립합니다. 정렬된 데이터를 순서대로 넣으면 트리가 한쪽으로 늘어져서 연결 리스트가 되고, 복잡도는 Θ(n) 으로 돌아갑니다.

이 절이 전달하는 것은 자료구조 지식이 아니라 인터페이스가 같아도 성능 특성은 표현이 결정한다 는 것입니다. Part 4 에서 “표현을 바꿔도 위층은 안 바뀐다” 고 했는데, 그건 정확성 에 대한 이야기이지 성능 에 대한 이야기가 아니었습니다. 추상화 장벽은 복잡도를 숨겨 주지 않습니다.

Go 에서 이 교훈이 나타나는 자리가 있습니다. map[K]V 를 인터페이스 뒤에 숨겼는데 실제 구현이 슬라이스 선형 탐색이면, 호출하는 쪽 코드는 그대로여도 프로덕션에서 죽습니다. 인터페이스는 계약이지 성능 보증서가 아닙니다.


8. 이번 편의 정리#

SICP 2.2.3~2.3 의 주장Go 에서
map/filter/accumulate 로 파이프라인을 만든다성립. 다만 any 라 타입 단언 잡음이 생김
파이프라인 부품은 재사용된다성립. iter.Seq 로 쓰면 제네릭까지 얻음
중첩 매핑으로 백트래킹이 저절로 나온다성립. 여덟 퀸 92개 재현
quote 로 기호를 만든다없음. Symbol 타입으로 우회. 4장에서 청구서 도착
cond 로 표현의 종류를 분기한다성립. Go 의 태그 없는 switch 가 거의 cond
표현을 바꿔도 위층은 안 바뀐다성립. Huffman 을 구조체로 바꿔도 인터페이스 동일
추상화 장벽이 복잡도를 숨겨 주지는 않는다그대로 성립

다음 편은 2장의 마지막인 2.4~2.5 입니다. 하나의 연산이 여러 표현을 동시에 지원하는 시스템을 만듭니다. 태그드 데이터, 데이터 지향 프로그래밍, 그리고 메시지 패싱.

이 대목이 이 시리즈에서 Go 와 궁합이 가장 좋은 곳 입니다. 원서가 1985년에 “데이터 지향 프로그래밍” 이라고 부른 것이 Go 인터페이스 설계 이야기와 사실상 같은 내용이기 때문입니다. 그런데 완전히 같지는 않고, 다른 부분이 오히려 더 흥미롭습니다. 원서가 지적하는 “새 타입을 추가하기 쉬운가, 새 연산을 추가하기 쉬운가” 라는 딜레마가 Go 인터페이스에도 그대로 있습니다.


References#

1차 자료

본문의 실측 데이터

  • 모든 출력값은 go version go1.26.0 darwin/arm64 에서 직접 실행한 결과입니다.
  • 여덟 퀸의 해 개수(4×4=2, 5×5=10, 6×6=4, 7×7=40, 8×8=92)는 잘 알려진 값과 일치합니다.
  • Huffman 부호는 원서 그림 2.18 과 트리 모양이 다릅니다. 같은 무게 노드의 병합 순서 차이 때문이며, 부호 길이 분포(A 1비트, B 3비트, 나머지 4비트)는 같습니다. 이 사실을 본문에 명시했습니다.
  • 연습문제 2.71 의 부호 길이(가장 흔한 심볼 1비트, 가장 드문 심볼 n−1비트)는 n=2 부터 12 까지 실제로 트리를 만들어 Go 테스트로 확인했습니다.
  • 42비트·54비트·“20% 이상 절약” 은 원서 2.3.4 절 본문의 값과 일치합니다. 예제 메시지 BACADAEAFABBAAAGAH 도 원서와 같은 것입니다.
  • 7절의 복잡도 표는 원서 2.3.3 절의 서술을 정리한 것이며, 코드로 측정한 값이 아닙니다. unionSet 은 원서가 연습문제로 남겨 두었으므로 표에서 제외했습니다.

시리즈