Menu

Zero World 능력: 전역 없는 명시적 I/O

Zero에는 전역 stdout도, 기본으로 깔린 파일 시스템도, 암묵적인 네트워크도 없습니다. 바깥 세상에 닿는 모든 것은 main에 전달되는 World 능력을 통해 흐릅니다. 왜 그렇게 됐고 어떻게 동작하는지 살펴봅니다.

이 페이지에는 실행 가능한 에디터가 있습니다 — 편집하고 실행하면 결과를 바로 볼 수 있습니다.

한 문장으로 보는 아이디어

Zero 프로그램은 기본 전역을 가져다 쓰는 게 아니라, I/O를 할 권한을 받아서 I/O를 합니다.

그 권한은 World 타입의 값입니다. 런타임이 main을 호출하기 전에 하나를 만들고, 프로그램은 바깥 세상과 상호작용해야 하는 곳마다 그 값(또는 그것의 일부)을 흘려보냅니다.

왜 전역이 없을까

대부분의 언어는 어디서든 어느 함수든 stdout에 쓰거나 파일을 열게 둡니다. JavaScript에는 console.log가, Python에는 print가, C에는 printf가 있죠. 편리함은 진짜이지만 비용도 진짜입니다. 함수의 시그니처만으로는 그것이 I/O를 할지 알 수 없거든요. 알려면 본문을 — 재귀적으로 — 읽어야 합니다.

Zero는 다른 자세를 취합니다. 전역 print는 없습니다. 기본 os.Stdout 같은 것도 없어요. 함수가 I/O를 한다면 그 사실이 시그니처에 드러나야 합니다. I/O를 할 유일한 방법은 능력을 건네받는 것이기 때문이에요.

이점은 세 곳에서 나타납니다.

  1. 시그니처를 읽으면 함수가 무엇을 할 수 있는지 알 수 있다. World를 언급하지 않는 함수는 stdout에 쓸 수도, 소켓을 열 수도, 파일을 읽을 수도 없습니다. 타입 시스템이 그것을 단단한 보장으로 만들어요.
  2. 순수한 코드 테스트가 쉽다. 순수 함수는 print를 스텁하거나 파일 시스템을 목할 필요가 없습니다. 애초에 그것들에 접근할 수가 없으니까요.
  3. 에이전트가 지역적으로 추론할 수 있다. Zero 코드를 생성하거나 수정하는 AI 에이전트는 — 전체 코드베이스를 읽지 않고도 — 지금 보는 함수가 효과를 가지는지 알 수 있습니다.

정석적인 사용

hello-world에서 이미 기본 모양을 봤습니다.

세 가지가 일어나고 있어요.

  • main이 매개변수를 world: World로 선언합니다. 런타임이 프로그램에 World 값을 건네고 여기에 바인딩합니다.
  • world.outWorld 능력의 필드로 노출된 표준 출력 스트림.
  • world.out.write(...)가 문자열을 씁니다. 실패할 수 있는 값(쓰기가 실패할 수 있어요)을 반환하고, check가 그것을 전파합니다.

매개변수 이름은 w: Worldio: World로 바꿔도 됩니다. 하지만 더 넓은 Zero 생태계와의 일관성을 위해 world라는 관례를 유지하는 게 좋아요.

World에 무엇이 있나

World 능력은 프로그램이 필요로 할 만한 표면을 노출합니다. 정확한 모양은 런타임이 지원하는 것에 따라 다르지만, 다음 같은 항목을 기대할 수 있어요.

  • world.out — 표준 출력.
  • world.err — 표준 에러.
  • world.in — 표준 입력.
  • 파일을 열고, 환경 변수를 읽고, 네트워크를 통해 연결하는 방법.

권위 있는 필드 목록은 최신 Zero 표준 라이브러리 문서를 참고하세요. 일부 표면(네트워크, 파일 시스템)은 최상위가 아니라 World를 통해 접근 가능한 더 좁은 능력 타입 뒤에 있을 수 있습니다.

능력을 코드 사이로 흘려보내기

명시적 효과의 대가 — I/O가 필요한 함수가 능력을 받아야 한다는 점입니다. 암묵적으로 가져올 수가 없어요.

fun log(world: World, message: String) -> Void raises {
    check world.out.write(message)
}

pub fun main(world: World) -> Void raises {
    log(world, "starting\n")
    log(world, "done\n")
}

logworld.out.write를 부르기 위해 world를 받습니다. logworld를 받지 않았다면 본문에서 world.out.write를 부를 수 없어요. 그 바인딩이 존재하지 않거든요.

기본 I/O가 있는 언어보다 매개변수가 많아집니다. 대가는 main의 전체 호출 그래프가 시그니처만으로 보이게 된다는 것이에요.

  • mainworld를 받으니 I/O를 할 수 있다.
  • logworld를 받으니 I/O를 할 수 있다.
  • 시그니처에 world가 없는 함수는 할 수 없다.

더 좁은 능력

모든 함수에 World 전체를 건네는 건 무딘 접근입니다. 루트 권한을 나눠주는 셈이죠. Zero가 권장하는 패턴은 실제로 필요한 World의 일부만 받는 것입니다.

fun log(out: Stream, message: String) -> Void raises {
    check out.write(message)
}

pub fun main(world: World) -> Void raises {
    log(world.out, "starting\n")
    log(world.out, "done\n")
}

이제 logStream(world.out이 노출하는 같은 타입)에만 접근할 수 있습니다. 그것을 통해 쓸 수는 있지만, 파일을 열거나 네트워크에서 읽을 수는 없어요. 호출자가 log가 할 수 있는 일을 골라 준 셈입니다.

실제 Zero 코드에서 보게 될 정확한 타입 이름(Stream, Writer, 능력 슬라이스 같은 것)은 툴체인 버전의 표준 라이브러리 어휘를 따릅니다. 패턴 — 최소만, 최대 말고 — 은 보편적입니다.

순수 함수

World가 필요 없는 함수는 World를 받지 말아야 합니다. 일부는 스타일 선호이고 일부는 타입 시스템이 강제하는 부분이에요. 능력 없이는 I/O를 할 방법이 없으니까요.

fun sum(point: Point) -> i32 {
    return point.x + point.y
}

sum은 바깥 세상에 대해 순수합니다. 시그니처를 본 호출자는 이 함수가 절대 무언가를 출력하거나, 파일을 열거나, 서버를 핑하지 않으리라는 걸 확실히 알 수 있어요. 본문을 읽지 않고도 정적 분석기(또는 에이전트)가 의지할 수 있는 속성입니다.

능력과 raises

능력 위의 거의 모든 연산은 실패할 수 있습니다. world.out.write는 스트림이 닫혀 있어서 실패할 수 있고, 파일 열기는 파일이 없어서 실패할 수 있죠. 능력 API 표면은 raisescheck와 짝을 이룹니다. 실패할 수 있는 연산은 시그니처에 그 실패 모드를 선언하고, 호출자는 check로 그것을 인지합니다.

이 둘이 합쳐져 Zero 효과 이야기의 핵심을 이룹니다.

  • 무엇이 일어날 수 있는가 → raises { ... }.
  • 무엇을 통해서World(또는 그 일부).
  • 어디서 → 능력과 raises가 보이는 곳.

이 정도면 시그니처만으로 함수의 효과를 정확하게 추론할 수 있는 정보가 됩니다.

테스트에 관한 메모

능력 기반 I/O는 본질적으로 테스트를 쉽게 만들어 줍니다. 테스트하려는 함수의 출력을 캡처하고 싶다면, 무엇을 쓰라고 요청받았는지 기록하는 가짜 out 능력을 건네면 됩니다. 순수해야 할 함수를 테스트하고 싶다면, 아무 능력도 건네지 마세요. 타입 시그니처상 바깥 세상에 손도 댈 수 없습니다.

표준 라이브러리는 정확히 이 목적을 위한 가짜 또는 인메모리 능력을 만드는 테스트 하니스를 제공할 수 있어요. 정확한 API는 언어와 함께 진화하지만, 원칙(능력은 대체 가능한 값) 자체가 지렛대입니다.

다음 글: Raises와 Check

World는 효과 이야기의 절반입니다. 함수가 닿을 수 있는 표면이죠. 나머지 절반은 실패입니다. 무언가 잘못됐을 때 어떻게 전파되는지요. 다음 Raises와 Check에서 다룹니다.

자주 묻는 질문

Zero에서 World가 뭔가요?

World는 런타임이 제공하는 능력 객체로, Zero 프로그램에 바깥 세상에 대한 접근을 부여합니다. stdout, stdin, 파일, 네트워크, 환경 변수 등이요. 런타임이 World 값을 만들어 main에 전달합니다. I/O가 필요한 함수는 World(또는 그것의 더 좁은 일부)를 전달받아야 해요. 전역 탈출구는 없습니다.

왜 main이 World 매개변수를 받죠?

Zero에는 기본 전역이 없습니다. 어떤 함수든 권한 없이 부를 수 있는 printf, console.log, os.Stdout 같은 것이 없어요. 런타임은 mainWorld 능력을 건네고, main(과 그것이 호출하는 함수)은 그 값을 통해서만 I/O를 할 수 있습니다. 그래서 모든 효과가 함수 시그니처에 드러나게 됩니다.

능력 기반 I/O가 일반 I/O와 어떻게 다른가요?

대부분의 언어에서 I/O는 암묵적입니다. 어느 함수든 언제든지 stdout에 쓰거나 파일을 읽을 수 있죠. 능력 기반 I/O는 I/O를 할 권한을 값으로 만듭니다. 그것을 쓰려면 World(또는 그 일부)를 건네받아야 해요. 순수 계산 함수는 World를 받지 않으므로 말 그대로 I/O를 할 수 없는데, 타입 시스템이 이를 강제합니다.

호출 스택 깊은 곳에서 World를 암묵적으로 얻을 수 있나요?

의도적으로 안 됩니다. 호출 스택 깊은 곳의 도우미가 stdout에 써야 한다면, World — 또는 더 좁은 능력 — 을 명시적으로 전달해야 해요. 기본 I/O가 있는 언어보다 매개변수가 많아지지만, 함수의 시그니처만 보고 그 함수가 I/O를 할 가능성이 있는지 알 수 있게 되는 대가입니다.

world.out.write는 무엇을 하나요?

world.out.write("text\n")는 런타임이 제공한 능력을 통해 프로그램의 표준 출력 스트림에 주어진 문자열을 씁니다. 실패할 수 있는 값을 반환해요 — 쓰기가 실패할 수 있거든요 — 그래서 호출을 check로 감싸 에러를 호출 스택 위로 전파합니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기