Ken Okabe氏による
- 関数型プログラミングが『銀の弾丸』であるという非常識な常識2022
https://kentutorialbook.github.io/functionalprogramming2022/
の率直な感想を書いていきます。
「【追記】」の部分はTwitterでの他の人の反応や氏のはてなブログによる反論を受けて記載したものです。
JavaScriptで演算子オーバーロードを実現しようとするのは筋が悪い
氏は二項演算子に拘っておられますが、JavaScriptにはユーザー定義可能な演算子オーバーロードはないのだから、JavaScriptに適したやり方(関数・メソッド)を使うか、演算子オーバーロードに適した言語(特にStandard ML, OCaml, Haskellなどはユーザー定義の演算子を書けます)を使うべきだと思います。
【追記】もちろんC++やRustでも演算子オーバーロードはできますが、C++やRustを含む多くの言語では演算子としてあらかじめ決まった種類のものしか使えません。「パイプライン演算子がないから |>
を演算子として使おう」ということはできないのです。一方で、上の段落で挙げたML系言語では(言語が許す種類の文字からなる)任意の記号列を演算子として定義することができます。元の文書の主題からすると任意の記号列を演算子として使える方が好ましいかと考えたのでStandard ML, OCaml, Haskellを挙げました。
例として、氏の「パイプライン演算子」を見てみましょう。氏のパイプライン演算子は
// f(x) に相当するコード
P(x)['>'](f)
という形をしています。まず、文字数が増えています。また、18.8にある定義を見ると、元の値がObjectであればそれ自身を改変するコードとなっています。例えば、氏のパイプライン演算子を使うと次のコード
const x = {'>': "Hello world!"};
console.log(x['>']);
P(x)['>'](x => x);
console.log(x['>']);
は
Hello world!
[Function: value]
を出力します。与えられた引数を改変しており、これは汎用ライブラリーとしては非常に行儀の悪い挙動です。
氏がそこまでJavaScriptにこだわる理由は私には分かりませんが、「アテにならない」TC39が支配するJavaScriptに無理やり二項演算子を定義するのではなくてもっと筋の良い言語の啓蒙に勤しむ方が活動として有意義だと私は考えます。
【追記】このセクションは特に私の「感想」色が強く、人によって意見が異なることは自然なことだと思います。なので、あまりとやかく言い争う気はありません。
reduceにはinitialValueを指定しよう/reduceは二項演算と言えるか?
JavaScriptのArray.prototype.reduceは次のような引数を受け取ります:
array.reduce(callbackFn, initialValue)
ここで、initialValueは省略可能です。MDNの説明を見てみましょう。
initialValue Optional
A value to which previousValue is initialized the first time the callback is called. If initialValue is specified, that also causes currentValue to be initialized to the first value in the array. If initialValue is not specified, previousValue is initialized to the first value in the array, and currentValue is initialized to the second value in the array.
Exceptions
TypeError
The array contains no elements and initialValue is not provided.
initialValueが省略されたときの挙動を見てください。initialValueが省略された場合は、配列が空でなければreduceは
callbackFn(... callbackFn(callbackFn(array[0], array[1]), array[2]), ..., array[array.length - 1])
を返します。そして、入力となる配列が空だった場合はTypeError例外を送出します。
言うまでもなく、TypeError例外が発生する状況はプログラマーとしては避けたいです。そして、この場合はinitialValueをきちんと与える、というのが簡潔な解決策になります。reduceに渡す演算がモノイド演算であれば単位元を与えれば良いでしょう。加算なら 0
, 乗算なら 1
です。
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].reduce(add, 0)
堅牢なプログラムを書く上では、reduceは二項演算ではなく三項演算として捉えるべきです。
【追記】initialValueを省略した場合に「加法の場合は0となる」「乗法の場合は1となる」という説明は間違っています。だって空配列に .reduce(add)
を適用しても 0 は返ってこないでしょう?氏の反論記事では空配列のことを無視しており、私の文章の書き方が悪かったかなあと反省しております。
ちなみに、堅牢なプログラミング言語であるStandard MLでは、JavaScriptのreduceに相当する関数 List.foldl
はきっかり3つの引数を受け取る関数となっています。
val foldl : ('a * 'b -> 'b) -> 'b -> 'a list -> 'b
「型=集合」か?
「型」が何かというのは(私にとっては)難しい問題です。
初学者向けには「型は集合のようなものだよ」という説明で良いかもしれません。
しかし、私が思うに、型は集合よりも幾分抽象化されています。ここでの抽象化というのは、できる操作が限られているという意味です。例えば、
- 集合は自由に和集合 (union) や共通部分(交差, intersection)を取れますが、型について和や交差を取れるかどうかは型システムに依存します。
- 例えば、今のTypeScriptにはunion typeやinteresction typeがありますが、リリース当初のTypeScriptにはどちらもありませんでした(union typeはTypeScript 1.4から、intersection typeは1.6からです)。
- 別の例で言うと、Haskellにはそもそも部分型関係がないためunionもintersectionもありません。
- 集合に対しては冪集合 (power set) という構成がありますが、型に対する類似物はあまり聞きません。ある型の部分型全てを集めた型…となるのでしょうか?
- 【追記】「
A -> Bool
がそうではないか」という意見を頂きました。特性関数と部分型を同一視すればそうなりそうですが、筆者としては「部分型を集めた」感がないなあ、と思ったのでこの記事の当初の版では言及しませんでした。
- 【追記】「
という点で型と集合は違うものだと考えます。
繰り返しますが、私は初学者向けに「型は集合のようなものだよ」と説明することを否定はしません。しかし、Twitterで「こいつ @mod_poppo は、型が集合である事実すらしあず」と仰っているからにはKen Okabe氏は本気で「型=集合」と考えているようです。それほどご自身の信念に自信をお持ちなら、解説文書中の「型」を全部「集合」に置き換えてはいかがでしょうか。
FunctorとMonadは二項演算か?
HaskellのFunctorやMonadはそれぞれ
(<$>) :: Functor f => (a -> b) -> f a -> f b
(>>=) :: Monad m => m a -> (a -> m b) -> m b
という二項演算を持ちます。Monadはこのほかに単位射 return :: a -> m a
を持ちます。Monadについてはこの時点で「二項演算」だけじゃないだろって感じですが。
ここで、一般的な圏論の教科書における関手 (functor) の定義を確認してみましょう。ここでは最近出た「みんなの圏論 演習中心アプローチ」を当たってみます。5.1.2節によると、関手の構成要素は対象関数および
対象のすべての対 $c,d\in\mathrm{Ob}(\mathcal{C})$ に対する射関数 (morphism function)
\mathrm{Hom}_F(c,d)\colon\mathrm{Hom}_{\mathcal{C}}(c,d)\rightarrow\mathrm{Hom}_{\mathcal{C}'}(F(c),F(d))
だそうです。
ここで、もしもHomが(プログラミング言語における)関数型のようなものであればこれはカリー化された関数、二項演算と思えるでしょう。しかし、 $\mathrm{Hom}_F(c,d)$ の $\rightarrow$ は集合の間の関数の意味なのに対し、行き先の $\mathrm{Hom}_{\mathcal{C}'}(F(c),F(d))$ は圏 $\mathcal{C}'$ の射です。($\mathcal{C}'=\mathbf{Set}$ でない限り)住んでいる世界が違うのです。なので、圏について特別の事情がない限り、関手は二項演算とは言えないと考えます。集合と写像の圏 $\mathbf{Set}$ やHaskellの型と関数の圏(っぽいもの)には特別の事情(内部ホムの存在)があるため、関手が二項演算だと思えるわけです。
モナドについては、「圏論の基礎」を当たってみましょう。第VI章「モナドと代数」の最初の定義がモナドで、曰く
定義1 圏 $X$ におけるモナド (monad) $T=(T,\eta,\mu)$ は函手 $T\colon X\rightarrow X$ と二つの自然変換
$$\eta\colon I_X\dot{\rightarrow}T,\quad\mu\colon T^2\dot{\rightarrow}T$$
からなり,次の図式を可換にするものである.
(図式省略)
だそうです。この本の中では $\eta$ は単位元 (unit), $\mu$ は乗法 (multiplication) と呼ばれていますが、私の記憶では $\eta$ は単位射と呼ばれることもあったと思います。
「圏論の基礎」での $\eta$ はHaskellのMonadに対応させるなら return :: a -> m a
で、 $\mu$ は join :: m (m a) -> m a
に相当します。モナドの数学的な定義にはHaskellの >>=
に相当するものは含まれていないのです。なので、モナドのことを二項演算と呼ぶのは不適切だと考えます。
これもやはり圏についての特別の事情があれば話は別で、strong monadと呼ばれる構造を持つモナドであればお馴染みの (>>=) :: m a -> (a -> m b) -> m b
に相当するものが定義できます。
【追記】私は「型≠集合」という立場なので関数型プログラミングのモナドが集合圏上のモナドと同じものだとは思いません。
「タイプコンストラクタ」の用法
詳しい人向けの解説:氏の文書では「タイプコンストラクタ」を「(モナド等の)単位射」の意味で使っています。これは間違いです。
19.6より引用:
こういう何もないところから、ある特別な型(Type)を新規に構築する(生み出す)関数のことを、
型構築子/タイプコンストラクタ(Type constructor)といいます。
オブジェクト指向でいうところの、コンストラクタ(Constructor)に該当します。TypeScriptにおいてはタイプコンストラクタにも当然きちんと型(Type)がつくのですが、
ジェネリックを使って、以下のようにタイプ定義します。type F<A> = A[]; const F = // type constructor <A>(a: A): F<A> => [a];
素の値であるAから、
タイプコンストラクタであるFによってF<A>
という新しい型(Type)が新規に構築された(生み出された)様相がう> まく表現されています。
とのことです。ここで定義した F
は (a => [a]) : <A>(a: A) => Array<A>
という関数で、要素が1つの配列を作る関数です。Haskell風に型を書けば a -> F a
となるでしょう。これはモナドの単位射に他なりません。
一方で、型構築子 (type constructor) の世間一般での意味を確認しましょう。
まずは「型システム入門 プログラミング言語と型の理論」を見てみます。定義9.1.1では、「型構築子 $\rightarrow$」とあります。ここでの型構築子は関数型を表記する $\rightarrow$ のことのようです。そして11.12には
これまで見てきた型付けの機能は、BoolやUnitといった基本型もしくは、既にある型から新たな型を作る $\rightarrow$ や $\times$ といった型構築子に分類できる。別の便利な型構築子としてはListがある。
とあります。説明の通りですが、すでにある型から新たな型を作るものが型構築子です。TypeScriptで言えば関数型 =>
や配列型 Array<>
が該当します。
type constructorについては、Wikipediaにも記事があります。ここでは、基本型のことも(0項)型構築子と呼んでいます。
いずれにせよ、型構築子というのは型を作るもので、型レベルの概念です。「その型の値を作る値レベルの関数」ではありません。オブジェクト指向の「コンストラクタ」と混同しないでください。
用語に関してついでに言うと、Ken Okabe氏の文書ではMonadを返す関数のことを「Monad関数」と呼んでいるようです。Haskellで言えば a -> m b
みたいなやつです。圏論の文脈的には、これにはKleisli射という名前がついています。
Ken Okabe氏の22.9には
基本的に、プログラミングを含む工学では、なるだけ既存の数学的な概念と用語を踏襲すべきであって、同じ意味の造語を無闇に増やすことはあまり意味がないどころか、混乱をもたらすだけだと考えます。
という記述があります。私としてはこの記述に非常に同意します。Ken Okabe氏自身がこれを徹底していればもっと良かったと思います。
最後に、Ken Okabe氏へ
この記事について反論があるなら元の記事に追記するか、ご自身で新たに記事を書いてください。
読者にとって重要なのはTwitterでのバトルの勝者ではなく、それぞれが書いた記事のどちらに説得力を感じるか、でしょう。なのでTwitterでの見苦しいバトルは不毛です。
というわけで、Twitterで私にこれ以上絡むのはやめてください。私もあなたの相手をする時間が無制限にあるわけではないので、これ以上粘着されるようであればブロックします。言いたいことがあるなら記事に書いて、読者の判断に委ねると良いでしょう。