実践 C++
開成学園コンピューター部(KCLC)において筆者が作成した記事を、投稿に際する多少の変更を加えた上でQiitaでも公開することにしました。以下本文です。
41stKCLCによるC++の講座です。この講座では主に開発の場面においてC++でプログラムを書く際のベストプラクティスなどを紹介、解説していきます。
簡単な内容ではないので、読んですぐ理解できなくても大丈夫です。何より大事なのはこの講座の内容に拘らずたくさんのプログラムを書いていくことなので、あくまでこの講座は今後大きめの規模のプログラムを書く際にみなさんがぶつかるであろう壁を乗り越えるための一助として活用していただければ幸いです。
また、この講座は読者がある程度C++の知識を持っていることを前提に書かれているので、C++を書いたことがない人はまずC++の入門書などを読むことをお勧めします。
C++のバーションは17を推奨しますが、C++14でも大抵のサンプルコードは動作すると思います。
目次
-
準備編
- メモリー
- ポインター
- ヒープとスタック
- クラス
- クラスとは
- 演算子オーバーロード
- ジェネリックプログラミング
- 「ジェネリック」の意味
- テンプレートを使う
-
安全なプログラムを書く
- 安全なメモリ管理
- deleteを書かない
- RAII イディオム
- スマートポインター
- 右辺値
- std::move
- unique_ptrを実装しよう
- 型による表現
- 便利な標準ライブラリたち
- ADT
- ADTの活用
-
きれいなプログラムを書く(以下の項目は変更の可能性あり)
- デザインパターン
- さまざまなデザインパターン
- 直和型を利用してVisitorパターンを実装しよう
- クラス設計のコツ
- テンプレートメタプログラミング入門
- プログラミングパラダイム
- オブジェクト指向
- 関数型
-
開発の知識
- CUIに慣れよう
- 様々なコマンド
- 練習
-
分割コンパイル
- コンパイラーとリンカー
- ビルドスクリプト
- フレームワークを使う
- ドキュメントを読もう
- ソースを読もう
- テスト
- テストを書こう
- テストの自動化
この講座では第一部「安全なプログラムを書く」までを扱います。
0.準備編
前提知識:C++の基本文法ーif,forなど
ここでは、本編に入る前に必要な知識などを解説しますが、ここで扱う知識は他の入門書などの方が詳しく丁寧に解説しているので、あくまで知識の確認くらいに考えてください。
C++の知識に自信がある方は本題の1.安全なプログラムを書くから読みはじめても大丈夫です。
0.1 メモリー
C++はPythonやRuby,C#などの言語のように実行時に自動的にメモリを管理してくれる言語ではないので、プログラマが自分でメモリを管理する必要があります。そこで、ここではメモリについての扱いを解説していきたいと思います。
0.1.1 ポインター
ポインタは、C/C++を学ぶ人がぶつかる壁の一つと言われていますが、あまり難しく考える必要はありません。
ポインタとは、メモリ上の値のアドレス(場所)を表した物です。例えば、
pre_sample1.cpp
#include <iostream>
int main()
{
int num = 100;//①
int* num_pointer= #//②
*num_pointer = 99;//③
std::cout << *num_pointer << std::endl;//④
}
このコード例ではポインタを利用しています。
①:int
型の変数num
に100を代入
②:int
型へのポインター型であるint*
型の変数num_pointer
に&num
で取得したnum
のアドレスを代入
③:num_pointer
が指す場所を(もともと100だったものを)99に書き換えている
④:num_pointer
が指す場所の値を出力している(99が出力される)
このように、T
型へのポインターはT*
で宣言し、オブジェクトvalue
のアドレスは&value
で取得し、ポインタptr
が指す値は*ptr
で取得します。
- 演習
ポインタはとにかく手を動かして理解した方が早いです。ということで、演習問題を解いてみましょう。
practice1:何が出力される?
#include <iostream>
int main()
{
int num = 100;
std::cout << *&num<< std::endl;
}
practice2:何が出力される?
#include <iostream>
int main()
{
int num = 100;
std::cout << "num is " << num << std::endl;
int num_copy = num;
int* num_pointer = #
*num_pointer = 99;
std::cout << "num is " << num << std::endl;
std::cout << "num_copy is " << num_copy << std::endl;
num = 0;
std::cout << "*num_pointer is " << *num_pointer << std::endl;
}
practice3:何が出力される?
#include <iostream>
int main()
{
int num = 0;
int* num_pointer = #
*num_pointer = 2;
int num_copy = num;
num_copy = 1;
std::cout << num << std::endl;
}
-
解答
- practice1
100
- practice2num is 100 num is 99 num_copy is 100 *num_pointer is 0
- practice3
2
0.1.2 ヒープとスタック
ポインターはオブジェクトのメモリ上のアドレスを示す物でしたが、メモリの領域にもいくつか種類があります。ここでは「ヒープ領域」と「スタック領域」というメモリの領域について解説します。
- スタック領域
普通の変数はスタック領域に確保されると思ってください。この領域には関数内で宣言された変数などが配置されます。
void function()
{
int a = 0;//aはスタックに置かれる
int b = 0;//bもスタックに置かれる
char* str = "aaaaa";//strもスタックに置かれる
}//関数が終わるのでa,b,strはもう使えなくなる
関数が終わるとスタックは解放されます。
また、
pre_sample2.cpp
#include <iostream>
int* return_ptr()
{
int num = 100;
return #
}//numは解放され、使えなくなるのにそのポインタを返している!!
int main()
{
std::cout << *return_ptr() << std::endl;
}
このようなコードを書くと実行時にエラーが起きます(もう使えないリソースへのポインタにmain関数内でアクセスしてしまっているため)。
- ヒープ領域
ヒープ領域は関数やスコープが終了しても、メモリを明示的に解放する処理を書かないとずっと解放されない領域です。
pre_sample3.cpp
#include <iostream>
int* return_ptr()
{
int* num = new int(100);
return num;
}//numは破棄されないのでOK
int main()
{
int* ptr = return_ptr();
std::cout << *ptr << std::endl;
delete ptr;//ptrの指すメモリ領域を解放
}
しかし、二重にメモリを解放すると実行時エラーが起きます。
int* ptr = new int(100);
delete ptr;
delete ptr;//実行時エラー
基本的に、ヒープ領域の確保は軽い処理ではないので基本的にはスタック領域を使うべきです(何も考えていなければスタックに確保されます)。ヒープ領域を使うべきなのはスコープを超えてそのリソースを利用したい時です。
0.2 クラス
C++には「クラス」という機能がありますが、どのように使うのでしょうか。処理をまとめて再利用できるようにするなら関数だけで事足りるじゃないか、と思うかもしれません。しかし、C++はクラスベースのオブジェクト指向言語ー関連するデータや処理をまとめたオブジェクトを基本単位としてプログラムを書くスタイルの言語ーですから、少なくともC++を使うのならクラスは理解して活用した方がいいです。
0.2.1 クラスとは
クラスとは、簡単にいうと「処理とデータをまとめた物」です。或いは「ある目的のために必要な処理やデータを同じ名前空間に配置し、相互にアクセスしやすくした物」だとか「利用者が詳細な処理を意識する必要がない、再利用可能なプログラムの単位」のように考えても良いかも知れません。前述の「オブジェクト指向」の実現手法の一つです。
とにかくこういった抽象的な概念は感覚で理解するよりないのです。そのためにはその概念の必要性やその概念が生まれた背景を理解することが重要です。
まずは機能と文法を見てみましょう。
pre_sample4.cpp
#include <iostream>
#include <string>
class HasNum
{
int num;//①
public:
HasNum(int initial_value):num(initial_value){}//②
std::string str;//③
int get_num()//④
{
return num;
}
};
int main()
{
HasNum hn(1);//⑤
//std::cout << hn.num1 << std::endl; ⑥
std::cout << hn.str << std::endl;//⑦
std::cout << hn.get_num() << std::endl;//⑧
}
⑤:コンストラクタ(②で定義、初期化時に呼び出される)を引数1
で呼び出し、`numを初期化。
⑥:何も指定がないとprivate
なメンバとみなされるためクラス外部からはアクセスできず、コンパイルエラーが起きる
⑦,⑧:public
と指定されたメンバ(③,④)にはアクセスできる
このように、データ(num
やstr
)と処理(get_num
)を外部からアクセス可能か、などを指定しつつひとまとめにできます。
では、クラスの使い所を見ていきましょう。
以下の要件を満たすプログラムの例です。
動物園の入場者を管理するプログラムを書きたい。名前と所持金と年齢の情報が与えらるので、年齢が16歳以上の入場者には所持金を確認し、1000円以上持っていたら所持金から1000円を引き、持っていなかったら入場を拒否する。
pre_sample5.cpp
#include <iostream>
#include <vector>
#include <string>
class Person
{
const std::string name;
unsigned int money;
const unsigned short age;
public:
Person(const std::string& name,unsigned int money,const unsigned short age):name(name),money(money),age(age){}
const std::string& get_name() const {return name;}
unsigned int get_money() const {return money;}
void set_money(unsigned int new_money) {money = new_money;}
unsigned short get_age() const {return age;}
void self_introduction() const
{
std::cout << "====== " << name << "'s introduction ======" << std::endl;
std::cout << "I Have " << money << " yen" << std::endl;
std::cout << "I'm " << age << " years old" << std::endl;
}
};
class Gate
{
private:
std::vector<Person> people;
public:
bool accept(Person p)
{
if (p.get_money() < 1000 && p.get_age() >= 16)
{
std::cout << "Sorry,but you're not welcome... You don't have enough money." << std::endl;
return false;
}
else
{
std::cout << "welcome " << p.get_name() << std::endl;
if (p.get_age() >= 16) p.set_money(p.get_money() - 1000);
people.push_back(p);
return true;
}
}
const std::vector<Person>& get_people() const {return people;}
};
int main()
{
Person john = Person("John",2000,20);
Person jack = Person("Jack",0,5);
Person tomy = Person("Tomy",500,30);
Gate gate;
gate.accept(john);
gate.accept(jack);
gate.accept(tomy);
std::cout << "+++++++++++++++" << std::endl;
for (auto& p:gate.get_people())
{
std::cout << p.get_name() << " is in zoo" << std::endl;
p.self_introduction();
std::cout << std::endl;
}
}
このようなプログラムはクラスを活用すると「人の情報に関する処理(Personクラス)」と「入場者情報と新規入場者管理の処理(Gateクラス)」のようにうまく目的ごとにコードを分離でき、全体的に見通しが良くなります。
もっとプログラムを拡張させる事を考えると、このサンプルコードはあまり良い書き方ではありませんが、それについてはもっと後の章で扱います。
とりあえずここではクラスによる処理の分離の仕方やその効果を実感していただければ充分です。といってもすぐには理解できないと思うので、実践を通して感覚的に理解していきましょう。本稿はその一助として活用していただければ幸いです。
0.2.2 演算子オーバーロード
演算子とは+(プラス)や−(マイナス)など各種演算を表す記号です。+なら「足し算」、−なら「引き算」を表しています。C++では通常足し算や引き算は組み込みの数値型(int,floatなど)などに対してしか行えませんが、自作の数値クラスなどに組み込みの数値型と同様の振る舞いをさせたい時など、演算子によって処理をカスタムしたいことがあります。これを実現する方法を「演算子オーバーロード」と言います。
コード例を見てみましょう。
pre_sample6.cpp
#include <iostream>
class MyInt
{
int num;
public:
MyInt(int num):num(num){}
MyInt operator+(const MyInt& rhs) const
{
std::cout << num << " plus " << rhs.num << " !!" << std::endl;
return MyInt(num + rhs.num);
}
MyInt operator-(const MyInt& rhs) const
{
std::cout << num << " minus " << rhs.num << " !!" << std::endl;
return MyInt(num - rhs.num);
}
};
int main()
{
MyInt x(10);
MyInt y(2);
MyInt sum = x + y;//10 plus 2 !!
MyInt difference = x - y;//10 minus 2 !!
MyInt sum_plus_difference = sum + difference;//12 plus 8 !!
}
このように、演算の返り値の型 operator演算子(演算子の右側にくる値)
のようにメンバー関数を定義してやるとその演算子のオーバーロードができます。
演算子オーバーロードはうまく使うと便利ですが、下手に使いすぎると訳がわからなくなるので使い所には注意しましょう。
0.3 ジェネリックプログラミング
この節では重要なプログラミングの技法であるジェネリックプログラミングについて解説します。
0.3.1 「ジェネリック」の意味
ジェネリックとは「総称」の意で、ジェネリックプログラミングとは特定の型に限定しない汎用的な処理を記述できるプログラミングの手法の一つです。
0.3.2 テンプレートを使う
それではジェネリックプログラミングの使い所を見ていきましょう。「特定の型に依存しない処理」に使うのがベストです。以下はtemplate
キーワードを使って引数の型のサイズを表示するようなジェネリック関数を定義するコード例です。
pre_sample7.cpp
#include <iostream>
#include <string>
template <typename T>
void print_size_of_argument(T& argument)
{
std::cout << "argument size is " << sizeof(argument) << std::endl;
}
int main()
{
int x = 100;
long long y = 0;
std::string str = "aaa";
char c = 'a';
print_size_of_argument(x);
print_size_of_argument(y);
print_size_of_argument(str);
print_size_of_argument(c);
}
このように、いちいち特定の型のサイズを表示する関数ー例えばprint_size_of_argument(int argument)
とかprint_size_of_argument(std::string argument)
を定義せずに済みます。
これで、0.準備編は終わりです。次からはいよいよ本題に入っていきます。頑張ってください!
1.安全なプログラムを書く
前提知識:スコープ、変数の生存範囲、ポインタ、new/delete、クラス、(テンプレート)
1.1 安全なメモリ管理
皆さんご存知のように、C++は「静的型付けのコンパイル型言語」に分類される言語で、型チェックなどのおかげである程度実行時エラーを減らすことができます。しかし、全ての実行時エラーがコンパイル時に検知できるわけではありません。コンパイル時に検知できない実行時エラーとしては
- メモリ関連のエラー(未定義動作含む)
- 例外送出
などが挙げられ、特にメモリ関連のエラーは、エラーが起きても確実にプログラムが終了する保証はなく、異常な動作を続けてしまったり、デバッグが困難であったりと、非常に厄介で危険なものです。そこで、今回はメモリ関連のエラーを減らす方法について考えていきます。
一口にメモリ関連のエラーと言っても二重解放、不正なメモリアドレス(NULLなど)へのアクセス、メモリ確保失敗などいくつかの種類があります。まずは二重解放や不正なメモリアドレスへのアクセスを未然に防ぐ方法を紹介したいと思います。
1.1.1 delete を書かない
C++では
delete ptr
でメモリを解放しますが、解放するポインタ(ここではptr)が既に解放済みか、そもそも有効なアドレスなのか、といったことがコンパイル時には把握できず、とても危険な操作であると言えます。危険な操作はしない方がいいので、基本的にdeleteは書くべきではありません。
では、どうやってメモリを解放すればいいのでしょうか?
1.1.2 RAII イディオム
ここで役に立つ考え方が「RAII イディオム」です。
RAIIとはResource Acquisition Is Initializationの略で、日本語にすると「リソースの確保は初期化である」、つまり「リソースは確保時に必ず初期化しよう」くらいの意味です。また、RAIIという名前には含まれていませんが、このイディオムでは変数の破棄処理(デストラクタ)とリソースの解放を同時に行うということも含まれています。
実際にコードを見てみましょう。
sample1.cpp
#include <iostream>
class Value
{
public:
Value()=default;
~Value() {std::cout << "destruct 'Value' class instance" << std::flush;}
};
template <typename T>
class Manager
{
const T* ptr;
public:
Manager()=delete;//*
Manager(T* ptr):ptr(ptr){} //①
~Manager(){delete ptr;} //②
};
int main()
{
{
Manager<Value> ptr(new Value()); //③
} //④
}
これは、適当に定義したValueクラスと、任意の型のポインタをRAIIの考え方に基づいて自動で管理するManagerクラスのコード例です。
③:リソース確保( new V()
)とManager<Value>
型の変数ptr
の初期化を同時に行っている(①のコンストラクタで初期化している)
④:スコープが終わるのでptr
のデストラクタ( ~Manager<Value>()
)でManager::ptr
の指すメモリ領域が解放される(②のデストラクタで解放している)
このようにすると随所でdeleteする、というような危険な操作をしないで済みます。
ただ、これだとわざわざnew
してヒープ領域に確保したリソースの利用がスコープ内で終わっているのであまりヒープ領域に確保した意味がありませんよね。これについてはもう少しあとの章で解説します。
また、このRAIIイディオムはポインタに限らずファイルのopen/closeの管理など様々な場面で応用できます。
(*...メンバ変数Manager::ptrを初期化しないようなコンストラクタ呼び出しを禁止している)
1.1.3 スマートポインター
前節では自作のManagerクラスなんてものを作りましたが、こんなことをしなくてもC++には同じ考え方でポインター(が指すメモリ領域=リソース)を管理する「スマートポインター」というものが標準で実装されています。スマートポインターには「unique_ptr」「shared_ptr」「weak_ptr」という三つの種類があります。
- unique_ptr
unique_ptrは「unique(ユニーク:唯一の、一意な)」という名の通り、リソースの所有者が一つだけであるというケースで利用するスマートポインタです。
でも、「リソース(ポインタの指すメモリ領域)の所有者が一つだけ」とはどういうことでしょうか?コード例を見てみましょう。
sample2.cpp
#include <memory>
int main()
{
{
std::unique_ptr<int> int_ptr(new int(100));
//std::unique_ptr<int> int_ptr_2 = int_ptr; ① コンパイルエラー!!
std::unique_ptr<int> int_ptr_3 = std::move(int_ptr); //② コンパイルできる
}//③ スコープ終了
}
このコード例では、①でint_ptr
をコピーしてint_ptr_2
を初期化しています。また、②ではstd::move(int_ptr)
というものでint_ptr_3
を初期化しています。そのあと③の時点でスコープが終わるのでstd::unique_ptr<int>
のデストラクタが呼ばれ、はじめにnew int(100)
で確保したメモリが解放されます。
なぜ、②ではコンパイルエラーが出てしまうのでしょうか。
先ほどのsample1.cppのManagerクラスのデストラクタを見てみてください。
~Manager(){delete ptr;}
デストラクタで、初期化時に渡されたポインタを解放していますね。でも、
{
Manager<int> manager_1(new int(100));
Manager<int> manager_2 = manager_1;
}//二重解放!!
↑の例のように
同じManager::ptr
を持つManagerクラスが複数あったらどうなるでしょうか。同じポインタが二回以上deleteされ、二重解放のエラーが起きてしまいます。
こういった理由から、std::unique_ptr型の値はコピーができない(コピーコンストラクタが明示的に削除されている)のです。コピーできてしまったら二重解放が起きてしまいますからね。
これが、①でエラーが起きてしまう理由です。
ではなぜ②はコンパイルが通るのか、不思議に思うかもしれませんが、これについてはもう少しあとの章で解説します。
- shared_ptr
shared_ptrはその名の通り、リソースをshare(共有)できるスマートポインタです。
つまり、unique_ptrと違ってコピーができます。なぜコピーしても二重解放がされないか不思議に思うかもしれませんが、shared_ptrはコピーコンストラクタ(コピー時に呼び出される)で「参照カウンタ」というものを+1し、デストラクタではその参照カウンタを−1し、参照カウントが0の場合のみdeleteでリソース解放をしているのです。
イメージが掴みづらいと思うので以下にコード例を挙げます。
sample3.cpp
#include <memory>
int main()
{
{
std::shared_ptr<int> shared_p(new int(100)); //参照カウンタは1
{
std::shared_ptr<int> copy = shared_p; //参照カウンタは2
}//参照カウンタは1
}//参照カウンタは0→解放される
}
こんな感じで、自動的にタイミングを見計らってメモリを解放してくれます。ただし、この参照カウンタを操作してメモリを管理するという方法には問題もあり、一つは非常に小さいオーバーヘッドがある(=普通のポインタより少し動作が遅い)こと、二つめは循環参照が発生した時にメモリが解放されない(=メモリリークが発生する)ことです。循環参照は、複数のshared_ptrインスタンスが互いに参照することで発生してしまいます。
- weak_ptr
weak_ptrは上記の循環参照問題を解消するためのスマートポインタです。weak_ptrは参照カウンタを操作しないので、循環参照は発生しません。ただ、同じ理由で無効なポインタにアクセスしてしまう可能性もあります。
sample4.cpp
#include <memory>
int main()
{
{
std::shared_ptr<int> shared_p(new int(100)); //参照カウンタは1
{
std::weak_ptr<int> copy = shared_p; //参照カウンタは1のまま変わらない
}
}//参照カウンタは0→解放される
}
ちなみに、このように参照カウンタを操作しない参照の仕方を「弱い(weak)参照」と呼び、shared_ptrのように参照カウンタを操作する参照の仕方を「強い参照」と呼びます。これらの言葉はC++以外でも出てくるのでこの機に覚えてしまいましょう。
1.2 右辺値
この章では「右辺値」という概念を扱っていきます。この概念は理解するのに少し時間がかかるかもしれませんが、初めのうちはあまり深く考えず色々実験してみるといいと思います。
まずはコード例を見てみましょう。
sample5.cpp
#include <iostream>
int main()
{
int num = 100;//① numはlvalue 100はrvalue
int num2 = num;//②
std::cout << num << std::endl;//③
int&& rvalue_ref = 99;//④
}
(lvalueは左辺値、rvalueは右辺値のことです)
このように、
左辺 = 右辺
の形のプログラムがあった際の左辺に相当するのが左辺値で右辺に相当するのが右辺値、と考えると大体の場合合っていると思います。
これでは雑すぎるのでもう少し丁寧に言うと、左辺値は代入が可能なオブジェクトであり、右辺値はすぐに破棄されてしまう代入が不可能で一時的なオブジェクトです。文章で説明するのは難しいですが、例えばsample5.cppの例では変数num
は1とか2とかの値を代入できますよね。しかし、100
という値に1とか2とかの値を代入する事はできません。100
という値そのものに代入するなんて意味がわからないですからね。また、100
という値そのものはどこからも参照できない(=用済み)わけです。なのですぐに破棄されてしまいます。それに対して左辺値はそれが指しているものは基本的にスコープの終わりまで生存しています。①の段階でnum
の中身が破棄されてしまったら③がおかしくなってしまいますからね。
また、②のように左辺値が右辺側に来ることもあるので、左辺 = 右辺
というのはあくまで簡易的な判断方法です。
④は右辺値の参照というもので、右辺値参照型(int&&
)に代入することで右辺値はスコープの終わりまで延命されます。
右辺値(rvalue)はもっと厳密にはxvalueやprvalueなどに分類できるようですが、普通はそこまで覚える必要はないと思います。筆者も覚えていませんがそれで困った事はありません。
1.2.1 std::move
「1.1.3 unique_ptr」のsample2.cppでも出てきたstd::move
という関数は、左辺値を右辺値にキャストする関数です。でも、せっかく代入可能で中身がスコープが終わるまでは破棄されない左辺値をわざわざ右辺値に変換してなんのメリットがあるんだ?と思うかもしれません。確かに無闇に使ってもなんの意味もないのですが、使った方がいいケースというのも存在します。もう一度sample2.cppを見てみましょう。
sample2.cpp
#include <memory>
int main()
{
{
std::unique_ptr<int> int_ptr(new int(100));
//std::unique_ptr<int> int_ptr_2 = int_ptr; ① コンパイルエラー!!
std::unique_ptr<int> int_ptr_3 = std::move(int_ptr); //② コンパイルできる
}//③ スコープ終了
}
②でstd::moveが登場していますね。左辺値であるint_ptr
をstd::moveで右辺値にキャストしたものでint_ptr_3
を初期化しています。
つまり、std::unique_ptr<int>&&
(std::unique_ptr<int>
の右辺値の参照の型)を引数に取るstd::unique_ptr<int>
のコピーコンストラクタが呼び出されているという事です。
ここで重要なのは、右辺値はもう使われないということです。std::move(int_ptr)
という記述によって、int_ptr
の中身は、すぐに破棄されてしまう”用済み”なオブジェクトになる(*)のです。つまり、プログラマがint_ptr
を利用して最初にnew int(100)
したリソースにアクセスする事はないだろうと解釈できます。この考え方が実際にコードに現れているのがstd::unique_ptr
のコピーコンストラクタです。std::unique_ptr
のコピーコンストラクタでは、コピー元(ここではint_ptr
)の保持するポインタ(=new int(100)
の返り値)を自分で保持し、コピー元のポインタはnullptr
に書き換えてしまうのです。つまり、そのリソースの所有権を奪い取っている、というような挙動をするのです。これによってコピー元のint_ptr
のデストラクタではnullptr
をdeleteするだけで二重解放は発生せず、元々int_ptr
が所持していたリソースはコピー先のint_ptr_3
が所有することになります。このようにポインタのみを挿げ替えることでコピーコストの削減にもつながっています。
std::move
という名前の"move"(移動)は、「リソースの所有権の移動」を表していると考えると理解しやすいかも知れません。実際にはstd::move
は左辺値を右辺値にキャストするだけなのですが、その振る舞いに「リソースの所有権の移動」という意味を持たせることも多々あります。
初めて右辺値の概念を知った方は理解するのに少し時間がかかるかも知れませんが、章の頭でも触れたように、あまりきちんと理解することに固執せず、とにかくたくさんコードを書いていくことが長期的には理解の質の深化にも繋がっていくと思います。
- 1.1.2の解説
また、std::unique_ptrは通常のコピーは禁止(1.1.3)されていますが、std::moveで右辺値にされたstd::unique_ptrを引数にとるコピーは許可されています。(sample2.cpp-②)これの意味するところは、std::moveによってコピー先に所有権を譲渡することで、スコープ内での破棄を免れリソースを”延命”させることができるのです。これで1.1.2で述べていた
ただ、これだとわざわざ`new`してヒープ領域に確保したリソースの利用がスコープ内で終わっているのであまりヒープ領域に確保した意味がありませんよね。これについてはもう少しあとの章で解説します。
は理解できたでしょうか。
(*...std::move
で右辺値に変換されたオブジェクトが破棄されるのはムーブコンストラクター内で、実際にstd::move
によるキャストが持つ効果は「このオブジェクトは用済みなので破棄しても構わない」という意思表明くらいのものです。)
1.2.2 unique_ptrを実装しよう
ここまで、RAIIイディオムや右辺値といった抽象的な事柄について扱ってきました。なかなか理解に時間がかかるかも知れませんが、頭の中を整理するためにも一度このあたりでアウトプットしてみましょう。
「unique_ptrの実装」は、今までの知識の応用としてぴったりな題材なので、是非取り組んでみてください。あまり理解できていなくて手探りで実装しても全く構いません。初めから完全に理解しようとするよりも、不完全な理解を実践による感覚の滋養で補っていきましょう。
1.3 型による表現
C++に限らず多くのプログラミング言語には型という仕組みがあります。int型には整数しか格納できない、string型には文字列しか格納できない、など型によって値を制限することで「整数だと思っていた値が実は文字だった!」といったバグを事前に検知し、防ぐことができます。この章では、型によってその型に格納される値の特性や意図を表現し、より安全でわかりやすいプログラムを書けるようになることを目標に解説をしていきます。
1.3.1 便利な標準ライブラリたち
さて、まずは「型による表現」に関連した標準ライブラリについて見ていきましょう。使えるものは使った方がいいです。
- std::optional
「値が存在する場合としない場合」を表現できるクラス(型)です。
まずはstd::optionalなんて難しそうなものは使わず、ポインタで表現してみましょう。
int型の整数が存在する場合としない場合で場合わけし、処理をするコード例です。
sample6.cpp
#include <iostream>
#include <optional>
void check_num(int* num)
{
if (num != nullptr)// 存在する場合
{
std::cout << *num << std::endl;
}
else
{
std::cout << "'num' has no value." << std::endl;
}
}
int main()
{
int* num_or_none = nullptr;
check_num(num_or_none);
num_or_none = new int(1);
check_num(num_or_none);
}
ここでは「存在しない場合」をnullptrで表現していますね。
次は、std::optionalを使った例です。
sample7.cpp
#include <iostream>
#include <optional>
void check_num(std::optional<int> num)
{
if (num.has_value())// 存在する場合
{
std::cout << num.value() << std::endl;
}
else
{
std::cout << "'num' has no value." << std::endl;
}
}
int main()
{
std::optional<int> num_or_none;
check_num(num_or_none);
num_or_none = 1;
check_num(num_or_none);
}
どちらも大して変わりませんね。では、わざと「存在しない値を使ってしまう」プログラムを書いてみましょう。実行時エラーが起きるはずです。
sample8.cpp
#include <iostream>
#include <optional>
void check_num(int* num)
{
std::cout << *num << std::endl; //numが存在しない=nullptrの場合エラー
}
int main()
{
int* num_or_none = nullptr;
check_num(num_or_none);// 実行時エラー
num_or_none = new int(1);
check_num(num_or_none);
}
このコードを実行すると、コンパイルはできますが実行時にエラーが起きるはずです。
次は、std::optionalを使った例です。
sample9.cpp
#include <iostream>
#include <optional>
void check_num(std::optional<int> num)
{
std::cout << num << std::endl;//①
}
int main()
{
std::optional<int> num_or_none;
check_num(num_or_none);
num_or_none = 1;
check_num(num_or_none);
}
今度は①のところでコンパイルエラーが起きるはずです。なぜならstd::optional<int>
はそのままではint
としては扱えず、std::cout
で処理できないからです。std::optional<int>::value
メンバ関数で、intの値を取り出す必要があります。
なので、①のnum
をnum.value()
に書き換えればコンパイルは通り、実行時エラーが起きます。
なんだ、std::optionalなんてものを使っても実行時エラーは起きうるじゃないか、と思うかも知れません。確かに実行時エラーを完全に防ぐ事はできませんが、std::optional
を使う事で「この値は存在しない場合もある」ということを明示できますし、std::optional::value()
という処理を挟む必要があることから、存在しない場合の処理をしないといけないことに気が付きやすく、人為的なミスを減らすことができます。
これが、型による表現が大切な理由の一つです。sample6.cppやsample8.cppのような書き方だと、「int型のポインタである」という情報しか与えられませんから、「値が存在しない場合もある」ということに思い至らず、実行時エラーの可能性を孕んだコードを量産してしまいかねませんが、std::optional
型で「値が存在しない場合もある」ということを表現することで、このようなミスを減らし、より安全なプログラムを書くことができます。
- std::variant
「いくつか値の種類の選択肢があり、必ずそのうちどれか一つの種類の値を格納している」ということを表現できる型です。後述する「直和型」のような型です。
この説明だけではなかなか理解できないと思うので、以下のようなケースを考えてみましょう。
sample10.cpp
#include <string>
#include <variant>
#include <iostream>
void print(std::variant<int,std::string> var)
{
if (std::holds_alternative<int>(var))//③
{
std::cout << "variant has int value " << std::get<int>(var) << std::endl;
}
if (std::holds_alternative<std::string>(var))//④
{
std::cout << "variant has string " << std::get<std::string>(var) << std::endl;
}
}
int main()
{
std::variant<int,std::string> int_or_string = 100;//① 選択肢はint,std::stringの二つ
print(int_or_string);
int_or_string = "ABCDEFG";//②
print(int_or_string);
}
整数または文字列を格納する変数に対して、それぞれの型に応じて処理を切り替えているコード例です。
①:std::variant<int,string>
型の変数int_or_string
をint型の100
で初期化→この時点でint_or_string
はint
型の値を格納しているため③に合致し、実際に格納されている値(100)を出力している
②:int_or_string
にstd::string型のABCDEFG
を代入→この時点でint_or_string
はstd::string
型の値を格納しているため④に合致し、実際に格納されている値("ABCDEFG")を出力している
先ほどのstd::optional
が値が「存在する」か「存在しない」かの二択だったのに対し、このstd::variant
は値が「型Tのとき」「型T2のとき」「型T3のとき」...など要件に応じて複数の選択肢を表現できます。
今はまだ使いどころが分からないかも知れませんが、少し後の節でstd::variantの便利な活用法を紹介するので、頭の片隅にでも置いておいてください。
1.3.2 ADT
ADTとはAlgebraic Data Typeの略で、日本語にすると「代数的データ型」です(Abstract Data Typeの略の場合もありますが、今回はこちらは扱いません)。
本節ではADTを構成する「直和型」と「直積型」を紹介していきます。
- 直和型
直和型とは、「いくつか値の種類の選択肢があり、必ずそのうちどれか一つの種類の値を格納している」ような型で、それぞれの種類は「タグ」というもので区別されます。あれ、先ほどのstd::variant
の説明と同じですね。そう、std::variant
はC++で直和型を実現するためのクラスなのです。ただ、std::variantは「タグ」での区別という点では少々弱く、普通直和型といったら
Type int_or_int_or_string = number(int) | age(int) | name(string)
int_or_int_or_string value = int_or_int_or_string.number(100);
のように同じ型(ここではint)の選択肢があっても名前(ここではnumber,age)で区別できるものなのですがstd::variantでは同じ型の選択肢があると
std::variant<int,int,std::string> value;
value.emplace<0>(100);//0番目(=int)の種類の値
このように何番目の選択肢か、という区別の仕方しかできません。ここが少々不便といえば不便です。
- 直積型
直和型が「いくつか値の種類の選択肢があり、必ずそのうちどれか一つの種類の値を格納している」型だったのに対し、直積型は「いくつか値の種類の選択肢があり、必ず同時に全ての選択肢の種類の値を格納している」ような型です。何か思いつきませんか?そう、C++のclass/structです。例えばC++で
sample11.cpp
#include <string>
class Sample
{
public:
int number;
int age;
std::string name;
Sample()=delete;
Sample(int number,int age,const std::string& name):number(number),age(age),name(name){}
};
int main()
{
Sample instance(1,2,"AAAAA");
instance.number;//①
instance.age;//②
instance.name;//③
}
このようなコードを書くと、ある時点で一種類の値しか格納していない直和型と違って、全ての選択肢(number,age,name)の値が同時に存在していることがわかると思います(①、②、③)。これが、直積型です。
これら二つの型は機能としては非常に単純で拍子抜けしたかも知れませんが、実はこれらの型を活用することで「型による表現」、ひいては安全でわかりやすいプログラムを書くことが非常に簡単になります。
1.3.3 ADTの活用
それでは直和型と直積型の活用の仕方について学んでいきましょう。
このような要件でプログラムを書くとします。
個人情報を管理するクラスを設計したい。情報は、郵便番号か住所のいずれかと、氏名、年齢の三つが必ずセットになっている必要がある。
まずはADTを活用することを気にせず愚直に書いていきましょう。
#include <string>
#include <optional>
class Person
{
const std::optional<const unsigned int> postal_code;//郵便番号
const std::optional<const std::string> address;//住所
const std::string name;
const unsigned int age;
public:
Person()=delete;
Person(
std::optional<const unsigned int> postal_code,
const std::optional<const std::string>& address,
const std::string& name,
unsigned int age
):postal_code(postal_code),address(address),name(name),age(age){}
const std::optional<const unsigned int> get_postal_code() const {return postal_code;}
const std::optional<const std::string>& get_address() const {return address;}
const std::string& get_name() const {return name;}
unsigned int get_age() const {return age;}
};
int main()
{
auto person1 = Person(111111111,std::nullopt/*std::optionalで値が存在しない場合を表す定数*/,
"Taro",16);//①:郵便番号
auto person2 = Person(std::nullopt,"Tokyo Japan","Jiro",15);//②:住所
auto person3 = Person(std::nullopt,std::nullopt,"Saburo",10);//③:郵便番号も住所も無い
}
先ほど学んだstd::optional
を活用していますね。しかし、このプログラムにはバグがあります。③の箇所です。要件には
郵便番号か住所のいずれかと、氏名、年齢の三つが必ずセットになっている必要がある。
とあるのに、郵便番号と住所が共にstd::nullopt
(存在しない)になってしまっています。まあこのバグにはすぐに気がつくかも知れませんが、人力でバグを探すよりもコンパイル時に自動的に検知してくれる方がいいですよね。
そこで、今度は直和型と組み合わせてこのようなバグが起きえないプログラムに書き換えていきます。
#include <string>
#include <variant>
#include <optional>
using AddressInfo = std::variant<const unsigned int,const std::string>;//郵便番号もしくは住所
class Person
{
const AddressInfo address_info;
const std::string name;
const unsigned int age;
public:
Person()=delete;
Person(
const AddressInfo& address_info,
const std::string& name,
unsigned int age
):address_info(address_info),name(name),age(age){}
const AddressInfo& get_address_info() const { return address_info;}
const std::string& get_name() const {return name;}
unsigned int get_age() const {return age;}
};
int main()
{
auto person1 = Person(111111111,"Taro",16);//①
auto person2 = Person("Tokyo Japan","Jiro",15);//②
//auto person3 = Person("Saburo",10);//③コンパイルエラー
}
こうすれば郵便番号も住所も不明な要件を満たさないPersonクラスが原理上ありえなくなりました。このように直和型と直積型を場面に応じて使い分け、組み合わせることでそもそもバグが起きえないプログラムを書くことができるのです。これが本章の題である「型による表現」が重要な理由です。適切な型、つまり要件を表現した型はバグを減らし、プログラムをより安全でわかりやすい物にします。
これで、1.安全なプログラムを書くは終わりです。2.綺麗なプログラムを書く以降の投稿は未定です