friction: F2 취소 — else if는 원래 된다

"else if가 없어서 3~4단 중첩이 된다"고 적었는데 틀렸다. parser.ml:480이
처음부터 else 뒤의 if를 처리한다. 확인하지 않고 습관대로 중첩해 쓰고
언어를 탓한 것이다.

고쳐 쓰니 config.cool이 208줄에서 196줄이 되고 parse_line은 4단 중첩에서
평평한 5갈래가 됐다.

개밥 먹기 자체에 대한 교훈이라 문서에 남긴다. 한 사람이 쓴 300줄에서 나온
불편은 언어의 성질일 수도 있고 그 사람의 습관일 수도 있다. 확인 없이 적은
것 하나가 "심각" 등급을 달고 v1 설계 입력이 될 뻔했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ZVDeU6KLuUVL3gs18Hm3E
This commit is contained in:
2026-08-30 16:17:30 +09:00
co-authored by Claude Opus 5
parent 5831af7760
commit 7be05be77f
4 changed files with 57 additions and 53 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ m101.cool:21:3: match가 모든 경우를 덮지 않습니다 (빠진 경우: Up
## 실제로 써본 결과 ## 실제로 써본 결과
`samples/app`은 검사기를 시험하려고 쓴 것이 아니라 일을 하려고 쓴 `samples/app`은 검사기를 시험하려고 쓴 것이 아니라 일을 하려고 쓴
프로그램이다 — 설정 파서 + 리포트 도구, 2모듈 304줄. 프로그램이다 — 설정 파서 + 리포트 도구, 2모듈 292줄.
``` ```
$ coolc run samples/app/main.cool samples/app/example.conf $ coolc run samples/app/main.cool samples/app/example.conf
+32 -18
View File
@@ -1,7 +1,7 @@
# 개밥 먹기 보고 — samples/app을 쓰면서 걸린 것들 # 개밥 먹기 보고 — samples/app을 쓰면서 걸린 것들
2026-08-30. coollang으로 처음 쓴 "일하는 프로그램" 하나(설정 파서 + 리포트 2026-08-30. coollang으로 처음 쓴 "일하는 프로그램" 하나(설정 파서 + 리포트
도구, 2모듈 304줄)에서 실제로 걸린 마찰을 적는다. 도구, 2모듈 292줄)에서 실제로 걸린 마찰을 적는다.
v0의 샘플 16개는 전부 검사기를 시험하려고 쓴 것이고, 그래서 "언어가 쓸 v0의 샘플 16개는 전부 검사기를 시험하려고 쓴 것이고, 그래서 "언어가 쓸
만한가"에 대해서는 아무것도 말해주지 않았다. 이 문서가 v0가 남기는 마지막 만한가"에 대해서는 아무것도 말해주지 않았다. 이 문서가 v0가 남기는 마지막
@@ -81,28 +81,39 @@ pub fn first_text(xs: List[String]) -> Option[String] {
> 같은 자리에서 흔한 `List.split_first(xs) -> Option[(a, List[a])]`. > 같은 자리에서 흔한 `List.split_first(xs) -> Option[(a, List[a])]`.
> 튜플이 없으므로 마지막 것은 문법 결정을 동반한다. > 튜플이 없으므로 마지막 것은 문법 결정을 동반한다.
### F2. `else if`가 없다 (심각) ### F2. ~~`else if`가 없다~~ — 취소. 관찰자가 틀렸다
`if`가 식이고 `else`는 식 하나를 받으므로, 문법상 `else if`가 가능해야 처음 이 문서를 쓸 때 "`else if`가 없어서 3~4단 중첩이 된다"고 적었다.
하는데 파서가 받지 않는다. 그래서 이렇게 된다: **틀렸다.** `else if`는 처음부터 된다 (`lib/parser.ml:480`이 명시적으로
`else` 뒤의 `if`를 처리한다).
그런데도 `config.cool`을 중첩 `if`로 썼다. 언어가 강제한 것이 아니라
내가 확인하지 않고 습관대로 쓴 것이고, 그다음 언어를 탓했다.
고쳐 쓰니 208줄이 196줄이 됐고 `parse_line`은 4단에서 평평한 5갈래가
됐다:
```cool ```cool
if raw == "true" { if String.is_empty(line) {
Flag(true) 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 { } else {
if raw == "false" { add_entry(cfg, ...)
Flag(false)
} else {
match Int.parse(raw) { ... }
}
} }
``` ```
세 갈래 분기가 3단 중첩이 된다. `parse_line`은 4단까지 갔다. **개밥 먹기 자체에 대한 교훈이다.** 한 사람이 쓴 300줄에서 나온 불편은
**이것은 언어 결정이 아니라 파서의 빈틈으로 보인다.** 확인이 필요하다. 언어의 성질일 수도 있고 그 사람의 습관일 수도 있다. 둘을 나누려면 불편을
적을 때마다 "언어가 정말 막는가"를 확인해야 한다. 확인 없이 적은 것 하나가
"심각" 등급을 달고 v1 설계 입력이 될 뻔했다.
> 제안: `else` 뒤에 블록 대신 `if` 식을 허용한다. 새 개념이 아니라 나머지 항목들은 확인했다 — F1은 `List.first`/`nth`가 std에 실제로 없고,
> 이미 있는 규칙(else는 식을 받는다)의 적용이다. F3은 `String.join`이 실제로 없다.
### F3. `String.concat`이 2항이라 중첩 지옥이 된다 (심각) ### F3. `String.concat`이 2항이라 중첩 지옥이 된다 (심각)
@@ -175,9 +186,12 @@ pub copyable struct Numbered {
## 결론 ## 결론
v1로 넘길 때 **F1, F2, F3이 먼저다.** 다 "언어가 틀렸다"가 아니라 v1로 넘길 때 **F1 F3이 먼저다.** 다 "언어가 틀렸다"가 아니라
"없어서 우회했다"이고, 우회 비용이 코드에 그대로 보인다 — 304줄 중 "std에 없어서 우회했다"이고, 우회 비용이 코드에 그대로 보인다 — 292줄 중
70줄쯤이 이 셋 때문에 존재한다. 40줄쯤이 F1 하나 때문에 존재한다.
F2는 취소됐다. 그리고 그것이 이 문서에서 두 번째로 중요한 발견이다:
확인 없이 적은 불편 하나가 "심각" 등급을 달고 v1 설계 입력이 될 뻔했다.
반대로 **되돌리기 비싼 결정들은 하나도 후회되지 않았다.** capability를 반대로 **되돌리기 비싼 결정들은 하나도 후회되지 않았다.** capability를
인자로 나르는 것, effect를 시그니처에 적는 것, 실패를 버릴 수 없는 것, 인자로 나르는 것, effect를 시그니처에 적는 것, 실패를 버릴 수 없는 것,
+4 -2
View File
@@ -367,11 +367,13 @@ prelude는 없다. std도 명시적으로 가져온다 — 암묵적으로 끌
결과를 버릴 방법이 언어에 없다는 성질도 여기서 처음 확인됐다. 결과를 버릴 방법이 언어에 없다는 성질도 여기서 처음 확인됐다.
■ 개밥 먹기 (2026-08) ■ 개밥 먹기 (2026-08)
samples/app — 설정 파서 + 리포트 도구, 2모듈 304줄. 검사기를 시험하려고 samples/app — 설정 파서 + 리포트 도구, 2모듈 292줄. 검사기를 시험하려고
쓴 것이 아니라 일을 하려고 쓴 첫 프로그램이다. 쓴 것이 아니라 일을 하려고 쓴 첫 프로그램이다.
결과: 되돌리기 비싼 결정은 하나도 후회되지 않았고(capability 전달, effect 결과: 되돌리기 비싼 결정은 하나도 후회되지 않았고(capability 전달, effect
명시, 실패를 버릴 수 없음, 소진적 match), 불편은 전부 되돌리기 싼 것들이었다 명시, 실패를 버릴 수 없음, 소진적 match), 불편은 전부 되돌리기 싼 것들이었다
— 리스트 n번째 접근 없음, else if 없음, String.concat 2항. — 리스트 n번째 접근 없음, String.concat 2항. "else if가 없다"고 적은
항목 하나는 확인해 보니 관찰자가 틀린 것이었다 — 개밥 먹기의 불편은
언어의 성질일 수도, 쓴 사람의 습관일 수도 있다.
전문과 증거는 docs/friction.md. 이것이 v1 설계의 첫 입력이다. 전문과 증거는 docs/friction.md. 이것이 v1 설계의 첫 입력이다.
■ 측정 (2026-08, v0 fast path) ■ 측정 (2026-08, v0 fast path)
+9 -21
View File
@@ -37,8 +37,7 @@ pub copyable struct Config {
pub fn parse_value(raw: String) -> Value { pub fn parse_value(raw: String) -> Value {
if raw == "true" { if raw == "true" {
Flag(true) Flag(true)
} else { } else if raw == "false" {
if raw == "false" {
Flag(false) Flag(false)
} else { } else {
match Int.parse(raw) { match Int.parse(raw) {
@@ -47,7 +46,6 @@ pub fn parse_value(raw: String) -> Value {
} }
} }
} }
}
pub fn show_value(v: Value) -> String { pub fn show_value(v: Value) -> String {
match v { match v {
@@ -70,30 +68,22 @@ pub fn type_name(v: Value) -> String {
// 부르는 쪽이 두 겹을 벗겨야 한다. Config 하나에 둘 다 모으는 편이 낫다. // 부르는 쪽이 두 겹을 벗겨야 한다. Config 하나에 둘 다 모으는 편이 낫다.
pub fn parse_line(cfg: Config, no: Int, raw: String) -> Config { pub fn parse_line(cfg: Config, no: Int, raw: String) -> Config {
let line = String.trim(raw) let line = String.trim(raw)
let parts = String.split(line, "=")
if String.is_empty(line) { if String.is_empty(line) {
cfg cfg
} else { } else if String.starts_with(line, "#") {
if String.starts_with(line, "#") {
cfg cfg
} else { } else if List.len(parts) != 2 {
let parts = String.split(line, "=") add_problem(cfg, no, String.concat("= 가 하나여야 합니다: ", line))
if List.len(parts) == 2 { } else if String.is_empty(String.trim(head_or(parts, ""))) {
let key = String.trim(head_or(parts, ""))
let val = String.trim(second_or(parts, ""))
if String.is_empty(key) {
add_problem(cfg, no, "이름이 비어 있습니다") add_problem(cfg, no, "이름이 비어 있습니다")
} else { } else {
add_entry(cfg, Entry { add_entry(cfg, Entry {
line: no, line: no,
key: key, key: String.trim(head_or(parts, "")),
value: parse_value(val), value: parse_value(String.trim(second_or(parts, ""))),
}) })
} }
} else {
add_problem(cfg, no, String.concat("= 가 하나여야 합니다: ", line))
}
}
}
} }
pub fn add_entry(cfg: Config, e: Entry) -> Config { pub fn add_entry(cfg: Config, e: Entry) -> Config {
@@ -134,14 +124,12 @@ pub copyable struct Pick {
pub fn take_at(p: Pick, x: String, want: Int) -> Pick { pub fn take_at(p: Pick, x: String, want: Int) -> Pick {
if p.taken { if p.taken {
Pick { taken: true, at: p.at + 1, found: p.found } Pick { taken: true, at: p.at + 1, found: p.found }
} else { } else if p.at == want {
if p.at == want {
Pick { taken: true, at: p.at + 1, found: x } Pick { taken: true, at: p.at + 1, found: x }
} else { } else {
Pick { taken: false, at: p.at + 1, found: p.found } Pick { taken: false, at: p.at + 1, found: p.found }
} }
} }
}
pub fn parse(text: String) -> Config { pub fn parse(text: String) -> Config {
let lines = String.split(text, "\n") let lines = String.split(text, "\n")