diff --git a/README.md b/README.md index aaaaf36..03072bf 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ m101.cool:21:3: match가 모든 경우를 덮지 않습니다 (빠진 경우: Up ## 실제로 써본 결과 `samples/app`은 검사기를 시험하려고 쓴 것이 아니라 일을 하려고 쓴 -프로그램이다 — 설정 파서 + 리포트 도구, 2모듈 304줄. +프로그램이다 — 설정 파서 + 리포트 도구, 2모듈 292줄. ``` $ coolc run samples/app/main.cool samples/app/example.conf diff --git a/docs/friction.md b/docs/friction.md index 302ddc4..96a9ed6 100644 --- a/docs/friction.md +++ b/docs/friction.md @@ -1,7 +1,7 @@ # 개밥 먹기 보고 — samples/app을 쓰면서 걸린 것들 2026-08-30. coollang으로 처음 쓴 "일하는 프로그램" 하나(설정 파서 + 리포트 -도구, 2모듈 304줄)에서 실제로 걸린 마찰을 적는다. +도구, 2모듈 292줄)에서 실제로 걸린 마찰을 적는다. v0의 샘플 16개는 전부 검사기를 시험하려고 쓴 것이고, 그래서 "언어가 쓸 만한가"에 대해서는 아무것도 말해주지 않았다. 이 문서가 v0가 남기는 마지막 @@ -81,28 +81,39 @@ pub fn first_text(xs: List[String]) -> Option[String] { > 같은 자리에서 흔한 `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 -if raw == "true" { - Flag(true) +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 { - if raw == "false" { - Flag(false) - } else { - match Int.parse(raw) { ... } - } + add_entry(cfg, ...) } ``` -세 갈래 분기가 3단 중첩이 된다. `parse_line`은 4단까지 갔다. -**이것은 언어 결정이 아니라 파서의 빈틈으로 보인다.** 확인이 필요하다. +**개밥 먹기 자체에 대한 교훈이다.** 한 사람이 쓴 300줄에서 나온 불편은 +언어의 성질일 수도 있고 그 사람의 습관일 수도 있다. 둘을 나누려면 불편을 +적을 때마다 "언어가 정말 막는가"를 확인해야 한다. 확인 없이 적은 것 하나가 +"심각" 등급을 달고 v1 설계 입력이 될 뻔했다. -> 제안: `else` 뒤에 블록 대신 `if` 식을 허용한다. 새 개념이 아니라 -> 이미 있는 규칙(else는 식을 받는다)의 적용이다. +나머지 항목들은 확인했다 — F1은 `List.first`/`nth`가 std에 실제로 없고, +F3은 `String.join`이 실제로 없다. ### F3. `String.concat`이 2항이라 중첩 지옥이 된다 (심각) @@ -175,9 +186,12 @@ pub copyable struct Numbered { ## 결론 -v1로 넘길 때 **F1, F2, F3이 먼저다.** 셋 다 "언어가 틀렸다"가 아니라 -"없어서 우회했다"이고, 우회 비용이 코드에 그대로 보인다 — 304줄 중 -70줄쯤이 이 셋 때문에 존재한다. +v1로 넘길 때 **F1과 F3이 먼저다.** 둘 다 "언어가 틀렸다"가 아니라 +"std에 없어서 우회했다"이고, 우회 비용이 코드에 그대로 보인다 — 292줄 중 +40줄쯤이 F1 하나 때문에 존재한다. + +F2는 취소됐다. 그리고 그것이 이 문서에서 두 번째로 중요한 발견이다: +확인 없이 적은 불편 하나가 "심각" 등급을 달고 v1 설계 입력이 될 뻔했다. 반대로 **되돌리기 비싼 결정들은 하나도 후회되지 않았다.** capability를 인자로 나르는 것, effect를 시그니처에 적는 것, 실패를 버릴 수 없는 것, diff --git a/docs/thesis.md b/docs/thesis.md index 58f4183..391c300 100644 --- a/docs/thesis.md +++ b/docs/thesis.md @@ -367,11 +367,13 @@ prelude는 없다. std도 명시적으로 가져온다 — 암묵적으로 끌 결과를 버릴 방법이 언어에 없다는 성질도 여기서 처음 확인됐다. ■ 개밥 먹기 (2026-08) -samples/app — 설정 파서 + 리포트 도구, 2모듈 304줄. 검사기를 시험하려고 +samples/app — 설정 파서 + 리포트 도구, 2모듈 292줄. 검사기를 시험하려고 쓴 것이 아니라 일을 하려고 쓴 첫 프로그램이다. 결과: 되돌리기 비싼 결정은 하나도 후회되지 않았고(capability 전달, effect 명시, 실패를 버릴 수 없음, 소진적 match), 불편은 전부 되돌리기 싼 것들이었다 -— 리스트 n번째 접근 없음, else if 없음, String.concat 2항. +— 리스트 n번째 접근 없음, String.concat 2항. "else if가 없다"고 적은 +항목 하나는 확인해 보니 관찰자가 틀린 것이었다 — 개밥 먹기의 불편은 +언어의 성질일 수도, 쓴 사람의 습관일 수도 있다. 전문과 증거는 docs/friction.md. 이것이 v1 설계의 첫 입력이다. ■ 측정 (2026-08, v0 fast path) diff --git a/samples/app/config.cool b/samples/app/config.cool index b502e6c..c0de347 100644 --- a/samples/app/config.cool +++ b/samples/app/config.cool @@ -37,14 +37,12 @@ pub copyable struct Config { pub fn parse_value(raw: String) -> Value { if raw == "true" { Flag(true) + } else if raw == "false" { + Flag(false) } else { - if raw == "false" { - Flag(false) - } else { - match Int.parse(raw) { - Ok(n) => Number(n), - Err(_) => Text(raw), - } + match Int.parse(raw) { + Ok(n) => Number(n), + Err(_) => Text(raw), } } } @@ -70,29 +68,21 @@ pub fn type_name(v: Value) -> String { // 부르는 쪽이 두 겹을 벗겨야 한다. Config 하나에 둘 다 모으는 편이 낫다. pub fn parse_line(cfg: Config, no: Int, raw: String) -> Config { let line = String.trim(raw) + let parts = String.split(line, "=") if String.is_empty(line) { cfg + } else if String.starts_with(line, "#") { + cfg + } else if List.len(parts) != 2 { + add_problem(cfg, no, String.concat("= 가 하나여야 합니다: ", line)) + } else if String.is_empty(String.trim(head_or(parts, ""))) { + add_problem(cfg, no, "이름이 비어 있습니다") } else { - if String.starts_with(line, "#") { - cfg - } else { - let parts = String.split(line, "=") - if List.len(parts) == 2 { - let key = String.trim(head_or(parts, "")) - let val = String.trim(second_or(parts, "")) - if String.is_empty(key) { - add_problem(cfg, no, "이름이 비어 있습니다") - } else { - add_entry(cfg, Entry { - line: no, - key: key, - value: parse_value(val), - }) - } - } else { - add_problem(cfg, no, String.concat("= 가 하나여야 합니다: ", line)) - } - } + add_entry(cfg, Entry { + line: no, + key: String.trim(head_or(parts, "")), + value: parse_value(String.trim(second_or(parts, ""))), + }) } } @@ -134,12 +124,10 @@ pub copyable struct Pick { pub fn take_at(p: Pick, x: String, want: Int) -> Pick { if p.taken { Pick { taken: true, at: p.at + 1, found: p.found } + } else if p.at == want { + Pick { taken: true, at: p.at + 1, found: x } } else { - if p.at == want { - Pick { taken: true, at: p.at + 1, found: x } - } else { - Pick { taken: false, at: p.at + 1, found: p.found } - } + Pick { taken: false, at: p.at + 1, found: p.found } } }