NULLの話
whoami
- Yusuke Arai (nulab)
- Programmer
- AWS Certified
- Professional Solutions Architect
- Professional DevOps Engineer
概略
-
null
について個人的に知っていることを整理してみました。 - プログラミングから説明できる範疇で「代数」という言葉を使います。これは数学的な代数(線形代数学における代数など)に関する言及ではありません。ご容赦ください。
NULLについて
NULL連想ゲーム
- NullPointerException
- Nullオブジェクトパターン
'\0'
(void*) 0
- /dev/null
- 三値理論(真、偽、NULL)
- [英, 米] 発音は「ナル(/nʌl/)」
NULLの大別
前述の連想ゲームからもわかるように、NULL
という言葉が指す意味は、その文脈によって異なります。プログラミングで取り扱われる文脈のうち、代表的なものには以下があります。
- Null Pointer (Null Reference)
- NULL Value
- Null Object pattern
- Null-terminated string
前提
これからお話するのは Null Pointer についてです。
表現としては Null Reference のほうがより適切と思っていますが、通りのいい Null Pointer を用います。
TL;DR
NullPointerExceptionには人並みに苦しめられて来ました。今も出逢えば苦しみます。
ただそれを理由に Null Pointer を排除すべきとは思いません。
また、得てして NPE を発生させやすいような Null Pointer の使い方(後述)も、これを一概に否定はできないと考えています。
目次
- Null Pointerとは何か
- 問題解決と複雑さ
- Null Pointer Exception
目次
- Null Pointerとは何か
- 問題解決と複雑さ
- Null Pointer Exception
Null Pointer とは何か
原意はポインタが有効な値への参照を持たないことを表します。
reserved for indicating that the pointer does not refer to a valid object
しばしば0番地への参照として表現されていました。
#define NULL 0
#define NULL ((void*) 0)
Null Pointer とは何か (1)
今日では、ポインタ型かどうかの区別はなく、より広義の参照型における無効参照を表す言葉になっています(Null Reference)。
String name; // reference type
name = null;
if (name.equals("")) { // NPE
System.out.println("please input your name");
}
各言語の Null Pointer
抜粋して、おおむね最新の処理系において:
- null (Java, Scala)
- null (C#)
- nil (Go)
- nullptr (C++)
これらは Null Pointer を表現しています。
Null Pointer ではない
以下の値は Null Pointer ではありません。
- nil (Ruby)
- 実態を持つオブジェクトです。
- NilClassを継承する唯一のオブジェクトです。
- undefined (JavaScript)
- 未定義、ここでは変数の未初期化を限定的に表します。
- null (JavaScript) は無効な参照を表します。
- nil (Emacs Lisp)
- 「要素ゼロのリストで十分だ。古事記にもそう書かれている」
- Nil (Scala)
- 連結リストのおしり
- Cons(1, Cons(2, Cons(3, Nil))))
目次
- Null Pointerとは何か
- 問題解決と複雑さ
- Null Pointer Exception
問題解決と複雑さ
問題解決と複雑さ
Null Pointer は嫌われています。なぜですか。(配点10点)
問題解決と複雑さ (2)
一連の処理からなるアプリケーションを記述するときは、個々の処理の失敗について記述側が想定する必要があります。
result_t result = do_something_with_side_effects();
if (is_successful(result)) {
result = …;
if (is_successful(result)) {
…
} else {
…
}
} else {
…
}
成功と失敗を表す値
例として、アリティ0の関数 Bark
について考えます。
func Bark()
失敗と成功を判定するには、その2値の和を表すことができるデータ型が必要です。
例えば、ブール型が利用できます。
func Bark() bool
isSuccess := Bark()
if isSuccess {
…
}
成功と失敗を表す値 (2)
Bark
が有意な値を返す関数の場合、成功と失敗を表すことができません。
func (dog Dog) Bark() string {
if dog.IsAlive() {
return "わんわんお"
} else {
return /* what? */
}
}
成功と失敗を表す値 (3)
この場合、しばしば、結果型そのものが表わす値域の内に異常値を表すメタ値を導入します。
const BarkFail := ""
func (dog Dog) Bark() string {
if dog.IsAlive() {
return "わんわんお"
} else {
return BarkFail
}
}
成功と失敗を表す値 (4)
ところで、結果型が参照型である場合には、Null Pointer はメタ値としての魅力に溢れています。
public class Cat extends LivingLike {
public String bark() {
if (this.isAlive()) {
return "にゃ〜ん";
} else {
return null;
}
}
}
成功と失敗を表す値 (5)
Cat cat = new Cat();
cat.heartAttack();
String barking = cat.bark().replace("にゃ", "にょ"); // NPE
「失敗」の値としてNull Pointerを導入することは、しばしばNull Pointerそれ自体の必要さを無視して、全てのNull Pointerを排除すべきだという極論を引き起こします。
成功と失敗を表す値 (6)
成功と失敗の2値の直和型です。成功が直積型である場合にも、代数的なデータ型を用いればこれを正しく表現できます。成功の結果型を侵略する必要はありません。
以下は Maybe で失敗を表します。
def bark: Option[String] =
if this.isAlive Some("わんわんお") else None
成功と失敗を表す値 (7)
左右値は失敗それ自体が複数定義されるケースに対応できます。
しばしば左右値は Either<A, B>
として導入されますが、左右どちらかの値に規約的に重みをつけるならば、二値のタプルでもこれを表現できます。
var (
ErrDead = errors.New("it's dead.")
ErrHungry = errors.New("it needs food first.")
)
func (dog Dog) Bark() (string, error) {
if dog.IsAlive() {
return "", ErrDead
} else if dog.IsHungry() {
return "", ErrHungry
} {
return "わんわんお", nil
}
}
不在を表す値
不在もしばしば結果型の値域に踏み込んで表現されます。
['aaa', 'bbb', 'ccc'].indexOf('ddd') === -1
UserRository repo = UserRepository.fromContext(context);
User user = repo.findUserByName("星空凛");
if (user != null) {
…
}
同様に極論を引き起こします。
不在を表す値 (2)
これも今日ではMaybeがよく使われます。
Optional<User> user = repo.findUserByName("星空凛");
return user.flatMap(u -> …);
Nullの持ち込んだ複雑さ
「無効な参照」を表す特別な値として生まれた Null Pointer は、処理結果の代数値として流用されました。これまでの例のように、Null Pointer が失敗や不在を表す値として結果型に導入されると、呼び出し側はnullチェックが必要になり、それを怠った場合に NPE (, NRE, Panic) が発生します。
Nullの持ち込んだ複雑さ (2)
呼び出し側がnullチェックを怠らなければNPEは発生しません。
ただし結果型に "その" null が存在するかどうかを結果型自体は表明できません。
自然言語でのドキュメンテーションを信じるか、実装を読むか、全ての参照型にnullチェックを書くかを選択することになります。
Nullの持ち込んだ複雑さ (3)
今日の Java の場合は @NotNull
アノテーションによる表明もあり得ます。
ただし失敗や不在の表明には Optional が適当です。
複雑さへの対応
アプリケーションが持つ複雑さは失敗を孕む処理の連結だけではありません。
並行処理やデータ一貫性の複雑さも抱えています。
ここで例えば Scala には
- 並行処理: 非同期処理を表す
Future[A]
型があります。 - 失敗: 左右値を表す
Either[A, B]
型があります(他にもあります)。 - 不在:
Option[A]
型があります。
複雑さへの対応 (2)
しかし
- DBからユーザーをユーザーIDで取得する(findUserById)
- 対象が不在の可能性があり、処理に失敗の恐れがあり、かつ非同期処理
def findUserById(id: UserID): Future[Either[DBError, Option[User]]
// (implicit ec: ExecutionContext)
大抵はこのように進みます。さらにDBトランザクションを表す型(一貫性)が加わることもあります。アプリケーションの記述には、このような型を複数組み合わせる必要があります。
複雑さへの対応 (3)
型レベルで複雑さに立ち向かうと、最終的に、関数型プログラミングのアプローチなしに処理を記述することが難しくなります。
前述の例 Future[Either[DBError, Option[User]]
には、モナド変換子を導入する手筋があり、比較的よく知られてます。しかしながら、一度それを導入してしまうと、コードベースに手を付けられるプログラマーが限られ、人的リソースの調達が難しくなります。
複雑さへの対応 (4)
MaybeとしてNull Pointerを導入する、という行為は、型に頼らない複雑さへのアプローチとしてあって良いと考えています。
例えば、Goにジェネリクスはありません。
失敗への対応としては関数が2値以上を同時に返却できるのみです。
しかし、error が返されればその場で評価する、という規約の上で、型に頼らない失敗の複雑さへの対応が成立します。
並行処理への対応として、システムコールの呼び出し時には勝手に次のgoroutineにスイッチします。プログラマーはその敷かれた仕様に則るよりありませんが、そのかわりに、型に頼らずに並行処理の問題に対応できます。
複雑さへの対応 (5)
なので、仮にnullがありえるかどうかが型レベルで表明できる言語で、nullが処理結果の代数値として用いられることに合意があり、nullがありえる場合には必ずnullチェックを行うという規約を敷くのであれば、それは有効な手段と考えます。
func FindUserByID(id UserID) (*User, error)
user, err := FindUserByID(UserID("masa"))
if err != nil {
return err
} else if user == nil {
return ErrUserNotFound
}
…
複雑さへの対応 (付録1)
独自定義のランタイム例外と例外キャッチを用いた異常伝達の手法があります。
この場合の失敗は関数の結果型として表されません。また並行処理のシーンでは、言語が型情報を強くサポートするにもかかわらず、結果型情報の消失が発生します。
public User getUser() throws LackOfPermissionError;
Callable<User> f = () -> { return getUser(); };
// call() throws Exception
// information about LackOfPermissionError has been lost!!
もちろんシステム例外の表現方法としてはいまだ有用です。
複雑さへの対応 (付録2)
func SuggestUsers(query string) ([]User, error)
// empty slice as Nothing ……like what?
def SuggestUsers(query: String): List[User]
// Nil(List.empty) as Nothing ……like what?
目次
- Null Pointerとは何か
- 問題解決と複雑さ
- Null Pointer Exception
Null Pointer Exception
NPEの恐怖
Null Pointer はオブジェクト思考のパラダイムと相性が悪かったのではないかと考えています。
// after you implemented Name class' equals fine
Name name1 = new Name("Rin");
Name name2 = new Name("Rin");
name1.equals(name2);
オブジェクト同士の等価性を判定するためにレシーバが必要ですが、Null Pointer は無効な参照であり、オブジェクトではありません。
NPEの恐怖 (2)
プログラマーはレシーバが Null Pointer である可能性を孕むとき、null チェックを行います。
User user = repo.findUserById(id);
if (user != null) {
user.setName("Nozomi");
…
}
NPEの恐怖 (3)
これを怠っても直ちに NPE は発生しません。
User user = repo.findUserById(id);
user.setName("Nozomi");
想定される異常系が実行されて初めて NPE が発生します。
異常系のテストが書かれていれば直ちに発覚しますが、そういったプログラマーはこのミスを犯さず、まれに起こしても Pull Request で弾かれる(テストが書かれ CI が行われているならば Pull Request がある*1)というジレンマがあります。
一方でこのミスを犯すプログラマー*2が書いた多くの_シングルスレッドアプリケーション_は、この NPE で異常終了します。
*1 極論
*2 あの頃の僕たち
NPEの恐怖 (4)
_そういった状況のもと_で NPE の発生源(user.setName
)を特定でなかった経験が、私たちが NPE について考えるときのバイアスになっているかもしれません。
NPE 自体は Null Pointer の性質を表す自然なものだったはずです。
NPEの恐怖 (5)
しばしば、等価性の判定と NPE の複雑さは、「全てのものはオブジェクトであれ」というイデオロギーに帰結します。
irb(main):001:0> nil.==("who are you?")
=> false
irb(main):002:0> nil.nil?
=> true
user = nil
if user.respond_to? "set_name"
user.set_name "Nozomi"
end
NPEの恐怖 (6)
ただし、一時的に NPE を回避する手筋は、問題の顕在化を後伸ばしにしているにすぎないように思います。
class NullUser extends User {
public void setName(String name) {
}
}
User user = new NullUser();
user.setName("Nozomi");
// nothing happened. but is it actually what you want?
まとめ
まとめ
- Null Pointer は無効な参照ただそれだけを表します。
- nullチェックを怠れば NPE が発生します。
- 複雑さに対する型に依らないアプローチとして、得てして NPE を発生させやすいような Null Pointer の使い方(Null Pointer as Maybe)も、これを一概に否定はできないと考えています。