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

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

2018-01-12 (金)

TypeScript、お前もか: nullやundefinedの扱いがイイカゲン過ぎ【事実誤認あり】

| 09:35 | TypeScript、お前もか: nullやundefinedの扱いがイイカゲン過ぎ【事実誤認あり】を含むブックマーク

TypeScriptには期待してたんだけど、ガッカリだよ。

それでもまー、割と好きだけど。

*1

[追記]各所でご指摘いただきました(ありがとうございます)ように、コンパイル・オプション付きなら、シングルトン型のセマンティクスになります。この記事はほぼ言いがかりでした。事実誤認した経緯と、内容的修正を「TypeScript、僕が悪かった、ゴメン: nullやundefinedの扱いはマトモだった」に書きました。

この記事の本文自体はそのままにします。事実誤認も含めて記録が残ってもいいかな、と思うので。ただし、この記事だけを読む人が同じ誤認をしないように、何箇所かに修正記事へのリンクは入れます。[/追記]

内容:

  1. 特殊な型や値の扱い方はイイカゲンになりがち
  2. 特殊な型とは
  3. TypeScriptの基本型にnullは入らないと信じていた
  4. どんな伝統なんだよ
  5. TypeScriptにシングルトン型はないのか?
  6. ユニオン型は便利なんだけど
  7. never型はシッカリしている
  8. さいごに

関連:

特殊な型や値の扱い方はイイカゲンになりがち

プログラミング言語において、整数型やブール型のような普通の型の扱いがオカシイ例は知りません。しかし、null, none, nil, undefined, void, never, anyなどの言葉で呼ばれる型や値の扱いがイイカゲンで、トンチカンなことになっている例はあります。

例えば、R言語のNULLは、まともなセマンティクスを持たず、単に雰囲気で使われているため、実際にろくでもないことになります。

もっとずっと酷く、現実的に甚大な被害が生じているのは、データベースにおけるNULLでしょう。

「ヌルなんて、正常処理では使わないどうてもいい値だから」と考えるのはとんでもない間違いです。特殊な値・型も含めてちゃんと考えないと、型システムが破綻します。

特殊な型とは

ここで、特殊な型の基本をまとめておきます。

まず、シングルトン型(単元集合型)、これはその型に属する値がただ一つしかない型です。唯一の値がなんであれ、集合としてシングルトン(単元)ならシングルトン型と呼ぶことにします。例えば、C++で次のような列挙型が書けます。

enum OnlyOne {
  ONE
};

これで定義されるOnlyOne型は、値ONEしか持たないのでシングルトン型です。ONEの内部的な値は整数0ですが、次のコードはちゃんとエラーします(エラーするのが妥当な振る舞い)。g++のエラーメッセージは、"invalid conversion from 'int' to 'OnlyOne'"です。

enum OnlyOne {
  ONE
};

// コンパイルエラー
OnlyOne theValue() {
  return 0;
}

0をNULLに変えても当然ながらエラーです。

#include <stdlib.h>

enum OnlyOne {
  ONE
};

// コンパイルエラー
OnlyOne theValue() {
  return NULL;
}

NULLはlong long intとして定義されているらしく(これは環境/コンパイラ依存)、"invalid conversion from 'long long int' to 'OnlyOne'"というエラーメッセージになります。

次にユニット型。ユニット型とは、シングルトン型のなかで標準的な型を意味するとします。シングルトン型はいくらでも考えられますが、そんなかのどれかを特定したとき、それをユニット型と呼ぼう、ということです。関数型言語の場合、空タプル()だけを値とするシングルトン型をユニット型と呼ぶことが多いようです。

[補足]

集合圏Setにおいて、終対象はたくさんあります。シングルトンセット(単元集合)はなんであっても集対象です。しかし、どれかひとつのシングルトンセットを特定して、それを終対象と呼ぶのが普通です。つまり、「終対象はたくさんあるが、特定された終対象はひとつだけある」という状況になります。

任意の終対象に対応する型がシングルトン型、特定された終対象に対応する型がユニット型と考えると都合がいいでしょう。

[/補足]

それから空集合型。値がひとつもない型が空集合型です。空集合型を基本型に入れている型システムは少ないようですが、空集合型をサポートするときは、empty型とかnever型と呼ぶようです。

最後に何でもいいよ型。文字通りあらゆる値を許す型です。何でもいいよ型を認めると、型システムのチェックをバイパスできるので、好ましくないです。しかし、TypeScriptのように、もともと型チェックがなかった言語(JavaScript)に後付けで型システムを追加しているときは、互換性から何でもいいよ型が必要です。必要悪だけど。

TypeScriptの基本型にnullは入らないと信じていた

TypeScriptはJavaScriptの上位互換の言語になるように設計されています。また、処理系はJavaScriptへのトランスパイラとして実装されています。なので、JavaScriptのセマンティクスを大きく変えるような言語仕様は採用できません。

そうはいっても、新しく設計された言語ですから、JavaScriptの欠陥は矯正してくれてるだろう、と僕は期待してました。nullやundefinedに関しても、整合的で安全なセマンティクスをかぶせているだろう、と。「期待していた」というより、思い込んでいた、信じていたのです。ところが… [追記]裏切られた、というのは事実誤認です。[/追記]

前節のC++コードをTypeScriptに翻訳してみます。

enum OnlyOne {
  ONE
}

function theValue() : OnlyOne {
  return null;
}

これはエラー無しでコンパイル(トランスパイル)されてしまいます。[追記]これはコンパイルオプションなしの場合です。[/追記]

「えっ!?」と思って、今更ながらに小さなテストコードで試したみたら、TypeScriptの基本型(プリミティブ型)であるnumber, string, booleanはすべてnullを含んでいます。そればかりか、undefinedも含んでいます。

単一の値を持つ(はず)の列挙型OnlyOneは、実はシングルトン型になりません。3つの値を持ちます。

  1. OnlyOne.ONE
  2. null
  3. undefined

自動的に、nullとundefinedは追加されるのです。あらゆる型において、事前に、あるいは勝手にnullとundefinedが入るのです。もう、なんだよソレー。

TypeScriptはJavaScriptコードを受け入れるので、次のコードはコンパイルできます*2

function repeatString(s, n) {
  var r = "";
  for (var i = 0; i < n; i++) {
    r = r + s;
  }
  return r;
}

コンパイルできることは、もちろんいいです(互換性大事)。安全性・頑健性を増すために、引数、戻り値、変数などに型宣言(型注釈)を追加しましょう。

function repeatString(s:string, n:number) :string {
  var r:string = "";
  for (var i = 0; i < n; i++) {
    r = r + s;
  }
  return r;
}

これで、変な引数は弾いてくれと思うじゃないですか。しかし、次の関数は、コンパイルも実行も可能です。

function doRepeatString() {
  return repeatString(null, undefined);
}

JavaScriptの奇妙で強烈な型変換機能により、実行は失敗しません。nullの文字列への変換は"null"で、undefinedの数値への変換はNaNなので、上記の呼び出しは repeatString("null", NaN) と解釈されます。0 < NaN という比較もできるので、何ら問題ないわけです。

だけど、だけど、だけど、JavaScriptのワケわからんトンデモ・セマンティクスに持ち込んで解釈するのかよー!? TypeScriptの上位層では、まともなセマンティクスを保って欲しかった。基本型(プリミティブ型)や列挙型にnullとundefinedを突っ込んで何が嬉しいんだよ? 誰得?

どんな伝統なんだよ

Javaではどうなってるか、試してみます。

class Test {
  enum OnlyOne {
    ONE
  }
  OnlyOne theValue() {
    return null;
  }
}

これはコンパイル成功です。Javaの列挙型もnullを勝手に含めるようです。OnlyOneはシングルトンになりません。列挙型の実装にオブジェクトを使っているからでしょう。

Javaの基本型(プリミティブ型)にnullが…、いくらなんでも、まさか…

class Test {
  int intValue() {
    return null;
  }
}

エラーしてくれました。ホッ。String型になると、これはオブジェクトの型(参照型)なので、nullを許します。

それにしても、「オブジェクトの型には自動的にnullを含める」という習慣はどっから来たんでしょうか? 僕は由来を知りません。

単なる想像を言うと; 昔のC言語の文字列は、文字値(アスキー文字なら8ビット整数値)が並んだ配列へのポインターでした。ポインターだからNULLもあるわけです。構造体へのポインターとかでも同じくNULLがあります。仕掛け上、複雑なデータ型はポインターで指すことになるので、必然的にNULLも入ってしまう、と。「仕掛け上、そうなってしまった」を無思慮にずっと踏襲してしまった、ということじゃなかろか。

nullが自動的に入って嬉しいことなんてないけど、ひとつだけ思い当たるのは、Maybeモナドみたいなことかな。例外を使わずに、正常値と異常値ひとつを一緒にしたデータ型を使いたい、と。しかしね、これは明示的にやればいいことでしょ。例えば、Standard MLのdatatypeを使うなら:

datatype 'a nullable = Null | Just of 'a

null(上の定義ではNull*3)も許すstringが欲しいなら、string nullableという型を使います。

良き伝統、守るべき伝統というのもあるけど、たいしたメリットもなく、問題を引き起こすろくでもないnullの伝統を守るってのはどういう了見なんだか。

古き悪しきNULLポインターの系譜といえば、Javaより直系と思えるC++ですが、次のコードで、FooValue()とFooRefValue()はコンパイルエラーになります。FooPtrValue()がエラーにならないのは、ポインターのセマンティクスだからしょうがないでしょう。意図的にポインターを使わなければNULLが許されない点では、悪しき伝統を断ち切っているように思えます。

struct Foo {
  int foo;
};

// コンパイルエラー
Foo FooValue() {
  return NULL;
}

// コンパイルエラー
Foo& FooRefValue() {
  return NULL;
}

// コンパイル成功
Foo* FooPtrValue() {
  return NULL;
}

TypeScriptにシングルトン型はないのか?

TypeScriptでは、null型、undefined型、void型に関して、明白な定義とセマンティクスを見い出せなかったんじゃないのかな。null型にundefined値が含まれ、undefined型にnull値が含まれ、void型にはnull値とundefined値が含まれます。結局、値の集合としてはどれも {null, undefined} です。念のために言うと、null === undefined の値はfalseなので、{null, undefined} がシングルトンセットに潰れることはありません。二元集合です。[追記]これはコンパイルオプションなしの場合です。[/追記]

null値とundefined値の役割上の違いというと、配列の範囲外インデックスによるアクセスとか、初期化してない変数の値は、nullではなくてundefinedです。しかし、こういう目的で“値”を使う必要があるのか? と疑問も感じます。例えば、初期化してない変数を参照したとき:

  1. 初期化してない変数にアクセスしたらundefined値が正常に得られる
  2. 初期化してない変数にアクセスしたら未定義例外が発生する。

プログラマのミスを発見しやすい、という観点からは二番目のほうが望ましいでしょう。とはいえ、ランタイムがJavaScriptであるTypeScriptでは、実行時例外には介入できないし、コンパイル時の範囲外インデックス/未定義変数参照の検出も難しいので、致し方ないですね。ゴメンね。

null値とundefined値の用例をみるに、例外が起きてもよさそうな場面でundefinedが使われている雰囲気はあります。実際には例外が起きずにundefined値が返るので、正常な値にundefined値が混じるのは避けられない、したがって、すべての型にundefinedが入る -- まー、ここまでは納得できなくもないのですが、undefined型にnull値を許すのが解せない。

もし、undefined型の値の集合 = {undefined} なら、undefined型はシングルトン型であり、特定されたシングルトン型、つまりユニット型として使えたはずです。実際には、undefined型も二元集合なので、どうやってもシングルトン型を作れそうにありません。[追記]これはコンパイルオプションなしの場合です。[/追記]

そもそもシングルトン型は必要なのか? と言われると、絶対必須ではないかも知れません。しかし、ある型がシングルトンだという保証があると、型を簡略化できることがあります。例えば、ストリング型とシングルトン型のタプル(ペア、長さ2の配列)の型があると、これは安心して単なるストリング型にしてしまって大丈夫です。しかし、ストリング型とundefined型(値はnullとundefined)のタプルでは、[x, null]と[x, undefined]に意味的差があるかも知れないので同一視できません。

ユニオン型は便利なんだけど

僕がTypeScriptで気に入っていた点に、ユニオン型が簡単に書けることがあります。例えば次のようです。

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

enum YesNo {
  YES,
  NO
}

// 返答はYesNo型かブール型
type Answer = YesNo | boolean;

既に書いたように、僕は、TypeScriptの基本型にnullは入らないと誤解していたいので、次のような型定義(型シノニム)を書いていました。

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

このようにすると、HaskellStandard MLの代数的データ型(の簡略な場合)が、データ構築子なしで書けて便利だと思っていたのです。

誤解のもとになったもうひとつの先入観があります。かつて、Kuwataさんと僕で作っていたCaty型システムで、直和型(ディスジョイントユニオン型)を実装していました。構文が上記TypeScriptとまったく同じで、排他性(値集合の共通部分がないこと)のチェックをして、直和型のセマンティクスに基いて型計算/型チェックをしていたのです。

この経験から、(ディスジョイントな)ユニオン型は便利だし実装可能だから、TypeScriptもやったんだな、と勝手に納得していたのです。しかし、集合として null⊆string なので、string | null に意味はなく、TypeScriptが排他性チェックをしてないことが分かります。となると、型計算/型チェックのセマンティクスも信用できるか怪しくなります。[追記]これはコンパイルオプションなしの場合です。[/追記]

never型はシッカリしている

「型システムがちゃんとしているな」と感心する言語のひとつにStandard MLがあります。Standard MLは、“型システムいのち”みたいなところがあるので、ちゃんとしていて当然です。

そんなStandard MLでも「あれ?」という点があります。

exception Ouch

fun ouch () = raise Ouch

Ouchという例外を定義して、常にOuch例外を起こす関数ouchを定義しました。ouchの型を見ると、val ouch = fn: unit -> 'a となります。これは、ouch関数(正確には関数オブジェクト)が引数なしで、任意の型を取ることを意味します。

ouchが値を返すことは絶対にありません。「値を返さないから戻り値型は未定だ」という理屈です。おそらくこれは、論理における「矛盾が起きたら何でも証明できる」に触発されたのでしょう。しかし、オカシイです。

常に例外を起こす(投げる)関数の戻り値型は空集合型です。これは例外モナドによる例外機構を考えれば明らかです。次の記事など(古い順)を参照してください。

TypeScriptにはnever型があり、これはホントに空集合型になっています。neverには、nullもundefinedも入れません。次のコードはちゃんとエラーになります。

function neverNull() : never {
  return null;
}

function neverUndefined() : never {
  return undefined;
}

never型は、常に例外を起こす関数の戻り値型に使います。

function ouch() : never {
  throw "Ouch";
}

never型の定義と使用法では、Standard MLよりTypeScriptのほうがまっとうです。えらいぞ、TypeScript。

重箱の隅をつつく話をしますが、never型を引数型とする関数はあるでしょうか? ほぼ役に立ちませんが、実はあります。任意の型(集合)Xに対して、neverからXへの関数がただ一つあります。neverの意味が空集合だとすれば、そうなります。

さいごに

シングルトン型や空集合型なんて、どうでもよさそうですが、実は大事です。特殊な型ですが、特殊なだけに型システムの要〈かなめ〉のような役割を果たしています。ちゃんと設計して実装しないと、型システムの整合性を壊してしまいます。にも関わらず、イイカゲンな設計・実装が現存しています。

壊れた型システムがまったく使いものにならないわけではありません。ワークアラウンドとして、壊れた部分を避けるような使い方でしのげます。しかし、やはり望ましくはないです。TypeScriptの場合、完全に新規な言語ではなく、色々としがらみを抱えているので、理想論も言ってられないわけですが、基本型にnullとundefinedを入れるのはやめて欲しかったなー、と。[追記]コンパイルオプションを付ければ、ちゃんとチェックします。[/追記]

*1:ロゴ画像 http://www.windward.net/blogs/a-whole-bunch-of-typescript-tips/attachment/typescript-logo/ より

*2:トランスパイラは何もしないで出力に書き出すだけです。

*3:小文字のnullも使えますが、リストが空かどうかを判定する関数がnullという名前で、この関数を隠してしまうのでマズイです。

通りすがり通りすがり 2018/01/12 13:59 コンパイルの際に--strictNullChecksを付けたらどうでしょう?

Nfm4yxnW8Nfm4yxnW8 2018/01/12 18:52 シングルトン型ってString Literal Types,Numeric Literal Typesじゃダメなんですか?(strictオプションはonで)

https://www.typescriptlang.org/docs/handbook/advanced-types.html#enum-member-types

m-hiyamam-hiyama 2018/01/12 18:54 通りすがりさん、Nfm4yxnW8さん、ありがとうございます。
ごめんなさい、修正記事書きました。今日の2番めの記事です。