null/nil の扱いをオブジェクト指向的に考え直す

Objective-C で nil のメソッドを実行すると例外が発生しないのは変…ではないよ、というのを解説。

■そもそもの NULL の意味

C++ も Objective-C も C言語を発端としているので、「NULL」≒「値が無い状態」というのを継承しています。
御存じの通り、C言語では、NULL というのは(void*)0 あるいは 0 として定義されています。定義されているのですが、これが NULL という意味を示しているかどうかは別なのです…が、「現実主義的な」C言語としては、NULL = 0 のほうが都合が良かったわけですよ。

基本は NULL はポインタとして扱うので、0 ポインタ自体を「有効」にできないという矛盾があります。かつて、8bit 時代の CPU ではメモリが貴重だったので、0 ポインタを「無効」にするとはなんということかッ!!! という話ががあったとかなかったとか聞きます…いえ、聞いたことはありませんが、0 ポインタを特別な値にするのは、結構なもめごとだったのです。当然、アセンブラだと 0 から始まったほうが良いですからね。

ですが、16bit 時代になり、ほどよくメモリが広くなると捨て領域としての 0 ポインタが作れるようになりました。つまりは、先頭の 16 バイトぐらいは捨て領域にしたわけです。これは、NULL ポインタアクセスを検出するためと同時に、NULL アクセスをしたときに OS ごと落ちてしまう(MS-DOSの場合は落ちますが)ことを防ぐために、0 ポインタは「無効」な領域として存在させていたわけです。

そんな訳で、NULL というものが「無効」であると同時に、0 であることを決めたのが「#define NULL 0」あるいは「#define NULL ((void*)0)」な訳ですね。ちなみに、20年以上前の古いコードを見ていくと、この定義が散見しています。これは、組み込み系のCコンパイラが NULL を定義していなかったからなんですね。まぁ、インクルードとして stdio.h に define が定義されているため、というのもあります。

つまりは NULL の定義としては、

  • そのポインタが「無効」である。
  • そのポインタが「0」である。

の二重の定義があるのです。実は2番目の定義がデファクトスタンダードで、なんか NULL を 0 に定義してまったら、定着してしまったという悪習ではありますが、まぁ、そういう二重の定義を NULL は持ちます。

■NULL ポインタをアクセスするという意味

では、NULL ポインタをアクセスするという意味を考え直してみます。
現実的に 0 ポインタにアクセスするということは、実はメモリというものは「メモリがある限りアクセスできる」という原則に従えば、0 ポインタでも何処でもよいわけです。実際、i386 のノーマルなモードではそういう動きをしています。

しかし、i386 のプロテクトモードからメモリ保護の概念が入ってきました。詳細は i386 アセンブラの本を読んで頂くとして、メモリアクセスに「範囲」を指定できるようになったわけです。で、「範囲」以外のときには、アクセス保護例外が発生できるようになりました。この「アクセス保護例外」自体が、まさしく「NULL ポインタアクセス例外」な訳です。もう少し詳しく言えば、0 ポインタをアクセスできるようにしてしまえばアクセス保護例外は発生しないのです。つまりは、あえて 0 ポインタにアクセス保護を掛けている訳ですね。

さて、通常の NULL ポインタのアクセスとしては、C言語ではこんな風に書きます。

int *n = NULL;
*n = 10;

NULL = 0 ポインタを示すので 10 を代入しようとしたときに「アクセス例外」が発生します。

string *str = NULL;
int sz = str->size();

この場合は、どうでしょうか? str が 0 ポインタとなっているので、0 ポインタの size メソッドを呼び出そうとしてエラーになります、という仮定ができます。現在の vector<> では NULL ポインタアクセスのエラーになりますが、実は、こんな風に書くとエラーになりません。

class A {
private:
	 int _n ;
public:
	A() { _n = 0; }
	void func() {
		cout << "in A::func " << endl;
	}
	static void sfunc() {
		cout << "in A::sfunc" << endl;
	}
};

int main( void )
{
	A a;
	a.func();
	a.sfunc();

	A *p = NULL;

	p->func();
	p->sfunc();

	return 0;
}

これを実行すると、エラーを出さずに動きます。 p->func や p->sfunc のところで p が NULL なのでエラーが発生しそうな気がするのですが、実はエラーにならないのです。なぜでしょうか? 実は、先の「仮定」が間違っていて、C++ の場合クラスのメソッドを呼び出すときには、クラス自身の関数テーブルを呼び出し、その中でメソッドのポインタを呼び出すからなんですね。なので、クラス自身の関数テーブルだけを参照するような static メソッドや内部変数を扱わないメソッドはエラーにならないのです。

※ コメントにある通り、ここでは、virtual method を使っていないので実は vtbl は関係ありませんね。ちょっと後でこのあたり修正します。実際、virtual func2 を定義してやって、p->func2 と呼び出すと、this->vtbl が呼び出せないためにエラーなります。

なので、次のように内部変数の _n を参照させてやると、p->func の実行時にエラーになります。

class A {
private:
	 int _n ;
public:
	A() { _n = 0; }
	void func() {
		cout << "in A::func " << this->_n << endl;
	}
	static void sfunc() {
		cout << "in A::sfunc" << endl;
	}
};

ということは、NULL ポインタへのアクセスというのは二つのパターンがあって、

  • NULL ポインタそのものにアクセスする。
  • NULL ポインタを持つオブジェクトのメソッドやプロパティにアクセスする

というパターンがあるわけです。既に分かる通り、NULL ポインタそのものにアクセスする時は「アクセス保護例外」が発生するのでエラーになります。しかし、NULL ポインタのオブジェクトに対しては、C++ では一度クラスの this ポインタにアクセスするためにエラーにならない場合もあるのです。いや、エラーになるのは、内部の this ポインタからオブジェクトそのもののポインタへアクセスしようとした(この場合は 0 ポインタです)

■ちなみに C# の場合はどうなのか?

C# の場合をみてみましょう。

public class A
{
	private int _n;
	public void func()
	{
		Debug.Print("in func");
	}
	public static void sfunc()
	{
		Debug.Print("in sfunc");
	}
}

同じようにクラスを作っておいて、以下のようにアクセスをすると p.func() のところで例外が発生します。

A a = new A();
a.func();
A.sfunc();

A p = null;
p.func();
A.sfunc();

C# の場合は、NULL ポインタそのもの(擬似的に null ですが)にアクセスするために、例外が発生していると考えられます。
ちなみに static メソッドの場合は、クラス名でしかアクセスできないので、A.sfunc のために例外が発生しません。C++ の場合は、A::sfunc でもアクセスできるし、p->sfunc でもアクセスできる、という違いがあります。

■Objective-C の nil の挙動を考える

さて、このことを踏まえて、Objective-C の nil の挙動を考えます。nil の場合は、オブジェクトに値を設定しても「アクセス例外を発生しない」という挙動があります。

 A *a = nil;
 [a func];

この func を呼び出すときには、どのような挙動が望ましいのでしょうか?

  • C++ の static メソッドのようにアクセス例外を発生させない。
  • C# のようにアクセス例外を発生させる。

実は、Objective-C の発祥は古くて、ほぼ C++ と同じぐらい(30年前ぐらい?)です。実は F# の元になっている関数言語も同じぐらいの発祥だったりします(教科書をみるとその位の年代)。なので、プログラム言語のカンブリア紀って感じだったのかもしれません。その後、Perl や Ruby などのスクリプト言語(sed, awk, sh は以前からあるし)にも関係していきます。

そこで、Objective-C としては、C++ の挙動によってアクセス保護例外が発生するよりも、全く例外が発生しない、という道を選んだわけです。これが、nil が単なる 0 ポインタ(NULL ポインタ)ではなくて、オブジェクトとして扱ったときの C++ や C# との大きな違いになります。

# java の null の扱いはどうだったかなぁと思っているのですが … 確かめてみると、C# と同じように p.func() の時にエラーを発生しますね。

■nil の利点を考える

長々と書きましたが、実は nil の挙動に関しては、他にはない非常に有利な点があります。
通常は、オブジェクトの NULL 判別をしなくて便利とか、間違って NULL を渡されてしまったときもエラーにならないくて便利、という「便利」さが強調されていますが、実は、もっと大きな利点があります。
この「利点」どの本にも書いてないので、もともと objective-c の発想にあったのかさだかではないのですが、解説すると、

スレッド間やプロセス間でオブジェクトの値が変更されたとしても正常に動く、という驚異的な動きが可能なのです。

  1. A スレッドと B スレッドが動作している。
  2. 共有のオブジェクトを使っている。
  3. A スレッドが非同期に共有オブジェクトの値を nil にした。
  4. B スレッドはオブジェクトが nil であっても正常に動く。

という動きをします。

これを C++ のコードで書いてみると、

// 共有オブジェクト
static Com *com ;

void Athread()
{
	if ( com == NULL )
		return ;
	for ( int i=0; i<100; i++ ) {
		// 何かの処理
		com->method() ;
		...
	}
}
void Bthread()
{
	if ( com == NULL )
		return ;
	com->method() ;
	// 解放するとか
	com = NULL;
}

このような場合、A スレッドは、com->method で落ちてしまう可能性があります。なので、com を使う時にはいちいち NULL であるかどうかをチェックしないと駄目なのです。非同期処理の場合は、これが結構面倒で、C/C++ の場合はロックを掛けたりしますね。C# も似たような苦労があります。
この例では、一か所しかないのですが、複数の共有オブジェクト、複数のメソッドを呼び出すとこのチェックは結構手間です。

void Athread()
{
	if ( com == NULL )
		return ;
	for ( int i=0; i<100; i++ ) {
		// 何かの処理
		if ( com != NULL )
			com->method() ;
		...
	}
}

また、細かく云えば、if ( com != NULL ) と com->method() の間にインタラプト(割り込み)が発生する可能性もあるわけで、これを完全にガードする方法はロックしかありません。実際、OS 内部ではこの手の非同期処理をちまちまとアセンブラレベルでロックしているのですが、実は、Objective-C で書くとすんなり解決ができます。

// 共有オブジェクト
static Com *com ;

- (void)Athread
{
	for ( int i=0; i<100; i++ ) {
		// 何かの処理
		[com method];
		...
	}
}
- (void)Bthread
{
	[com method];
	// 解放するとか
	com = nil;
}

こんな風に、com ポインタが NULL であるかどうかを気にせずに書けます。これは、そもそもが com のポインタが nil である場合は method が呼び出されないという objective-c の仕組みにより有利な書き方ができるのです。
更に云えば、C/C++ ではロックを掛ける必要があった部分が、objective-c ではロックを掛ける必要はありません。おそらく、相当非同期に com ポインタが nil になったとしても正常に動くと思われます。

■まとめ…のようなもの

と、まぁ、こんなことを objective-c のコードを書きながら思ったわけですが、実のところはどうなんでしょうね? iOS の中身が objective-C とは思われないので、多少は違うのかもしれませんが、nil の挙動のおかげで非常にバグが少ないプログラムが書けるはずです(それでも、不具合があるのは Apple の C言語部分ではないかな、と想像したり)。

nil/NULL を指定しているのだから「アクセス例外」が発生して欲しい、という意見もあるでしょうが、実行時にはこういう「フェールセーフ」(安全系に倒れる仕組み)は重要かと思っています。人的ミス(コーディングミス)が発生しても、ほぼ安全にコードが動くという話はまた別の機会に。
↓のところに、ちょっとだけ書いてあります。

フェイルセーフなコードを書くには? | Moonmile Solutions Blog
http://www.moonmile.net/blog/archives/2173

カテゴリー: C#, C++, Objective-C パーマリンク

null/nil の扱いをオブジェクト指向的に考え直す への8件のフィードバック

  1. masuda のコメント:

    ええと、補足。
    共有オブジェクトのところ「共有リソース」の場合はもう少し正確にロックを掛けないといけないので、その時はセマフォとかを使いますね。ってことで。

  2. masuda のコメント:

    更に補足。
    そうそう、その昔の PC の場合は、0 ポインタは BIOS の ROM 領域なんですよね。PC が起動するときに 0 ポインタから読み込まれるので、ブートストラップの起動コードが書いてある BIOS なのです(今の PC はどうなんですかね?)
    なので、BIOS は ROM(Read Only Memory)なので、0 ポインタへの書き込みは、書込み保護例外が発生します。割り込み例外 0 が走るハズです。
    逆に読み込みの場合はどうなのかというと、リアルモード(だっけ?)の場合は、そのまま読み込みめて、プロテクトモードの場合はシステム領域なのでアクセス違反だったような覚えが。

    あと、i286 や i386 のセグメントレジスタ方式の場合は、もうちょっと違いますけどね。0 ポインタは各データセグメントの先頭領域になります。多分、現在でも同じ。

  3. 匿名 のコメント:

    C++の場合、virtualなメソッドがなければそのクラス/構造体はvtblを持たないのでは?

    クラスの非staticなメソッドを呼び出す時は、
    thisポインタを暗黙の引数として渡しているはず(呼出規約で言うところのthiscall)。
    このthisポインタがnullだと記事の通りエラーになる場合がある。ここではvtblは関係ない。

    vtblはvirtualなメソッドを呼び出す際に使っていると思うのですが。

    • masuda のコメント:

      Thank you.
      YES.YES. vtbl は virtual のほうでした。
      後で確認して修正しておきます。

    • masuda のコメント:

      そんなわけで修正しました。
      本記事の主旨としては、[p method] の動きは変じゃないよ、ってことなので、危うい vtbl の話はオミットということで、お茶を濁しておきます。

  4. 匿名 のコメント:

    インドのzeroの発見と同じでnilは無しと同じです

    実用的なCが意味合いをプログラマーに任せている部分があるのですが
    if (pt==0) //0番地にアクセス
    if(pt==0xFF) // pt = 0xFFで終端ポインタ未定義とする
    とかってコメント・定義が必要になります
    ですので本文にあるcom=nilになっても単にcomが使用不要と判断される
    だけですので.
    無理矢理,使用不要といってる物にアクセスしにいって落ちたりしません

    また、SmallTalk,Forth等は環境に近いので(Linuxコマンドみたいなもの)
    いちいちPC落ちていたら使い物になりません。相手がいなかったら
    *0番地がデーター領域とかって、昔は結構ありました

  5. 匿名 のコメント:

    ごめん人のブログ借りてだけどもう一つ書かせて

    なぜMacはObjective-Cかといいますと.
    WindowsもMac(NextStep)のSmallTalk->Altoから派生したオブジェクト指向のOSです
    WinAPIに見れるようにWindowsは完全にオブジェクト指向です
    キーボードを押すたびにWM_KEYDOW,ウィンドウを動かす毎にWM_MOVE
    とかのメッセージが内部で流れています。
    このメッセージに介入する事もできます。Objective-Cでいうカテゴリですね
    たとえばSendMessage(hwnd,WM_CLOSE,0,0)を他のウィンドウに送信すると
    そのウィンドウは閉じます、
    この時hwndはウィンドウのハンドルですが
    そのウィンドウが存在しなくても例外処理が発生してシステムが落ちる事はありません
    hwnd==NULLで無視されます

    というようにWindows = Object-Cなのですが なぜかMSはC++を採用
    (ちょっとPASCALがらみで違うし、当時Cは流行ってたし)
    C++(MFC)でObjective-Cぽく使っているという変な状況が生まれている訳です

    ブログ主さまお借りしてすみません
    IOS Objective-Cが異端のような事があちこちで云われていて腹が立ってました

    • masuda のコメント:

      歴史を見ると、C++, Objective-C, 関数言語 は同時期に発生しているんですよね。なので、この記事を書いたときに巷で流行っていた「nil がキモイ」、「かっこがキモイ」というのが「C++ と比べて」となっていたので、ちょっと違うかなぁ、と思って書いたのがこれです。
      ちなみに、MFC の m_hWnd の扱いは乱雑で、SendMessage/PostMesssage の場合は伝達先がないので NULL でも落ちないのですが、CWnd::GetWindowRect なんかの通常メソッドはバスバス落ちます。せっかく、CWnd クラスでくるんであって、Attach/Detach のメソッドもあるのに、NULL の場合には、ASSERT で落ちるという…なんとも変な設計思想だなあと、常々思っています。

コメントは停止中です。