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もそれに合わせる。
この設計で実現したいこと
- 関数の実装を読まなくても、シグネチャだけでpureな計算とI/Oを伴う処理の境界が分かる。
- pureな関数から
io関数を呼ぶことを、コンパイラがエラーとして検出する。 - テストで差し替えるべき境界、キャッシュ・リトライを入れるべき境界を、シグネチャから見つけられる。
このnoteで決めないこと
- effect annotationの具体的なsyntax(
ioをどこに置くかも含めて仮)。 ioを単一のeffectとするか、ファイル・ネットワーク・乱数などより細かく分類するか。- interface・高階関数・genericな関数でeffectをどう扱うか。
- asyncやconcurrencyと
ioの関係。 log以外に、ioを要求しない例外を増やすか(メトリクス送信など、結果に影響しないと言い切れるか怪しい処理をどう線引きするか)。
他言語比較
I/Oをシグネチャに表す仕組みは、言語によって大きく4つに分かれる。
- 示す仕組みがない: I/Oも通常の関数呼び出しと同じ形で書け、シグネチャからは判別できない(C++・Go・JavaScript・Python・PHP・Ruby・Elixir・OCaml・Zig)。
- 慣習・部分的な型で示唆する:
Result/Task/Promiseのような既存の型がI/Oの手がかりにはなるが、コンパイラは強制しない(Rust・C#・TypeScript)。 - 特定のeffectだけ伝播が強制される: 非同期性や例外を示す印が、呼び出し元にも伝播することを言語が強制するが、対象はI/O専用ではない(Kotlinの
suspend・Swiftのasync/throws・Javaのchecked exception)。 - effectを型として明示し、pureと分離する: I/Oを行う関数の型そのものが変わり、pureな関数から直接呼べない(Haskell、およびcats-effect/ZIOを使うScala)。
| 言語 | シグネチャから判別 | 表現の仕組み | 純粋関数からI/O関数を呼べるか | 説明 |
|---|---|---|---|---|
| Rust | できない(規約のみ) | async / Result | 可能 | 同期的なI/Oは、ただの関数呼び出しと区別されない。 `io::Result`はI/Oが失敗しうることを示す命名規約であり、コンパイラソースコードを解析し、実行可能な形式(機械語や中間表現)に変換するプログラム。静的型付け言語では、実行前にコンパイラが型の不整合などをチェックする役割も担う。が強制するeffect注釈ではない。同期的なI/O呼び出しは通常の関数呼び出しと構文上区別されない。`async fn`は呼び出し元にも`async`か`.await`を要求し伝播する点で`io`に近いが、表しているのは「非同期に実行されうるか」であって、I/Oの有無そのものではない。`unsafe`が示すのはメモリ安全性のeffectで、I/Oとは別軸。 |
| MoonBit | 部分的 | async(呼び出し側は無印) | 同期I/Oは可能 | `async`関数は宣言が必要だが、呼び出し側は推論され明示を求めない。 MoonBitのコルーチン方式はKotlinに近いが、非同期呼び出しに`await`のような印を書く必要がなく、コンパイラソースコードを解析し、実行可能な形式(機械語や中間表現)に変換するプログラム。静的型付け言語では、実行前にコンパイラが型の不整合などをチェックする役割も担う。が型から自動的に判定する。同期的なI/O(標準出力など)は`async`を伴わずに書けるため、シグネチャからI/O全体の有無を判別できる設計ではない。 |
| Swift | 部分的 | async / throws | 不可能(async文脈のみ) | `async`と`throws`が関数の型の一部となり、呼び出し側にも伝播する。 `async`/`throws`はSwiftの関数型の一部として組み込まれており、呼び出し側は`await`/`try`を書く必要がある。ただし表しているのは非同期性とエラー送出であって、同期的なI/O(`FileManager`の一部API)はこれらを伴わずに呼べる。 |
| Kotlin | 部分的 | suspend | 不可能(suspend文脈のみ) | `suspend`修飾子が伝播し、非suspend関数からは呼べない。 `suspend`はコルーチンの中断可能性を示すマーカーで、I/O専用ではないが、多くのI/O処理がsuspend関数として書かれる。呼び出し元への伝播が強制される点は`io`ときわめて近いが、表現しているのは「中断されうるか」であって「I/Oを行うか」ではなく、同期的なI/O(`File.readText()`など)はsuspendなしで呼べてしまう。 |
| Java | 部分的 | checked exception | 可能 | checked exceptionの`throws`が、I/Oの可能性を間接的に示す。 `IOException`はchecked exceptionのため、呼び出し元はcatchするか自身の`throws`に伝播させる必要があり、`io`の伝播規則に近い構造を持つ。ただし目的は例外処理でありeffectの分類ではなく、unchecked例外を投げるI/O API(一部のNIOなど)は対象外になる。 |
| Scala | 規約次第 | IOモナド(cats-effect / ZIO) | 言語としては可能 | ライブラリの`IO`/`ZIO`型で表現できるが、言語コアは強制しない。 cats-effectやZIOの`IO`/`ZIO`型は、Haskellの`IO`と同じ考え方で副作用を値として表現し合成する。ただしScala言語自体はこれを強制しておらず、`println`のような素のメソッドをどの関数からでも直接呼べる。effectの分離はライブラリとチームの規約に依存する。 |
| C# | できない(規約) | Task / async | 可能 | `Task`を返す慣習はあるが、コンパイラソースコードを解析し、実行可能な形式(機械語や中間表現)に変換するプログラム。静的型付け言語では、実行前にコンパイラが型の不整合などをチェックする役割も担う。は強制しない。 `Task`を返すメソッドを`async`を付けずに呼ぶこと自体は許される(結果を待たないだけ)。メソッド名に`Async`を付ける命名規約はあるが、コンパイラソースコードを解析し、実行可能な形式(機械語や中間表現)に変換するプログラム。静的型付け言語では、実行前にコンパイラが型の不整合などをチェックする役割も担う。は検査しない。同期的なファイルI/Oなどは、通常のメソッドと区別なく呼べる。 |
| C++ | できない | なし | 可能 | 副作用を示す標準的な仕組みがない。 `noexcept`は例外を投げないことを示すが、I/Oを行うかどうかとは無関係。標準ライブラリのI/O関数とそれ以外の関数は、シグネチャ上まったく区別されない。 |
| C | できない | なし | 可能 | 副作用を示す標準的な仕組みがない。C++・Goの直接の起源。 `errno`はI/Oを含む多くのライブラリ関数が失敗を示すのに使うグローバル変数だが、設定されるかどうかは関数ごとの規約に過ぎず、型シグネチャには一切現れない。I/Oを行う関数と行わない関数は構文上まったく区別されず、C++・Goはこの「区別なし」をそのまま受け継いでいる。後発言語のasync/awaitやeffectシステムは、この「どの関数が何をするか分からない」という問題への解決策の系譜と見なせる。 |
| Go | できない | なし | 可能 | すべての関数が同じ形で書け、I/Oも区別されない。 `(T, error)`という戻り値の形はGoの慣用句だが、これは失敗しうることを示す規約であり、I/Oかどうかは示さない。goroutineの起動やチャネル操作も、通常の関数呼び出しと同じ構文で書ける。 |
| Zig | できない | なし(allocator/writerの明示的な引数渡しで代替) | 可能 | 型でI/Oを区別する仕組みはないが、allocator・writerを引数で明示する規約がある。 ある関数がI/Oを行うかどうかを型シグネチャで区別する仕組みはなく、純粋な処理からI/Oを呼び出すことも制限されない。一方でZigは「no hidden control flow」「no hidden memory allocation」を設計思想として掲げており、allocationが必要な標準ライブラリ関数はallocatorを、出力を行う関数はwriterを、それぞれ明示的な引数として受け取るのが標準的な作法になっている。これはHaskellの`IO`モナド値を文脈(コンテナ)に包んだまま、その文脈を保って値を合成するための抽象化。Haskellでは`IO`型がI/Oという文脈を表すモナドで、`do`記法によってIOアクションを順に合成する。のような型レベルのeffect追跡ではなく「何に依存して動くか」を関数シグネチャに明示する規約であり、I/Oかどうかそのものを区別するわけではない。失敗し得る処理はerror union(`!T`)で表現されるが、それがI/Oによるものかは区別されない。 |
| OCaml | できない | なし(5.xのeffect handlerも型には現れない) | 可能 | 通常の関数はI/Oを区別せず、effect handlerも型シグネチャには現れない。 OCaml 5で導入されたeffect handler(`effect`構文)は、軽量スレッドや協調的並行処理を実装するための機構で、Haskellのeffect systemのような型レベルの追跡は持たない。ある関数がどのeffectを起こしうるかは、現状の型シグネチャからは読み取れない。型システムへのeffect tracking追加は研究段階にある。 |
| F# | できない(規約次第) | なし(async { }は型に現れない) | 可能 | OCamlと同様、通常の関数はI/Oを区別しない。非同期処理も専用のcomputation expressionにとどまる。 OCamlと同じく、I/Oを行う関数と行わない関数は戻り値の型からは区別されない。非同期処理には`async { }`というcomputation expressionが用意されているが、これは`Async<'T>`という値を組み立てる糖衣構文であり、Haskellの`IO`のように型システムがI/O全体を強制的に分離する仕組みではない。同期的なファイルI/Oなどは、通常の関数呼び出しと同じ構文で書ける。 |
| Haskell | できる | IOモナド | 不可能 | `IO`型が、I/Oを行う関数の戻り値型として必須になる。 I/Oを行う関数の戻り値は必ず`IO a`型になり、`IO a`から`a`を一般的な方法で取り出すことはできない(`do`記法でIOアクションとして合成するのみ)。そのため純粋な関数からI/O関数を呼ぶことは型エラーになり、コンパイラソースコードを解析し、実行可能な形式(機械語や中間表現)に変換するプログラム。静的型付け言語では、実行前にコンパイラが型の不整合などをチェックする役割も担う。が強制する。`unsafePerformIO`という明示的な抜け道はあるが、名前の通り安全性の放棄として扱われる。標準の`IO`は単一のeffectで、ファイル・ネットワーク・乱数などを型レベルで区別しないが、`mtl`や`effectful`などのライブラリでより細かいeffectを型として分離できる。 |
| Erlang | できない | なし | 可能 | 動的型付けで、I/Oを示す仕組みもない。 `-spec`で型を後付けしDialyzerで検査できるが、effectを表す語彙は用意されていない。並行処理はプロセス間メッセージパッシング(`!`と`receive`)で行うが、これも通常の関数呼び出しとは別構文である一方、I/Oの有無自体を型シグネチャから読み取る手段ではない。 |
| Elixir | できない | なし | 可能 | 動的型付けで、I/Oを示す仕組みもない。 `@spec`で型を後付けしDialyzerで検査できるが、effectを表す語彙は用意されていない。並行処理はプロセス間メッセージパッシングで行うが、これも通常の関数呼び出しと同じ構文で書ける。 |
| TypeScript | 部分的 | Promise | 可能 | 非同期I/Oは`Promise`型で見えるが、同期I/Oは見えない。 非同期処理は`Promise<T>`という型で戻り値に現れ、呼び出し側は`await`を書かないと値を取り出せないが、`await`せずに呼び出すこと自体は型エラーにならない。同期的なI/O(`fs.readFileSync`など)は通常の関数と区別されない。 |
| JavaScript | できない | なし | 可能 | 型注釈自体がなく、I/Oを示す仕組みもない。 `async`/`await`はあるが、静的検査がないため、非同期関数を`await`せずに呼んでも実行時までエラーにならない。I/Oかどうかを示す構文自体が存在しない。 |
| PHP | できない | なし | 可能 | I/Oを示す構文がない。 引数・戻り値の型宣言はできるが、effectを示す仕組みは言語にない。 |
| Ruby | できない | なし | 可能 | 動的型付けで、I/Oを示す仕組みもない。 RBSやSorbetで型を後付けできるが、いずれもeffectの分類までは扱わない。 |
| Python | できない | なし(型ヒントは任意) | 可能 | 型ヒントは任意で、I/Oを示す標準の仕組みもない。 `async def`は非同期関数を示すが、型ヒントと同じく強制力がなく、実行時にもI/Oの有無を判別する仕組みはない。 |
比較の観点は次の通りである。
- I/Oの有無が、関数シグネチャから判別できるか。
- effectを型・シグネチャとして表現する仕組みがあるか。
- effectが明示必須か、それとも推論されるか。
- pureな関数から、I/Oを行う関数を呼べるか。
- 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など)
- 利点: 学ぶ概念が増えない。シグネチャが簡潔で、effect注釈を書く手間がない。
- 欠点: 関数の実装を読まないとI/Oの有無が分からない。テストで差し替えるべき境界や、リトライ・キャッシュを入れるべき箇所を、シグネチャから発見できない。
慣習・部分的な型でI/Oを示唆する設計(Rust, C#, TypeScriptなど)
- 利点: 既存の型(
Result/Task/Promise)を流用でき、専用構文を増やさずに手がかりを与えられる。 - 欠点: 規約をコンパイラが検査しないため、印を付け忘れても壊れない。同期的なI/Oはこれらの型を経由しないことが多く、結局見えなくなる。
特定のeffectだけ伝播を強制する設計(Kotlin, Swift, Java)
- 利点: 呼び出し元にも印が伝播し、書き忘れをコンパイラが検出できる。
ioが目指す「伝播の強制」に近い仕組みが、すでに実在する。 - 欠点: 表現しているのは「非同期かどうか」「例外を投げるか」であって、I/Oそのものではない。同期的なI/Oまで含めて漏れなく捕捉するようには作られていない。
effectを型として明示し、pureと分離する設計(Haskell、およびScala + IOモナド系ライブラリ)
- 利点: pureな関数からeffectfulな関数を直接呼べないため、境界をコンパイラが保証する。
- 欠点: pureな計算にもeffectの合成(
do記法やモナド変換子)を学ぶ必要があり、学習コストが増える。標準のIOは単一のeffectで粒度が粗く、DBかHTTPかを型で区別するには追加の設計(mtl・algebraic effectsなど)が要る。Scalaのように言語コアが強制しない場合、規約を破ることもできてしまう。
melangの選択
melangは、Haskellの「pureな関数からeffectfulな関数を直接呼べない」という保証を、IO aのようなモナドで値を包む形ではなく、KotlinのsuspendやSwiftのasync/throwsに近いシグネチャへのeffect注釈として実現する。
- Pros: シグネチャだけでpureな計算とI/Oの境界を把握でき、hidden I/Oを防ぎやすい。pureなドメインロジックとeffectfulな処理を分離しやすい。高階関数や将来の
for式などで「I/Oを許可する文脈」を静的に制御できる可能性がある。 - Cons: I/Oの伝播により、多くの関数へeffect annotationが必要になる。abstractionを挟んだ場合にeffectをどう表現するかを、別途検討する必要がある。I/Oの定義範囲(DB / HTTP / filesystem / console / clock / randomness等)を決める必要がある。
logを例外にしたことで、結果に影響しないはずのログに実質的な副作用(外部監視サービスへの送信、ログの有無を分岐条件にするなど)を混ぜ込まれると、ioが保証しているはずの境界が形骸化する恐れがある。
I/Oを示す構文を持たない設計の簡潔さよりも、シグネチャだけでpureとeffectfulの境界を読み取れることを優先する。