Files
coollang/docs/friction.md
T
coolguyandClaude Opus 5 6c0d08b6b0 std: F5 해결(List.enumerate), F4·F8은 넣지 않는 것으로 닫음
F5 — "튜플이 없어서 enumerate가 불가능하다"고 적었는데 틀렸다. 제네릭
struct 하나면 된다. fold_indexed보다 이쪽이 낫다: enumerate 하나가 기존
each/map/filter/fold 전부와 조합되고, fold_indexed를 만들면 map_indexed,
each_indexed가 따라와 "한 개념 한 방식"을 깬다.
samples/app에서 Numbered struct가 사라졌다 (244줄 → 232줄).

F2에 이어 두 번째로 관찰자가 틀린 사례다. 마찰 8건 중 2건이 "언어가 못
한다"고 적었다가 확인해 보니 되는 것이었다.

F4 — 넣지 않는다. 전체 나열이 귀찮은 것은 맞지만 그 귀찮음이 값을 한다.
필드를 추가하면 모든 생성 지점이 컴파일 오류를 내고, 컴파일러가 전부
방문하도록 강제한다. ..base는 그것을 없앤다. "오류를 더 빨리 잡는다"가
1번 목표인데 F4는 정확히 그것을 깎는 거래다.

F8 — 넣지 않는다. 진짜 질문은 "미사용 지역 변수를 잡을 것인가"가 아니라
"경고 등급을 만들 것인가"였다. 오류로 넣으면 성가시고, 경고로 넣으면 경고
등급의 첫 입주자가 된다. 경고가 없다는 것은 이 언어의 좋은 성질이고 죽은
지역 변수 하나 때문에 팔 것이 아니다.

둘 다 "보류"가 아니라 "닫음"으로 적는다. 근거를 적어두지 않으면 다음에
같은 논의를 처음부터 다시 한다. 다시 열 조건도 함께 적었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ZVDeU6KLuUVL3gs18Hm3E
2026-08-30 16:30:56 +09:00

384 lines
16 KiB
Markdown

# 개밥 먹기 보고 — samples/app을 쓰면서 걸린 것들
2026-08-30. coollang으로 처음 쓴 "일하는 프로그램" 하나(설정 파서 + 리포트
도구, 2모듈 292줄)에서 실제로 걸린 마찰을 적는다.
v0의 샘플 16개는 전부 검사기를 시험하려고 쓴 것이고, 그래서 "언어가 쓸
만한가"에 대해서는 아무것도 말해주지 않았다. 이 문서가 v0가 남기는 마지막
데이터이자 v1 설계의 첫 입력이다.
기록 원칙: **불편은 증거와 함께 적고, 해법은 제안까지만 한다.** 여기서
바로 고치면 그것은 개밥 먹기가 아니라 기능 추가가 된다.
---
## 현황 한눈에
| | 항목 | 상태 |
|---|---|---|
| F1 | 리스트의 n번째를 꺼낼 방법이 없다 | **해결**`List.first`, `List.nth` |
| F2 | ~~`else if`가 없다~~ | **취소** — 관찰자가 틀렸다 |
| F3 | `String.concat`이 2항이라 중첩 지옥 | **해결**`String.join` |
| F4 | struct의 한 필드만 바꿀 방법이 없다 | **닫음 — 넣지 않는다** |
| F5 | fold에 인덱스가 없다 | **해결**`List.enumerate` |
| F6 | Option/Result에 조작 함수가 없다 | **해결**`std/option`, `std/result` |
| F7 | std와 인터프리터가 어긋날 수 있다 | **해결** — 양방향 테스트 |
| F8 | 미사용 지역 변수·파라미터를 안 잡는다 | **닫음 — 넣지 않는다** |
여덟 항목이 전부 처리됐다. 다섯은 해결, 하나는 취소(관찰자 오류), 둘은
"넣지 않는다"로 닫았다.
**닫은 둘을 "보류"가 아니라 "닫음"으로 적는 이유**: 근거까지 적어두지
않으면 다음에 같은 논의를 처음부터 다시 하게 된다. 마음이 바뀔 조건도
같이 적었다 — 그 조건이 오면 다시 연다.
## 잘 된 것부터
**1. 시그니처가 프로그램의 전부를 말한다.**
```cool
pub fn main(c: Console, f: File, a: Args)
effects {Console.print, File.read, Args.all}
```
이 한 줄을 읽으면 이 프로그램이 할 수 있는 일이 끝난다. 네트워크를 쓸 수
없고, 다른 파일을 쓸 수 없고, 프로세스를 띄울 수 없다 — 문서가 아니라
컴파일러가 보장한다. 300줄을 쓰는 내내 이것이 어색하지 않았다. 오히려
`f.read`를 쓰려고 `File`을 인자에 추가하는 순간이 "이 함수가 권한을 하나
더 갖는다"는 사실을 자각하게 만들었다.
**2. `?`가 기대대로 동작했다.**
```cool
let path = first_arg(a.all())?
let text = f.read(path)?
```
읽기 좋고, 실패 경로가 보이고, 삼켜지지 않는다.
**3. 소진성이 실전에서 값을 했다.**
`Value`에 경우 하나(`List`)를 추가해 봤다:
```
config.cool:54:5: 빠진 경우: List(_)
config.cool:62:5: 빠진 경우: List(_)
config.cool:196:5: 빠진 경우: List(_)
config.cool:204:5: 빠진 경우: List(_)
```
고쳐야 할 자리 넷을 전부, 정확히 짚었다. 이것이 없으면 `show_value`
고치고 `get_int`를 잊는다.
**4. 모듈 경계가 자연스러웠다.** 파싱(`config`)과 출력(`main`)을 나누는 데
마찰이 없었고, `Cfg.Config` 같은 한정 이름이 오히려 읽기 좋았다.
---
## 걸린 것 — 심각한 순서로
### F1. 리스트의 n번째를 꺼낼 방법이 없다 (심각)
인덱싱 연산자를 뺀 결정 자체는 옳다고 본다. 그런데 `std`에 대안이 없다.
"첫 원소"를 꺼내려고 이 코드를 네 번 썼다:
```cool
pub fn first_text(xs: List[String]) -> Option[String] {
List.fold(xs, None, fn(acc, x) {
match acc {
Some(prev) => Some(prev),
None => Some(x),
}
})
}
```
`String.split(line, "=")`의 결과에서 앞의 둘을 꺼내는 데는 보조 struct
`Pick`까지 만들어야 했다 — 순전히 "몇 번째를 보고 있는가"를 나르려고.
**304줄 중 약 40줄이 이 문제 하나 때문에 존재한다.**
> 제안: `List.first`, `List.nth(xs, i) -> Option[a]`, 그리고 `String.split`
> 같은 자리에서 흔한 `List.split_first(xs) -> Option[(a, List[a])]`.
> 튜플이 없으므로 마지막 것은 문법 결정을 동반한다.
### F2. ~~`else if`가 없다~~ — 취소. 관찰자가 틀렸다
처음 이 문서를 쓸 때 "`else if`가 없어서 3~4단 중첩이 된다"고 적었다.
**틀렸다.** `else if`는 처음부터 된다 (`lib/parser.ml:480`이 명시적으로
`else` 뒤의 `if`를 처리한다).
그런데도 `config.cool`을 중첩 `if`로 썼다. 언어가 강제한 것이 아니라
내가 확인하지 않고 습관대로 쓴 것이고, 그다음 언어를 탓했다.
고쳐 쓰니 208줄이 196줄이 됐고 `parse_line`은 4단에서 평평한 5갈래가
됐다:
```cool
if String.is_empty(line) {
cfg
} else if String.starts_with(line, "#") {
cfg
} else if List.len(parts) != 2 {
add_problem(cfg, no, ...)
} else if String.is_empty(String.trim(head_or(parts, ""))) {
add_problem(cfg, no, "이름이 비어 있습니다")
} else {
add_entry(cfg, ...)
}
```
**개밥 먹기 자체에 대한 교훈이다.** 한 사람이 쓴 300줄에서 나온 불편은
언어의 성질일 수도 있고 그 사람의 습관일 수도 있다. 둘을 나누려면 불편을
적을 때마다 "언어가 정말 막는가"를 확인해야 한다. 확인 없이 적은 것 하나가
"심각" 등급을 달고 v1 설계 입력이 될 뻔했다.
나머지 항목들은 확인했다 — F1은 `List.first`/`nth`가 std에 실제로 없고,
F3은 `String.join`이 실제로 없다.
### F3. `String.concat`이 2항이라 중첩 지옥이 된다 (심각)
리포트 한 줄이 이렇게 생겼다:
```cool
c.print(String.concat(" ", String.concat(e.key,
String.concat(" = ", String.concat(Cfg.show_value(e.value),
String.concat(" (", String.concat(Cfg.type_name(e.value), ")")))))))
```
읽을 수 없다. 이 프로그램에서 가장 나쁜 코드이고, 원인은 명확하다.
> 제안 두 가지. (a) `String.join(sep, List[String])` — 작고 안전하다.
> (b) 문자열 보간 `"${key} = ${value}"` — 훨씬 낫지만 문법과 타입 규칙을
> 정해야 하고, "한 개념 한 방식"에서 concat과 겹친다.
> 지금 판단으로는 (a)를 먼저 넣고 (b)는 체감 데이터를 더 모은 뒤.
### F4. struct의 한 필드만 바꿀 방법이 없다 — 닫음. 넣지 않는다
```cool
pub fn add_entry(cfg: Config, e: Entry) -> Config {
Config {
entries: List.push(cfg.entries, e),
problems: cfg.problems, // ← 안 바뀌는데 적어야 한다
}
}
```
필드가 둘이라 견딜 만하지만 다섯이면 못 쓴다. `take_at`은 세 필드를 매번
전부 나열한다.
> 처음 제안: `Config { ..cfg, entries: x }`. affine 타입에서는 `cfg`가
> move되는 것이므로 소유권 규칙과 충돌하지 않는다고 적었다.
**결론: 넣지 않는다.** 처음 적을 때 놓친 것이 있다.
전체 나열이 귀찮은 것은 맞다. 그런데 **그 귀찮음이 값을 한다.** `Config`
필드를 하나 추가하면 지금은 모든 생성 지점이 "필드가 빠졌다"고 컴파일
오류를 낸다. 컴파일러가 전부 방문하도록 강제한다.
`..cfg`를 넣으면 그것이 사라진다. 새 필드가 조용히 base의 값을 이어받고,
정말로 손봐야 했던 자리를 지나친다.
**"오류를 더 빨리 잡는다"가 1번 목표인데 F4는 정확히 그것을 깎는 거래다.**
브레비티를 얻고 강제 방문을 잃는다. 그 거래가 옳다는 근거가 지금 없다.
소유권 규칙과 충돌하지 않는다는 처음의 관찰은 맞지만, 충돌하지 않는 것과
넣을 값이 있는 것은 다른 문제다.
> 다시 열 조건: 다음 개밥 먹기에서 필드 5개 이상인 struct의 갱신 함수가
> 여러 개 나오고, **매번의 전체 나열이 실제로 아무것도 잡지 못했다면**
> 그때 넣는다. 반대로 한 번이라도 "필드가 빠졌다"가 진짜 버그를 잡았다면
> 이 항목은 영구히 닫힌다.
### F5. fold에 인덱스가 없다 — 해결 (`List.enumerate`)
줄 번호를 세려고 struct를 하나 더 만들었다:
```cool
pub copyable struct Numbered {
no: Int,
cfg: Config,
}
```
"인덱스가 필요한 fold"는 드문 요구가 아니다.
> 처음 제안: `List.fold_indexed`. `List.enumerate`가 더 조합적이지만
> 튜플이 없어서 불가능하다고 적었다.
**"튜플이 없어서 불가능하다"가 틀렸다.** 제네릭 struct 하나면 된다:
```cool
pub copyable struct Indexed[a] {
i: Int,
value: a,
}
pub fn enumerate[a](xs: List[a]) -> List[Indexed[a]]
```
`fold_indexed`보다 이쪽이 낫다. `enumerate` 하나가 기존 `each`/`map`/
`filter`/`fold` 전부와 조합된다. `fold_indexed`를 만들면 `map_indexed`,
`each_indexed`가 따라오고 그것이 "한 개념 한 방식"을 깨는 방향이다.
`samples/app`에서 `Numbered` struct가 사라졌다 (244줄 → 232줄):
```cool
// 전 — 줄 번호를 나르려고 struct를 하나 더 만들었다
let start = Numbered { no: 1, cfg: Config { entries: [], problems: [] } }
List.fold(lines, start, fn(acc, line) {
Numbered { no: acc.no + 1, cfg: parse_line(acc.cfg, acc.no, line) }
}).cfg
// 후
let lines = List.enumerate(String.split(text, "\n"))
List.fold(lines, empty, fn(cfg, l) { parse_line(cfg, l.i + 1, l.value) })
```
이것이 **관찰자가 틀린 두 번째 사례**다(F2에 이어). 둘 다 "언어가 못
한다"고 적었는데 확인해 보니 됐다. 마찰을 적을 때 "정말 막히는가"를
확인하는 절차가 없으면 이런 것이 v1 설계 입력으로 들어간다.
### F6. Option/Result에 조작 함수가 하나도 없다 (중간)
`map`, `unwrap_or`, `or_else`가 없어서 전부 `match`로 풀었다. `match`
나쁜 것은 아니지만, 세 줄이면 될 것이 여섯 줄이 된다.
> 제안: `Option.map/unwrap_or`, `Result.map/map_err/unwrap_or`.
> 타입 이름이 함수의 이름공간이라는 규칙이 이미 있으므로 문법 결정은 없다.
### F7. std를 늘릴 때마다 인터프리터를 고쳐야 한다 (구조)
`std/list.cool``filter`를 적으면 `lib/interp.ml`에도 구현을 넣어야
한다. 두 곳이 어긋나면 검사는 통과하고 실행이 죽는다.
이것은 v0의 구조적 한계이고 v1에서 사라진다(std를 coollang으로 구현).
다만 v0 동안은 **std 시그니처와 런타임 구현이 일치하는지 검사하는 테스트**가
있어야 한다. 지금은 없다.
---
### F8. 미사용 지역 변수와 파라미터를 안 잡는다 — 닫음. 넣지 않는다
투어용 예제를 쓰다 발견했다. 이 코드가 아무 말 없이 통과한다:
```cool
pub fn f(used: Int, never_used: Int) -> Int {
let alive = used + 1
let dead = 999
alive
}
```
**미사용 import는 오류로 막아놓고 미사용 지역 변수는 통과시킨다.** 둘 다
"쓰지 않는 선언"인데 한쪽만 잡으니 규칙이 고르지 않아 보인다.
다만 근거의 성격은 다르다. import를 막은 이유는 재검사 범위를 넓히기
때문이었고 — 그건 이 아키텍처의 실제 비용이다 — 미사용 지역 변수에는
그런 비용이 없다. 그냥 죽은 코드다.
그래서 이것은 "고치면 되는 항목"이 아니라 **판단이 필요한 항목**이다.
철학 1(오류를 더 빨리 잡는다)에는 부합하지만, 디버깅 중에 한 줄 주석
처리했다고 컴파일이 막히는 것은 실제로 성가시다. 그 성가심이 잡아주는
버그보다 큰지는 지금 데이터로 알 수 없다.
**결론: 넣지 않는다.** 그리고 처음에 질문을 잘못 잡았다.
진짜 질문은 "미사용 지역 변수를 잡을 것인가"가 아니라 **"coollang에 경고
등급을 만들 것인가"**다.
지금 이 언어에는 경고가 없다. 모든 진단이 오류이고 빌드를 멈춘다. 미사용
지역 변수를 그 등급에 넣으면 디버깅 중에 한 줄 주석 처리했다고 컴파일이
막힌다 — 실제로 성가시다. 그래서 자연스럽게 "경고로 만들자"가 나오는데,
그것이 **경고 등급의 첫 입주자**가 된다. 경고 등급은 한 번 생기면 자란다.
무시되는 진단이 쌓이는 언어가 된다.
**경고가 없다는 것은 지금 이 언어의 좋은 성질이고, 죽은 지역 변수 하나
때문에 팔 것이 아니다.**
미사용 import와의 불일치는 감수한다. 근거가 다르다 — import는 재검사
범위라는 이 아키텍처의 실제 비용을 만들고, 죽은 지역 변수는 아무 비용도
만들지 않는다. 규칙이 고르지 않아 보이는 것과 근거가 없는 것은 다르다.
> 다시 열 조건: 경고 등급을 다른 이유로 만들게 되는 날. 그때는 이 항목이
> 첫 입주자가 아니라 두 번째가 되므로 비용 계산이 달라진다.
---
## 후속 (같은 날)
F1, F3, F6을 std 보강으로 처리하고 `samples/app`을 다시 썼다. 문법은
건드리지 않았다 — 표본이 한 사람이 쓴 300줄 하나뿐인데 되돌리기 비싼 축을
움직일 수는 없다.
추가한 것: `List.first`, `List.nth`, `String.join`, 그리고 `std/option.cool`,
`std/result.cool` (`map`, `unwrap_or`, `ok_or`, `map_err`, `is_ok`).
결과:
| | 전 | 후 |
|---|---|---|
| config.cool | 196줄 | **148줄** |
| main.cool | 96줄 | 96줄 |
| 합계 | 292줄 | **244줄** |
출력은 한 글자도 다르지 않다.
**F1의 값이 확인됐다.** 48줄이 사라졌고 전부 config.cool에서 나왔다 —
`head_or`, `second_or`, `Pick`, `take_at`, `first_text`, `first_entry`
통째로 없어졌다. 예측(40줄쯤)과 실제(48줄)가 맞았다.
```cool
// 전
let key = String.trim(head_or(parts, "")) // + Pick struct + take_at 20줄
// 후
let key = String.trim(Option.unwrap_or(List.nth(parts, 0), ""))
```
**F3은 줄 수로는 값이 안 보인다.** main.cool이 96줄 그대로다. 4단 중첩
`String.concat`을 4줄짜리 `String.join` 배열로 바꿨으니 줄 수가 같다.
그런데 읽기는 확실히 낫다:
```cool
// 전
c.print(String.concat(" ", String.concat(e.key,
String.concat(" = ", String.concat(Cfg.show_value(e.value),
String.concat(" (", String.concat(Cfg.type_name(e.value), ")")))))))
// 후
c.print(String.join("", [
" ", e.key, " = ", Cfg.show_value(e.value),
" (", Cfg.type_name(e.value), ")",
]))
```
**줄 수는 읽기 좋음의 대리 지표일 뿐이고, F3에서 그 대리가 깨진다.**
다음 개밥 먹기에서는 줄 수 말고 다른 것을 재야 한다.
**F7도 처리했다.** `Interp.implemented` 목록과 `std/*.cool`의 선언이
서로를 덮는지 테스트가 양방향으로 검사한다. 어긋나면 빌드가 깨진다.
남은 것 없음. F5는 `List.enumerate`로 해결했고(244줄 → 232줄), F4와 F8은
"넣지 않는다"로 닫았다. 둘 다 다시 열 조건을 함께 적었다.
---
## 결론
(아래는 처음 쓸 때의 결론이다. 후속에서 F1·F3·F5·F6·F7이 해결되고 F4·F8이
닫혔으므로 지금은 역사적 기록이다.)
v1로 넘길 때 **F1과 F3이 먼저다.** 둘 다 "언어가 틀렸다"가 아니라
"std에 없어서 우회했다"이고, 우회 비용이 코드에 그대로 보인다 — 292줄 중
40줄쯤이 F1 하나 때문에 존재한다.
F2는 취소됐다. 그리고 그것이 이 문서에서 두 번째로 중요한 발견이다:
확인 없이 적은 불편 하나가 "심각" 등급을 달고 v1 설계 입력이 될 뻔했다.
반대로 **되돌리기 비싼 결정들은 하나도 후회되지 않았다.** capability를
인자로 나르는 것, effect를 시그니처에 적는 것, 실패를 버릴 수 없는 것,
소진적 match — 300줄을 쓰는 동안 이 넷이 거추장스러웠던 순간이 없었고,
소진성은 오히려 실수를 잡아줬다.
v0의 질문은 "되돌리기 비싼 결정이 옳은가"였다. 답은 **그렇다**이고,
남은 불편은 전부 되돌리기 싼 것들이다.