28 입출력과 파일
먼저 알아야 할 것
cap io·cap file_system 과 effects ioresult 로 말한다돌아보기
19장에서 어떤 타입이 “완결이 필요하다” 는 것은 무엇으로 정해졌는가?
답. 그 타입을 owned 로 받아 result 를 돌려주는 op 이 있으면 정해졌다. 그런 값을 끝내지 않고 범위를 벗어나게 두면 E-OWN-INCOMPLETE 로 거절되었다. 이 장에서 그 규칙이 가장 쓸모 있는 자리 — 열었으면 반드시 닫아야 하는 파일 — 을 본다.
이 장의 필요성과 맥락
이 장이 끝나면
write_out 과 표준 라이브러리 files 로 파일을 여닫고 읽고 쓰는 법을 익힌다. 파일 핸들이 완결이 필요한 소유 값이라서 닫기를 잊으면 번역이 거절된다는 것을 확인한다. 읽기의 답이 “읽었다·끝이다·실패다” 세 자리로 갈리는 까닭과, 실패를 일부러 일으켜 시험하는 방법도 알게 된다.이 장에서 답할 질문
- 버퍼보다 큰 파일은 어떻게 읽는가?
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
main은 권한 셋을 받는다. 출력(cap io), 파일(cap file_system), 버퍼를 받을 할당(cap allocator)이다. 머리만 보고도 이 프로그램이 네트워크에 닿지 않는다는 것을 안다.files.slurp fs "notes.txt" buf는 파일 전체를buf에 읽고 읽은 바이트 수를result u64 files.file_error로 준다. 파일이 없거나 버퍼보다 크면 오류다. 자르지 않는다.- 오류면 메시지를 쓰고 종료 코드 2 를 돌려준다. 경계에서 실패를 다루는 모양이다(17장).
- 읽은 부분을
subslice로 잘라pipe로 줄바꿈을 세고(24장), 내용을 그대로 표준출력에 쓴다.
예제는 자기 폴더의 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 noneslurp 은 버퍼가 파일보다 한 바이트만 작아도 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
files.open fs <경로> 1은 쓰기 모드로 연다. 모드는 0 읽기, 1 쓰기(자르고 만들기), 2 덧붙이기다.- 성공하면
ok_value o를owned files.handle에 담는다. files.write로 쓰고files.close fs h로 닫는다.close는owned handle을 받고result를 돌려준다. 닫기는 진짜로 실패할 수 있다 — 네트워크 파일 시스템이나 가득 찬 디스크에서 마지막 버퍼를 비우지 못하면 닫기에서 드러난다.
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 를 돌려준다. 셋째 줄에서는 실패가 끝과 다른 답으로 오므로, 둘을 가르는 코드만 이 경로를 바르게 지난다.
실제 사례. 줄 세기 프로그램이 찾아낸 결함
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 buf | ok (some n) 읽었다 · ok none 끝 · error e 실패 | 값 하나가 두 뜻을 나르지 않는다 |
files.write fs h bytes | 쓴 수를 result 로 준다 | 짧게 쓰일 수 있다 — 수를 확인한다 |
files.close fs h | owned 로 받아 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 주입기로 일부러 돌려 본다.