10 묶음 — struct 와 enum
먼저 알아야 할 것
match 는 모든 경우를 덮어야 한다돌아보기
7장에서 match 가 if 사슬보다 나은 점으로, 망라 검사 말고 무엇을 들었는가?
답. 나중에 경우가 늘어날 때를 들었다. 열거형에 변형을 하나 더하면 그 열거형을 가르는 모든 match 가 번역에서 멈춰 고칠 자리를 알려 주지만, if 사슬은 새 경우를 조용히 마지막 else 로 흘려보낸다. 이 장에서 그 열거형을 직접 만든다.
이 장의 필요성과 맥락
struct)과 여럿 중 하나(enum)가 있어야 데이터가 문제의 모양을 닮는다. 특히 값을 지닌 enum 과 match 의 짝은 Lowent 에서 상속을 대신하는 주된 도구이고, 뒤에 나오는 option 과 result(11장), 트레이트(23장)가 모두 이 위에 선다.이 장이 끝나면
struct 를 선언하고 make 로 만들고 field 로 읽는 법을 익힌다. 붙임 점이 없는 이유를 알게 된다. enum 의 갈래가 값을 지닐 수 있다는 것, match 로 갈래를 가르며 값을 이름에 묶는 법, 갈래를 마침표로 닫아야 하는 이유를 보게 된다. 자기 자신을 품는 타입이 거절되는 까닭과, 나무 구조를 번호로 잇는 법도 알게 된다.이 장에서 답할 질문
- 칸 이름으로
to나in을 쓸 수 있는가? - 번호로 잇는 것은 포인터로 잇는 것보다 불편하지 않은가?
10.1 struct — 이름 붙은 칸의 모음#
struct 는 이름 붙은 칸들의 모음이고, 칸마다 자기 타입이 있다. 선언의 몸은 do … end 이고 칸마다 마침표로 닫는다.
examples/ch10/points.low
module points .
rem run: demo_dist 3 4
rem run: demo_move 5
struct point do
x i64 .
y i64 .
end
struct segment do
start point .
stop point .
end
fn manhattan input s segment . output i64 .
do
let dx i64 be sub (field s stop x) (field s start x) .
let dy i64 be sub (field s stop y) (field s start y) .
return add (abs dx) (abs dy) .
end
fn demo_dist input dx i64 . input dy i64 . output i64 .
do
let a point be make point do x 1 . y 1 . end .
let b point be make point do x (add 1 dx) . y (add 1 dy) . end .
return manhattan (make segment do start a . stop b . end) .
end
fn moved input p point . input k i64 . output point .
do
return make point do x (add (field p x) k) . y (field p y) . end .
end
fn demo_move input k i64 . output i64 .
do
let p point be make point do x 10 . y 0 . end .
let q point be moved p k .
return field q x .
end
실행 결과
$ lowentc --run demo_dist points.low 3 4
demo_dist(3, 4) = 7
$ lowentc --run demo_move points.low 5
demo_move(5) = 15
make point do x 1 . y 1 . end가 값을 만든다. 만들 때 모든 칸을 채워야 한다. 채우지 않은 칸이 조용히 0 이 되는 일은 없다.field s stop x는s의stop칸, 그 안의x칸을 읽는다. 마디를 여럿 적으면 왼쪽부터 한 칸씩 내려간다.- 칸의 타입이 다른
struct여도 된다.segment는point둘을 품는다. moved는 칸 하나를 바꾼 새 값을 만들어 돌려준다. 원래 값은 그대로다.
p.x 같은 붙임 점은 없다. 점은 이미 모듈 이름(allocs.byte_allocator)과 갈래 이름(shape.dot)에 쓰인다. 값의 안을 보는 일까지 점으로 적으면, a.b 가 무엇인지는 a 가 값인지 모듈인지를 찾아본 뒤에야 정해진다. field 폼은 읽는 순간에 정해진다.
문. 칸 이름으로 to 나 in 을 쓸 수 있는가?
답. 쓸 수 없다. to 와 in 은 옛 문법에서 칸을 읽는 중위 표기(a to b)였고 지금은 없앤 낱말이다. 낱말과 같은 철자는 이름이 될 수 없어서 E-VOCAB-REMOVED 로 거절된다. 이 예제가 from·to 대신 start·stop 을 쓴 이유다.
field 는 읽는 자리이면서 쓰는 자리이기도 하다. mut 로 받은 값의 칸은 set (field p x) 3 . 으로 바꾼다. 읽기와 쓰기가 같은 철자다. 값을 빌려 바꾸는 규칙은 12장에서 다룬다.
10.2 enum — 여럿 중 하나#
enum 은 여러 갈래 가운데 하나다. 갈래는 값을 지닐 수 있고, 지니는 값은 <칸 이름> <타입> 짝으로 적는다.
examples/ch10/shapes.low
module shapes .
rem run: demo_area 0
rem run: demo_area 1
rem run: demo_area 2
enum shape do
circle r u32 .
rect w u32 h u32 .
dot .
end
fn area input s shape . output u32 .
do
match s do
case circle r . do return mul 3 (mul r r) . end
case rect w h . do return mul w h . end
case dot . do return 0 . end
end
end
fn demo_area input k u8 . output u32 .
do
if eq k 0 . do return area (shape.circle 2) . end
if eq k 1 . do return area (shape.rect 3 5) . end
return area shape.dot .
end
실행 결과
$ lowentc --run demo_area shapes.low 0
demo_area(0) = 12
$ lowentc --run demo_area shapes.low 1
demo_area(1) = 15
$ lowentc --run demo_area shapes.low 2
demo_area(2) = 0
circle r u32 .는r이라는 값 하나를,rect w u32 h u32 .는 둘을 지닌다.dot .은 아무것도 지니지 않는다.- 값을 만들 때는
shape.circle 2처럼 갈래 이름으로 적는다. match의case rect w h .는 갈래가rect이면 지닌 두 값을w와h에 묶는다.
갈래가 몇 개든 match 는 모두 덮어야 한다. dot 을 빠뜨리면 거절된다.
examples/ch10/missing_case.low
module missing_case .
rem expect: E-MATCH-INEXHAUSTIVE
enum shape do
circle r u32 .
rect w u32 h u32 .
dot .
end
fn corners input s shape . output u32 .
do
match s do
case circle r . do return 0 . end
case rect w h . do return 4 . end
end
end
실행 결과
$ lowentc --check missing_case.low
missing_case.low:12:0 E-MATCH-INEXHAUSTIVE: this `match` does not handle every variant of the enum — add the missing `case` (that exhaustiveness is what a `match` buys you over an if-chain: add a variant later and the compiler finds every place that must change), or add a `case _ .` wildcard to cover the rest
이 검사가 enum 의 가장 큰 값이다. 나중에 triangle 갈래를 더하면, area 와 corners 를 비롯해 shape 를 가르는 모든 match 가 번역에서 멈춘다. 고쳐야 할 자리를 사람이 기억할 필요가 없다.
match 를 쓰지 않고 갈래만 물을 때는 isa n lit 이 bool 을 준다.
10.3 갈래는 마침표로 닫는다#
개행은 닫개가 아니다(3장). 그래서 갈래를 줄마다 적고 마침표를 빠뜨리면 여러 갈래가 한 갈래로 이어진다.
examples/ch10/enum_dot.low
module enum_dot .
rem expect: E-ENUM-DOT
enum color do
red
green
blue .
end
실행 결과
$ lowentc --check enum_dot.low
enum_dot.low:6:0 E-ENUM-DOT: this enum variant is not closed with `.`, so the NEXT line was read as part of it — a newline does not close a form (RFC-0103). Close every variant: `red .` · `green .`. Without the dot `red green blue` is ONE variant `red` whose payload field `green` has a type `blue`
red green blue 가 한 갈래의 이름과 칸 짝으로 읽힐 뻔했다. 컴파일러가 거절하고, 갈래마다 red . 처럼 닫으라고 알려 준다.
흔한 오해. enum 은 C 의 열거형처럼 이름 붙은 정수다
enum 은 정수 상수에 이름을 붙인 것이고, 정수와 섞여 쓰인다. Lowent 의 enum 은 타입이다. 정수와 섞이지 않고, 갈래마다 다른 모양의 값을 지닐 수 있으며, 가를 때 모든 갈래를 덮어야 한다. Rust 의 enum 이나 ML 계열의 대수적 자료형에 가깝다.10.4 자기 자신을 품을 수 없다#
갈래가 자기 타입을 값으로 품으면 크기가 무한해진다.
examples/ch10/infinite.low
module infinite .
rem expect: E-ENUM-INFINITE
enum expr_tree do
lit v u32 .
add l expr_tree r expr_tree .
end
실행 결과
$ lowentc --check infinite.low
6:0 E-ENUM-INFINITE: an enum variant cannot embed its OWN type BY VALUE — the size would be infinite. Recursion must go through indirection: an index into a node arena (e.g. `u32`) or an `owned` box (RFC-0080 §4.2)
6:0 E-ENUM-INFINITE: an enum variant cannot embed its OWN type BY VALUE — the size would be infinite. Recursion must go through indirection: an index into a node arena (e.g. `u32`) or an `owned` box (RFC-0080 §4.2)
struct 도 같다. 직접이든 다른 타입을 거쳐서든 자기를 칸으로 품으면 E-STRUCT-CYCLE 로 거절된다. 나무 같은 구조가 필요하면 진단의 제안대로 번호로 잇는다. 노드들을 슬라이스에 담고, 자식은 그 슬라이스의 번호로 가리킨다.
examples/ch10/tree.low
module tree .
enum node do
lit v u32 .
add l u32 r u32 .
mul l u32 r u32 .
end
fn eval input nodes slice node . input i u32 . output u32 .
do
let n node be index nodes i .
match n do
case lit v . do return v . end
case add l r . do return add (eval nodes l) (eval nodes r) . end
case mul l r . do return mul (eval nodes l) (eval nodes r) . end
end
end
fn is_leaf input n node . output bool .
do
return isa n lit .
end
실행 결과
$ lowentc --check tree.low
== check: ok ==
add l u32 r u32 . 의 l·r 은 노드 자체가 아니라 nodes 안의 번호다. eval 은 번호로 자식을 찾아 재귀한다. 크기가 정해지고, 번호를 따라가는 index 는 경계 검사를 그대로 받는다. 이런 노드 슬라이스를 만드는 저장소 — 영역 — 는 18장에서 다룬다.
문. 번호로 잇는 것은 포인터로 잇는 것보다 불편하지 않은가?
답. 한 가지는 불편하다. 노드를 지우고 그 번호를 다시 쓰면, 옛 번호를 들고 있던 쪽이 엉뚱한 노드를 본다. 표준 라이브러리의 pool 은 번호에 세대 수를 함께 담는 세대 핸들로 이 문제를 실행 중에 잡는다 (34장). 대신 얻는 것이 크다. 번호는 복사해도 안전하고, 파일에 그대로 쓸 수 있으며, 노드들이 한 슬라이스에 붙어 있어 캐시에 잘 맞는다.
10.5 흔한 실수#
반례. make 에서 칸 하나를 빠뜨린다
examples/ch10/mistake_missfield.low
module mistake_missfield .
rem expect: E-TYPE-FIELD
struct point do
x u64 .
y u64 .
end
fn origin_x output u64 .
do
rem ✘ `y` 를 채우지 않았다 --- `make` 는 모든 칸을 채워야 한다
let p point be make point do x 1 . end .
return field p x .
end
실행 결과
$ lowentc --check mistake_missfield.low
mistake_missfield.low:12:0 E-TYPE-FIELD: a declared field is missing from this `make` (every field must be given)
C 의 구조체 초기화는 빠진 칸을 0 으로 채우고, 어떤 언어는 기본값을 넣는다. Lowent 는 E-TYPE-FIELD 로 거절한다. 빠진 칸이 의도였는지 실수였는지 소스에서 알 수 없기 때문이다. 0 이 필요하면 y 0 . 이라고 적는다 — 적힌 0 은 누가 봐도 의도다. 칸 이름을 잘못 적어도 같은 코드가 “그런 칸이 없다” 로 나온다.
반례. 값을 지니지 않은 갈래를 타입.갈래 로 넘긴다
examples/ch10/mistake_unitvariant.low
module mistake_unitvariant .
rem run: pick_bare
rem run: pick_qualified
enum light do
red .
green .
end
fn code input c light . output u64 .
do
match c do
case red . return 1 .
case green . return 2 .
end
end
fn pick_bare output u64 .
do
rem 값을 지니지 않은 갈래는 이름만 적는다
return code green .
end
fn pick_qualified output u64 .
do
rem ✘ 이 판에서는 `light.green` 으로 넘기면 실행 중에 멈춘다
return code light.green .
end
실행 결과
$ lowentc --run pick_bare mistake_unitvariant.low
pick_bare() = 2
$ lowentc --run pick_qualified mistake_unitvariant.low
pick_qualified() = 2
값을 지니는 갈래는 shape.circle 2 처럼 타입 이름을 붙여 만들고, 아무것도 지니지 않는 갈래는 green 처럼 이름만 적는 것이 이 책의 버릇이다. 둘 다 같은 값이다 — pick_bare 와 pick_qualified 가 같은 2 를 낸다. 2026-09-16 까지 이 판의 도구는 값 없는 갈래를 light.green 으로 적으면 다른 표현(태그를 가진 레코드)으로 낮췄고, 그래서 실행 중에 E-VM-TYPE 으로 멈췄다. 지금은 둘이 같은 인덱스로 낮아진다. 짧은 쪽을 쓰되, 어느 열거형의 갈래인지 헷갈리는 자리에서는 타입 이름을 붙여도 된다.
반례. 구조체 둘을 eq 로 비교한다
examples/ch10/mistake_eqstruct.low
module mistake_eqstruct .
rem expect: E-TYPE-KIND
struct point do
x u64 .
y u64 .
end
fn same output bool .
do
let p point be make point do x 1 . y 2 . end .
let q point be make point do x 1 . y 2 . end .
rem ✘ 구조체 둘을 `eq` 로 비교했다 --- `eq` 는 수와 참거짓을 비교한다
return eq p q .
end
실행 결과
$ lowentc --check mistake_eqstruct.low
mistake_eqstruct.low:14:0 E-TYPE-KIND: `eq` / `ne` compare numbers and booleans, not STRUCTS. What "equal" means for a struct differs by type — every field, or only the identifying one? — so the language does not guess: write an op that says it (field by field). This used to pass `--check` and stop at run time with `E-VM-TYPE`
eq 는 수와 참거짓을 비교한다. 구조체 둘이 “같다” 는 것은 타입마다 뜻이 다르다 — 모든 칸이 같아야 하는지, 이름표 칸만 같으면 되는지. 그래서 E-TYPE-KIND 로 번역 때 거절한다. 같음의 뜻을 op 으로 적는다.
examples/ch10/eqstruct_fixed.low
module eqstruct_fixed .
rem run: same
struct point do
x u64 .
y u64 .
end
rem 무엇이 "같은" 것인지 칸마다 적는다 --- 그 뜻은 타입마다 다르다
fn point_eq input a point . input b point . output bool .
do
return expr ((field a x) eq (field b x)) and ((field a y) eq (field b y)) .
end
fn same output bool .
do
let p point be make point do x 1 . y 2 . end .
let q point be make point do x 1 . y 2 . end .
return point_eq p q .
end
실행 결과
$ lowentc --run same eqstruct_fixed.low
same() = 1
반례. 다른 이름에 담으면 복사본이 된다고 믿는다
examples/ch10/mistake_alias.low
module mistake_alias .
rem run: copy_then_change
struct point do
x u64 .
y u64 .
end
fn copy_then_change output u64 .
do
let p point be make point do x 1 . y 2 . end .
rem ✘ 복사본을 만든다고 믿었다 --- 이 판에서 q 는 p 와 같은 자리를 가리킨다
var q point be p .
set (field q x) 99 .
rem p 는 let 인데도 99 가 나온다
return field p x .
end
실행 결과
$ lowentc --run copy_then_change mistake_alias.low
copy_then_change() = 1
var q point be p . 는 p 의 칸을 베낀 새 값을 만든다. 그래서 q 의 칸을 99 로 바꾸어도 p 는 1 그대로다. 소유가 없는 값은 베껴지고, 소유가 있는 값은 옮겨진다(19장). 2026-09-16 까지 이 판의 도구는 구조체를 담을 때 같은 자리를 가리키는 별칭을 만들어 p 까지 바뀌었다 — let 의 약속이 깨지는 자리였고, 지금은 하강이 칸을 베낀다.
examples/ch10/alias_fixed.low
module alias_fixed .
rem run: copy_then_change
struct point do
x u64 .
y u64 .
end
fn copy_then_change output u64 .
do
let p point be make point do x 1 . y 2 . end .
rem 새 값이 필요하면 make 로 새로 만든다 --- 칸을 하나씩 옮겨 적는다
var q point be make point do x (field p x) . y (field p y) . end .
set (field q x) 99 .
return field p x .
end
실행 결과
$ lowentc --run copy_then_change alias_fixed.low
copy_then_change() = 1
이 장 첫 예제의 moved 가 칸을 바꾸지 않고 새 값을 만들어 돌려주는 모양인 까닭도 같다. 값을 고치기보다 새 값을 만드는 편이 별칭 문제를 아예 피한다.
10.6 이 장의 문법 한눈에#
| 모양 | 뜻 | 왜 이렇게 |
|---|---|---|
struct point do x u64 . y u64 . end | 이름 붙은 칸의 모음 | 칸마다 이름과 타입이 한 줄씩 |
make point do x 1 . y 2 . end | 값 만들기 — 모든 칸을 채운다 | 빠진 칸이 조용히 0 이 되지 않게 |
field p x · field s stop x | 칸 읽기 · 여러 마디 내려가기 | 붙임 점 없이 — 읽는 순간 뜻이 정해진다 |
set (field p x) 3 . | 칸 쓰기(mut 로 받은 값) | 읽기와 쓰기가 같은 철자 |
enum shape do dot . circle r u32 . end | 여럿 중 하나 — 갈래는 값을 지닐 수 있다 | 갈래마다 마침표로 닫는다 |
shape.circle 2 · dot | 값을 지닌 갈래 만들기 · 값이 없는 갈래 | 갈래 이름이 곧 만드는 op |
match s do case circle r . … end | 갈래를 가르고 지닌 값을 묶기 | 모든 갈래를 덮어야 한다 |
isa s circle | 그 갈래인가(bool) | 값을 꺼내지 않고 묻기만 할 때 |
번호로 잇는 나무(l u32 · r u32) | 자기 자신을 품는 대신 슬라이스의 번호 | 크기가 정해지고 경계 검사를 받는다 |
표 10.1 — 묶음의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나
복습 정리
struct 는 make 로 모든 칸을 채워 만들고 field 로 읽고 쓴다. 붙임 점은 없다. enum 의 갈래는 값을 지닐 수 있고, <타입>.<갈래> 로 만들며 match 로 모든 갈래를 덮어 가른다. 갈래는 마침표로 닫는다. 자기 자신을 값으로 품는 타입은 크기가 무한하므로 거절되고, 나무 구조는 번호로 잇는다.