はじめに
C言語から派生したオブジェクト指向プログラミング言語であるC++は、21世紀に入ってまったく別物とも言えるプログラミング言語に成長していきました。それは、Modern C++と称されています。1990年代にC++を触っていたプログラマが現在の仕様を知れば、隔世感に苛まれるのではないでしょうか。本連載では、かつてはC++をたしなんでいたという方、今からC++言語を始めるという方に向けて、Modern C++らしい言語仕様をピックアップし紹介していくことで、今のC++言語の姿を理解していただきます。
対象読者
- かつてはC++をたしなんでいたという方
- 今からC++言語を始めるという方
- モダンなプログラミング言語のパラダイムに興味のある方
必要な環境
本記事のサンプルコードは、以下の環境で動作を確認しています。
-
macOS Sonoma/Windows 11
- Xcode Command Line Tools 2395
- MinGW GCC 9.2.0
ムーブセマンティクスって何よ?
20世紀のC++プログラマ(筆者)が、今(21世紀)のC++を知って驚く連載の第4回です。今回は、ムーブセマンティクスです。またまた、よく分からない言葉が出てきましたね。最近の、モダンを自称するプログラミング言語が難しく感じるのって、こういった言葉の問題があるんじゃないかと思ってしまいます。個人的な感覚では、オブジェクト指向なんてものが出てきてからこんな感じになったんじゃないかと思ってます。
日本語にしにくいものはそのままカタカナ語として使うので、それがそもそも分かりにくいということもあります。そして、日本語にできてしまえばそれをそのまま使うことで、却って分かりにくくなるということも起きがちです。
今回は、そのような事象との闘いと思って読んでいただければ幸いです(脱稿後、筆者の白髪の面積が少し増えたようです)。
[NOTE]C++プログラムのコンパイル
以降のサンプルは、基本的に「gcc -o 出力先ファイル名 ソースファイル名」でコンパイルできますが、環境によってはライブラリやC++バージョンの指定が必要になることがあります。コンパイルやリンクでエラーが発生するときには、以下のようにライブラリの指定(-lstdc++)、C++バージョンの指定(-std=c++11)の追加を検討してください。
% gcc -o sample sample.cpp -lstdc++ -std=c++11 // c++14, c++17, c++20など
ムーブセマンティクス=移動意味論?
まずは日本語を何とか当てはめてみるというところから、はじめてみましょう。ムーブ=移動、セマンティクス=意味論といったところなので、「移動意味論」でしょうか。これじゃ何のことか分かりませんね。少しアレンジして「移動に意味を持たせる」「移動という考え方」といったところでしょうか。どうやら、C++の世界に「移動」という意味を持ち込んだのがムーブセマンティクスと言えそうです。じゃあ、「移動」って何?何を移動するの?ということになりますね。
C++の世界での移動とは、オブジェクトの移動を指します。オブジェクトの場所をaからbに移動する、そんな感じです。それにどういう意味があるのか、疑問が湧きますね。以降は、多くのドキュメントに倣って「移動」を「ムーブ」とそのまま称して、ムーブの意味を探っていきましょう。
C/C++の基本はコピー(複製)
ところで、ムーブに対する概念といえばコピー(複製)です。ファイルのコピー、ファイルの移動というように、コンピュータで何かするときには対になって現れます。あまり意識することはないと思いますが、C/C++の初期化や代入の基本はコピー(複製)です。変数(インスタンス)の代入しかり、初期化子を伴う変数(インスタンス)の初期化も、基本的に「コピー」となります。ええ?本当?という人(筆者です)のために、サンプルで確かめてみましょう。変数(インスタンス)を宣言し、それを使って別の変数(インスタンス)を初期化するというものです(#includeやnamespaceはスペースの都合で割愛しているので、全体は配布サンプルを参照してください)。
// 整数型変数xをyにコピーしてそれぞれ書き換える int x = 100; auto y = x; y = 200; cout << x << " " << y << endl; // 実行結果:100 200 // 1000要素のベクターvをwにコピーして先頭要素のみ書き換える vector<int> v(1000); auto w = v; v[0] = 123; w[0] = 456; cout << v[0] << " " << w[0] << endl; // 実行結果:123 456
整数型変数の動きは通常の感覚でしょう。ベクターの方は、これはクラスのインスタンスだからコピーは参照だけとなるのでは?と(ちょっとJavaとかをかじった)筆者なら思うのですが、実際はコピーとなり、ベクターの先頭要素をそれぞれ書き換えてもそれは独立したものとなります。ちなみに、これらは「コピー構築」と呼ばれます。単なる代入なら「コピー代入」です。
ちょっと深入りしますが、vector<T>クラスのインスタンスは、メンバフィールドとは別に全要素を格納するためのメモリ領域を別に保持しています。メンバフィールドをコピーするだけならコピーの実行コストは一定ですが、要素をコピーすると要素数に応じた実行コストとなります。このように、要素数によってはコピーに大きなコストがかかってくるのが注意点と言えます。
このようなコピーは、「ディープコピー」と呼ばれます。変数がクラスのインスタンスである場合も、ベクターのように個々の要素まで「深く」コピーするからディープコピーというわけですね。このディープコピー、便利なようでいて、振る舞いをよく理解していないとトラブルや性能劣化の元なのですが、それについては後に譲ります。
ディープコピーとシャローコピー
ディープコピーに対するシャローコピーというものもあります。ディープに対して「浅い」コピーだからシャローコピーというわけですが、これは、実行効率を考えるC/C++プログラマなら普通にやってきたことで、特別な言葉がくっついているだけと思ってます。
// 整数型ポインタ変数xをyにシャローコピーしてそれぞれ書き換える auto* x = new int(100); auto* y = x; *y = 200; cout << *x << " " << *y << endl; // 実行結果:200 200 // 1000要素のベクターvをwにシャローコピーして先頭要素のみ書き換える auto* v = new vector<int>(1000); auto* w = v; (*v)[0] = 123; (*w)[0] = 456; cout << (*v)[0] << " " << (*w)[0] << endl; // 実行結果:456 456
丸ごとコピーするのではなく、場所(ポインタ)のみをコピーするのがシャローコピーと言えます。ここでは構築を示しましたが、関数の引数に与えるときも同じです。メモリの有効利用に敏感なC/C++プログラマなら、こういった書き方の方が自然かも知れませんね。
シャローコピーは、ポインタを利用することでメモリのコピーを極力減らし、メモリの節約や実行速度の向上を図れます。ただ、複数のポインタ間で同じメモリ領域を共有していることを常に意識しておかないと、意図しない書き換えや解放が起きてしまうという危険性もあります。第3回でポインタの厄介さを紹介しましたが、こういった負担をプログラマに課すことからも、やっぱりポインタは厄介なんだと思いますね。
シャローコピーとポインタの無効化
シャローコピーを紹介しましたが、実はこれがムーブセマンティクスの考え方のベースだったりします。shallow_copy.cppにちょっとコードを追加して書き換えたものが、以下のリストです。
auto* x = new int(100); auto* y = x; // yにシャローコピーしてxは用済みとしてマークする x = nullptr; *y = 200; cout << *y << endl; // *xは使えないので*yのみ表示、実行結果:200 auto* v = new vector<int>(1000); auto* w = v; // wにシャローコピーしてvは用済みとしてマークする v = nullptr; (*w)[0] = 456; cout << (*w)[0] << endl; // *vは使えないので*wのみ表示、実行結果:456
追加したコードは、シャローコピーのソースとなったポインタにnullptrを代入するというもので、ソースはもう用済みとしてポインタを無効化していることになります(別の回で取り上げますが、nullptrはヌルポインタを言語仕様で定めたものです。#defineしたNULLなどとは異なります)。これによって、シャローコピーの宛先となった変数のみがポインタとして有効となるので、意図しない書き換えや解放を「ある程度」防止できます。