
2026/03/22 22:03
「カリー化に反対する事例」
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
概要:
本稿は、関数型言語におけるマルチパラメータ関数の記述スタイル—命令的/パラメーターリスト、カリー化、およびタプル化—を対比しています。カリー化がエレガントで部分適用(→add' = add 1)を自然にサポートする一方で、余分なクロージャ生成が発生し、非対称の型シグネチャ(Int → Int → Int)が一般的な高階合成を妨げる可能性があり、しばしばP₁ → P₂ → … → Rが必要になると説明しています。uncurry
タプル化関数(、型f(p₁,p₂,p₃) = …)は中間クロージャを避け、合成を簡素化します。部分適用はタプルスタイルでも可能であり、穴オペレータや構文糖衣のような手法を使って実現できます。(P₁,P₂,P₃) → R
本稿は、Haskell がすべてのスタイルをサポートし、Rust では構文トリックでエミュレートできること、また Coq/Agda のような依存型システムが自然に型依存性(
)を表現するためカリー化タイプを好むと指摘しています。plus1 : ∀ n, fin n → fin (n+1)
著者は読者に利点と欠点を比較検討し、タプル形式や代替部分適用構文の探索が将来の言語設計やライブラリ実装に影響を与える可能性があることを示唆しています。 ライブラリや DSL を構築する実務者は性能と合成性の観点からタプルを選択するかもしれませんし、証明支援ツール開発者は表現力豊かな型関係のためにカリー化を好むでしょう。
本文
カリー化関数は、命令型言語から関数型言語へ移行する際に最初に出会う新しい概念の一つです。
純粋関数型言語では、n 個の引数を取る関数を「段階的に」定義します。すなわち、関数を第 1 引数に適用すると、残りの引数(2…n)を取る関数が返ります。その関数に第 2 引数を渡すと、さらに残り(3…n)の引数を取る関数が返ります。こうして全ての引数が与えられた段階で結果が返されます。
例えば、3 つの整数を足し合わせる
add を定義するとします:
add x y z = x + y + z -- これは次のように書いたものと同じです: add = \x -> (\y -> (\z -> x + y + z)) -- add の型は以下の通りです: add :: Int -> (Int -> (Int -> Int)) -- 左から右へ適用できます: ((add 1) 2) 3 -- 6 を返します
-> は右結合にし、関数呼び出しは左結合にすることで Int -> Int -> Int -> Int と書けます。さらに、関数適用を左結合にしておけば、add 1 2 3 のように括弧を最小限で済ませることができます。
このスタイルの欠点について
カリー化したスタイルは洗練されている一方で、何かが失われています。
多引数関数を定義する際に、プログラミング言語は主に以下の三つの「スタイル」を提供します。
-
命令型スタイル(パラメータリスト) – 関数に組み込みの機能として存在。
fn f(p1: P1, p2: P2, p3: P3) -> R { … } // 定義 f(a1, a2, a3) // 呼び出し f : fn(P1, P2, P3) -> R // 型 -
カリー化スタイル – Haskell のような純粋関数型言語で採用される。
f p1 p2 p3 = … -- 定義 f a1 a2 a3 -- 呼び出し f :: P1 -> P2 -> P3 -> R // 型 -
タプルスタイル – 1 つの引数としてタプルを渡す。
f(p1, p2, p3) = … -- 定義 f(a1, a2, a3) -- 呼び出し f :: (P1, P2, P3) -> R // 型
一部の命令型言語でもこれらのスタイルを模倣できますが、少々扱いにくくなります。例えば JavaScript でカリー化スタイルを書けば:
const f = p1 => p2 => p3 => …; f(a1)(a2)(a3);
Rust でタプルスタイルを書くと:
fn f((p1, p2, p3): (P1, P2, P3)) -> R { … } f((a1, a2, a3)); f : fn((P1, P2, P3)) -> R;
理論上は等価である
(P1, P2) -> R と P1 -> P2 -> R は同型(isomorphic)です。つまり、両者の関数には 1 対 1 の対応が存在します。では、なぜカリー化スタイルを選ぶのでしょうか?
部分適用
インターネットで「カリー化関数を使う理由」を尋ねると、主に「部分適用が簡単になる」ことが答えとして挙げられます。
部分適用とは、多引数関数の一部のパラメータを固定し、残りだけを取る新しい関数を返す操作です。
3 引数の
add を例にすると:
add' = add 1 -- ここで add' = \y -> (\z -> 1 + y + z) add' :: Int -> Int -> Int add'' = add' 2 -- ここで add'' = \z -> 1 + 2 + z add'' :: Int add'' 3 -- 6 を返します
map や fold のような高階関数と組み合わせるとさらに便利です:
length = foldr (+) 0 . map (const 1) length2d = foldr (+) 0 . map length length2d [[1,4,2], [], [7,13]] -- 5 を返します
カリー化が「特殊な」性質を持つと誤解されることも
実際には、パラメータリストスタイルやタプルスタイルでも部分適用は可能です。
add をタプルスタイルで再定義すると:
add (x, y, z) = x + y + z add :: (Int, Int, Int) -> Int add' = let x = 1 in \(y, z) -> add (x, y, z) add'' = let y = 2 in \z -> add' (y, z) add'' 3 -- 6 を返します
さらに、
$(ホール演算子)を使って構文的に整えれば:
add' = add (1, $, $) add'' = add' (2, $) add'' 3 -- 6 を返します
より複雑な例は次のようになります:
length = foldr ((+), 0, $) . map (const 1, $) length2d = foldr ((+), 0, $) . map (length, $) length2d [[1,4,2], [], [7,13]] -- 5 を返します
この形は「データの流れ」を明示的に示すため、読みやすくなります。
第 2 引数以降の部分適用
タプルスタイルでは第 1 引数だけでなく任意の引数を固定できます。カリー化スタイルではデフォルトでは第 1 引数しか固定できません。
例えば、
map の第 2 引数(関数)を固定したい場合:
allColors = ["red", "green", "blue"] forEachColor = map ($, allColors)
ネストされた関数呼び出しが多くなると制限がありますが、その際は明示的にラムダ式を書くことで対処できます。
カリー化スタイルは「部分適用を強力にするわけではない」
少し構文糖衣を付ければ、カリー化スタイルの利点を再現できます。
しかし、関数型プログラマがカリー化関数に対して持つ「雰囲気」や「直感的な美学」が根底にあると考えられます。
「カリー化は良い」という主張への反論
1. パフォーマンス
add 2 3 のように呼び出すと、最初のステップで新しい関数 \y -> add 2 y が生成され、その後 3 に適用されます。つまり多引数関数を呼び出すたびに中間関数が作られます。ただし、十分な最適化器があればこのオーバーヘッドは除去できますので、重大な問題ではありません。
2. 型の形
カリー化型は「入力→出力」の形を持ちます。
P1 -> P2 -> P3 -> R の場合、入力 In = P1、出力 Out = P2 -> P3 -> R となります。一方でタプル型 (P1, P2, P3) -> R なら In = (P1, P2, P3)、Out = R とより直感的です。この非対称性は、複数の出力を返す関数がタプルで、入力は段階化しているという矛盾を生み、関数合成を難しくします。
sayHi name age = "Hi I'm " ++ name ++ " and I'm " ++ show age people = [("Alice", 70), ("Bob", 30), ("Charlotte", 40)] -- エラー: sayHi は String -> Int -> String、person は (String, Int) conversation = intercalate "\n" (map sayHi people) -- これを動かすには uncurry sayHi を渡す必要があります
map が期待する関数は In -> Out の形であるため、カリー化型は直接合成できません。2 引数の場合はそれほど問題ではありませんが、引数が増えると複雑さは増します。
3. 証明助手の例
Coq(Rocq)などの依存型証明助手で「状態を返す関数」について述べる命題を考えます:
Definition P {In Out : Type} (f : In -> State Out) := … f に関する命題 …
推奨されるカリー化スタイルで定義すると、
f : P1 -> … -> Pn -> State R となり、In -> State Out と全く一致しません。結果として P(f) は型エラーになり、毎回手動で関数を非カリー化(uncurry)する必要があります。
結論
多くの Haskell コードがカリー化スタイルで書かれているため、それに乗っ取ることは簡単です。しかし、新しい関数型言語や標準ライブラリを設計する際には、タプルスタイルと部分適用の代替構文を試してみる価値があります。
この記事を真剣に受け止めすぎないでください。実際、多くの高階関数(
map, fix など)ではカリー化定義が非常に自然です。ただ、ほとんどの場合でタプルスタイルの方が合理的だと感じます。
他にもカリー化関数の利点・欠点について知っている方はぜひ教えてください。私自身は証明助手で頻繁に「uncurry」を使わざるを得なかった経験があります。
補足:依存型関数
稀ではありますが、カリー化スタイルが優れているケースも存在します。Gallina(Coq)や Agda のような依存型言語では、戻り値の型が入力に依存することがあります。
Definition fin (n : nat) := { x : nat & x < n }. Definition plus1 (n : nat) (i : fin n) : fin (n + 1) := (i.1 + 1 ; (* i.1 + 1 < n + 1 の証明 *)).
ここで
plus1 は forall n : nat, fin n -> fin (n + 1) と型付けされ、これはカリー化された依存関数です。タプルスタイルにすると:
(* 仮想的な構文 *) Definition plus1 (n : nat ; i : fin n) : fin (n + 1) := (i.1 + 1 ; (* 証明 *)).
この場合、第二引数の型が最初の値
n に依存する「依存タプル」を扱う必要があります。これは実装上は可能ですが、型推論をさらに複雑にし、読みづらくなります。
参考文献
- Certified Programming with Dependent Types(Adam Chlipala 著)
URL: http://adam.chlipala.net/cpdt/html/toc.html (MIT Press 版もあり)
© emilia‑h 2025‑2026 CC BY‑SA 4.0 で公開。