Build Insiderオピニオン:岩永信之(11)

Build Insiderオピニオン:岩永信之(11)

nullが生まれた背景と現在のnullの問題点 ― null参照問題(前編)

2017年1月10日

Cの系譜を継ぐC#ではnullが長らく使い続けられてきたが、最近ではその存在が大きな問題だと認識されている。前後編でこの問題を取り上げ、今回(前編)はnullを取り巻く事情について考察する。

岩永 信之
  • このエントリーをはてなブックマークに追加

 近年、nullの存在は、billion dollar mistake10億ドル規模の損失をもたらす過ち)と呼ばれるくらい忌避されるものになっている。

 nullは、低コストでそこそこ安全に参照を扱えるという意味で悪くない妥協ではあるが、技術が進歩した現在ではもう少し賢い参照の扱い方があるはずである。C#のように、これまでnullを認めてしまっているプログラミング言語で、今からそれを完全になくすというのは現実的ではないが、nullに起因する問題を少しでも避ける手段はこれからでも追加していけるだろう。

 今回は、nullが生まれるに至った背景から始め、nullが抱える問題や、nullを避けるに当たっての課題などについて説明していく。そして、nullに対するC#の現在の取り組み状況について触れる。

初期化処理とnull

 null自体の話の前段階として、変数やフィールドの初期化についての話から始めたい。

不定動作

 メモリというのは確保した時点ではどういう状態になっているか分からず、誰かが適切に初期化しなければ不定な動作を招く。C#ではあまり気にすることはないが、それは.NETランタイムやC#コンパイラーが適切に初期化作業をしてくれているからである。

 説明のために、あえて不定動作を起こしてみよう。C#でも、unsafeコンテキストでは不定動作を起こすことができる。例えばリスト1のようなコードを書いたとしよう。AllocHGlobalメソッドでメモリ確保したての領域に入っている値を出力している。

C#
using System;
using System.Runtime.InteropServices;

class Program
{
  unsafe static void Main()
  {
    var pb = (byte*)Marshal.AllocHGlobal(4);
    for (int i = 0; i < 4; i++)
    {
      Console.WriteLine(pb[i].ToString("X2"));
    }
    Marshal.FreeHGlobal((IntPtr)pb);
  }
}
リスト1: 不定動作を起こす例

 このコードは実行するたびに異なる値が表示される。確保したてのメモリ領域には決まった値が入っていないのである。この領域が以前使われたときに入っていた値がそのまま残っているが、いつ誰がどう使ったものなのかを知るすべはなく、実質的には不定な値といってよい。この状態を「未初期化」と呼ぶ。

 安全性の観点からいうと、未初期化領域を残すことはトラブルの原因となる。不定動作なため、たまたまテストをすり抜けてしまったバグが、本番環境で顕在化するといったこともあり得る。

確実な初期化

 話を通常の(safeな)コンテキストに戻そう。通常、C#ではこのような不定動作は起きない。以下の2つのルールがあり、変数やフィールドは必ず初期化されるようになっている。

  • 確実な代入 値を代入しないままローカル変数を読み出すとコンパイルエラーになる
  • 既定値による初期化 フィールドや配列の要素は0初期化する

 まず、ローカル変数では、何も代入していない状態の変数から値を読み出すことができない。図1に例を示すように、コンパイラーがフロー解析(=ソースコードの制御フローを追って変数の利用状況を調べる)をして、値を代入していない変数の読み出しがあればコンパイルエラーにする。これを「確実な代入ルール」(definite assignment rule)という。

図1: 確実な代入のためのフロー解析
図1: 確実な代入のためのフロー解析

 フィールドや配列の要素に対しては、new演算子でインスタンスを作った時点で全て既定値default value)に初期化される。既定値というのは要するに0初期化のことで、0false'\0'nullなど、(C#では)内部表現的には全てのビットが0の値で初期化される。

 ようやく本稿の主題であるnullが出てきたわけである。要するに、nullというのは、不定動作と比べれば0初期化の方がマシという妥協の産物といえる。少なくとも「無効な参照」ということが確実に分かって、決定的に「null参照エラー」を起こせるという点では有益である。パフォーマンス的にも、0初期化であれば耐えられないほどの負担にはならない。悪くない妥協だろう。

有効な値の確実な代入

 ここで1つの疑問が生じる。フロー解析で確実な代入を調べられるのなら、「有効な値を代入した」というのもフロー解析で調べられるはずである。なのにどうして、既定値(=0初期化。0が無効な値なこともあり得る)による初期化を必要とするのだろうか。

 理由は単純で、有効な値で初期化できない場面がどうしても残るからである。有名なものは以下の2つだろう。

  • 大き目のバッファー領域を確保する場合
  • 循環参照がある場合

 大き目のバッファー確保は、要するにList<T>クラス(System.Collections.Generic名前空間)などが内部で行っていることである。最低限の説明のために必要な部分を抜き出すと、リスト2のような状態である。

C#
using System;

class List<T>
{
  T[] _buffer;
  int _count;

  public List(int capacity)
  {
    // 事前に大き目の領域を確保しておくが、中身は使わない
    _buffer = new T[capacity];
    _count = 0;
  }

  public void Add(T item)
  {
    // _count番目の要素に有効な値を代入
    _buffer[_count] = item;
    _count++;
  }
}
リスト2: 大き目のバッファーを確保する例

 今現在使う分だけの配列を作るのでは、Addするたびに配列の確保し直しが発生して、パフォーマンス的にかなり厳しい。そこで、この例のように事前に大き目の配列を作ってしまって、満杯になるまでは同じ配列に値を追加して(配列のサイズを超えたら、そこで新たに配列を確保し直して)いくという手法がとられる。

 もう一つの循環参照は、リスト3のような状況である。

C#
class Node
{
  public Node Ref;

  public static (Node a, Node b) Create()
  {
    var a = new Node();
    var b = new Node { Ref = a };
    a.Ref = b;
    return (a, b);
  }
}
リスト3: 循環参照の例

 2つのインスタンスが互いを参照している。2つ目に作ったbの方は、newの時点でRefプロパティに有効な値を渡すことができるが、1つ目のaの方は原理的に不可能である。bが作られるまで、a.Refを有効な値で埋めることはできない。

 2例ほど紹介したが、これらの状況下でも不定動作は起こさないようにするためにあるのが、既定値(null)による初期化である。

nullの許容/拒否の区別

 後述するが、nullを完全になくそうとすると過剰なコストが発生する場面もある。また、C#のように現在nullを持ってしまっている言語からnullを取り除くというのは現実的ではない。とはいえ、nullは多くの場面で必要なく、むしろ「nullが来ることを期待していないのにnullが来る」というバグの原因になっている。少なくとも、nullの許容/拒否(nullability)を区別できる必要があるだろう。

nullの許容/拒否は型で表現すべき

 C#は「静的な型付けの言語」や「コンパイル型の言語」などといわれている。こういうタイプのプログラミング言語は、リスト4に示すように、以下のような利点を持っている。

  • メソッドのシグネチャ(=メソッド名と引数リスト)だけ見れば、そのメソッドがどういうデータを受け付けるのか一目で分かる
  • ビルド時に、コンパイラーが判断できるエラーは全て取ってしまえる
C#
class Program
{
  // シグネチャ(「F(int x)」という部分)だけ見て、
  // このメソッドがどういうデータを受け付けるかが分かる
  static int F(int x) => x * x;

  static void Main()
  {
    // intを求めるメソッドにstringを渡していて、正しく動かないことは明白
    // コンパイル時に間違いが分かるので、修正を強制できる
    var x = F("abc");
  }
}
リスト4: コンパイル型・静的型付けの言語の強み。早い段階で間違いが分かる

 ところが、nullの許容/拒否の判定に関しては、上で述べた「静的な型付け」「コンパイル型」の利点から漏れてしまっている。リスト5に示すように、nullの許容/拒否はメソッドシグネチャに表れず、実行してみないとエラーかどうか分からない状態である。

C#
class Program
{
  // こちらはnull拒否。nullが来ると実行時エラー
  static int F1(string x) => x.Length;

  // こちらはnull許容。nullが来ても平気
  static int F2(string x) => x?.Length ?? -1;

  // でも、シグネチャはF1(string x)とF2(string x)で、
  // nullの許容/拒否が分からない

  static void Main()
  {
    // F1は実行時エラーで、F2は平気
    // でも、実行してみるまで間違いには気付けない
    var x = F1(null);
    var y = F2(null);
  }
}
リスト5: nullの許容/拒否に関してはコンパイル型・静的型付けの言語の強みを生かせていない

 これは、静的な型付けの言語としては好ましくない状況である。本来であれば、int型とstring型を区別できるのと同程度に、nullを許可するか拒否するかも型を見て区別できるべきだろう。

 ちなみに、シグネチャだけ見て分かるというのは結構重要なポイントとなる。コンパイル済みのライブラリだけで(=ソースコードなし)で知ることができる情報はこのシグネチャの部分だけである。情報がないものを表示することはできないわけで、例えばVisual Studio上で、メソッドF1F2を参照すると図2のようなヒントが表示されるが、ここにはメソッドのシグネチャしか表示されない。

図2: メソッド呼び出しの際に表示されるヒント
図2: メソッド呼び出しの際に表示されるヒント

 そして、シグネチャを見て分からない/実行するまで分からないことによって、「過剰防衛」が発生することも多い。例えばリスト6のように、呼ぶ側と呼ばれる側の両方で同じnullチェックを繰り返すことがある。これは完全に無駄な処理で、nullの許容/拒否が分かりにくいことによって発生するコストである。

C#
static void Caller(string s)
{
  if (s == null) throw new ArgumentNullException(nameof(s));
  Callee(s);
}

static void Callee(string s)
{
  if (s == null) throw new ArgumentNullException(nameof(s));
  Console.WriteLine(s.Length);
}
リスト6: 呼ぶ側・呼ばれる側の両方でnullチェック

これまでのnull許容型

 C#には、C# 2.0からnull許容型nullable type)という機能が存在する。int型など、値型と呼ばれる本来はnullがあり得ない型に対してnullの代入を認める機能である。リスト7に示すように、型名の後ろに?(疑問符)を付けることで、「null+本来の値」を代入できる型を作れる。

C#
// 値型はnullにはできない
int i1 = 1;     // OK
int i2 = null;  // コンパイルエラー

// 値型にnullを追加したのがnull許容型
int? n1 = 1;     // OK
int? n2 = null;  // OK
リスト7: C#のnull許容型

 これによって、値型の場合にはnullの許容/拒否の区別を型で表現できている。ただ問題は、値型の場合だけでしか表現できていないという点だ。参照型の場合は常にnullを認めてしまっていて、拒否する手段が用意されていない。つまり、図3の上段に示すような非対称が発生している。

図3: 値型/参照型とnullの許容/拒否
図3: 値型/参照型とnullの許容/拒否

 当然、参照型にもnullの許容/拒否の区別を導入してほしいという話はC# 2.0以来ずっといわれ続けている。値型との一貫性を考えると、図3の下段のように、「T」でnullを拒否(non-nullable)、「T?」で許可(nullable)とすべきだろう。しかし、挙動を変更するためには、既存ソースコードを壊さないよう、旧仕様(Tのみ)と新仕様(TT?を区別)の切り替えオプションが必要になる。どういう形でオプション指定できるようにするかや、新旧世界に分かれてしまうことの是非などが問われている。

まとめ

 null(要するに0初期化による「不定動作」除け)は、低コストで比較的安全な動作を得られるため、妥協的に重宝されている。その一方で、意図してnullを必要とする場面はそう多くない。また、意図的にnullを使っているのか、何らかのミスでnullが残っているだけなのかが分からなくて困るといった問題が出ている。

 C#でもこの問題を解消してほしいという要望はかねてから出ているが、既存ソースコードを壊しかねない問題であるため、慎重な姿勢を示している。しかし、いつまでも避けて通れるものではない。

岩永 信之(いわなが のぶゆき)

岩永 信之(いわなが のぶゆき)

 

 

 ++C++; の中の人。C# 1.0がプレビュー版だった頃からC#によるプログラミング入門を公開していて、C#とともに今年で15年になる。最近の自己紹介は「C#でググれ」。

 

 

 

 

※以下では、本稿の前後を合わせて5回分(第8回~第12回)のみ表示しています。
 連載の全タイトルを参照するには、[この記事の連載 INDEX]を参照してください。

8. 見えてきたC# 7: C#の短期リリースサイクル化

C# 7にはどんな新機能が含まれるのかが見えてきた。これまでと比べて、C# 7はかなり速いペースでのリリースとなる。その背景にはどんな事情があるのだろうか。

9. C# 7、そしてその先へ: 非同期処理(前編) - Task-like

C#の進化の中でも「非同期メソッド」はコーディング方法を大きく変えるほど革新的だったが、そこにはまだ課題もある。C# 7~将来のC#で、非同期処理はどう進化するのか、前後編で見ていこう。

10. C# 7、そしてその先へ: 非同期処理(後編)- 非同期シーケンス

C#(とVisual Basic)が切り開いた非同期処理の新たな世界。そこにはまだ課題もある。これを克服する方法として、前後編の後編となる今回は「非同期シーケンス」がC# 7でどうなるかを見てみよう。

11. 【現在、表示中】≫ nullが生まれた背景と現在のnullの問題点 ― null参照問題(前編)

Cの系譜を継ぐC#ではnullが長らく使い続けられてきたが、最近ではその存在が大きな問題だと認識されている。前後編でこの問題を取り上げ、今回(前編)はnullを取り巻く事情について考察する。

12. C#でのnull参照問題への取り組み ― null参照問題(後編)

最近のC#ではnullの存在が大きな問題となっている。前回(前編)で説明したnullの事情を踏まえ、今回(後編)は、将来のC#がnullをどう取り扱っていくのかを見ていく。

イベント情報(メディアスポンサーです)

Azure Central の記事内容の紹介

GrapeCity Garage 記事内容の紹介

Twitterでつぶやこう!


Build Insider賛同企業・団体

Build Insiderは、以下の企業・団体の支援を受けて活動しています(募集概要)。

ゴールドレベル

  • 日本マイクロソフト株式会社
  • グレープシティ株式会社