結局、C言語のポインタはどこが難しいのか?

何やら最近C言語のポインタが熱いらしいので、自称Cプログラマとして書き残しておこうと思う。

私はポインタについて余り苦労することなく使ってきた人で、というかむしろポインタが無いと困る。構造体を引数にとる関数の仮引数はポインタで、リストやツリー構造での参照もポインタで*1、関数ポインタと関数ポインタの配列のどちらも大好き、という程度にはポインタを使う人なので「ポインタ禁止」とか無理だと思う。*2

なので本エントリは「ある程度ポインタを使える人による『ポインタの難しさ』の考察」ということになる。

Cの文法のマズさによる混乱

C言語の宣言周りの文法の筋の悪さについては例えば『エキスパートCプログラミング』のchapter 3で触れられている。この筋の悪さ故に、慣れないとポインタの宣言に型修飾子(constとvolatile)を付ける時に迷うことになる(要は「『constなオブジェクト』を指し示すポインタ」と「オブジェクトを指し示す『constな(readonlyな)ポインタ』」の違い。前者は例えば「const char *p;」で、後者は「char * const p;」。)。ダブルポインタやトリプルポインタでは何がconstで何がconstじゃないのか混乱の元だ――ポインタ配列を引数でやり取りする関数を書くとどうしても仮引数がダブルポインタやトリプルポインタになってしまうからなあ。

「オブジェクトの宣言の形式と利用の形式をできる限り一致させる」というCの設計方針も若干の混乱の種で、宣言時と参照時とで `*' の意味が異なる部分はC言語の初学者(特にプログラミング言語のような人工的な記号の集合を他人が作ったルールに基づいて並びたてることに不慣れな人)を惑わせる要因となりうる。但しこれによる混乱はかなり小さい。

罪の重さでは配列とポインタとで文法が似ている点が最も重いかもしれない。見た目が似ている上にポインタを使って配列を参照できて、しかも文脈によっては配列とポインタが同一視される*3。見た目と性質が似ている故に混同してしまいがちだ。でもポインタと配列は全くの別物だ。悪いことに混同している人による誤った解説が21世紀になってもしぶとく残っているようだ。特に口伝は性質が悪い。

この文法絡みの問題については先に挙げた『エキスパートCプログラミング』や『C言語ポインタ完全制覇』などで十分説明されている。本の何章かになる程度のボリュームがあるネタだ。

ただこれだけボリュームがあるネタにも関わらず、ポインタの難しさの全てをカバーしているとは言い難い。

ポインタについての概念モデル構築で失敗する可能性

ポインタは文字通り「指し示す人」だ。何を指し示すのか? 変数? では変数とは何か――。

ポインタの「何かを指し示す」という概念自体は比較的簡単だ、なのでここでつまづく人は(無勉強をのぞけば)少数だ……と書こうとして、本当にそうだろうかとふと疑問に思った。

ポインタについての学習にて、初歩的な手段として「図や絵を書いてみる」というものがある。図や絵を書くという事はある種のモデリングだ。ポインタを使用したプログラムを実行した時のコンピュータ(あるいはプロセス)の内部挙動についてプログラマが持っている概念モデル*4に基づく動作イメージの断片を図式化する、と言い換えてもよい。

C言語の学習においてポインタは中盤から後半の時期に登場することが多いと思う。そこに到達するまでの間、学習者は通常の変数や配列を使用したプログラミングを経験し、多少なりとも慣れているはずだ。つまり通常の変数/配列によるプログラミングに対応した概念モデルが構築されているはずだ。

その状態でポインタを学ぶとどうなるか? 学習者は、通常の変数/配列に対する自身の概念モデルに基づく「ポインタについてのメンタルモデル」を持った状態でポインタについて学ぶ。ここで、既存の概念モデルを拡張することでポインタに対応するか、又は既存の概念モデルを破棄して新たな概念モデルを構築するか、そのどちらかを迫られるはずだ。

概念モデルの拡張ないし再構築のコストは、想像だが各人の概念モデルの中身に左右される気がする。箱モデルを学んだプログラミング初学者は、箱モデルの拡張で対応するだろう(箱モデルを理解できているのなら)*5アセンブラ経験者なら「間接アドレス指定のようなもの」で済むだろう*6Ruby経験者だと……ポインタではつまづかないけど変数でつまづく人がいる気がする*7。異なるパラダイムプログラミング言語を学んだことがある人は、プログラミングの初学者よりも相対的にコストが低いかもしれない。

とにかく、ポインタ学習における「分かる人は分かる、つまづく人はつまづく」という現象は、概念モデルの拡張ないし再構築のコストの個人差が大きいからではないかと考えている。コストが小さいほどすっと理解してしまい、逆にコストが大きいほど挫折しやすい、ということだ。証拠は全く無い。

この仮説が正しいとしたら、C言語の学習者が構築する概念モデル(主に変数まわり)が適切なものになるように配慮することによって「ポインタの壁」を低くすることができる可能性がある。誰か実験してくれないだろうか?

実際の所、C言語の変数に関する正しい概念モデルを構築するのは思ったよりも難しいのではないかと思う。例えばC言語の配列は「二級市民*8」なのだけど、初学者はどこまで理解しているだろうか? 入門書を書く側にも苦しい事情があって、例えば(配列が二級市民であるが故に)式中の「Tの配列」型のオブジェクトへの参照は3つの例外を除いて「配列の先頭の要素を指し示すポインタ」という意味に解釈される*9のだけど、大抵の入門書ではポインタを教える前に配列を教えるので、配列を教える段階ではこのことをうまく説明することができない(何故なら相手はポインタを知らないから)。ではポインタについての章にて「ポインタと配列」のような項目を設ければ済むかというと、配列の章にて誤った概念モデルを構築してしまった学習者は「ポインタと配列」の項にて既存の概念モデルを否定されて混乱してしまう可能性がある。それならポインタの章の後に配列の章がくるように構成すればよいかというと、序盤の他の章を配列抜きで書くのは意外と大変な気がする。

キャストなんかも、ポインタの型キャストになってくると難しい。前提知識を色々と教える必要があるのだけど、その多くは(具体的に説明しようとすればするほど)処理系依存の内容だったりする。下手すると初学者は処理系依存の部分も含めて全て「C言語の一般知識」と理解してしまいかねないからなあ。

なお、この概念モデルは現実のC言語の薄皮を剥いだ中身とは幾分乖離した抽象的なモデルでも辻褄さえ合っていればある程度までは大丈夫だ。実際、私のポインタに関する初期のモデルは随分と抽象的で、それを補うように「関数の内部変数を指し示すポインタをreturnしたらNG」といった幾つかのルールを順守することで対応していたし、今もその名残で機械的に処理する部分がある。

コンピュータの基礎知識をちゃんと理解しているか?

ポインタは文字通り「指し示す人」だ。何を指し示すのか? 変数? では変数とは何か――。

前項で概念モデルについて触れた。プログラマはポインタを使ったプログラムの実行時の内部動作に関する概念モデルを持っていて、ソースを読み書きする際に暗黙のうちにそのモデルを利用している、と仮定しよう。

このモデルの中身はハードウェアから乖離した抽象的な内容でも辻褄さえ合っていれば大丈夫だ。しかしCプログラマとして腕を磨きたいのなら、せめて基本情報技術者レベルのコンピュータの知識に基づいたモデルに転換する必要がある。

何故か? C言語が「高水準アセンブラ」であり、深く理解しようとすると否応なくその薄皮を剥いで中を覗き込むことになるからだ。そしてその契機となるのがポインタだ。

プログラマにとって高水準言語はコンピュータを覆う抽象化層だ。現実のコンピュータの本来の姿を隠し、より抽象的な計算モデルに基づくプログラミングを可能にする。プログラマはその抽象的なモデルを土台として物事を捉え、判断していけばよい。大多数のプログラミング言語では性能要件が絡まない限りそれで十分だ。

一方でC言語は「高水準アセンブラ」と揶揄される程に高水準言語の中では低水準で、薄い抽象化層を提供している。この薄さ故に高水準言語でありながらハードウェアに近いレイヤーでのプログラミング(OS、デバイスドライバ、組込み機器等)が可能になったのだけど、「プログラマは全知全能」という基本哲学*10を持つ為にある問題を抱えることになった――何か問題が起きた際、時としてC言語の薄皮を剥ぐ必要があるのだ。

例えば4バイトの大きさを持つ型の変数があるとして、ポインタによって意図せずその変数の4バイト全てを書き換えてしまうだけでなく、1バイトだけ書き換えてしまうこともある*11 *12。これがどういうことか理解するためには、例えば抽象的な箱モデルによる変数の理解から現実的なハードウェアの挙動に基づくモデルへの転換が必要になる。そして残念ながらCでプログラミングする上でこの手の問題によるバグは避けられないので*13、いつかはこの問題を理解する為にモデルを転換することになるだろう。

そもそも「意図せず他の変数の中身を書き換えてしまう」とはどういうことなのか? 変数とは何で、変数の型はどういう意味を持ち、そして結局ポイントとは何なのか? ポインタの型キャストによって何が起こるのか? 「プログラマは全知全能」という哲学に基づく言語を使いこなす為には、この辺りを理解しておかなくてはならない。そしてC言語の抽象化層が薄い為に、C言語の「抽象化された計算モデル」の部分を学ぶだけだけでは十分な答えは得られない。低水準の、OSやハードウェアに近い部分を学ぶ必要がある。ポインタの場合は主にメモリ周りの理解が欲しい。

ここで『Hacking: 美しき策謀 第2版』ばりに対象OS・ハードウェアに密着するのも悪くないし、いずれはそっちの方向に進む必要があるのだけど、実は基本情報技術者試験程度の知識でも(ちゃんと理解していれば)ある程度は対応できる。

ポインタは型を持った間接アドレス指定方式のようなものだ。プログラム記憶方式から推測すれば、関数ポインタの存在は理解できる*14。大抵の処理系では変数はメモリ空間のどこかに確保された領域で*15、変数の型は確保する領域の大きさと内部のビットの並びの意味を決定するものだ*16 *17。ポインタも変数で、確保される領域の大きさはハードウェアやOSに依存し、中身のビットはアドレス値であることが多いが規格上は絶対ではない*18。仮に中身がアドレス値だった場合、仮想記憶に対応したOS上で動くアプリケーション・プログラムを書いているのなら、そのアドレスは仮想アドレス(論理アドレス)に違いない。

またメモリマップドI/Oを知っていて且つ物理アドレスと仮想アドレスの区別がついているのなら、(CPUによるけど)組込み系のCプログラミングで整数値を何らかのポインタに型キャストしているコードに遭遇しても戸惑わないだろう。

ちょっと基本情報技術者の範囲から逸脱している部分*19もあるけど、本質的な所をちゃんと理解していればこの程度のことは分かるはずだし、これぐらいの内容でもそれなりに役に立つ。

ところで最近C言語を学ぶ人はどのような社会的ポジションにいるのだろうか? 私の想像では情報系か電子工学あたりの学生*20か組込み系の会社の新人が大半ではないかと根拠もなく考えていて、学生ならコンピュータ科学基礎の授業で基本情報技術者試験に通ずる内容を学ぶ可能性が高いだろうし社会人なら研修で似たようなことを勉強するか「基本情報技術者を取れ」と圧力がかかるだろうから、(C言語にどっぷり浸かる必要があるなら)初めから座学で学んだコンピュータの基礎知識を絡めてC言語を勉強すればよいのではないか、と思ってしまう。

実行時のメモリ構成の理解度は?

C言語の変数やポインタについて基本情報技術者レベルの知識に基づく概念モデルを持っているだけでもそれなりに有効ではあるものの、やはりそれだけでは足りない。OS上で動かすプログラムを書いているなら、そのOSにおけるプロセスのメモリ構成の知識は欲しい。デバイスドライバや組込み開発では対象ハードウェアの物理メモリ上の構成を知る必要に駆られることもある。

例えば「関数の内部変数を指し示すポインタをreturnしたり引数経由で返したりしてはならない」とはどういうことか? 一方で関数呼び出し時に内部変数を指し示すポインタを引数指定してもOKなのは何故か?*21 関数の引数にて構造体をポインタ経由でやり取りするコードが多いのはなぜか? コールスタックの破壊とはどういうことか? スタックを食い潰すとどうなるのか? WindowsLinuxあたりのアプリケーションを書いているなら、プロセスのメモリ構成を知っていればある程度想像がつく話だ。

またメモリのアライメントについて軽く知識があれば、オフバイワンエラーのような表面化しにくい不具合の怖さも分かるはずだ。たまたま詰め物があったので問題が起こらなかっただけだ、運がよかっただなんて考えるな、配列の大きさが変更された場合を考えろ、もっと集中して取り組むべき問題があるのだこの程度でまごつくな防衛的に書いて回避率を上げろ!

古い例ではMS-DOSの頃はfarとかnearとかIntel 8086アーキテクチャと無縁ではいられなかったようだ。

この辺りの知識は不具合に遭遇した時に解決の糸口になることも多いし、何より「よく分からないけど、とりあえず良さげな感じで動作しているコード」の危険を痛感して襟を正す契機となる。

実行時のデータ構造やアルゴリズムを把握しきれないことによる失敗

ポインタはある種の動的なデータ構造と共に使われることも多い。その為、プログラム実行時のデータ構造の姿やその変遷をうまく思い浮かべることができなかったり、又は誤った姿を思い浮かべてしまうことで、ポインタ絡みのトラブルを引き起こしてしまうことがある。

例えばダブルポインタやトリプルポインタといった具合にポインタの `*' が増えるにつれて難易度が上がり、ある程度慣れているCプログラマでもまごつくことがある。私の場合、ダブルポインタには大分慣れたけどトリプルポインタでは作業スピードが結構落ちてしまう。

では私も含む彼らがポインタを苦手としているかといえば、そうとは言い難い。例えば紙に図を書けば比較的スムーズに理解できる。

これはポインタそのものの難しさというよりは「ポインタを使用したデータ構造の実行時の姿が脳内に思い浮かばない(なので落ち着いて図表化すれば理解できる)」ないし「姿は思い浮かぶけど、対応する文法(書き方)が出てこない」が正しい答えである気がする。この辺りは慣れの問題も大きいだろう。

データ構造の変遷を追いきれてないことに関しては、例えばfree(3)したメモリ領域へのデリファレンスや多重解放が該当する。データ構造が変化し(て、そのメモリ領域が解放され)たことを把握できているならば、デリファレンスしたり再びfree(3)したりなんて考えるはずがない。

もっとも構造化プログラミングの基本理念からすれば人間のちっぽけな能力でプログラムの動的構造を全て理解しようなんて正気の沙汰ではない。なのでリソースバランスの観点でメモリのアロケートと解放を分かりやすく対応づけるように記述してソースコードの見た目(静的構造)のレベルで理解できるようにしたり、解放したメモリ領域をいつまでも参照しないようにポインタの値をNULLにしたり、ちょっとした工夫で傷を小さくしようと努力するものだ。

あるいはデバッガ上でプログラムを動かしてみて実行時の実際のデータ構造の姿を確認する、という方法もある。GDBにおけるDDDのようにデータ構造の可視化に長けたツールもある(但し私はDDDを使ったことはない)。

初学者がメリットを理解しにくいことによる学習モチベーションの低下

ここまで書いてきたように、C言語における変数やポインタを深く理解するためには最終的にC言語以外の部分(コンピュータの基礎知識、対象OSないしハードウェアの知識)を学習する必要がある。学習することでポインタの存在の意義を理解できるようになる。

高水準の知識が求められる部分もある。例えば関数ポインタだ。関数がファーストクラスのオブジェクトである言語を触ったことがある人なら関数ポインタのメリットと不完全さが分かるはずだ。

データ構造絡みの場合、当然ながらデータ構造自体の知識が必要になるし、サンプルプログラムではなくて何かしら本格的なモジュールなりアプリケーションを書いてみないと見えてこない部分もある。

逆に言えば、これらの知識や経験を持っていない初学者はポインタのメリットを理解しにくい。人間は現金なもので、便利なものには飛びつくけどよく分からないものには目も向けないものだ。メリットが分からなければ学習のモチベーションは低下する。

この辺りは先に挙げた文法の不味さと並んで「初心者にポインタを教える時の大きな壁」となっていると思う。

まとめ

  • C言語の文法の不味さがポインタの学習に悪影響を与えている。
  • ポインタ登場前と登場後とで異なる概念モデルが要求される? この辺りは研究が必要なのかも。
  • 深入りしようとするほど、C言語以外の部分(OSとかハードウェア)の知識が要求される。
  • 実行時のデータ構造の姿を思い浮かべることができるか否かも重要。
  • ポインタのメリットはC言語以外の部分の知見から得られる所が大きいので、初学者にメリットを伝えにくい。メリットが分からないとポインタを勉強する意義を見出しにくい。

おまけ

C言語によるコーディングの世界には幾つかレイヤーが存在する。

  1. 言語本体の規格
  2. 周辺の規格(具体的にはPOSIXとか)
  3. 処理系(コンパイラ)が絡む部分
  4. OSが絡む部分
  5. ハードウェアが絡む部分
  6. サードパーティのライブラリ

ポインタの学習について、上記の1だけでは足りなくて最終的に4や5を学ぶ必要がある、と書いたのだけど、ややこしいことに「今、直面している問題はどのレイヤーのものか?」について把握しておかないと痛い目に遭う。

このレイヤーの存在は個人的に(ポインタを含めて)C言語を教える側にまわった時に悩む部分だったりする。一般的なC言語の入門書は比較的1の範囲に収まっているのだけど、深入りすると2〜5あたりに踏み出す必要がある。でも新人はそんなレイヤーなんて気にせずに何でも「C言語」と見なしてしまう。違うのだよ若人よ、それはVisual C++の独自拡張された仕様だ……。

履歴

日時 内容
2012-08-27 初版作成
2013-06-05 s/メンタルモデル/概念モデル/g

*1:ただしリストに関しては、没ネタだけど昔「固定配列を使ったシリアライズ/デシリアライズ対応の双方向リスト」を作ったことがあって、その時はポインタではなく添字で参照を管理していた。

*2:まあポインタ禁止とか言い出すと「関数の仮引数では配列はポインタになるから、配列を引数にとる関数は禁止だよね」とかいう話になってしまうので……え、揚げ足取り?

*3:この辺りはC-FAQ 6.1〜6.13を参照。

*4:ここでの「概念モデル」は、多分ユーザインタフェースについての議論の文脈における概念モデルの意味に近い。

*5:但し「箱を指し示すことができると何が嬉しいのか?」という疑問を持ちそう。

*6:但し型の存在を失念しているとポインタの演算でつまづくかも。

*7:Rubyの変数はCのポインタに近い性質を持っている。オブジェクトを指し示すラベルのようなものだ。変数同士の代入やオブジェクトの破壊的操作が絡んだ時の変数の見た目上の振る舞いがRubyとCでは異なるので、そこでつまづく人がいる気がする。私はこの逆(Cを学んでからRuby)のパターンにて、この辺りの問題で悩んだ。

*8:C-FAQ 6.5より。

*9:C-FAQ 6.3より。「int array[10];」と宣言されている場合、「array」という記述はしばしば「array[0]を指し示すポインタ(int *)」と解釈される、ということ。

*10:『エキスパートCプログラミング』P.61より。

*11:例えばアライメントによる詰め物が無い状態でchar型配列への書き込みでオフバイワンエラーをやらかすと、配列の直ぐ後ろのアドレスに配置されている変数にて1バイトの破壊が発生する。……あれ、ポインタじゃなくて配列絡みの問題だ。でもポインタで配列を参照できるから「ポインタでも起こる問題だ」とごり押しすることにしよう。

*12:4バイトの変数のアドレスに対して1バイトのデータ型のポインタで参照して代入する、というパターンでも発生する。ポインタをインクリメント/デクリメントすることで書き換え対象の1バイトを選択することも可能。先に挙げたオフバイワンエラーの例も、よく考えれば暗黙のうちに似たようなことをやっていると言えなくもない。

*13:自分がどれだけ気をつけて色々工夫しても、バグは減るけどゼロにはならない。何より他人のバグは自分ひとりでは回避できない。

*14:但しプログラム記憶方式と「関数ポインタという機能が存在する理由」は結びつかない。関数ポインタの便利さはまた別の話だ。

*15:C言語の場合。変数の見た目上のあり方は言語によって異なるので注意。

*16:ここでの変数の型に関する説明は幾分と低水準なものである点に注意すること。

*17:実際にソースを書く際にはtypedefを用いて型に抽象的な意味を持たせることも多い。しかしコンパイル後にはそれらの型の情報は何も残らない。

*18:JIS X 3010-1993 6.1.2.5 (P.24) に曰く「ポインタ型は,被参照型の実体を参照するための値をもつオブジェクトを表す」。その値の中身についての規定はない。もっとも大半の処理系ではアドレス値だ。

*19:変数の型に関する部分とか。確か私の場合、CASL IIを勉強していた時に講師の人に「アセンブラではデータの大きさ(何ワード使うか)はプログラマ自身で管理する」とか「そのアドレスの中身のビット列を整数と見なすか文字と見なすかは、プログラマが管理する話だ」とか言われた記憶がある。

*20:情報系の場合はC言語以外の可能性もある。学校によって違うのだろうなあ。

*21:例えば関数Aの中で関数Bを呼び出すとき、関数Aの内部変数のアドレスを関数Bの引数に設定する、ということ。