← melang

I/O の明示

Last Updated: 2026-09-28 02:00

結論

melangでは、関数がI/Oを行う可能性をシグネチャに明示する。コードを追わなくても、シグネチャだけでI/Oの有無を判断できるようにする。

I/Oを行わない関数(純粋関数同じ引数に対して常に同じ値を返し、I/Oやグローバルな状態の変更などの副作用を持たない関数。実行結果がその場の外部状態に依存しないため、実装を読まなくても呼び出し前後の挙動を予測できる。)は、これまで通り書く。

fn calculatePrice(item: Item) -> Price { ... }

I/Oを行う可能性がある関数には、戻り値の型のあとにioを付ける。

fn findUser(id: UserId) -> User io { ... }

io関数を呼び出す関数も、ioを明示しなければならない。

fn getUser(id: UserId) -> User {
    findUser(id) // NG: ioが伝播していない
}

fn getUser(id: UserId) -> User io {
    findUser(id) // OK
}

effectは自動推論してシグネチャへ暗黙に付与せず、利用者が明示する。ioという具体的なsyntaxは現時点では仮とする。

ログだけは例外にする理由

ログ出力だけは例外とし、logは純粋関数からもioなしで呼べる。

fn calculatePrice(item: Item) -> Price {
    log("calculating price for #{item.id}") // OK: logはioを要求しない
    item.basePrice * item.taxRate
}

ログは、呼び出し元から見た関数の入出力関係(同じ引数なら同じ戻り値を返すこと)に影響しない。デバッグや監視のために処理の経過を書き出すだけで、プログラムの結果を左右しないなら、それをioとして扱う理由はない。この考え方はHaskellにも前例があり、Debug.Trace.traceはunsafePerformIOを使ってIOを経由せずにログを出力できる、純粋関数からの数少ない例外になっている。

この例外は、専用のlog関数だけに限る。標準出力への通常の書き込みや標準入力の読み取りなど、プログラムの結果そのものに関わる入出力は、引き続きioを要求する。

記号ではなくキーワードにする理由

当初は!ioのように記号を付ける案も検討したが、採用しなかった。!は「注意」や「例外的な処理」のような特別な響きを持ちやすく、I/Oを普段づかいの語彙から外れたものに見せてしまう。呼び出し元への伝播を強制する既存の言語(Kotlinのsuspend、Swiftのasync/throws)は、いずれも記号ではなくキーワードで表しており、melangもそれに合わせる。

この設計で実現したいこと

このnoteで決めないこと

他言語比較

I/Oをシグネチャに表す仕組みは、言語によって大きく4つに分かれる。

言語シグネチャから判別表現の仕組み純粋関数からI/O関数を呼べるか説明
Rustできない(規約のみ)async / Result可能
MoonBit部分的async(呼び出し側は無印)同期I/Oは可能
Swift部分的async / throws不可能(async文脈のみ)
Kotlin部分的suspend不可能(suspend文脈のみ)
Java部分的checked exception可能
Scala規約次第IOモナド(cats-effect / ZIO)言語としては可能
C#できない(規約)Task / async可能
C++できないなし可能
Cできないなし可能
Goできないなし可能
Zigできないなし(allocator/writerの明示的な引数渡しで代替)可能
OCamlできないなし(5.xのeffect handlerも型には現れない)可能
F#できない(規約次第)なし(async { }は型に現れない)可能
HaskellできるIOモナド不可能
Erlangできないなし可能
Elixirできないなし可能
TypeScript部分的Promise可能
JavaScriptできないなし可能
PHPできないなし可能
Rubyできないなし可能
Pythonできないなし(型ヒントは任意)可能

比較の観点は次の通りである。

  1. I/Oの有無が、関数シグネチャから判別できるか。
  2. effectを型・シグネチャとして表現する仕組みがあるか。
  3. effectが明示必須か、それとも推論されるか。
  4. pureな関数から、I/Oを行う関数を呼べるか。
  5. I/O以外のeffect(例外・非同期など)も区別する仕組みがあるか。

C++・Go・JavaScript・Python・PHP・Ruby・Elixirは、I/Oを示す仕組み自体を持たない。関数の実装を読むまで、I/Oを行うかどうかは分からない。OCaml 5はeffect構文によるeffect handler値を文脈(コンテナ)に包んだまま、その文脈を保って値を合成するための抽象化。Haskellでは`IO`型がI/Oという文脈を表すモナドで、`do`記法によってIOアクションを順に合成する。を導入したが、これは軽量スレッドの実装機構であり、Haskellのように型シグネチャへeffectを反映するものではない。Zigも型システム上I/Oを示す仕組みは持たないが、「no hidden control flow」「no hidden memory allocation」という設計思想から、allocationが必要な標準ライブラリ関数はallocatorを、出力を行う関数はwriterを明示的な引数として受け取る規約があり、「何に依存して動くか」をシグネチャの一部に表す点は他の言語にはない特徴である。

Rust・C#・TypeScriptは、Result / Task / Promiseのような型がI/Oの手がかりになるが、あくまで慣習である。同期的なI/Oはこれらの型を経由しないことが多く、結局シグネチャから見えなくなる。C#ではTaskを返すメソッドをawaitせずに呼んでもコンパイルは通り、Rustではio::Resultを無視して呼び出せる。

Kotlin・Swift・Javaは、特定のeffectについて、呼び出し元への伝播を言語が強制する。Kotlinのsuspendは、非suspend関数から呼べず、呼び出し元にもsuspendを要求する構造がioの伝播規則に近い。Swiftのasync/throwsも同様に、呼び出し側にawait/tryと、囲む関数自身へのasync/throwsを要求する。Javaのchecked exceptionは、IOExceptionを投げるメソッドを呼ぶ側にthrowsの宣言かcatchを強制する点で、歴史的に近い発想を持つ。ただし、これらはいずれも「非同期かどうか」「例外を投げるか」を表すためのものであり、同期的なI/Oまで含めて漏れなく捕捉するようには作られていない。

HaskellはI/Oを行う関数の戻り値を必ずIO a型にし、pureな関数からIO aの中身を直接取り出す一般的な方法を持たない。そのため、pureな関数がI/O関数を呼ぶことは型エラーになり、コンパイラが境界を強制する。melangが目指す設計に最も近い。例外はDebug.Trace.traceで、unsafePerformIOを使ってIOを経由せずログを出力でき、純粋関数からのログだけを許すmelangの設計と同じ発想を持つ。Scalaのcats-effect/ZIOはIO/ZIOと同じ考え方をライブラリとして提供するが、Scala言語自体はこれを強制せず、printlnのような素の副作用呼び出しをどの関数からでも書けてしまう。

MoonBitはasync関数の宣言を要求するが、呼び出し側にawaitのような印を書く必要がなく、コンパイラが型から同期/非同期を自動判定する。同期的なI/Oはasyncを伴わずに書けるため、シグネチャからI/O全体の有無を判別できる設計ではない。

トレードオフ

I/Oを示す仕組みがない設計(Go, C++, JavaScript, Python, Rubyなど)

慣習・部分的な型でI/Oを示唆する設計(Rust, C#, TypeScriptなど)

特定のeffectだけ伝播を強制する設計(Kotlin, Swift, Java)

effectを型として明示し、pureと分離する設計(Haskell、およびScala + IOモナド系ライブラリ)

melangの選択

melangは、Haskellの「pureな関数からeffectfulな関数を直接呼べない」という保証を、IO aのようなモナドで値を包む形ではなく、KotlinのsuspendやSwiftのasync/throwsに近いシグネチャへのeffect注釈として実現する。

I/Oを示す構文を持たない設計の簡潔さよりも、シグネチャだけでpureとeffectfulの境界を読み取れることを優先する。