檜山正幸のキマイラ飼育記 このページをアンテナに追加 RSSフィード Twitter

キマイラ・サイトは http://www.chimaira.org/です。
トラックバック/コメントは日付を気にせずにどうぞ。
連絡は hiyama{at}chimaira{dot}org へ。
蒸し返し歓迎!
このブログの更新は、Twitterアカウント @m_hiyama で通知されます。
Follow @m_hiyama
ところで、アーカイブってけっこう便利ですよ。

2018-01-17 (水)

TypeScriptのはなし : ユニオン型がけっこうイイね

| 11:47 | TypeScriptのはなし : ユニオン型がけっこうイイねを含むブックマーク

TypeScriptのはなし : サンプル記述に最良の言語」に続き、「ごめんねTypeScript」シリーズ第二弾。って、シリーズにするほどの話題があるか分からんけど、TypeScriptのいい点を紹介したり褒めたりする話です。

内容:

  1. TypeScriptのユニオン型はこんなに便利
  2. TypeScriptのユニオン型はこんなことも出来る: 有限集合型

TypeScriptのユニオン型はこんなに便利

型Xと型Yのユニオン型〈union type〉は、X | Y と書かれ、“ユニオン型 X | Y”の値としてXの値でもYの値でも許されます。縦棒'|'は、集合のユニオン(合併)演算子'∪'と解釈していいです。

まず、以前の記事(事実誤認あり)でも紹介した例をもう一度挙げます。

// ユーザーIDはユーザー名とユーザー番号の
// どちらもあり得る
type UserId = string | number;

// nullも許すstring型
type nullableString = string | null;

上記のnullableString型が意味を持つのは、stringにnullが入らないことが前提ですから、コンパイルオプションstrictNullChecksを付けてください(「TypeScript、僕が悪かった、ゴメン: nullやundefinedの扱いはマトモだった」参照)。

型パラメータも使えるので、nullableStringを次のようにも書けます。

// nullも許す型 一般
type nullable<X> = X | null;

// nullも許すstring型
type nullableString = nullable<string>;

ちょっと作為的だけど、こんなん(↓)も。

// なんかの限界値の設定に使う
// (リテラルは、その値だけを含むシングルトン型を表す)
type limit = number | "unlimited";

// 三値の真偽値
type TriValLogical = boolean | undefined;

次の例題として、ツリーのデータ構造(二進木にします)を考えてみましょう。ツリーのノードをオブジェクトで表すとして、次のようなコードを書くことが多いと思います。

// 注意! 単にNodeという名前にすると、DOMのNodeと
// 名前がかぶってしまう。
interface TreeNode {}

class Branch implements TreeNode {
  left: TreeNode;
  right: TreeNode;
  constructor(left: TreeNode, right: TreeNode) {
    this.left = left;
    this.right = right;
  }
}

class Leaf implements TreeNode {
  value: string;
  constructor(value: string) {
    this.value = value;
  }
}

var helloWorld : TreeNode = new Branch(new Leaf("Hello"), new Leaf("World"));
var greeting : TreeNode = new Branch(new Leaf("greeting"), helloWorld);

この書き方はいくつか鬱陶しいところがあります。

  1. 分岐ノード〈branch node〉と末端ノード〈leaf node〉を一律に扱うために、実質的意味がないインターフェイスTreeNodeを導入している。
  2. ノードが必ずTreeNode(を実装した)オブジェクトである必要があるため、末端ノードに生の文字列や数値を使えない。
  3. オブジェクト型に必ずnullが入ってしまう仕様だと(例えばJava)、空なツリーを認めるかどうかをプログラマがコントロール出来ない。

TypeScriptのユニオン型を使ってみます。

class Branch<T> {
  left: T | Branch<T>;
  right: T | Branch<T>;
  constructor(left: T | Branch<T>, right: T | Branch<T>) {
    this.left = left;
    this.right = right;
  }
}

type Tree<X> = void | X | Branch<X>;

var helloWorld : Tree<string> = new Branch<string>("Hello", "World");
var greeting : Tree<string> = new Branch<string>("greeting", helloWorld);

ユニオン型を使わないバージョンと比較してみると:

  1. インターフェイスTreeNodeのようなものは導入してない。分岐ノードも末端ノードも一様に扱うために、ユニオン型 T | Branch<T> を使う。
  2. 末端ノードは何でもよい。特定のインターフェイスを実装する必要もないし、オブジェクト型である必要さえない。
  3. 上記の例では、空なツリー(ノードをまったく持たないツリー)はvoidとして指定している。voidを入れなければ空なツリーは除外される。(voidを入れないほうが使いやすいと思いますが、例として入れてみました。)

また、ユニオン型使用バージョンでは最初から型パラメータを入れてますが、インターフェイス使用バージョンに型パラメータを入れること(ジェネリック化)は、ちょっと面倒でした。

interface TreeNode {
}

class Branch implements TreeNode {
  left: TreeNode;
  right: TreeNode;
  constructor(left: TreeNode, right: TreeNode) {
    this.left = left;
    this.right = right;
  }
}

class Leaf<T> implements TreeNode {
  value: T;
  constructor(value: T) {
    this.value = value;
  }
}

var helloWorld : TreeNode =
  new Branch(
    new Leaf<string>("Hello"),
    new Leaf<string>("World")
  );
var greeting : TreeNode =
  new Branch(new Leaf<string>("greeting"), helloWorld);

Leafにだけ型パラメータを入れました。これだと、var helloWorld : TreeNode<string>; のような型宣言(型注釈)が書けません。TreeNodeには型パラメータがないからです。TreeNodeに単純に型パラメータを追加するとコンパイルできず、結局次のようにしました。

interface TreeNode<T> {
  value?: T
}

class Branch<T> implements TreeNode<T> {
  value?: T;
  left: TreeNode<T>;
  right: TreeNode<T>;
  constructor(left: TreeNode<T>, right: TreeNode<T>) {
    this.left = left;
    this.right = right;
  }
}

class Leaf<T> implements TreeNode<T> {
  value: T;
  constructor(value: T) {
    this.value = value;
  }
}

var helloWorld : TreeNode<string> =
  new Branch<string>(
    new Leaf<string>("Hello"),
    new Leaf<string>("World")
  );
var greeting : TreeNode<string> =
  new Branch<string>(new Leaf<string>("greeting"), helloWorld);
  1. interface TreeNode<T> {} としたかったのですが、「型パラメータTを使ってない」と怒られます*1。しょうがないので、 value?: T を入れました。
  2. 分岐ノードTreeBranchに値を持たせる気はないので、 value?: T; は書きたくないのですが、「interface TreeNode<T> を満たしてない」と怒られます。
  3. value? という書き方は、「valueはあってもなくてもいい」という意味だから、実装クラス側で省略してもインターフェイスに違反してないと思うのだけど、「書け」とおっしゃる。でも、書いたら今度は、valueの存在を許すから、僕の意図(分岐ノードに値はない)と違う。

TypeScriptは、「ツリーのノード用にオブジェクトを定義することなんて面倒くさい」というモノグサ野郎(僕です)も面倒みてくれるでしょうか? 分岐ノードとして長さ2の配列を使って、配列要素で左右のサブツリーを表すことにします。

// ジェネリック版
// >>> コンパイルエラー
type SimpleTree<X> =  X | [SimpleTree<X>, SimpleTree<X>]

// 具体的な型(string)の版
// >>> コンパイルエラー
type SimpleStringTree = string | [SimpleStringTree, SimpleStringTree];

残念ながら、型パラメータがある無しに関わらず「循環参照はダメよ」と言ってコンパイルしてくれません。typeによる定義は、単に型の別名を定義する趣旨なので、ここはそんなに頑張んなくてもいいかも知れません。

しかし、このような循環的(再帰的)型定義が絶対に処理できないわけではありません。以前、Kuwataさんと、完璧じゃないけど実用上は問題ない程度の再帰的型定義は実装したことがあります。ココラヘンに名残があります。確か、完全に処理できるアルゴリズムもあったと思います。

TypeScriptのユニオン型はこんなことも出来る: 有限集合型

有限個の値からなる型は、通常は列挙型として定義します。

// 「はい・いいえ」の返答の値
enum YesNo {
  YES,
  NO
}

// 飲食店を、
// 星ナシから星五つのあいだで評価するときに使う
enum StarRating {
  Star0,
  Star1,
  Star2,
  Star3,
  Star4,
  Star5
}

TypeScriptの列挙型は、型ごとに名前空間を持つ*2ので、その値は YesNo.NO とか StarRating.Start3 とか書きます。StarRating.3 という値を定義したり書けたりすると便利だな、と思うのですが、それは出来ません。

TypeScriptはモノグサ野郎にやさしい仕様で、次のようにしても有限集合型を定義できます*3

type YesNo = "Yes" | "No";

type StarRating = 0 | 1 | 2 | 3 | 4 | 5;

列挙型のメリットのひとつは、switch文に余分なcaseが入ってないかコンパイラがチェックしてくれる点です。例えば、次の関数は case 6: のエラーが指摘されます。

function showRating(r : StarRating) : void {
  switch (r) {
  case 0:
    console.log("no rank"); break;
  case 1:
    console.log("one star rank"); break;
  case 2:
    console.log("two star rank"); break;
  case 3:
    console.log("three star rank"); break;
  case 4:
    console.log("four star rank"); break;
  case 5:
    console.log("five star rank"); break;
  case 6: // コンパイルエラー
    console.log("six star rank"); break;
  }
}

列挙型でもユニオンによる有限集合型でも、「漏れなく」はチェックしてくれないようです。漏れじゃなくて意図的な省略もあるのでエラーにはできないでしょう。でも、defaultがなくて、caseが抜けている場合って、たいていはプログラマのミスでしょうから警告して欲しいのですが。

[追記]「漏れ」のチェックは出来ます。次のようなdefaultを書きます。

  switch (r) {
  // 省略
  default:
    const _exhaustiveCheck: never = r;
  }

列挙した残りの値の集合が空集合だというセマンティクスだけど… ウーン、しかし、これは技巧的に過ぎるよな。手でこんなん書かなくても、コンパイルオプションかなんかで漏れを検出して欲しい。[/追記]

ユニオン型(バリアント型とも呼ぶ)が巷で積極的に推奨されない理由として、型による制約がユニオン型でゆるくなったり、制約逃れの手段にユニオン型が使われるリスクがあります*4。設計がまずいせいで、ユニオン型を多用せざるを得ない、なんてこともあります。

なので、ちょっと良いか悪いか分からんのですが、次のようなこともできます。

enum Judge {
  YES,
  NO
}

type YesNo = "Yes" | "No";

// 二値の返答が色々あるんだよなー
type Answer = boolean | Judge | YesNo;

function replyToAnswer(a : Answer) : void {
  // 色々ある値を分類して処理する
  switch(a) {
  case true:
  case Judge.YES:
  case "Yes":
    console.log("Thank you!");
    break;
  case false:
  case Judge.NO:
  case "No":
    console.log("It's a pity.");
    break;
  }
}

色々ゴチャゴチャあるのを何でもいいから混ぜちゃえ、と事後対策としてユニオン型を使うのは好ましくありませんが、事前にちゃんと考えてユニオン型を使うなら、便利で役に立ってくれます。

*1:ちょっと厳しすぎる感じなので、なんらかの手段で緩和できるのか?

*2:const enumは実行時の名前空間を持ちません。

*3:既存の型の部分集合型は、たとえ有限集合であっても扱いが面倒になるので、型システムの実装者には嫌われる傾向があります。既存型の有限集合型をサポートする判断は、実用性・利便性を重視してのことなのでしょう。こういう判断をするところも僕は好きです。

*4:悪用されなくても、そもそも型が複雑になるので嫌だ、ということもあるでしょう。