3. そのほかのnull

最後に、比較的最近誕生した言語であるSwift [1] のnilとKotlin [2]のnullについて、 Java、C#と比較しながら考える。 また、C++のstd::optionalについても簡単に紹介する。

Swift 5

Swiftのnilは安全である。まず、ローカル変数は宣言時に初期化を強制する。 つまり、未初期化の変数の心配は不要だ。

それから、 JavaのOptional<T>同様、 選択型のOptional<T> [3] 型があり、 そのインスタンスは不変オブジェクトである。 ただし、 Javaとは異なり、 SwiftではT?という記法でも記述できる。

T?Optional<T>のシンタックスシュガーでしかなく、 Javaでの参照型の値をjava.util.Optional<T>でラップしたもの、 あるいはC#での値型の値をSystem.Nullable<T>でラップしたものと同様に考えてよい。

ラップした値にアクセスする方法は複数用意されている。

無条件アンラップ

Optional<T>型の式に強制アンラップ演算子を適用(式に!を後置する)すると、 強制的に型Tの値をアンラップする。 ただし、値が存在しないと、ランタイムエラーとなる。

Swiftの!後置演算子は、 Javaのjava.util.Optional<T>のメソッドget()、 C#のSystem.Nullable<T>のプロパティValueに相当する操作を表す記法である。

選択連鎖

Optional<T>型の式に選択連鎖演算子を適用(式に?を後置)して、 ラップされた型Tのインスタンスのメソッド、プロパティのアクセス、 または添え字アクセスを実行する。 値が存在しない場合、何もアクセスされず、式はnilとなる。 メソッドの戻り値の型、プロパティの型、 または添え字アクセスの型がUならば、式の型はU?になる。

戻り値がVoid型のメソッドは()、すなわち空のタプル、を返すと定義されている†1。 そのため、選択連鎖演算子で戻り値がVoid型のメソッドを呼び出すと、 式の型はVoid?、すなわち()?になる。 さらに、プロパティに値をセットする式は、 戻り値がVoid型のセッターメソッドを呼び出す式と同等なので、 選択連鎖演算子でプロパティに値を設定する式の型も()?となる。

†1 Void()型エイリアスtype alias)である。

したがって、選択連鎖演算子のアクセスの結果をnilと比較して、 アクセスできたかどうかを調べることができる。 公式ドキュメントで例証されているコードを次に引用する:

// printNumberOfRooms()は戻り値がVoid型のメソッド
if john.residence?.printNumberOfRooms() != nil {
    print("It was possible to print the number of rooms.")
} else {
    print("It was not possible to print the number of rooms.")
}

// プロパティaddressに値をセットする
if (john.residence?.address = someAddress) != nil {
    print("It was possible to set the address.")
} else {
    print("It was not possible to set the address.")
}

nil合体演算子

nil合体演算子(??)はOptional<T>型の式に値が存在しないときのデフォルト値を提供する演算子である。例えば、expr1が選択型のときexpr1 ?? expr2は、 expr1の値が存在すればその値に、存在しなければ式expr2の値になる式である。

選択束縛

Optional<T>のオブジェクトにラップされている値が存在するときに、 それを別の変数に取り出すフロー制御の記法が用意されている:

  • if let
  • guard let
  • switch

if letC#のisパターンマッチングに似た感じで使用できる:

if let value = maybeNil {
    // maybeNilがラップする値が存在した場合: このスコープでvalueはその値となる
    ...
} else {
    // maybeNilがラップする値が存在しない場合
    ...
}

ところが選択型の値が複数あるときは、次のようにコードのネストが深くなりやすい†2:

if let value1 = maybeNil1 {
    if let value2 = maybeNil2 {
        if let value3 = maybeNil3 {
            // value1, value2, value3を使うコードが続く
            ...

†2 ちなみにC#のisパターンマッチングも同様の傾向にあるが、 C#では変数のスコープが異なるので、 ifの条件を反転させてSwiftのguard letのように使うこともできる。 しかし、そのような使用例を積極的に紹介しないところをみると、 Microsoftは Early Exit [4] の考え方があまり好きではないのだろう。 実際、 C#のisパターンマッチングの解説 [5] には、 次のような記載がある:

The samples in this topic use the recommended construct where a pattern match is expression definitely assigns the match variable in the true branch of the if statement. You could reverse the logic by saying if (!(shape is Square s)) and the variable s would be definitely assigned only in the false branch. While this is valid C#, it is not recommended because it is more confusing to follow the logic.

これを解消するのがguard letである。 次のように、必要な値のいずれかが存在しなかった時点でreturnするような構造にコードを書ける:

guard let value1 = maybeNil1 else {
    return
}
guard let value2 = maybeNil2 else {
    return
}
guard let value3 = maybeNil3 else {
    return
}
// value1, value2, value3を使うコードが続く
...

もちろん、return以外のフロー制御も可能で、 例えば、ループ内であればbreakcontinueを用いることもできる。 なお、guardは必ずletと組み合わせて使う必要があるわけではない。 guardステートメントの条件式で代入された定数や変数は、 そのguardステートメントを含むスコープが閉じるまで有効なので、 nilのチェック以外のガードでEarly Exitする際でも役に立つ。

最後にswitchを用いる束縛だが、次のように case letの後に定数名と?を指定する:

func printValue(_ maybeString: String?) {
    switch maybeString {
    case let value?:
        // maybeStringは値が存在し、valueに代入済み
        print("value: \(value)")
        break
    default:
        // maybeStringはnil
        print("no value")
        break
    }
}

printValue("foo")
printValue(nil)

出力は次のようになる:

value: foo
no value

実行結果

また、 switchにタプルを用いることで、複数の値を同時にチェックすることもできる:

func printValues(_ maybeInt1: Int?, _ maybeInt2: Int?) {
    switch (maybeInt1, maybeInt2) {
    case let (value1?, value2?):
        print("values: (\(value1), \(value2))")
        break
    default:
        print("one of the values is nil.")
        break
    }
}

printValues(2, 3)
printValues(4, nil)
printValues(nil, nil)

出力は次のようになる:

values: (2, 3)
one of the values is nil.
one of the values is nil.

実行結果

標準ライブラリにおける選択型の整合性

Swiftの選択型は、JavaのOptional<T>やC#のnull許容型と比べて明らかに優れている。 標準ライブラリの基本的な機能として最初から選択型が組み込まれているからだ。 例えば、Dictionary<K, V>の添え字アクセス(subscriptの戻り値はV?型であるし、 Array<E>firstの戻り値はE?型である。

理解を深めるため、 SequenceプロトコルのcompactMapについて言及しておきたい。 compactMapは引数に「シーケンスの要素を型T?の値に変換する」クロージャをとり、 型Tの要素を含むシーケンスを生成する。 つまり、compactMapはその引数のクロージャでシーケンスの要素を選択型のオブジェクトに変換後、 値をラップしていないものを除き、 さらに値をアンラップして取り出すことで、 型Tの要素だけのシーケンスを生成する。 この操作はnilの除去と、型T?からTへの変換の両方を含む。 重要なのは、変換後のシーケンスがnilを含まない、 ということが静的解析的にはっきりすることだ。 対照的に、要素が型T?のシーケンスにfilterを適用してnilの要素を除いても、 コンパイラは生成したシーケンスを要素が型T?のものとみなしてしまう。

これをC#のLINQを用いて説明してみよう。 次のコードは参照型の要素のリスト、 ただし要素がnullである可能性があるリスト、 を受け取り、 nullを含まないリストを生成して返す、つもりである:

public static IEnumerable<T> WhereNonNull<T>(this IEnumerable<T?> list)
    where T : class
{
    var newList = list.Where(e => e is {});
    ...

このようにWhereを使って、 nullを含むlistから、 nullを含まないnewListを作ることができる。 一見すると、 これでIEnumerable<T>newListが手に入り、目的を達成したように思える。 しかし、実際にはnewListの型はIEnumerable<T?>である。 つまり、元々のlistも生成したnewListも要素の型は同じT?である。 したがって、静的解析ではnewListnullを含む可能性があるとみなしてしまう。

なお、C#でnullの除去と、型T?からTの変換は、 次のようにOfTypeメソッドを使うことでも実現できる:

public static IEnumerable<T> WhereNonNull<T>(this IEnumerable<T?> list)
    where T : class
{
    var newList = list.OfType<T>();
    ...

実行結果

これを利用して、SwiftのcompactMapをC#で模倣すると、次のようになる:

public static IEnumerable<U> CompactReferenceMap<T, U>(
    this IEnumerable<T> list,
    Func<T, U?> transform)
    where U : class
{
    return list.Select(e => transform(e))
        .OfType<U>();
}

実行結果

また、Javaで模倣すると次のようになる†3:

private static <T, U> List<U> compactMap(
        List<T> list,
        Function<T, Optional<U>> transform) {
    return list.stream()
        .map(e -> transform.apply(e))
        .flatMap(o -> o.stream())
        .collect(Collectors.toList());
}

実行結果

†3 Java 9でOptionalクラスに追加されたstream()メソッドを使った。 APIリファレンスにも同様の説明がある。

Kotlin 1.3

Kotlinのnullも、Swiftのnilと同じように安全だ。 公式のリファレンスでnull安全性 [6] についてまとめられているので、 その全貌についてはそちらを参照してほしい。

Swiftと大きく違う点は、T?が選択型ではなく、null許容型であることだ。 null許容型は、C# 8のnull許容参照型と同様に、フェイク型である。 つまり、null許容型はコンパイラの静的解析によって実現されている。 Kotlinを発明したJetBrains社は、JavaのIDEであるIntelliJ IDEAの開発元でもある。 Java 11のパートで説明したように、 IntelliJ IDEAのコンパイラは@NotNull/@Nullableアノテーションをヒントにして、 データフロー解析でnullチェックが適切かどうか検証できる。 だから、その技術をKotlinとそのコンパイラに用いたのも不思議ではない。

プリミティブ型のnull許容型

ただし、プリミティブ型の値については注意が必要である。 例えば、 公式のドキュメントにも記載されているように、 Int?型の値はボクシングされたIntオブジェクトとなる。 これはJavaで、@Nullable Integerは可能だが@Nullable intは不可、 というのと同じである。 次のように、ボクシングされたプリミティブ型の値では、 値の等価性(equality)は保たれるが、 オブジェクトの同一性(identity)が保たれることは保証されない:

val a: Int = 10000
val boxedA: Int? = a
val anotherBoxedA: Int? = a
// Prints 'true'
println(boxedA == anotherBoxedA)
// Prints 'false'
println(boxedA === anotherBoxedA)

実行結果

null許容型の演算子

デジャブ感があるが、null許容型に対して、次の演算子が用意されている:

  • .?(safe call operator)
  • ?:(Elvis operator)
  • !!(not-null assertion operator)

それぞれ、C#/Swiftの.?演算子、??演算子、!後置演算子と同様の意味をもつ。 詳細については公式リファレンスを参照してもらうことにして、 興味深い点についてだけ紹介しておく。

.?演算子はSwift同様に左辺値に適用できる。 また、.?演算子とlet関数を次のように組み合わせる†4ことで、 JavaのOptional<T>ifPresent(Consumer)map(Function)と同様な処理を実現できる:

val item: String? = ...
item?.let { println(it) }
val length = item?.let { it.length }

†4 実際にはletに限らず、 公式ドキュメントのスコープ関数にあるrunapplyalsoなども組み合わせることができる。

?:演算子の右の項には、式の代わりにreturnまたはthrowを指定できる。 [6] から例証を引用して次に示す:

fun foo(node: Node): String? {
    val parent = node.getParent() ?: return null
    val name = node.getName() ?: throw IllegalArgumentException("name expected")
    ...

null許容型と集合

ArrayクラスとIterableインターフェースには、 SwiftのcompactMapに相当する、 mapNotNullメソッドがある。 さらに、 要素がnull許容型のコレクションから値の存在する要素だけを取り出すfilterNotNullメソッドも用意されている。

Swiftと同様、 このようなAPIの存在が、 最初からnull許容型が存在していた言語の優れている点である。

プラットフォーム型

Javaとの相互運用性(interoperability)はKotlinの重要な特徴のひとつである。 しかし、 Kotlinから見ればJavaの参照型はすべてnull許容型なので、 何の対策も無しにJavaのAPIを利用することはnull安全性に脅威をもたらすことになる。 つまり、JavaのAPIを呼び出し、戻り値をすべてnull許容型として扱うと、 エラーまみれになり、!!をひたすら追加することになる。 そうしているうちに、 本当に修正が必要なエラーは埋もれてしまい、 null安全性は崩壊する。

Kotlinの設計者は賢いので、 Javaからやって来る値を扱うために、 プラットフォーム型 [7] という特別な型を用意した。 といっても、それは銀の弾丸ではなく、 コンパイル時にnullに関するデータフロー解析をオフにするだけの型、 つまり暗黙に!!演算子が適用されている型である。 これにより、Javaからのインスタンスのnullチェックを怠れば、 実行時にNPEがスローされる、というだけの問題になる。 [6] から引用した例証を次に示す:

// listはnull非許容型(コンストラクタの結果)
val list = ArrayList<String>()
list.add("Item")

// sizeはnull非許容型(プリミティブ型)
val size = list.size

// itemはプラットフォーム型(Javaのオブジェクト)
val item = list[0]

// コンパイルは成功するが、実行時にitemがnullなら例外をスロー
item.substring(1)

// 何の問題もない
val nullable: String? = item

// コンパイルは成功するが、実行時に失敗するかもしれない
val notNull: String = item

このように、Javaからの値がすべてプラットフォーム型になるわけではなく、 コンストラクタの結果やプリミティブ型の値などnullでないことが自明なものはnull非許容型になる。 プラットフォーム型の値は速やかにnull許容型、 またはnullでないことが明白ならnull非許容型、 の変数に代入して扱うべきだろう。

プラットフォーム型は記述のための記法をもたない。 しかし、コンパイラがエラーなどで型の説明をするための表記法だけは用意されていて、 「TまたはT?」という意味のプラットフォーム型をT!として表示する。 表記例を [6] から次に引用しておく:

  • (Mutable)Collection<T>!
  • Array<(out) T>!

前者は「要素の型がTの、可変または不変のJavaの集合」の参照またはnull、 後者は「要素の型がTまたはTのサブタイプであるJavaの配列」の参照またはnullを表す。

なお、Kotlinのコンパイラは、 Javaのパートで説明したnullにまつわるアノテーションを理解するので、 Kotlinから参照するJavaのAPIを@NotNull@Nullableでアノテーションしておけば、 Javaのオブジェクトがプラットフォーム型になることを回避できる。

C++17

C++はC++11でnullptrキーワード、C++17でstd::optionalクラスが導入された。 std::optionalは、 JavaのOptionalと同様の課題を解決するためのものだ。

C/C++では、配列はオブジェクトではない。したがって、 Javaのパートで説明した「nullの代わりに長さ0または1の配列を返す」ようなことをモノマネすることはできない。 もちろん、理屈の上では配列の代わりにstd::vectorなどで同様なことを実現できるものの、 C++を使う動機の多くは、そのようなオーバーヘッドを許容しないため、だからだ。

C++の標準はstd::optionalの実装が(値を格納するために)動的にメモリを割り当てることを禁止†5している。 パフォーマンスを理由に選択型の採用を拒絶する輩への対策は、 標準化委員会がやっておいてくれた。

†5 誤解しないように念のため補足しておく。 std::optional<T>型のオブジェクトは、 生成された時点で型Tの値を格納するためのメモリ領域を事前に確保しておく。 だから、std::optional<T>型のオブジェクトに型Tの値を格納するときに、 動的なメモリ割り当ては発生しない、という意味である。 典型的な実装は、長さsizeof(T)のバイト配列を確保しておいて、 プレイスメントnewでそこに値を格納する。 そして、このことから分かるように、JavaやSwiftとは異なり、 Tの派生型の値を格納することはできない。

オブジェクトの生成

値をもつstd::optional<int>型のオブジェクトの宣言の例を示す:

std::optional<int> v1(123);
std::optional<int> v2 {{123}};
std::optional<int> v3 = 123;
auto v4 = std::optional<int>(123);
auto v5 = std::make_optional<int>(123);

コンパイル結果

どれでも同じ結果になる。同様に、値をもたない宣言の例を示す:

std::optional<int> n1;
std::optional<int> n2 {};
std::optional<int> n3 = std::nullopt;
auto n4 = std::optional<int>();

コンパイル結果

同じく、どれも同じ結果になる。

値の有無の確認と取り出し

他の言語の選択型、null許容型に比べると、 C++17のstd::optionalでできることは少ない。 std::optionalには、 JavaのOptionalifPresent(Consumer)メソッド、 map(Function)メソッドのような、 ラムダ式を受け取る操作がない。 そもそも、 C++は現在のところ標準ライブラリにリスト内包表記list comprehension)[8] のAPIがない。 だから、そのようなものがstd::optionalだけにあったところで、 使い勝手が劇的に良くなることはないだろう。

ただし、次のようなプロポーザルが出ているので、 将来的には他の言語でできることができるくらいに機能が追加されるかもしれない:

p0798R3 Monadic operations for std::optional

std::optionalの値の有無は、bool型の値を返すhas_value()メンバ関数で取得できる。 しかし、この関数を使う必要はない。 std::optionaloperator boolbool型への暗黙的な型変換)を定義しているので、 次のようにインスタンスをifなどの条件式にそのまま指定できる:

std::optional<int> maybeInt = ...;
if (maybeInt) {
    // maybeInt.has_value()がtrue、すなわち値が存在する場合
    ...
} else {
    // maybeInt.has_value()がfalse、すなわち値が存在しない場合
    ...
}

値の取り出しには、operator *もしくはvalue()メンバ関数を使う。 これらの結果が異なるのは、値が存在しない場合だけである。 その場合、前者は未定義の振る舞い、 後者は例外std::bad_optional_accessのスローとなる。

値が存在する場合は、operator ->を使って値のメンバにアクセスすることもできる:

std::optional<std::string> maybeString = ...;
if (maybeString) {
    // auto &s = *maybeString;
    // auto size = s.size();
    // と書くのと、次の行は同じ:
    auto size = maybeString->size();
    ...
}

実行結果

ただし、operator *同様、値が存在しないときは未定義の振る舞いとなる。

value_or(T)メンバ関数は、値が存在するときはその値、そうでなければ引数の値を返す:

std::optional<std::string> maybeString = ...;
auto s = maybeString.value_or(defaultValue);

遅延初期化

興味深いことに、 JavaのOptional、C#のNullable、SwiftのOptionalとは異なり、 C++のstd::optionalのインスタンスは不変オブジェクトではない。 値の有り、無しの状態を変更すること、 そして値有りの状態のままで値を別のものに変更することもできる。 値無しから値有りへの変更を用いて、 遅延初期化lazy initialization)を実現できる (#7「Immutable Object」の遅延評価、 #12「Javaのメモリモデル」の遅延初期化を参照)。

std::optional<T>のオブジェクトを、 値無しから値有りに状態を変える、または値を別のものに変更するには、 emplace(...)メンバ関数を呼び出す、 operator =T型のオブジェクトを代入する、 または値有りのstd::optionalオブジェクトを代入する、 などの操作がある。 また逆に、値有りから値無しに状態を変えるには reset()メンバ関数を呼び出す、operator =std::nulloptを代入する、 などの操作がある。

遅延初期化の例として、計算式を表す文字列をコンストラクタで受け取り、 getValue()で評価した値を返すクラスCalculatorを考えてみよう。 次のような使用例を想定する:

int main() {
    Calculator c("(8 * 7 + 6) / 4");
    std::cout << c.getValue() << std::endl;
}

計算式の評価を初回のgetValue()の呼び出しまで遅延するクラスCalculatorの実装例を次に示す:

class Calculator {
public:
    Calculator(std::string expr) : expr(expr) {
    }

    int getValue() {
        if (!value) {
            value.emplace(evalExpr());
        }
        return *value;
    }

private:
    std::string expr;
    std::optional<int> value;

    // exprを評価して返す
    int evalExpr() {
        return ...
    }
};

実行結果

もう少し実用的な例を示す。 クラスFooが、クラスBar型のメンバbarを持ちたいが、 Barクラスにはデフォルトコンストラクタが無い場合を考える。 ただし、Fooのコンストラクタではbarを初期化できず、 barを遅延初期化せざる得ないとする。

C++17より前では、次のようにstd::unique_ptrを使って解決することができる:

class Foo {
public:
    Foo() {
        ...
    }

    void initialize() {
        bar = std::make_unique<Bar>(...);
    }

private:
    std::unique_ptr<Bar> bar;
};

しかし、次のようにstd::optionalを使えば、 動的なメモリ割り当てを用いずに、遅延初期化を実現できる:

class Foo {
public:
    Foo() {
        ...
    }

    void initialize() {
        bar.emplace(...);
    }

private:
    std::optional<Bar> bar;
};

未定義の振る舞いから例外のスローへ

C++でこのstd::optionalを使う最大の魅力は例外std::bad_optional_accessのスローである。 例えば、あるAPIがstd::optionalを使わず、nullptrを返すとしよう。 戻り値がnullptrなのにnullチェックを忘れてアクセスしたら、 未定義の振る舞いになる。 そうではなく、そのAPIがstd::optionalを返すのであれば、 値の有無をチェックせずにvalueでアクセスしても、 所詮例外のスローで済む。この違いは大きい。

しかしながら、nullptrまたはNULLを返す過去の資産があるため、 これから新規に作成するAPIだけにstd::optionalを用いても、 焼け石に水なのだろう(長い時間をかけて変わっていく可能性もあるだろうけど...)。

Referecnces

[1] Wikipedia, Swift (programming language)

[2] Wikipedia, Kotlin (programming language)

[3] Apple, Swift Standard Library, Numbers and Basic Values, Optional

[4] Apple, The Swift Programming Language, Language Guide, Control Flow

[5] Microsoft, Pattern Matching (C# guide)

[6] Kotlin Foundation, Kotlin Programming Language, Language Guide, Null Safety

[7] Kotlin Foundation, Kotlin Programming Language, Language Guide, Calling Java code from Kotlin

[8] Wikipedia, List comprehension