TL, DR;
読んだ: TypeScriptの異常系表現のいい感じの落とし所 | Developers.IO
方向性はとても同意できるがデータがオブジェクトである積極的な理由がないのが分かる。今日び new Success(...)
もあるまい。 構造的型付が原則なんだから Namespace Import する前提で型定義と関数を公開してしまった方が単純な FP スタイルで書けて勝手が良い。
そういうわけで僕ならこう書く。
使い方
import * as Result from './result';
function doSomethingFailable(): Result.T<number, Error> {
const r = Math.random();
return r < 0.5
? Result.success(r)
: Result.failure(new Error('Something failed.'))
}
function orDefault<V>(result: Result.T<V, unknown>, defaultValue: V): V {
return Result.match(result, {
failure() { return defaultValue; },
success(value) { return value; },
});
}
const result = doSomethingFailable();
console.log(orDefault(result, NaN)); // Prints a number < 0.5, or NaN.
自明な flatMap
/ map
がないのでより低水準な変換として match
を提供しているが、もちろん型の利用者が合意できるなら Optional に類する定義を採っても良い:
function map<V, U, E>(f: V => U, result: Result.T<V, E>): Result.T<U, E> {
return Result.match(result, {
failure(value) { return Result.failure(value); },
success(value) { return Result.success(f(value)); },
});
}
function flatMap<V, U, E>(f: V => Result.T<U, E>, result: Result.T<V, E>): Result.T<U, E> {
return Result.match(result, {
failure(value) { return Result.failure(value); },
success(value) { return f(value); },
});
}
つまるところ何が違うか
値がユーザ定義クラスのインスタンスではなくて Object
のインスタンスであることが違う。
JavaScript の用語だと両方オブジェクトに違いないが、後者は専ら構造体に類する複合データ型として使われるので、この記事では区別のために前者を特にオブジェクトとし、後者はレコードと呼ぶことにする。換言すると差異は値がオブジェクトではなくレコードであることである。
type T
このモジュールが提供する型は Failure
、Success
そしてそれらの Union である T
である。
何故最も主要な型が Result
ではなく T
なのかというと、Namespace Import が前提なので名前が冗長 (Result.Result
) になるのを避けるためである。この流儀は OCaml から借用した。
その違いが何をもたらすか
脱カプセル化
端的に言うとカプセル化されなくなる。つまりデータ構造定義が公開インタフェースの一部になる。これを聞いて目尻が釣り上がる OO おじさん達はまわれ右。
静的型付された代数型の変更に憚るところはない。もし定義を変更する場合があったとして、それで互換性が破壊されるならビルドがちゃんと壊れるはずだ。コンパイラはエラー箇所を正しく列挙できるからそれを直せば良い。
だいたい Destructuring Assignment やら Rest/Spread Operator やらを使う時点でデータ表現はインタフェースの一部である。get
/ set
で云々すればインタフェースと内部データ表現を分離することは不可能ではないが、そんなところに労力を費して見た目と意味論を乖離させるよりは直交的なデータ構造設計に腐心する方が生産的だろう。
new の除去
見た目の問題。任意の関数は値を返すのだ。コンストラクタが特別な地位にある必要はない。
副作用の明示
メソッドがレシーバの状態を変更するか否かシグネチャから分からないというのは C++ を除くほとんどのオブジェクト指向言語が抱えている欠陥である (「常識的にメソッドの名前で分かるだろ」という手合は素晴しい同僚に恵まれた運命に感謝した方が良い。)
データ表現にレコードを使うと値の操作は必然的に自由関数として定義することになるため、その操作が破壊的か非破壊的かはシグネチャで明示される:
interface T {
x: string;
y: number;
}
type S = Readonly<T>;
// Mutates the given object's |x| property.
function setX(obj: T, newValue: string): void {
obj.x = newValue;
}
// Clone the given object with new |x| property.
function withX(obj: S, newValue: string): S {
return { ...obj, x: newValue };
}
(ただし明示されるだけである。TypeScript は "strictFuntionTypes": true
な環境でさえ不変オブジェクトを可変な同型オブジェクトにキャストできる variance 何ソレ言語なので呼出側が間違えると救われない。この辺は flow の方が整合性がある。)
また JavaScript でデータ変換パイプラインを書くときは lodash や ramda など関数指向の外部ライブラリを頼ることになるので、データ操作に自由関数を使うことは見た目の一貫性の点でも好ましい。
それにつけても with の惜しさよ
モジュールを Namespace Import すると当然だがその中の型や値は Namespace Object 経由で参照することになる。
これは設計の意図するところではあるが、const x: Optional.T<string> = Optional.orDefault(Optional.map(getOptional(...), f), 'default value');
などと書かれたら const O = Optional;
とか const { map, orDefault } = Optional;
したくなるのが人情であろう。
要は JavaScript の関数が多相性を持たないことが問題である (まあレコードをデータ表現に使う限り同じ型なので多相に仕様がないのだが。) map
とか flatMap
みたいな操作はほとんどあらゆるデータ型に定義されるので、複数のデータ型を同時に扱う場合に各実装を区別するには Namespace Object を含んだ完全修飾名で参照するしかない。
これは Haskell とか Scala とかだと勿論問題にならなくて、型クラスのインスタンスであると宣言しておけば勝手にアドホック多相になる。Common Lisp や Raku (former Perl 6) のようなジェネリック関数を動的にディスパッチするシステムでも、解決が実行時になることを別にすれば同様である。
一方 OCaml など型クラスを持たない ML 系の言語には実は JavaScript と同じ問題があるのだが、OCaml の場合は字句的スコープに限定して Namespace Object に相当するモジュールを開く let open
という操作ができる:
module My_lazy = struct
include Lazy
let map f v =
Lazy.from_fun (fun () -> f (Lazy.force v))
let bind v f =
Lazy.from_fun (fun () -> Lazy.force (Lazy.force v |> f))
let ( >>= ) = bind
end
let () =
let open My_lazy in
from_val 42
|> map string_of_int
|> map print_endline
|> force
一つの式中で複数の抽象データ型を使う場合には依然として一方に完全修飾名を使う必要があるなど注意は要るが、それでも大分簡潔になるのが分かると思う。
かつての JavaScript には同じ機能が存在した (厳密に言えば今でもある): with
文である。指定したオブジェクトをスコープ・チェーンの先頭に追加した字句的スコープを提供する構文で、つまりそのオブジェクトのプロパティに変数としてアクセスできようになる:
// CAUTION: JavaScript code, not TypeScript since the language does not support |with|.
import * as Result from './result';
with (Result) {
const n = Math.random();
const r = n < 0.5 ? success(n) : failure(new Error('higher than expected'));
match(r, {
failure(e) {
console.log(e);
},
success(n) {
...
},
});
}
非常に便利なのだが実行時に動的に名前が導入されることが最適化の妨げになり性能へのペナルティが大きいことにより現在では使用が推奨されていない。TypeScript でも意図的にサポートされていない機能である。
コメント
コメントを投稿