Lowent 매뉴얼←↑→

28 입출력과 파일

먼저 알아야 할 것

16장 권한 · cap io·cap file_system 과 effects io
19장 소유 · 완결이 필요한 값은 적어서 끝낸다
17장 실패를 설계하기 · 경계에서는 result 로 말한다

돌아보기

19장에서 어떤 타입이 “완결이 필요하다” 는 것은 무엇으로 정해졌는가?

답. 그 타입을 owned 로 받아 result 를 돌려주는 op 이 있으면 정해졌다. 그런 값을 끝내지 않고 범위를 벗어나게 두면 E-OWN-INCOMPLETE 로 거절되었다. 이 장에서 그 규칙이 가장 쓸모 있는 자리 — 열었으면 반드시 닫아야 하는 파일 — 을 본다.

이 장의 필요성과 맥락

제8부는 프로그램이 바깥 세계와 닿는 자리들이다. 첫째가 입출력이다. 입출력은 실패가 흔한 자리다 — 파일이 없고, 디스크가 가득 차고, 읽다가 끊긴다. 그리고 가장 흔한 결함은 실패를 끝과 섞는 것, 닫기를 잊는 것이다. Lowent 는 권한으로 누가 입출력을 할 수 있는지 정하고, 소유로 닫기를 잊지 못하게 하며, 답의 모양으로 끝과 실패를 가른다. 앞의 네 부에서 세운 도구들이 한자리에 모인다.

이 장이 끝나면

표준출력에 쓰는 write_out 과 표준 라이브러리 files 로 파일을 여닫고 읽고 쓰는 법을 익힌다. 파일 핸들이 완결이 필요한 소유 값이라서 닫기를 잊으면 번역이 거절된다는 것을 확인한다. 읽기의 답이 “읽었다·끝이다·실패다” 세 자리로 갈리는 까닭과, 실패를 일부러 일으켜 시험하는 방법도 알게 된다.

이 장에서 답할 질문

  1. 버퍼보다 큰 파일은 어떻게 읽는가?

28.1 표준출력#

표준출력에 쓰는 통로는 write_out <cap io> <fd> <바이트> 하나다. 파일 기술자 1 은 표준출력, 2 는 표준오류다. 쓴 바이트 수를 돌려준다. 이미 여러 장에서 썼으므로 새로울 것은 없지만, 한 가지를 짚는다. 이 op 이 권한을 첫 피연산자로 받는다는 것은, 표준출력에 한 바이트라도 내는 모든 op 의 머리에 cap io 가 보인다는 뜻이다. 디버그용으로 몰래 찍는 출력이 없다.

28.2 파일을 통째로 읽는다#

examples/ch28/lines.low

module lines .
rem run: main

use files .

fn is_newline input c u8 . output bool .
do
  return eq c 10 .
end

proc main input out cap io . input fs cap file_system . input al cap allocator .
  output u8 .
  effects io alloc .
do
  let g option mut slice u8 be alloc_bytes al capacity 4096 .
  guard is_some g . else return 1 .
  let buf mut slice u8 be some_value g .
  let r result u64 files.file_error be files.slurp fs "notes.txt" buf .
  guard not (is_error r) . else do
    let e u64 be write_out out 1 "cannot read notes.txt\n" .
    return 2 .
  end
  let body slice u8 be subslice buf 0 (ok_value r) .
  let n u64 be pipe body do
    filter is_newline .
    count .
  end .
  let w u64 be write_out out 1 body .
  return narrow u8 n .
end

실행 결과

$ lowentc --run main lines.low
first line
second line
third line
main() = 3

예제는 자기 폴더의 notes.txt 를 읽는다. 세 줄이므로 종료 코드는 3 이다.

문. 버퍼보다 큰 파일은 어떻게 읽는가?

답. files.open 으로 열고 files.read 를 되풀이한다. read 는 버퍼만큼만 읽으므로 파일 크기와 상관없이 조각으로 읽을 수 있다. 짧게 읽히는 것은 실패가 아니다. 부탁한 것보다 적게 주는 것은 정상이고, 부르는 쪽이 계속 읽어야 한다. slurp 이 안에서 그렇게 한다.

34 바이트짜리 notes.txt 를 두 길로 읽으면 이렇게 된다(이 판에서 버퍼 크기를 바꿔 가며 실측했다).

 files.slurp  buf 64  ──▶  ok 34
 files.slurp  buf 34  ──▶  ok 34
 files.slurp  buf 33  ──▶  error buffer_too_small
 files.read   buf 16  ──▶  ok (some 16) · ok (some 16) · ok (some 2) · ok none

slurp 은 버퍼가 파일보다 한 바이트만 작아도 buffer_too_small 로 답한다. 딱 맞는 크기는 된다. read 는 버퍼 크기와 상관없이 조각을 주고, 조각을 이어 붙이는 일은 부르는 쪽이 한다.

28.3 열었으면 닫는다#

examples/ch28/copy.low

module copy .
rem run: main

use files .

proc main input out cap io . input fs cap file_system . output u8 . effects io .
do
  let o result files.handle files.file_error be files.open fs "../../build/ch28-copy.txt" 1 .
  guard is_ok o . else return 1 .
  var h owned files.handle be ok_value o .
  let w result u64 files.file_error be files.write fs h "hello, file\n" .
  let c result void files.file_error be files.close fs h .
  guard is_ok w . else return 2 .
  guard is_ok c . else return 3 .
  let m u64 be write_out out 1 "wrote and closed\n" .
  return 0 .
end

실행 결과

$ lowentc --run main copy.low
wrote and closed
main() = 0

close 가 그 모양이므로 handle 은 완결이 필요한 타입이다. 닫지 않고 두면 거절된다.

examples/ch28/forgot.low

module forgot .
rem expect: E-OWN-INCOMPLETE

use files .

proc leak input fs cap file_system . output u8 . effects io .
do
  let o result files.handle files.file_error be files.open fs "notes.txt" 0 .
  guard is_ok o . else return 1 .
  var h owned files.handle be ok_value o .
  return 0 .
end

실행 결과

$ lowentc --check forgot.low
10:0 E-OWN-INCOMPLETE: this value is dropped automatically at the end of scope — but the program itself declares an op that takes this type `owned` BY VALUE and returns a `result`: finishing it CAN FAIL. An automatic drop is a RELEASE (total, non-suspending), and it has nowhere to hand you that failure — it would SWALLOW it. A fallible finish (flush/commit/close) is a COMPLETION and must be EXPLICIT: call it and handle the `result`. If you really mean to discard the value and its failure, say so with `drop`. RFC-0058

닫기를 잊은 프로그램은 번역되지 않는다. 운영체제가 프로세스 종료 때 파일 기술자를 닫아 준다는 기대에 기대지 않는다. 정말로 닫지 않고 버리려면 drop h . 로 버린다고 적는다.

핸들 하나의 일생을 한 장으로 그리면 이렇다.

 files.open ──▶ ok_value o ──▶ var h owned files.handle
                                    │
     files.write fs h "…"  ◀────────┤  h 를 넘겨도 소유는 h 에 남는다
                                    │
     files.close fs h      ◀────────┘  소유가 close 로 넘어간다
            │
            ▼
     result void file_error            닫기도 실패할 수 있다

 E-OWN-INCOMPLETE   close 없이 범위를 벗어났다
 E-OWN-MOVED        close 뒤에 h 를 또 썼다
 drop h .           닫지 않고 일부러 버린다

앞의 둘은 번역이 거절한다. 셋째는 버린다는 뜻을 소스에 남기는 길이다.

흔한 오해. 파일 핸들을 정수로 들고 다니면 더 가볍다

커널이 준 정수를 그대로 들면 실행 비용은 같다. files.handle 도 안에 정수 하나뿐인 구조체다. 차이는 번역이 무엇을 알 수 있느냐다. 정수는 복사해도, 잊어도, 두 번 닫아도 아무도 모른다. 소유 값은 옮겨지고, 잊으면 거절되고, 두 번 닫으면 거절된다. 같은 바이트에 규칙을 입힌 것이다.

28.4 끝과 실패는 다른 답이다#

files.read 의 답은 세 자리다.

답뜻
ok (some n)n 바이트를 읽었다
ok none파일의 끝이다. 실패가 아니다
error e실패다. e 는 어느 연산이 실패했는지 말한다

표 28.1 — read 의 답

34 바이트 파일을 16 바이트 버퍼로 되풀이해 읽으면 답이 이렇게 온다(이 판에서 실측).

 notes.txt  34 바이트           buf  16 바이트

 읽기 1   ok (some 16)    바이트  0 ~ 15
 읽기 2   ok (some 16)    바이트 16 ~ 31
 읽기 3   ok (some 2)     바이트 32 ~ 33
 읽기 4   ok none         끝 — 여기서 멈춘다
 읽기 5   ok none         끝에서 또 읽어도 끝이다

마지막 조각(2 바이트)이 버퍼보다 작다고 해서 그것이 끝을 알리는 것은 아니다. 끝은 ok none 이 따로 알린다.

한때 이 모듈의 slurp 은 읽기가 실패하면 반복을 멈추기만 했다. 그러면 실패한 읽기가 “파일을 다 읽었다” 로 보고되었고, 이 모듈로 지은 줄 세기 프로그램이 읽기 실패를 “0 줄” 이라는 성공으로 냈다. 값 하나가 두 뜻(끝과 실패)을 나르면 안 된다는 것이 그때 얻은 교훈이고, 그래서 답이 세 자리가 되었다.

반대로 모든 op 에 세 자리를 기계적으로 씌우지도 않았다. 여는 일에는 “끝” 이 없으므로 open 은 result handle file_error 두 자리다. 메모리 위의 슬라이스를 읽는 io 모듈의 리더는 실패할 자리가 없으므로 option 만 쓴다. op 마다 정직한 모양이 다르다.

28.5 실패를 일부러 일으킨다#

파일 실패는 평소에 잘 일어나지 않아서, 실패를 다루는 코드는 시험되지 않은 채 남기 쉽다. 표준 라이브러리의 호스트 연산에는 결함 주입기가 있다. 환경 변수로 켠다.

LOW_HOST_FAULT="open:err"        모든 open 이 실패한다
LOW_HOST_FAULT="read:err@2"      두 번째 읽기가 실패한다
LOW_HOST_FAULT="read:short@1=4"  첫 읽기를 4 바이트로 자른다
LOW_HOST_FAULT="close:err"       닫기가 실패한다

VM 과 네이티브가 같은 주입기를 쓰므로 두 백엔드의 답이 같아야 한다. LOW_HOST_FAULT="open:err" lowentc --run main lines.low 로 돌리면 lines.low 는 오류 갈래로 가서 메시지를 쓰고 2 를 돌려준다. 기본은 꺼져 있다.

앞 절의 16 바이트 읽기를 주입기 아래에서 다시 돌리면 답이 이렇게 바뀐다(이 판에서 실측).

 (주입 없음)         ok 16 · ok 16 · ok 2  · ok none
 read:short@1=4      ok 4  · ok 16 · ok 14 · ok none
 read:err@2          ok 16 · error read_failed

짧게 읽혀도 조각의 합은 34 로 같고, 끝은 여전히 ok none 이 알린다. eof_fixed.low 는 첫 줄과 둘째 줄 모두에서 34 를 돌려준다. 셋째 줄에서는 실패가 끝과 다른 답으로 오므로, 둘을 가르는 코드만 이 경로를 바르게 지난다.

실제 사례. 줄 세기 프로그램이 찾아낸 결함

위의 “0 줄” 결함은 주입기가 실물로 보였다. LOW_HOST_FAULT="read:err@1" 로 첫 읽기를 실패시키자 줄 세기가 성공으로 끝났다. 실패 경로가 한 번도 돌지 않았다면 이 결함은 실제 디스크 오류가 날 때까지 숨어 있었을 것이다. 권한과 소유가 “닫기를 잊음” 을 번역에서 막는다면, 주입기는 “실패를 잘못 다룸” 을 실행에서 드러낸다.

28.6 흔한 실수#

반례. 읽기 모드로 열고 쓴다 — 쓴 바이트 수를 보지 않는다

examples/ch28/mistake_readmode.low

module mistake_readmode .
rem run: main

use files .

proc main input out cap io . input fs cap file_system . output u8 . effects io .
do
  rem ✘ 모드 0 은 읽기다 --- 그런데 쓴다
  let o result files.handle files.file_error be files.open fs "notes.txt" 0 .
  guard is_ok o . else return 1 .
  var h owned files.handle be ok_value o .
  let w result u64 files.file_error be files.write fs h "extra line\n" .
  let c result void files.file_error be files.close fs h .
  drop c .
  guard is_ok w . else return 2 .
  rem 쓰기가 실패했으므로 여기서 2 로 나간다 --- 짧게 쓰인 것도 함께 본다
  guard eq (ok_value w) 11 . else return 3 .
  return 0 .
end

실행 결과

$ lowentc --run main mistake_readmode.low
main() = 2

files.open fs "notes.txt" 0 은 읽기 모드다. 거기에 쓰면 files.write 가 실패를 돌려주고(스트림 오류가 켜진 짧은 쓰기는 값이 아니라 실패다) 이 예제는 종료 코드 2 로 나간다. 전에는 같은 자리가 ok 0(“0 바이트 썼다”)이어서 is_ok w 만 본 코드가 성공으로 지나갔다. 모드를 확인하고(0 읽기 · 1 쓰기 · 2 덧붙이기), 쓰기의 답은 성공 여부와 함께 쓴 수까지 본다. 짧게 쓰인 것은 남은 바이트를 다시 써야 한다는 뜻이다.

반례. 닫은 핸들로 또 쓴다

examples/ch28/mistake_afterclose.low

module mistake_afterclose .
rem expect: E-OWN-MOVED

use files .

proc main input out cap io . input fs cap file_system . output u8 . effects io .
do
  let o result files.handle files.file_error be files.open fs "../../build/ch28-after.txt" 1 .
  guard is_ok o . else return 1 .
  var h owned files.handle be ok_value o .
  let c result void files.file_error be files.close fs h .
  drop c .
  rem ✘ 닫은 핸들로 또 쓴다 --- `close` 가 소유를 가져갔다
  let w result u64 files.file_error be files.write fs h "late\n" .
  drop w .
  return 0 .
end

실행 결과

$ lowentc --check mistake_afterclose.low
mistake_afterclose.low:14:0 E-OWN-MOVED: this `owned` value was already MOVED (consumed) — using it again is use-after-move, which SPEC-004 §4.8 has always called a compile error and which nothing enforced. To keep using it, either CONSUME AND PUT IT BACK (`set <name> <new value>` re-initialises the place — that is how a handle threads through a loop), or borrow it LOCALLY with `ref h`. ☞ borrowing across an OP BOUNDARY is not lowered yet (E-IR-UNSUP says so at the call site), so `f (ref h)` is not a way out today

files.close 는 owned handle 을 받으므로 부르는 순간 소유가 넘어간다. 그 뒤의 h 는 이미 없는 핸들이라 E-OWN-MOVED 다. C 에서 fclose 뒤의 fwrite 는 정의되지 않은 동작이고, 같은 번호가 새로 연 다른 파일에 재사용되었다면 엉뚱한 파일에 쓴다. 소유가 그 자리를 번역에서 막는다.

반례. 읽기의 답에서 실패만 묻는다

examples/ch28/mistake_eof.low

module mistake_eof .
rem trap: main

use files .

proc main input out cap io . input fs cap file_system . input al cap allocator .
  output u8 .
  effects io alloc .
do
  let g option mut slice u8 be alloc_bytes al capacity 64 .
  guard is_some g . else return 1 .
  let buf mut slice u8 be some_value g .
  let o result files.handle files.file_error be files.open fs "notes.txt" 0 .
  guard is_ok o . else return 2 .
  var h owned files.handle be ok_value o .
  var total u64 be 0 .
  var i u64 be 0 .
  while lt i 3 . do
    let r result (option u64) files.file_error be files.read fs h buf .
    guard is_ok r . else return 3 .
    rem ✘ 실패만 물었다 --- 파일 끝의 `ok none` 에서 `some_value` 가 멈춘다
    set total (add total (some_value (ok_value r))) .
    set i (add i 1) .
  end
  let c result void files.file_error be files.close fs h .
  drop c .
  return narrow u8 total .
end

실행 결과

$ lowentc --run main mistake_eof.low
== ir diagnostics (1) ==
0:0 E-VM-NONE: some_value of none (panic)

files.read 의 답은 세 자리다. is_ok r 는 “실패가 아니다” 만 말한다. 파일 끝의 ok none 도 실패가 아니므로 통과하고, 그 안에서 some_value 를 꺼내다 멈춘다. 세 자리를 모두 가른다.

examples/ch28/eof_fixed.low

module eof_fixed .
rem run: main

use files .

proc main input out cap io . input fs cap file_system . input al cap allocator .
  output u8 .
  effects io alloc .
do
  let g option mut slice u8 be alloc_bytes al capacity 16 .
  guard is_some g . else return 1 .
  let buf mut slice u8 be some_value g .
  let o result files.handle files.file_error be files.open fs "notes.txt" 0 .
  guard is_ok o . else return 2 .
  var h owned files.handle be ok_value o .
  var total u64 be 0 .
  var more bool be true .
  var rounds u64 be 0 .
  while and more (lt rounds 100) . do
    let r result (option u64) files.file_error be files.read fs h buf .
    guard is_ok r . else do
      let c1 result void files.file_error be files.close fs h .
      drop c1 .
      return 3 .
    end
    rem 세 자리를 모두 가른다 --- 읽었다 · 끝이다 · 실패다
    let got option u64 be ok_value r .
    if is_some got . do
      set total (add total (some_value got)) .
    end else do
      set more false .
    end
    set rounds (add rounds 1) .
  end
  let c result void files.file_error be files.close fs h .
  guard is_ok c . else return 4 .
  return narrow u8 total .
end

실행 결과

$ lowentc --run main eof_fixed.low
main() = 34

16 바이트 버퍼로 34 바이트 파일을 세 번에 나누어 읽고, 넷째 읽기의 ok none 에서 멈춘다. 실패하면 닫고 3 을, 닫기가 실패하면 4 를 돌려준다. rounds 의 상한은 끝을 영영 알리지 않는 원천에서도 반복이 끝나게 한다.

흔한 오해. 버퍼보다 큰 파일을 slurp 하면 앞부분만 읽힌다

examples/ch28/slurp_small.low

module slurp_small .
rem run: main

use files .

proc main input out cap io . input fs cap file_system . input al cap allocator .
  output u8 .
  effects io alloc .
do
  rem 34 바이트 파일에 8 바이트 버퍼
  let g option mut slice u8 be alloc_bytes al capacity 8 .
  guard is_some g . else return 1 .
  let buf mut slice u8 be some_value g .
  let r result u64 files.file_error be files.slurp fs "notes.txt" buf .
  rem 앞 8 바이트로 잘려 오지 않는다 --- 오류다
  guard not (is_error r) . else return 2 .
  return narrow u8 (ok_value r) .
end

실행 결과

$ lowentc --run main slurp_small.low
main() = 2

8 바이트 버퍼에 34 바이트 파일을 slurp 하면 앞 8 바이트가 오는 것이 아니라 오류가 온다. 잘린 내용을 온전한 파일로 믿는 결함 — 설정 파일의 뒷부분이 조용히 사라지는 일 — 을 막으려는 것이다. 파일이 버퍼보다 클 수 있으면 files.open 과 files.read 로 조각씩 읽는다.

28.7 이 장의 문법 한눈에#

모양뜻왜 이렇게
write_out out 1 "…"표준출력(1) · 표준오류(2)에 쓴다 — 쓴 수를 준다권한이 첫 피연산자 — 몰래 찍는 출력이 없다
use files . + input fs cap file_system .파일 모듈과 그 권한머리만 보고 파일에 닿는지 안다
files.open fs "notes.txt" 0연다 — 0 읽기 · 1 쓰기 · 2 덧붙이기 · result handle file_error여는 일에는 “끝” 이 없다 — 두 자리
var h owned files.handle be ok_value o .핸들을 소유로 담는다잊으면 E-OWN-INCOMPLETE · 닫은 뒤 쓰면 E-OWN-MOVED
files.read fs h bufok (some n) 읽었다 · ok none 끝 · error e 실패값 하나가 두 뜻을 나르지 않는다
files.write fs h bytes쓴 수를 result 로 준다짧게 쓰일 수 있다 — 수를 확인한다
files.close fs howned 로 받아 result — 완결닫기도 실패할 수 있다
files.slurp fs path buf통째로 읽는다 — 버퍼보다 크면 오류자르지 않는다
LOW_HOST_FAULT="read:err@2"실패를 일부러 일으킨다(환경 변수)실패 경로를 시험한다

표 28.2 — 입출력과 파일의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나

복습 정리

표준출력은 write_out <cap io> <fd> <바이트> 이고 권한이 첫 피연산자다. files 모듈은 cap file_system 을 받아 파일을 여닫고 읽고 쓴다. 파일 핸들은 close 가 owned 로 받아 result 를 돌려주므로 완결이 필요한 값이고, 닫기를 잊으면 번역이 거절한다. read 의 답은 읽었다·끝이다·실패다 세 자리이며, 실패 경로는 LOW_HOST_FAULT 주입기로 일부러 돌려 본다.