プログラミング言語の学習には、どの言語にもいくつかの難関があります。C言語の場合、「ポインタ」が最大の難関と言われていますが、コンピュータの基本 (CPU やメモリの概念) を正しく理解していれば、けっして難しい話ではありません。問題があるとすれば、C言語のポインタが不適切な操作や演算によってプログラムを簡単に暴走させてしまうことでしょう。
実は、Go 言語にも「ポインタ」がありますが、C言語のポインタのように危険なものではありません。もともとC言語は、UNIX という OS を記述するために設計されたプログラミング言語です。マシン語なみの操作ができないようでは役に立たないわけで、ポインタという危険なものでもユーザーに開放されているのです。Go 言語の場合、ポインタの操作でプログラムが暴走することはないので、安心して使ってください。
まず最初に、基本となるメモリの構成から説明しましょう。C言語のポインタがわかっている方は読み飛ばしてもらってかまいません。
ビ ッ ト 7 6 5 4 3 2 1 0 ┌─┬─┬─┬─┬─┬─┬─┬─┐ 0 │ │ │ │ │ │ │ │ │ ├─┼─┼─┼─┼─┼─┼─┼─┤ 1 │ │ │ │ │ │ │ │ │ ア ├─┼─┼─┼─┼─┼─┼─┼─┤ 2 │ │ │ │ │ │ │ │ │ ド ├─┼─┼─┼─┼─┼─┼─┼─┤ ・ │ │ │ │ │ │ │ │ │ レ ・ │ │ │ │ │ │ │ │ │ ・ │ │ │ │ │ │ │ │ │ ス ・ │ │ │ │ │ │ │ │ │ ・ │ │ │ │ │ │ │ │ │ ├─┼─┼─┼─┼─┼─┼─┼─┤ M-1│ │ │ │ │ │ │ │ │ ├─┼─┼─┼─┼─┼─┼─┼─┤ M │ │ │ │ │ │ │ │ │ └─┴─┴─┴─┴─┴─┴─┴─┘ │←─── 1バイト ───→│ 図 : メモリの構成
メモリにはプログラムやデータが記憶されています。メモリのことを「主記憶装置」といいます。CPU はメモリに格納されているプログラムを実行します。ハードディスクや CD-ROM, USB メモリなどに格納されているプログラムは、メモリに読み込まないと実行することはできません。
メモリ以外の記憶装置を「補助記憶装置」といいます。ハードディスクや CD-ROM, USB メモリなどいろいろな記憶装置がありますが、すべての方法に共通しているのは、情報をなんらかの方法で ON / OFF という 2 つの状態で表していることです。
この ON / OFF を数値の 1 と 0 に対応させます。つまり、コンピュータは情報を 0 と 1 で表すわけです。これを「ビット (bit)」といいます。ひとつのビットでは、0 か 1 かの 2 つの情報しか表せませんが、使用するビットの数を増やすと、それだけたくさんの情報を表すことができます。たとえば、4 ビット使用すると 16 通りの情報を表すことができます。このビットをたくさん集めたものがメモリなのです。
ビットでは情報が細かすぎるので、いくつかのビットをまとめた「バイト (byte)」を単位として、メモリは構成されています。現在は、1 バイトを 8 ビットとしてメモリを構成するコンピュータ [*1] がほとんどです。1 バイトは数値で表すと 0 から 255 までの 256 通りの情報を記憶できます。メモリは大きさをバイト単位で表します。
メモリから値を読み出す、または書き込む場合、最小の大きさがバイトとなります。どのメモリから値を読み出すのか、またはどのメモリに値を書き込むのかを指定するために、メモリにはバイト単位で「アドレス (番地 : address)」がつけられています。これは私たちの住所や電話番号と同じです。メモリの場合は単純に数値で表します。
上図を見てください。コンピュータの世界では 0 から数えるのが普通です。Go 言語やC言語の場合も、配列は 0 から数えましたね。ビットもアドレスも 0 から数えます。
数値計算をするときなど、1 バイトでは情報量が少ない場合は、2 バイトまたは 4 バイトまとめてメモリを使用します。文字を表したい場合は、文字を数値に対応させます。アルファベットは 26 種類ありますから、大文字小文字、そしてほかの記号を合わせても 1 バイトあれば表現できます。たとえば、パソコンで使われる文字コードの規則にアスキー (ASCII) コードがありますが、この規則では A という文字は 0x41 に対応します。しかし、1 バイトでは漢字を表現できません。そこで複数のバイト使って漢字を表現します。Go 言語では UTF-8 が採用されています。
グラフィックの場合は、点 (ドット : dot) をメモリに対応させて表現します。たとえば、1 ドットを 1 バイトで表現してみましょう。そのメモリの内容が 0 ならば、ドットが書かれていないことにします。それ以外のときはドットが書かれているという規則にします。このとき、1 から 255 に対応する色を決めておけば、256 色の絵が描けるわけです。もし、65536 色の絵を描くのであれば、1バイトでは表現できないので、1 ドットにつき 2 バイト使用することになります。フルカラー (1600 万色) 表示であれば、1 ドットにつき 3 バイト必要になるわけです。
このように、メモリに格納されるデータは、単なる数値に過ぎないのですが、使うソフトウェアによってその意味は異なるのです。ある場合は、数値計算のために使用され、ほかでは文字を格納するために、またあるときはグラフィックデータを保持します。
メモリをどのように使うかは、プログラマが決めます。そして、それを実現するための道具がプログラミング言語なのです。変数、配列、文字列といったデータも、すべてメモリに割り当てられますが、マシン語以外の高級言語ではアドレスを意識することはありません。プログラマは変数を定義するだけで、メモリの割り当てといっためんどうなことはすべてプログラミング言語 (と OS) が行ってくれます。
一般に、プログラミング言語で扱う名前は、そのデータが割り当てられたメモリの先頭アドレスを表しています。変数名や配列名は、そのデータが割り当てられたメモリの先頭アドレスであり、関数名は、そのコードが配置されたメモリの先頭アドレス (関数の開始アドレス) を表しています。高水準と呼ばれるプログラミング言語の場合、名前から実際のアドレスを求めることはできません。プログラマが勝手にメモリ割り当てを操作したり変更すると困る場合があるからです。もっとも、そのようなことをしなくてもプログラミングできないようでは、とても高水準とはいえません。
ところがC言語の場合は違います。C言語では名前からアドレスを求める演算子 & [*2] が用意されているのです。変数名にこの演算子を適用することで、変数が割り当てられたメモリの先頭アドレスを求めることができます。また、配列名や関数名はデータやコードの先頭アドレスにつけられた名前にすぎず、& 演算子を使わなくてもアドレスとして使用することができます。
そして、C言語ではアドレスを格納する変数を定義することができます。これが「ポインタ」です。アドレスは整数値ですから、ポインタの中身は整数値です。ですが、その値はある変数が割り当てられているメモリの先頭アドレスです。つまり、ポインタは「ある変数を指し示している変数」ということになります。また、関数名もアドレスを示しているのですから、ポインタに代入することができます。これを「関数へのポインタ」と呼びます。どちらにしても、ポインタはあるデータを指し示している変数なのです。これを図に示すと次のようになります。
番地 0x68000 0x70000 ↓ ↓ ┬─┬─┬─┬─┬─┬─┬─┬─┬ ┬─┬─┬─┬─┬─┬─┬─┬ メモリ │ 0x70000 │ │ │ │ │~│ │ │ │ 0x100000 │ ┴─┴─┴─┴─┴─┴─┴─┴─┴ ┴─┴─┴─┴─┴─┴─┴─┴ 変数 p │ ↑ 変数 i └───────────────────┘ 図 : ポインタ
変数 p はポインタです。図に示すように、ポインタもメモリ [*3] に割り当てられます。この例では 0x68000 番地になっています。CPU が 32 bit の場合、Cコンパイラはポインタに 4 byte のメモリを割り当てる場合がほとんどで、0 から 0xFFFFFFFF までのアドレス指定が可能になります。これだと約 4 G byte のメモリを扱うことができます。CPU が 64 bit の場合、ポインタには 8 byte のメモリを割り当てることになりますが、本稿ではポインタ変数の大きさは 4 byte として説明することにします。
変数 i は整数値を格納します。C言語には整数値を表すデータ型がいくつかありますが、今回は int を使いましょう。CPU が 32 bit の場合、Cコンパイラは int に 4 byte のメモリを割り当てるのが普通です。この場合、int は -2147483648 から 2147483647 までの整数を扱うことができます。上図の場合、変数 i は 0x70000 番地から割り当てられていて、値は 0x100000 です。
変数 p はポインタなので、変数 i のアドレスを代入することができます。すると、図のように変数 p の値は 0x70000 となり、変数 i を指し示すことになります。そして、ポインタ p を使って、変数 i の値を読み書きすることができるのです。これがポインタの基本的な考え方です。
ちょっと脱線しますが、実際にC言語のプログラムを示しましょう。ところで、図のアドレスは説明のためのもので、これから示すプログラムとは関係ありません。プログラムがロードされるアドレスは実行環境によって異なるので、変数 i と p のアドレスは実際にプログラムを実行してみないとわからないのです。ご注意くださいませ。
リスト : ポインタの使用例 int i; int *p; i = 0x100000; p = &i;
C言語の場合、変数は格納するデータ型を宣言しないといけません。int i; は、整数値を格納する変数 i を用意します。次の int *p; は整数値を指し示すポインタを用意します。C言語の場合、変数名にアスタリスク * をつけると、それはポインタとして定義されます。
変数 p にはポイントする変数のアドレスが格納されます。そして、* をつけた *p を使って、ポイントしている変数 (この場合は整数値) にアクセスすることができます。つまり、*p は p が保持しているアドレスに格納されているデータを参照 [*4] するのです。
変数の定義は値を入れる容器を用意するだけなので、その中身はまだ定まっていません。i = 0x100000; で変数 i に値がセットされます。次の p = &i; で、ポインタ p に変数 i のアドレスがセットされます。これでポインタ p は変数 i を指し示すことになります。
このあとは *p を使って変数 i の値にアクセスできます。*p の値を読み出せば 0x100000 になり、*p = 0x999; と値を代入すれば、i の値も 0x999 となります。p は変数 i のアドレスを保持し、*p はそのアドレスに格納されているデータにアクセスできるのですから、書き込みを行えば、変数 i の値も書き変わるのは当然ですね。
ところでこのポインタ、一体何の役に立つのでしょうか。C言語の場合、次の利点があります。
(1) 「参照呼び (call by reference)」を実現する (2) コンパクトで効率的なプログラムを書くことができる
(1) ですが、C言語の関数は Go 言語と同じく「値呼び (call by value)」です。関数の引数にポインタを渡すことで、呼び出し先の関数から呼び出し元の関数に定義されている変数にアクセスすることができるようになります。(2) はアドレス計算によって実現されます。ポインタが格納しているアドレスは単なる正の整数値です。四則演算ができるわけではありませんが、整数値の代入や加減算 [*5]、ポインタ同士の比較を行うことができます。とくに、インクリメント (++)、デクリメント (--) 演算子と組み合わせることで、効率的なプログラムを書くことができます。
このほかに、プログラムの実行時にメモリを取得したり、連結リストや二分木といった複雑なデータ構造を作るときにも、ポインタはとても役に立ちます。
もっとも、いいことばかりではありません。よくある間違いがポインタの初期化忘れです。ポインタを定義しただけでは、その値は定まっていません。どこをポイントしているのかわからないのですから大変危険です。また、ポインタには整数値を代入することができますが、次のように 0 を代入したらどうなるでしょうか。
リスト : ポインタの危険な操作 int *p; p = 0;
一般に、0 番地からある番地までは OS が使用するため、ユーザーが勝手にアクセスすることはできません。近代的な OS の場合、メモリはシステムエリアとユーザーエリアに区別されていて、ユーザーのプログラムがシステムエリアにアクセスすることを禁止しています。もし、このメモリにアクセスすると OS で例外 (エラー) が発生します。
このように、ポインタの操作には危険がつきまとうのですが、そのかわりに、ハードウェアを制御するプログラム [*6] でも、C言語だけで作ることができます。このため、C言語は高級アセンブラとか汎用アセンブラと呼ばれています。
Go 言語の入門記事なのになんでC言語のポインタを説明するのか、と疑問に思われたことでしょう。実は、C言語のポインタは単純明解で、メモリの概念をきちんと把握しておけば、理解するのは難しいことではありません。そして、ポインタの基本を押さえておくと、Go 言語のポインタも簡単に理解することができます。
Go 言語のポインタは、C言語のポインタと同じく、あるメモリ領域を指し示すデータのことです。使い方もほとんど同じです。それでは、さっそくポインタを使ってみましょう。
リスト : ポインタの使い方 (1) package main import "fmt" func main() { var n int = 10 var m int = 20 var p *int = &n var q *int = &m fmt.Println(n) fmt.Println(*p) *p = 100 fmt.Println(n) fmt.Println(*p) fmt.Println(p) fmt.Println(q) fmt.Println(p == q) fmt.Println(p != q) }
$ go run sample61.go 10 10 100 100 0xc000016080 0xc000016088 false true
変数 n には 10 がセットされています。この変数を指し示すポインタは次のように宣言します。
var 変数名 *型 = &変数
ポインタ型は型名の前にアスタリスク * を付けて表します。var p *int は int 型のポインタ変数を宣言します。初期値が指定されていない場合、Go 言語のポインタは nil (nil ポインタ) に初期化されます。C言語やC++では空ポインタを NULL で表しますが、Go 言語では nil を使います。ポインタ型の変数であれば、どんな型にも nil を代入することができます。nil ポインタにアクセスすると、Go 言語ではランタイムエラーが発生します。C言語と違って、OS の例外が発生することはありません。
記号 & はアドレス演算子でC言語のそれと同じ意味です。&n で変数 n のアドレスを求め、その値で変数 p を初期化します。これで変数 p は int 型の変数 n を指すポインタ変数になります。
p が参照している変数の値にアクセスする場合も簡単です。変数名の前に * をつけるだけです。これもC言語と同じです。Println で *p の値を表示すると、参照先の変数の値 10 が表示されます。もちろん、*p に値を代入することもできます。*p = 100 とすれば、参照している変数の値は 100 に書き換えられます。
それから、C言語のポインタと違って、Go 言語のポインタは整数値の代入や加減算といった危険な操作は行うことができません。同じポインタ型や nil との等値判定 (==, !=) は行うことができます。なお、Go 言語の場合、Print(p) とすると p に格納されているアドレス (整数値) を表示することができます。アドレスの値は実行環境によって異なります。
次は配列へのポインタを考えてみましょう。C言語の場合、配列名が配列の先頭アドレスを表していました。たとえば、int a[100]; という配列を定義すると、配列名 a は配列の先頭アドレス &a[0] と同じ意味になります。
これに対し、Go 言語の配列名は配列の先頭アドレスを表していません。また、配列にアドレス演算子を適用すると、配列へのポインタが生成されますが、それは配列の先頭アドレスを表しているわけではありません。Go 言語の配列はひとつの「値」なので、配列へのポインタは配列そのものを指し示すことになります。次の例を見てください。
リスト : 配列へのポインタ package main import "fmt" func main() { var a [8]int = [8]int{1,2,3,4,5,6,7,8} var b [8]int = [8]int{10,20,30,40,50,60,70,80} var p *[8]int = &a p1, p2 := &a[0], &a[1] fmt.Println(p) fmt.Println(*p) fmt.Println(p1) fmt.Println(*p1) fmt.Println(p[0]) fmt.Println(p2) fmt.Println(*p2) fmt.Println(p[1]) *p1 = 10 *p2 = 20 fmt.Println(a) p = &b; fmt.Println(*p) }
$ go run sample62.go &[1 2 3 4 5 6 7 8] [1 2 3 4 5 6 7 8] 0xc0000ba000 1 1 0xc0000ba008 2 2 [10 20 3 4 5 6 7 8] [10 20 30 40 50 60 70 80]
ポインタ変数 p の宣言は配列の型に * をつけるだけです。var p *[8]int = &a (または p := &a) とすれば、p は配列 a を指すポインタになります。p1, p2 は配列の要素へのポインタです。要素の型は int なので、p1, p2 の型は *int になります。p を表示すると、角カッコで配列の内容を表示しますが、その前に & が付いていてポインタであることを表します。
配列へのアクセスですが、Go 言語ではポインタ変数 p を配列と同じように使うことができます。たとえば、0 番目と 1 番目の要素にアクセスする場合は p[0], p[1] のように添字を指定してください。C言語のように *p や *(p + 1) で要素にアクセスすることはできません。p1 と p2 の型は *int なので、*p1, *p2 で参照先のデータにアクセスすることができます。
ポインタ変数 p は、同じ型の配列であれば値を書き換えることができます。配列 a と b は同じ型なので、p = &b とすると *p は配列 b を指し示すことになります。なお、c := *p とすると、新しい配列が確保され、p が指し示す配列からデータがコピーされることになります。ご注意くださいませ。
文字列とスライスに & 演算子を適用すると、ポインタを生成することができます。次の例を見てください。
リスト : 文字列とスライスのポインタ package main import "fmt" func main() { var s string = "hello, world" var p *string = &s // p1, p2 := &s[0], &s[1] var a = []int{1,2,3,4,5,6,7,8} var q *[]int = &a q1, q2 := &a[0], &a[1] fmt.Println(s) fmt.Println(p) fmt.Println(*p) *p = "oops!" fmt.Println(s) fmt.Println(*p) fmt.Println(q) fmt.Println(*q) fmt.Println(*q1) fmt.Println(*q2) *q = []int{10,20,30,40} fmt.Println(q) fmt.Println(*q) }
$ go run sample63.go hello, world 0xc000010230 hello, world oops! oops! &[1 2 3 4 5 6 7 8] [1 2 3 4 5 6 7 8] 1 2 &[10 20 30 40] [10 20 30 40]
文字列とスライスは配列と同様にポインタで操作することができます。ただし、文字列の要素へのポインタ、つまり、文字列の要素のアドレスを & 演算子で求めることはできません。
関数の引数にポインタを渡すと「参照呼び」と同様の動作を行うことができます。次の例を見てください。
リスト : 関数にポインタを渡す package main import "fmt" // 間違い func swap(x *int, y *int) { // func swap(x int, y int) { tmp := *x // tmp := x *x = *y // x = y *y = tmp // y = tmp } // } func timesArray(n int, ary *[8]int) { for i := 0; i < len(*ary); i++ { ary[i] *= n } } func main() { var a int = 10 var b int = 20 var c [8]int = [8]int{1,2,3,4,5,6,7,8} fmt.Println(a) fmt.Println(b) fmt.Println(c) swap(&a, &b) timesArray(10, &c) fmt.Println(a) fmt.Println(b) fmt.Println(c) }
C>go run sample64.go 10 20 [1 2 3 4 5 6 7 8] 20 10 [10 20 30 40 50 60 70 80]
関数 swap はC言語の関数が「値呼び」であることの説明によく使われる例題です。C言語は Go 言語のような多重代入がないので、局所変数の値を交換するには tmp のような変数を使って行います。この処理を関数で行う場合、引数の型を int とすると、変数の値が引数にコピーされるので、元の局所変数の値を交換することはできません。この場合、引数の型を *int としてポインタを渡すと、元の局所変数の値を交換することができます。
関数 timesElement は配列の要素を n 倍する関数です。同様に、引数の型を [8]int とすると、配列 c の要素が引数 ary にコピーされるので、配列 c の要素を 10 倍することはできません。引数の型を *[8]int に指定してポインタを渡すことで、元の配列の配列の要素を 10 倍することができます。ただし、Go 言語にはスライスがあるので、関数の引数に配列のポインタを渡すことは少ないと思います。
C言語の場合、ポインタを指すポインタを作ることができます。次の図を見てください。
番地 0x68000 0x69000 0x70000 ┬─┬─┬─┬─┬ ┬─┬─┬─┬─┬ ┬─┬─┬─┬─┬ メモリ │ 0x69000 │~│ 0x70000 │~│ 0x100000 │ ┴─┴─┴─┴─┴ ┴─┴─┴─┴─┴ ┴─┴─┴─┴─┴ 変数 q │ ↑変数 p│ ↑ 変数 i └─────┘ └─────┘ 図 : 多段階のポインタ
変数 q はポインタです。q はポインタ p を指しています。p は変数 i を指しています。つまり、q は p を経由して変数 i を指し示しているのです。これをC言語のプログラムで表すと、次のようになります。
リスト : 多段階のポインタ int i; int *p; int **q; i = 0x100000; p = &i; q = &p;
C言語の場合、ポインタを指し示すポインタは、経由するポインタと同じ数だけ * を追加します。変数 q はポインタ p を経由して変数 i を指し示すので int **q; となります。2 つのポインタを経由するのであれば、int ***q; と宣言します。
ポインタ q はポインタ p を指し示すので、初期化は変数 p のアドレスをセットします。p の値は変数 i のアドレスなので、q にセットしてはいけません。もし q = p; とプログラムすると、コンパイル時にワーニングが表示されます。
これで、**q とすることで変数 i の値にアクセスすることができます。**q =0x999; のように値を代入すると、変数 i の値を書き換えることができます。また、*q とすることで変数 p の値にアクセスすることができます。このとき、*q の値を書き換えると、ポインタ q と p は変数 i ではなく、別の値を指し示すことになります。このように、ポインタを操作するときは細心の注意を払わなければいけないのです。
Go 言語のポインタでも同じことができます。次の例を見てください。
リスト : 多段階のポインタ package main import "fmt" func main() { var i int = 100 var p *int var q **int p = &i q = &p fmt.Println(p) fmt.Println(*p) fmt.Println(q) fmt.Println(*q) fmt.Println(**q) }
$ go run sample65.go 0xc000016080 100 0xc00000e028 0xc000016080 100
変数 p は変数 i へのポインタを格納し、変数 q は変数 p へのポインタを格納します。したがって、変数 q は変数 p を経由して変数 i を指し示しています。 変数 q の指し示すデータを表示する場合は、経由するポインタ変数の数だけ * を追加します。つまり、**q で変数 i にアクセスすることができるわけです。*q では変数 p にアクセスすることになり、この値を書き換えると、変数 q は変数 i ではなく別のデータを指し示すことになります。
今までは大域変数または局所変数を宣言することで数値や配列のメモリを確保していました。どちらの変数もコンパイル時にその大きさが決定されるので、プログラムを実行している途中でサイズを変更することはできません。
これに対し、スライスは組み込み関数 make でプログラムの実行中に必要な大きさのメモリ領域を確保することができます。組み込み関数 append を使って、スライスの大きさを増やすこともできます。マップも同じですね。このような機能を「メモリの動的割り当て」といいます。Go 言語の場合、スライスやマップ以外のデータでも組み込み関数 new を使ってメモリを動的に割り当てることができます。
var 変数名 *T = new(T)
new の引数は型です。new は指定された型を格納するメモリ領域を確保し、ゼロ値で初期化してからメモリ領域へのポインタを返します。したがって、引数の型を T とするならば、返り値の型は *T になります。
簡単な例を示しましょう。
リスト : メモリの動的割り当て package main import "fmt" func main() { var p *int = new(int) var q *float64 = new(float64) var a *[8]int = new([8]int) fmt.Println(p) fmt.Println(*p) fmt.Println(q) fmt.Println(*q) fmt.Println(a) fmt.Println(*a) *p = 100 *q = 1.2345 a[0] = 10 a[7] = 80 fmt.Println(*p) fmt.Println(*q) fmt.Println(*a) }
$ go run sample66.go 0xc000016080 0 0xc000016088 0 &[0 0 0 0 0 0 0 0] [0 0 0 0 0 0 0 0] 100 1.2345 [10 0 0 0 0 0 0 80]
var p *int = new(int) とすると、int を格納するメモリが確保され、そのポインタが返されます。同様に、new(float64) も new([8]int) もその型のメモリ領域が確保され、そのポインタが返されます。あとはポインタ変数を使って、そのメモリ領域にアクセスすればいいわけです。
ところで、取得したメモリ領域は使い終わったら元に戻す処理が必要になります。メモリは有限なので使いっぱなしにしていると、いつかはメモリ不足になります。C言語の場合は関数 malloc でメモリを取得し、関数 free で取得したメモリを解放します。Go 言語の場合、どの変数からも参照されなくなったメモリ領域はゴミになり、「ゴミ集め (GC)」[*7] によって回収して再利用されます。
GC がないプログラミング言語では、不要になったメモリは自動的に回収されません。それを行うようにプログラムする必要があるのです。Go 言語のように GC があるプログラミング言語では、ゴミになったメモリは自動的に回収されるので、プログラマの負担はそれだけ少なくなります。
今回はここまでです。次回は「構造体」について説明します。構造体と new を使うと、連結リストや二分木といった複雑なデータ構造を簡単に構築できるようになります。