Lowent 매뉴얼←↑→

10 묶음 — struct 와 enum

먼저 알아야 할 것

7장 흐름 · match 는 모든 경우를 덮어야 한다
9장 줄 · 슬라이스는 길이를 함께 갖는다

돌아보기

7장에서 match 가 if 사슬보다 나은 점으로, 망라 검사 말고 무엇을 들었는가?

답. 나중에 경우가 늘어날 때를 들었다. 열거형에 변형을 하나 더하면 그 열거형을 가르는 모든 match 가 번역에서 멈춰 고칠 자리를 알려 주지만, if 사슬은 새 경우를 조용히 마지막 else 로 흘려보낸다. 이 장에서 그 열거형을 직접 만든다.

이 장의 필요성과 맥락

수와 슬라이스만으로는 “점 하나”, “선분 하나”, “원이거나 사각형이거나 점” 같은 모양을 적을 수 없다. 이름 붙은 칸의 모음(struct)과 여럿 중 하나(enum)가 있어야 데이터가 문제의 모양을 닮는다. 특히 값을 지닌 enum 과 match 의 짝은 Lowent 에서 상속을 대신하는 주된 도구이고, 뒤에 나오는 option 과 result(11장), 트레이트(23장)가 모두 이 위에 선다.

이 장이 끝나면

struct 를 선언하고 make 로 만들고 field 로 읽는 법을 익힌다. 붙임 점이 없는 이유를 알게 된다. enum 의 갈래가 값을 지닐 수 있다는 것, match 로 갈래를 가르며 값을 이름에 묶는 법, 갈래를 마침표로 닫아야 하는 이유를 보게 된다. 자기 자신을 품는 타입이 거절되는 까닭과, 나무 구조를 번호로 잇는 법도 알게 된다.

이 장에서 답할 질문

  1. 칸 이름으로 to 나 in 을 쓸 수 있는가?
  2. 번호로 잇는 것은 포인터로 잇는 것보다 불편하지 않은가?

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

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

갈래가 몇 개든 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 의 열거형처럼 이름 붙은 정수다

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 로 모든 갈래를 덮어 가른다. 갈래는 마침표로 닫는다. 자기 자신을 값으로 품는 타입은 크기가 무한하므로 거절되고, 나무 구조는 번호로 잇는다.