ポインタとユーザー定義型について
SDLを使い始めるに先立って、ポインタとユーザー定義型について簡単に書いておきたいと思います。
ウィンドウを表示するためにSDLの初期化を行う中で次のようなコードが出てきました。
SDL_Window* window = SDL_CreateWindow(...);
ここでの目標はこのコードの意味を理解できるようになることです。
ポインタ
ポインタはおおざっぱに言うとアドレスを保持することための変数です。変数のアドレスとはどういうことかと言うと、例えば、int a = 42;
のようにして変数を定義したとします。このプログラムを実行して該当する場所のコードのところまで来ると、変数aの内容はメモリ上のどこかに置かれることになります。メモリにはすべて番号(番地といったほうが分かりやすいかも)が振られています。このメモリの番号がアドレスです。
変数に何番のメモリ番号が割り当てられたのか確認することも出来ます。変数は&aのように、変数の名前の前に&を付けることでアドレスを取ることが出来ます。このアドレスはstd::coutに送ることで、実際に変数がどこのアドレスにあるのかを表示することが出来ます。
#include <iostream>
int main()
{
int a = 42;
std::cout << &a << '\n';
}
表示されるアドレスはプログラムを実行するごとにおそらく毎回違う結果になります。試しに5回実行してみました。
1回目 0x7ffd27cf1dcc
2回目 0x7ffe3c59ac8c
3回目 0x7fff7bc8138c
4回目 0x7ffee38d126c
5回目 0x7ffd0c74af9c
そしてこのメモリのアドレスを保持するための変数(オブジェクト)がポインタで、また、ややこしいですが、そのような変数の型のことをポインタとも呼ぶこともあります。ポインタを定義するにはint* p;
のようにそのポインタがアドレスで指すことになる型の後ろに*をつけます。
このとき、定義された変数pに注目して、
- 「pはint型の変数(オブジェクト)を指すポインタ」あるいは単に「pはintへのポインタ」
と言い表すこともあり、またpの型に注目して
- 「pの型はintへのポインタ」
と言い表すこともあります。 つまり、2つの意味で使われることになりますが、使っていくうちに文脈からどういう意味で使われているかは判断できるようになると思います。
記号*には変数の定義でその変数がポインタであることを指定する以外に、もう一つ使いみちがあります。ポインタとして定義された変数に対し、*pのように、変数名の前に*を付けることで、デリファレンス出来ます。これは&でアドレスを参照したのと逆で、アドレスからそのアドレスの位置にある値を参照する行為になります。
#include <iostream>
int main()
{
int a = 42;
int* p = &a;
*p = 84;
std::cout << a << '\n';
}
// 出力
// 84
上のコードでなぜ84が出力されるのか理解できたら一番基本的なことはだいたい理解できたといっていいと思います。
しかし、この機能が何の役に立つのかという疑問があります。コンピューターのメモリのアドレスを使って操作する低レベル過ぎる処理に思えます。こんな低レベルな機能は持っていない言語もたくさんあります。ポインタにはいろんな用途がありますが、今注目したいのは、あるオブジェクトを間接的に結びつける何かを、変数として保持するようにして、オブジェクトそのものを変数として持ち歩かなくていいようにする使い方です。その「何か」はC++ではオブジェクトのアドレスです。また「持ち歩く」というのは、例えば関数をまたいで持ち歩くこと、つまり引数として渡したり返り値として受け取ったりすることなどを言っています。
何を言おうとしているのか分かりやすくするために、変数の生存期間について、間違った使い方をしているコードを示します。
#include <iostream>
// まったくだめなコード
int* create_answer()
{
int a = 42;
return &a;
}
int main()
{
int* answer = create_answer();
std::cout << *answer << '\n';
}
このコードのやろうとしていることは、create_answerという関数の内側でint型の値を作って、その変数そのもののアドレスつまりポインタを呼び出し元に返そうとしていることです。int型のような組み込み型だとわざわざポインタを使って作る意味はあまりなく、単に、
int create_answer()
{
int a = 42;
return a;
}
のようにすればいいので、例として適切でないかもしれません。しかし、intではなく、何かもう少し複雑なものを表す型のオブジェクトを返したい場合、まさにSDL_Window型がそうなのですが、ポインタで返すことは意味を持ちます。SDL_Windowは構造体です。そのことについてはあとで書くつもりなので今はintで我慢しておき、intのポインタを返すことが必要な状況になったと仮定しておきます。
先のポインタを戻すコードがダメなのは、create_answer関数を抜けた時点で、変数aの生存期間が切れているからです。関数の中で普通に定義された変数は、関数を抜けると破棄されてしまいます。変数の生存期間は変数が定義されたスコープに依ります。おおざっぱにいうと{...}で囲まれた範囲です。関数の中で定義された変数はローカル変数といい、これらはメモリ上はスタックと呼ばれる領域に配置されます。関数を抜けると使用していたスタックは破棄されてしまいます。ローカル変数aのアドレスを返すことは、この破棄されてしまうスタックのアドレスを返すことです。破棄されてしまったアドレスの値を使おうとしてもその結果はどうなるか全く分かりません。先のローカル変数へのポインタを戻すコードを実行してみると"Segmentation fault"となりました。
有効なポインタを返すcreate_answer関数は次のように作ることが出来ます。
#include <iostream>
int* create_answer()
{
int* p = new int{42};
return p;
}
int main()
{
int* answer = create_answer();
std::cout << *answer << '\n';
}
// 出力:
// 42
ハイライトされている部分がポイントで、newというキーワードを使っています。これは動的なメモリ上にオブジェクトを作って値42で初期化しています。このnewは分類としては演算子です。英単語を演算子と呼ぶのには違和感あるかもしれませんが…ちなみに&や*も演算子です。このnewで作ったオブジェクトは、ローカル変数ではなく、スコープを抜けても生存し続けるオブジェクトとなります。newの結果はポインタとして返ってきます。そのため、関数からポインタを返すことで、呼び出し元はこのオブジェクトにアクセスすることが出来ます。
newを使ってポインタでオブジェクトを操作する上で一番厄介なことは、オブジェクトの解放をプログラマがきっちりやらないといけないということです。普通に定義したローカル変数のオブジェクトはスコープを抜けた時点で自動的に解放されます。しかし、newで作成したオブジェクトは自動で解放されません。そうしたくないからnewで作成したのでそうでなければ困るのですが、本当に、まったく自動で解放されるように面倒を見てくれません。newで作成したオブジェクトに対しては、きっちり1回だけdelete演算子で解放してやる必要があります。解放し忘れるとメモリーリークになり、2回以上解放したときは何が起き得るか分かりません。さらに、deleteで解放したオブジェクトはもう他の場所ではもう使わないようにしなければなりません。
先のコードはnewしたのを解放していないのでメモリリークしています。プログラム終了時にプログラムが使用していたメモリはOSによって解放されるので、このような小さなプログラムが問題でシステムリソースを食いつぶすことにはならないとは思いますが、礼儀正しく解放するようにしておきます。直にdeleteするのではなく、SDLをのAPIに習って、create_xxxの対でdestroy_xxxという関数を作ってみます。
#include <iostream>
int* create_answer()
{
int* p = new int{42};
return p;
}
void destroy_answer(int* answer)
{
delete answer;
}
int main()
{
int* answer = create_answer();
std::cout << *answer << '\n';
destroy_answer(answer);
}
このような小さなプログラムだけみるとdeleteするのはそんなに大変ではないですが、プログラムが大きくなっていくとものすごく気を使わければならなくなります。deleteに関わる問題から解放されるもっとも手っ取り早い方法はスマートポインタを使うのを徹底することです。しかし、ゲームのコードでは残念ながらいつでも使えるわけではないようです。
追記:何も考えずどこでもスマートポインタを使えばいいというわけではなく、使うべき場所で適切に使って所有権の管理を徹底して正しく行うということです。(2021-02-20)
ポインタについてはこの程度で一旦終えておきます。また何かあればその都度書いていこうと思います。
おまけでポインタがどのようなコードを吐き出すのか簡単な実験もしてみました。
ユーザー定義型
C++でのユーザー定義型とはだいたい構造体とクラスのことです。ユーザー定義型とポインタとの直接的な関連はそれほどありません。ポインタと関係が強いのは配列でしょう。ここでポインタと合わせてユーザー定義型のことを書くのは最初のあるコードを理解するためです。
SDL_Window* window = SDL_CreateWindow(...);
これを見ると、SDL_Windowが何かの型で、それのポインタを表す*が付いているので、ここは「windowはSDL_Windowへのポインタ」と読めます。ポインタはあらゆるところで使われる可能性がありますが、SDLを使うコードでは、SDLに限ったことでもなく多くのライブラリで、特にこのようにユーザー定義型へのポインタとして使われることが多いです。なのでここで簡単に書いておこうと思います。
ユーザー定義型と何度も繰り返してますが、あまりこのような呼び方をする機会はないかもしれません。もっとよく聞く呼び名は、クラスや構造体だと思います。もう一つ列挙体と呼ばれるものがあり、これもユーザー定義型に分類されます。これらがユーザー定義型と分類されるのは、あらかじめC++言語に備わっている組み込み型とはっきりと区別されるからです。組み込み型にはintやcharやdoubleなどが挙げられます。ユーザー定義型は言語自体にあらかじめ型として備わっているものではなく、intやcharやdoubleなどの組み込み型、それに別のユーザー定義型を組み合わせて作られるもので、そのような型を作るためのものがC++言語に備わっています。具体的にはキーワードclass、struct、enumなどです(unionも?)。そういう意味では標準ライブラリにあるstd::stringやstd::vectorといった基本的な型さえもユーザー定義型に分類されます。
構造体の例を挙げてみます。
#include <iostream>
struct Vector2 {
double x;
double y;
};
void addvec2(const Vector2* a, const Vector2* b, Vector2* out)
{
out->x = a->x + b->x;
out->y = a->y + b->y;
}
int main()
{
Vector2 v1 = {1.0, 2.0};
Vector2 v2 = {3.0, 4.0};
Vector2 v3;
addvec2(&v1, &v2, &v3);
std::cout << '(' << v3.x << ',' << v3.y << ")\n";
}
// 出力:
// (4,6)
このコードは2次元ベクトルを表現する構造体と、それに関係する操作として、2つのベクトルを足し合わせる関数を例として書いてみました。ハイライトされている部分が構造体の定義です。Vector2という名前の構造体で、2つのdouble型から構成されています。この型を使うには、int型などの組み込み型の変数を定義するのと同じようにVector2 name;とします。main関数でやってます。初期化には特別な書き方があって、{}で囲んでその中身を定義された順番で値を並べることで、その値を割り当てることが出来ます。addvec2という名前の関数の引数にはVector2へのポインタを取るようにしています。constは変更不可を意味します。3つ目のoutという引数は結果を入れるためのもので、これは変更不可ではいけないのでconstをつけてません。addvec2関数の呼び出しには3つのVector2型変数のそれぞれのアドレスを引数として渡しています。1つ目と2つ目を足した結果が3つ目のv3に入れられて返ってきます。最後にstd::coutに結果v3の値を渡して表示させています。
構造体の各フィールドには「.」演算子を使ってアクセスすることが出来ます。Vector2の変数の名前がv3で、フィールドxにアクセスしたければv3.xのように書きます。もし、Vector2へのポインタ、例えばpv3という名前とすると、ポインタでフィールドxにアクセスするには「.」の規則に従うと(*pv3).xのようにしなければならないのですが、これは醜いし不便なので「->」という演算子が用意されています。これを使えばpv3->xのようにしてアクセスすることが出来ます。これらの演算子は メンバアクセス演算子と呼ばれています。ポインタのところで出てきた「*」や「&」もメンバアクセス演算子に分類されます。
長々と書きましたが、C++でのこのような書き方をすることはあまりないかもしれません。この書き方は、少し違うところがあるものの、Cでの書き方に近いです。SDLはCで書かれているのでC++の書き方よりもこのような構造体を駆使して書かれたコードになっています。SDLがどうなっているかは実際にソースを覗いてみるのが早いです。
SDL_Windowはsrc/video/SDL_sysvideo.hに書かれています。ソース(ヘッダ)を見て分かるとおり、SDL_Windowは構造体として宣言されていて、中身はかなりの数のフィールドを持った大掛かりな型になっています。
次にSDL_CreateWindow関数を見てみます。SDL_CreateWindow関数はsrc/video/SDL_video.cに書かれています。細かいところは省略して、注目したい部分だけピックアップします。
SDL_Window *
SDL_CreateWindow(const char *title, int x, int y, int w, int h, Uint32 flags)
{
SDL_Window *window;
...
...
...
window = (SDL_Window *)SDL_calloc(1, sizeof(*window));
if (!window) {
SDL_OutOfMemory();
return NULL;
}
window->magic = &_this->window_magic;
window->id = _this->next_object_id++;
window->x = x;
window->y = y;
window->w = w;
window->h = h;
...
...
...
return window;
}
順を追ってみてくと、
- SDL_Windowへのポインタ変数を用意する。
- SDL_Windowのサイズ分だけメモリを確保して、そのアドレスをポインタに保持させる。
- メモリをちゃんと確保できた確認する。
- 各フィールドをセットアップする。
- SDL_Windowへのポインタを返り値として呼び出し元に返す。
このSDL_CreateWindow関数を見てみると、先のポインタのところで書いたcreate_answer関数と手順がよく似ていることに気が付きます。ポインタを用意する、メモリを確保する、ポインタを呼び出し元に返す、という流れです。
このパターンで最も大切なことは、このAPIの利用者は、SDL_Windowが何ものなのか、どういうフィールドを含んでそれぞれがどのように扱われるべきなのをまったく知る必要がないという点です。例えば、ウィンドウの中身を塗りつぶすためにウィンドウのサーフェイスを取得する、SDL_GetWindowSurface関数には、単にこのSDL_Windowのポインタを渡すだけで、何が行われているか知らないけどこのポインタを渡せばサーフェイスが取得できる、ということを知るだけで利用することが出来ます。このパターンはCのライブラリで頻出します。
もうここまできたら問題のコード
SDL_Window* window = SDL_CreateWindow(...);
さらに、
SDL_Surface* surface = SDL_GetWindowSurface(window);
最後に、ウィンドウを破棄するコード
SDL_DestroyWindow(window);
は理解できたとみなしてもいいのではないかと思います。
クラスと列挙体についてはまた必要になったときに書きます。