おっさんのためのModernC++入門ガイド(草稿)

みなさんはC++の読み書きができますか?

自信がある方、いつ頃勉強しましたか?もし20世紀に勉強したのであれば、その知識は相当古いです。実質現在のModernC++(C++11以降のC++)とは概念上の互換性がないので脳のアップデートが必要です。

自信がない方、文法は知っているけどなんとなく使いこなせていない方、マサカリ屋にあーだこーだ言われて大混乱している方。必勝パターンを身につければもっと楽にコードを読み書きできるようになるかもしれません。

この文章の目的は、ModernC++におけるメンタルモデル(考え方)や必勝パターンをざっくりと導入することでみなさんが楽にModernC++を読み書きできるようなお手伝いをすることです。主要な内容としてはムーブセマンティクスと右辺値 とその次の章でだいたいA4換算で15ページくらい?ほかは正直流し読みしてもらえるような内容です。また、内容的にはそのボリューム以上に薄いので気楽に読んでもらって、気軽にコードが書けるようになってもらえればいいなと思います。

前置き

ModernC++宣言

ModernC++を書く前に言っておきたい事がある
かなり厳しい話もするが俺の本音を聴いておけ

new/deleteは(直接)呼んではいけない
生ポインタも使ってはいけない

auto推論を使え、範囲forを使え
できる範囲で構わないから

忘れてくれるな
所有権を理解しない者に
まともなコードは書けないことを

お前にはお前しか
できない事もあるから
細かいことはライブラリに任せて
ロジックの実現に集中しろ

意訳:

ModernC++は21世紀に入ってから改善されてきたC++言語の仕様を指します。これまでのC++(C++03以前)の概念から、より明快な意味論を持つように改善されていることが特徴です。

ModernC++ではこれまで多用されてきた new / delete などのハンドコードによるメモリ管理が基本的に排除され、スコープとスマートポインタの概念で基本的なアロケーションの管理ができるようになっています。また、アロケーションの管理だけでなく、よりシンプルに記述できる構文規則を採用することで、実現したいロジックをより簡潔に表現できるようになっています。

ただし、この恩恵を受けるためには「明快な意味論」であるところのムーブセマンティクスをある程度理解している必要があります。

何をするか?

このガイドは主にこれまで、なんとなくC++ の構文を知ってたり、レガシーなC++なら読み書きできる人(ここでは単に短く「おっさん」と呼ぶ)が、モダンで実用的なC++アプリケーションコードをかけるようにするためのModernC++クイックスタートガイドを提供します。

ライブラリ利用者の立ち位置から簡潔な記述でアプリケーションコードを書けるレベルを目指します。基本的に、大胆不敵に説明し、大枠を掴んでリテラシーを確立することを目的にします。細かいところが間違っていても、後で調べてわかりゃそれでいいじゃないですか。

以降、本ガイドの内容についてもう少し説明します。(かったるそうだなと思ったらセクションの終わりまで読み飛ばしてもらっても大丈夫です。)

大まかには下記のような流れになります。

  • 主にムーブセマンティクスの直感的理解と変数/関数の宣言における必勝パターンの把握を目指します。

    • このガイド内での説明のほとんどをこれに割きます。

    • これをある程度理解ししていれば、あとは些末だと言っても過言ではありません。

  • 高速化その他の特殊な事情でデフォルトのメモリアロケーション機構を置き換えるなどの発展的な利用法については触れません。

  • 自分でテンプレートを書くレベルまで到達することは想定しません。

    • つまり次のような言葉は過去に耳にしていても一旦忘れろってことです。これらの概念はテンプレート書かない段階では基本的に関係ないです。

      • 完全転送

      • ユニバーサル参照

      • 参照圧縮

  • ライブラリ利用者の立ち位置でアプリケーションコードを書くという前提から、ある程度理解しておくべき事項を補足します。

言語仕様のベースは基本的にC++14からC++17くらいを想定します。これもだいたいの目安です。基本、だいたい、です。

対象のおっさんであることの確認

だいたい、次のことがある程度自分で説明できるか、事前に確認しておきましょう。

  • 式と文

  • 型定義

  • 例外

  • 参照、const 参照、ポインタ

  • struct / class / 可視性

  • 関数オーバーロード

  • インスタンス / new / delete

  • コンストラクタ(デフォルトコンストラクタ、コピーコンストラクタ)、デストラク

  • 配列

  • 継承、virtual 関数、オーバーライド

  • テンプレート(テンプレートについてあまり深入りしませんが、vector くらいは使えることを想定します)

ちなみに、上記の内容に不安がある場合、このガイドを読むより、C++の一般的な教本を読んだほうが効率がいいかもしれません。

ムーブセマンティクスと右辺値

前提:C++のオブジェクトの生存期間

ムーブセマンティクスを語る前に、前提知識となる、C++のオブジェクトの生存期間について確認します。

C++においてオブジェクトを確保したとき、そのライフサイクルは構文の側面から見てざっくり2種類あります。

左辺値と右辺値です。

左辺値(lvalue)

名前のついたオブジェクトです。名前のスコープが切れたところでデストラクタが呼ばれます。

左辺値の例
#include <vector>

void func(){
    std::vector<int> v;
    // なにか処理1
    // なにか処理2
    // スコープが切れるところでvectorのデストラクタが呼ばれる
}

右辺値(rvalue)

名前のついていない、いわゆる一時オブジェクトです。式全体の評価が終わったときにデストラクタが呼ばれます。右辺値の代表的な例として、演算結果、(参照でない)関数の戻り値、リテラルなどが挙げられます。

右辺値は「はかない」オブジェクトです。名前で束縛しない限り、その式の中で消え去ってしまいます。

右辺値の例
#include <vector>

void func(){
    int i = (std::vector<int>{1,2,3}).pop();
    // pop()の処理が終わったときにvectorのデストラクタが呼ばれる
    // なにか処理1
    // なにか処理2
}

この「はかない」右辺値がムーブセマンティクスと密接に関わってきます。

コピーとムーブ

ムーブセマンティクスのコアとなる、コピーとムーブの概念についてざっくり説明します。

次のような大きなデータを含む構造があったとします。

大きなvectorの例
std::vector<int> someBigVector{0,1,2,..<<>>.., 1999999};

このような大きなデータでも複製は可能で、代入演算子やコピーコンストラクタを使用して複製します。

vectorの複製
std::vector<int> otherBigVector0 = someBigVector;
std::vector<int> otherBigVector1(someBigVector);

一方で、 vector の内部のデータを複製せず、ポインタの挿げ替えだけをした方が効率が良い場合が多々あります。たとえば、データ構造の構築を行うファクトリ関数とデータの受け渡しをする場合などです。この場合、データを作成した関数の側ではもうそのオブジェクトを使用しないので、中身をそのまま「移動」してあげたほうが効率がいいということになります。

過去のC++では、こういうときに次のような方法がありました。

  • 呼び出し元で領域を確保し、参照に対してデータを書き込む。

  • newauto_ptr を使用してオブジェクト全体をダイナミックに確保してポインタ渡しする。

  • コピーコンストラクタ、代入演算子の複製版と移動版を作成して使い分ける。

このような方法では、記述や処理そのものが冗長になること、オブジェクトの種類によってコピーコンストラクタや代入演算子の意味合いが変わってくるなど混乱を招くことが問題になっていました。

C++11以降では「移動」に関わる処理はムーブコンストラクタ、ムーブ代入演算子というカテゴリを別途設けることで「コピー」とは処理を明確に区別します。こういう整理をしたほうが世界がシンプルになることがわかったという大進歩なのです。

ムーブコンストラクタ、ムーブ代入演算子の例

ムーブコンストラクタ、ムーブ代入演算子の記述例を示します。

ムーブコンストラクタ、ムーブ代入演算子の例
class BigArray{
    private:
    std::unique_ptr<char[]> big_data;
    public:
    BigArray(BigArray&& old) // ムーブコンストラクタ
    :big_data(old.big_data)
    {
        old.big_data = nullptr;
    }
    operator=(BigArray&& old) // ムーブ代入演算子
    {
        this.big_data = old.big_data;
        old.big_data = nullptr;
    }
};

大きな配列を確保するオブジェクトを持つ場合、むやみに複製を行うのは効率低下の原因になるため、ムーブコンストラクタ、ムーブ代入演算子が役に立ちます。

ムーブコンストラクタ、ムーブ代入演算子は新たにヒーブ上のデータを確保するわけではなく、既存のデータをすげ替えることだけをします。移動のもととなった old オブジェクトは実質的に機能しなくなります。(以降、本ガイドではこれを「old が破壊された」と表現します。)

ここで、いわゆるコピー系の機能(コピーコンストラクタ、代入演算子)とムーブ系の機能(ムーブコンストラクタ、ムーブ代入演算子)のシグニチャを比較してみましょう。とても頻繁に利用する std::vectorシグニチャの一部を簡略化したものを挙げます。

簡略化した std::vectorシグニチャの一部
namespace std {
    template <typename T>
    class vector
    {
        public:
        vector(); // デフォルトコンストラクタ
        vector(const vector&); // コピーコンストラクタ
        vector(vector&&); // ムーブコンストラクタ
        operator=(const vector&); // (コピー)代入演算子
        operator=(vector&&); // ムーブ代入演算子
    };
}

std::vector クラスのオブジェクトはコピー系、ムーブ系、両方の演算を持っています。

コピー系とムーブ系の区別として、コンストラクタ/代入演算子ともに、コピー系の処理は引数の型が const vector& になっているのに対し、ムーブ系の処理は引数の型が vector&& になっています。「&& ってなんだ?」となるかも知れませんが、これについて次のセクションでざっくり説明します。

# 便宜上、Allocator に関するテンプレート引数を省略しました

ムーブコンストラクタ、ムーブ代入演算子の呼び出し方

謎の && という記号は右辺値参照と呼ばれるものです。関数の引数に書いた場合、右辺値にのみマッチします。なぜそのようなマッチングを取るのかというと、「破壊しても良い」対象を識別するためです。少なくとも、その式が完了したあとは破壊されるようなオブジェクトは「移動して破壊しても良い」と解釈されるということです。

ムーブされる右辺値の例1
std::vector v;

v = std::vector{0,1,2,3}; // std::vector{0,1,2,3} は右辺値
// 呼び出される演算子はムーブ演算子 std::vector::operator=(std::vector&&)
// 右辺で生成されたvectorはムーブされ破壊される

左辺値を引数にした場合、右辺値参照にはマッチしません。したがって、コピー代入が行われることになります。

ムーブされない左辺値の例1
std::vector v;
std::vector v1{0,1,2,3};

v = v1; // v1は名前をつけたので左辺値
// 呼び出される演算子はコピー代入演算子 std::vector::operator=(const std::vector&)
// 右辺にあるv1は左辺値なのでムーブされない

値返しする関数の戻り値も右辺値と認識されるので、ムーブ対象です。

ムーブされる右辺値の例2
std::vector some_function();

std::vector v;

v = some_function(); // std::vector{0,1,2,3} は右辺値
// 呼び出される演算子はムーブ演算子 std::vector::operator=(std::vector&&)
// 右辺で生成されたvectorはムーブされ破壊される

では、一度名前をつけてしまったオブジェクトは左辺値になるのでムーブできないのか、と言われるとそういうわけではありません。std::move を使うことで、強制的に右辺値の扱いにすることができ、ムーブさせることができます。

ムーブしてしまったあとも左辺値のオブジェクトは残りますが、データとしては通常破壊されています。破壊されていることを型システム上検知する方法はないので、「気をつけて一度だけ実施してください」ということになります。

ムーブされる左辺値の例1
std::vector v0 = some_function(); // 名前をつけたので左辺値になった

std::vector v;

v = std::move(v0); // 左辺値だがstd::moveでキャストすることで強制的にムーブできる

// ただし、実行がここまで来るとv0の中身はムーブ済みになっていて空っぽである

右辺値参照によるマッチング

関数呼び出し時に右辺値参照でマッチングされたオブジェクトは「破壊して良いオブジェクト」と認識するのがC++の不文律です。破壊するつもりの関数引数には基本的に && をつけて宣言し、std::move を使用してムーブ系の演算を実際に呼び出す関数まで渡すのがよくある記述パターンです。

ムーブを行う関数の例1
MovableObj mc;

void moving_function1(MovableObj&& obj)
{
    mc = std::move(obj); // ムーブ代入を呼び出す
}
ムーブを行う関数の例2
std::vector<MovableObj> mv;

void moving_function2(MovableObj&& obj)
{
    mv.push_back(std::move(obj)); // std::vector<T>::push_back(T&&)は右辺値参照を受け取ると内部でムーブする
}

ここで例に挙げた関数は左辺値を引数に与えようとしてもマッチしないことに気をつける必要があります。

関数引数の型マッチングの整理

右辺値参照を引数に持つ関数について説明してきましたが、ModernC++で一般に使われる関数引数の型のパターンについて整理します。

型のパターン 典型的な使用ケース

T

データをコピーして使用する際に使用

const T&

元のデータを読み出す際に使用

T&

元のデータを書き換える際に使用

T&&

元のデータをムーブして破壊する際に使用

T&&const がつくことはその性質上、ありえないと言えます。

T&&const T& / T& の間のオーバーロード関係について、T&&オーバーロードがない場合、右辺値に対して const T& があればマッチします。T&オーバーロードがないときに const T& にマッチするのと同じノリだと思ってもらって良いと思います。

気をつけたいのは T&&T& にだけマッチしたい場合はそれぞれのオーバーロードを用意する必要がある点です。もちろん、テンプレートを使えば簡単に実現できますが、それは本ガイドの記述の範囲外とします。

戻り値の指定の仕方

戻り値の値返しの基本

すでに例の中でさんざん使用してしまっていますが、戻り値にもムーブが使えます。というか、戻り値ではコピーよりもムーブが優先されます。具体的に言うと、戻り値では左辺値を書いても右辺値のように扱われるため、std::move を書く必要がありません。return 文の中の式は常にその関数の中の最後の式になるため、破壊してもいいよという感覚なのでしょう。

リターン時にムーブが優先される例
MovableObj function(){
    MovableObj mc;
    return mc; // ムーブが優先される
}

ムーブできないオブジェクトの場合、コピーを試みます。(ムーブもコピーもできないオブジェクトはスコープから出られないのでコンパイルエラー、ですね)

RVOとNRVO

ただ、ここまでで説明をやめると、四方八方から大量に矢が飛んできます。「RVOとかNRVOがあるだろ!」って。。。これは基礎知識として知っておいたほうがいい気がするので説明します。(かったるそうだなと思ったらこのセクションは一旦読み飛ばしてもらっても大丈夫です)

実は先ほどの例で「ムーブが優先される」と曖昧に書いたのですが、このまま実装するとコピーもムーブもしないコードを生成するコンパイラが多いです。その理由がRVO、NRVOです。まず、RVOから説明します。

RVO(Return Value Optimization)はざっくりいうと「右辺値を値返しするコードを書いたときに、呼び出し元が確保した領域にオブジェクトを構築する仕組み」です。

RVOの例
MovableObj function()
{
    return MovableObj(); // <- この右辺値が確保されるのは呼び出し元のメモリ領域
}

int main()
{
    MovableObj mc = function(); // <- 最初からここにオブジェクトが割り当てられる
}

MovableObjmain 側に確保すればコピーもムーブもいらないから速いよね。当然じゃん。

C++17以降、RVOはコンパイラのサポートが必須とされました。だから大体のコンパイラはRVOベースでコードを吐いてくれると思います。なお、RVOが効いた場合、コピー/ムーブ系の処理の実装がなくてもコンパイルが通るようです。

この考え方を左辺値にも適用するのがNRVO(Named Return Value Optimization)で、「左辺値を値返しするコードを書いたときに、呼び出し元が確保した領域にオブジェクトを構築する仕組み」です。

NRVOの例
MovableObj function()
{
    MovableObj m; // <- NRVO有効時、この左辺値が確保されるのは呼び出し元のメモリ領域
    return m;
}

int main()
{
    MovableObj mc = function(); // <- NRVO有効時は最初からここにオブジェクトが割り当てられる
}

が。NRVOはRVOと違ってコンパイラのサポートが必須とされていません。というか、そもそもその性質上、常に適用できるわけではありません。

NRVOがうまくいかない例
MovableObj function()
{
    MovableObj m1, m2; // <- m1 と m2 どちらに NRVO を適用していいかがオブジェクトの生成時点で決まらない!
    if(some_condition())
    {
        return m1;
    } else {
        return m2;
    }
}

int main()
{
    MovableObj mc = function();
}

NRVOはコレクションの構築のときなどにとても好都合だと思いますが、常に有効なわけではないので、「ここはムーブになっちゃうかもなー、まぁでも、ムーブになってもいいか」くらいの感覚で使うと良いと思います。

NRVOを期待したmapの初期化
#include <map>

std::map<int,std::string> createMap()
{
    std::map<int,std::string> m;
    // 何かすごーく複雑な初期化をやるつもり
    return m; // mはNRVOかムーブで返却される
}

int main()
{
    std::map<int,std::string> m = createMap();
}

RVOやNRVOを最初から仮定したコードは読む側のリテラシーの高さをも仮定することにもなります。

ModernC++時代の動的な変数アロケーション

ムーブセマンティクスについて、ざっくり説明が終わりました。土台が固まったので、ModernC++時代の変数のアロケーションの考え方について説明していきます。

ModernC++では基本的にアプリケーションコードに new キーワードを書くことはありません。次の4パターンのいずれかを使ってアロケーション/構築を行います。

  • ローカル変数として構築する

  • make_unique を使用して構築する

  • make_shared を使用して構築する

  • ターゲットのオブジェクトの中に構築する

変数アロケーションの選択肢

ローカル変数として構築する

オブジェクトを構築するのであれば、ローカル変数として構築するのが第一の選択肢です。

ローカル変数として構築する
MovableObj function(){
    MovableObj mc;
    return mc;
}

ローカル変数として宣言しても、ムーブのコストが小さいのであればそのままあちこちに引き回せることになります。

代表的な動的構造である std::stringstd::vectorstd::map のようなオブジェクトはムーブのコストが小さいのでそのまま引き回すことになります。

代入やオブジェクトの引き渡しが発生した際に、オブジェクトがムーブされたのかコピーされたのか確実に把握して有効なデータのありかを管理しましょう。

make_unique で構築する

ムーブできなかったり、ムーブの効率の悪いオブジェクトを引き回したいときは unique_ptr を使って引き回しをすればよいでしょう。

かつて new で行っていたオブジェクト生成は unique_ptr を生成する make_unique 関数で代替するのが第一候補になります。実行効率的には生のポインタをハンドリングすることとほぼ同等とされています。

make_uniqueを使う1
std::unique_ptr<ImmobilizedObj> foo(){
    return std::make_unique<ImmobilizedObj>(100);
    // 内部では動的な領域が確保され、ImmobilizedObj(100)相当の初期化が実施される
}

unique_ptr で確保したオブジェクトを明示的に delete する必要はありません。他のポインタを代入して参照が切れたり、 unique_ptr ポインタの寿命が切れた(unique_ptr のデストラクタが呼ばれた)際にオブジェクトを自動開放してくれます。スコープ外にオブジェクトを持ち出したければ unique_ptr 自体をスコープ外に持ち出すことで寿命を延ばせます。

unique_ptr はコピーに対応せず、ムーブのみに対応しています。代入を行えますが、常にムーブ代入が行われるため、代入元の unique_ptr は無効になり nullptr 相当に初期化されます。これにより、unique_ptr に対してはその名の通り、単一性を確保されます。つまり、make_unique で生成されたオブジェクトと unique_ptr は常に一対一の関係にあります。同一のオブジェクトが複数の unique_ptr の管理下に入ることはありません。unique_ptr の管理対象から外れたオブジェクトはデストラクタが呼ばれ領域は開放されます。

unique_ptr が管理するオブジェクトにアクセスする場合は通常、-> 演算子を経由します。このあたりは旧来のポインタに似ています。

unique_ptr から * 演算子により参照を取り出すこともできます。これにより unique_ptr 自体を受け渡すことなく、効率よく処理を移譲できますが、管理下のオブジェクトの生存期間が確実に確保されている状況でのみ実施すべきです。

下の例では foo 関数で生成されたオブジェクトを boo 関数が受け取ったあとは boo 関数が unique_ptr を保持し、bar 関数に渡すことなく握り続けています。つまり、f の生存期間を boo 関数が管理しているということです。 このような状況を「 boof の所有権を握っている」などと表現したりします。

make_uniqueを使う2
std::unique_ptr<ImmobilizedObj> foo(){
    return std::make_unique<ImmobilizedObj>(100);
}

void bar(ImmobilizedObj& m){
    // なにか処理
}

void boo(){
    auto f = foo(); // fはfooからムーブされる
    std::cout << f->getNum() << std::endl; // fのメンバ値を出力
    bar(*f); // オブジェクトの生存期間が確保できていれば参照渡ししても良い
    // ここでやっとImmobilizedObjのデストラクタが呼ばれる
}

次の例では親オブジェクトである ParentObjunique_ptr を保持させる例を挙げます。ParentObj のデストラクタで unique_ptr のデストラクタが呼ばれ、その処理の中で ImmobilizedObjインスタンスについてもデストラクタコールされます。

make_uniqueを使う3(オブジェクトに対してムーブする例)
std::unique_ptr<ImmobilizedObj> foo(){
    return std::make_unique<ImmobilizedObj>(100);
}

class ParentObj
{
    private:
    std::unique_ptr<ImmobilizedObj> ptr = nullptr;
    public:
    void setImmobilized(unique_ptr<ImmobilizedObj> && ptr){
        // unique_ptrを受け取るときはムーブなので右辺値参照で受けるのが行儀が良い
        this.ptr = std::move(ptr); // ムーブ代入
    }
    ~ParentObj()
    {
        this.ptr = nullptr; // unique_ptrのデストラクタが呼び出される
        // 実は明示的にnullptrを代入しなくても~ParentObjの最後でデストラクタが呼ばれる
    }
};

void boo(){
    auto f = foo(); // fはfooからムーブされる
    {
        ParentObj p;
        p.setImmobilized(std::move(f)); // fはpに対してムーブされる
        // fはもはやムーブ済みでnullptr相当
        // fのデストラクタはpのデストラクタ経由で呼び出される
    }
    // fのスコープ内ではあるがfooが生成したImmobilizedObjのオブジェクトはこの時点ではすでに存在しない
}

覚えたばかりのムーブでポインタをハンドリングするのは多少苦しむかもしれませんが、unique_ptr の使用は基本的なイディオムになるので積極的に使用して慣れてゆくのが良いと思われます。

make_shared で構築する

unique_ptr は実行効率は良いのですが、所有者が一つに限られるため、複数のオブジェクトから参照されるような構造には向きません。このような場合に対応するため、C++では参照カウンタを使用した共有ポインタ shared_ptr を用意しています。

基本的な使用方法は unique_ptr とほぼ同じですが、 shared_ptr にはコピー系の演算も実装されており、コピーするたびに参照数を増加させ、shared_ptrインスタンスのデストラクタが呼ばれるたびに減少させます。オリジナルや複製されたすべての shared_ptr が破棄されたとき、管理対象のオブジェクトのデストラクタを呼び、領域を開放します。

とても便利ですが unique_ptr よりは実行効率が劣るとされます。

make_sharedを使う
std::shared_ptr<ImmobilizedObj> foo(){
    return std::make_shared<ImmobilizedObj>(100);
}

class ParentObj
{
    private:
    std::shared_ptr<ImmobilizedObj> ptr = nullptr;
    public:
    void setImmobilized(const shared_ptr<ImmobilizedObj> & ptr)
    {
        // shared_ptrをコピーするのであれば左辺値参照で受ける、
        // ムーブするのであれば右辺値参照で受けるのが行儀
        this.ptr = ptr; // ここでshared_ptrがコピーされ、参照数が増える
    }
    ~ParentObj()
    {
        this.ptr = nullptr; // shared_ptrのデストラクタが呼び出される
        // 実は明示的にnullptrを代入しなくても~ParentObjの最後でデストラクタが呼ばれる
    }
};

void boo(){
    auto f = foo(); // fはfooからムーブされる
    {
        ParentObj p1, p2;
        p1.setImmobilized(f); // fをp1に対してコピー
        p2.setImmobilized(f); // fをp2に対してコピー
        f = nullptr; // 全部で3つのコピーができたが、オリジナルはいらないので破棄
    }
    p1.setImmobilized(nullptr);
    // この時点ではImmobilizedObjのインスタンスは生きている
    p2.setImmobilized(nullptr);
    // すべてのshared_ptrのインスタンスが消えたので
    // ImmobilizedObjのデストラクタが呼ばれ破棄される

    // ここでfのスコープが切れるが、shared_ptrもImmobilizedObjも残っていない
}

ターゲットオブジェクトの中に構築する

大まかなアロケーション方法については説明してきたのですが、他のオブジェクトに含まれるオブジェクトを構築する方法について説明していませんでした。これについて少し説明します。

たとえば、コンテナなどの構造を利用する際にはコンテナに含まれるオブジェクトを初期化する必要があります。ひとたびコンテナ側にオブジェクトを構築すれば、あとの開放処理などはコンテナ側で呼び出してくれます。

コンテナの初期化を例にとって初期化のパターンを見てみましょう。

デフォルト構築する

配列などの初期化でよく発生するパターンです。デフォルトコンストラクタで構築してしまうので、その後改めてデータを上書きする必要があることが多いです。

デフォルト構築の例
std::unique_ptr<MovableObj[]> mv;

void initializer(std::size_t size)
{
    mv = std::make_unique<MovableObj[]>(size); // この時点で配列要素はデフォルトコンストラクタで初期化

    for(int i = 0 ; i < size ; i++){
        mv[i].setString("I am " + i + "th Object!"); // 追加で初期化処理を行う
    }
}
コピー構築する

初期化に使用するオブジェクト自体を使いまわしたかったり、コピーコストが安いときの手抜きとして使用するときに使用する手段です。

左辺値参照版のコレクション追加関数 std::vector::push_back(const T&) はデータをコピーして格納します。

コピー構築の例
std::vector<MovableObj> mv;

void initializer(std::size_t size)
{
    for(int i = 0 ; i < size ; i++){
        MovableObj m;
        m.setString("I am " + i + "th Object!"); // 何やら動的な初期化をしてみた
        mv.push_back(m); // push_back(const T&)は左辺値参照を受け取りコピーして格納
        // この時点でもmは意味のあるデータを格納している
    }
}
ムーブ構築する

複雑なオブジェクトを入れ込みたいときに通常使用する方法です。汎用性が高くコピーコストを回避しながら複雑な初期化を行えます。ただし、ムーブコストも高いようなオブジェクトにはこの方法は向きません。

右辺値参照版の追加関数 std::vector::push_back(T&&) は右辺値参照を受け取り、ムーブしてデータを格納します。

ムーブ構築の例
std::vector<MovableObj> mv;

void initializer(std::size_t size)
{
    for(int i = 0 ; i < size ; i++){
        MovableObj m;
        m.setString("I am " + i + "th Object!"); // 何やら動的な初期化をしてみた
        mv.push_back(std::move(m)); // push_back(T&&)は右辺値参照を受け取り内部でムーブする
        // この時点でmは破壊済みでありデータ上の意味を持たない
    }
}
unique_ptr / shared_ptr をムーブして構築する

直接ムーブできない、ムーブ効率の悪いオブジェクトの場合、unique_ptr / shared_ptr でラップしてムーブする方法もあります。unique_ptr についてはコンテナから直接取り出してしまうとコンテナ側の unique_ptrnullptr になってしまうので注意する必要があります。

unique_ptr をムーブする例
std::vector<std::unique_ptr<ImmobilizedObj>> iv;

void initializer(std::size_t size)
{
    for(int i = 0 ; i < size ; i++){
        auto io_ptr = std::make_unique<ImmobilizedObj>("I am " + i + "th Object!"); // 何やら動的な初期化をしてみた
        iv.push_back(std::move(io_ptr));
    }
}
// 参照なら取得できる。
ImmobilizedObj& getImmobilizedObj(std::size_t index)
{
    return *(iv[index]);
}
// unique_ptr自体を取り出すとvectorの外側にムーブされてしまうのでこれは危険
// std::unique_ptr<ImmobilizedObj> getImmobilizedPtr(std::size_t index)
// {
//     return iv[index];
// }
直接構築する

コンテナの中には直接構築といってコンストラクタ引数を渡すと中間のアロケーションをせずコンストラクタを呼び出して構築してくれるものがあります。ムーブコストすら回避できる性能劣化の少ない初期化の方法です。ただし、対象のコレクションが直接構築をサポートしている必要があります。

直接構築を使う場合、ライブラリのI/F記述やシグニチャを確認して直接構築の機能があるかを確認することがまず第一歩になります。たとえば、 std::vector には emplace_back というメンバー関数があります。

直接構築の例
std::vector<MovableObj> mv;

void initializer()
{
    for(int i = 0 ; i < 19999 ; i++){
        MovableObj& m = mv.emplace_back("I am " + i + "th Object!");
        // MovableObj("I am " + i + "th Object!")相当の処理で直接構築された
        m.setAdditionalAttribute(i); // C++14以降のemplace_backは参照を返すので追加で初期化処理することも可能
    }
}

#個人的にはムーブ構築を基本として直接構築があればそれを使うか〜、くらいの感覚でいます。

アロケーション選択方法まとめ

アロケーション、初期化の手段の選択について大まかな判断チャートを作ってみました。

  • ローカルでライフサイクル管理可能 → ローカルで宣言

  • ライフサイクルがスコープ外に出る

    • 共有不要でムーブコストが小さい → ローカルで宣言してムーブで管理する

    • 共有不要でムーブコストが大きい → unique_ptrを使用する

    • 共有が必要 → shared_ptrを使用する

  • 親になるオブジェクトがある

    • 共有不要でムーブコストが小さい → 親オブジェクト内に直接構築

    • 共有不要でムーブコストが大きい → 親オブジェクト内にunique_ptrで保持

    • 共有が必要 → 親オブジェクト内にshared_ptrで保持

アロケーションされた変数の引き回し方

アロケーション後のオブジェクトをどのように引き回すかについて簡単に説明します。

コピーして引き回す

コピーが可能で、ロジック上複製しても問題なく、かつコピーしても効率の良いものはコピーして引き回すと良いでしょう。 深いことは考えなくてよくなります。ただ、あまりこういうのが歓迎されるシチュエーションは少ないのではと思います。。。

LightObj getLightObj()
{
    return LightObj; // 戻り値もコピーで返しちゃえ
}
void eatLightObj(LightObj lo)
{
    // コピーして何かする
}
int main()
{
    LightObj lo = getLightObj();
    eatLightObj(lo);
    return 0;
}

参照を引き回す

呼び出し元となる関数がオブジェクトの生存期間を管理できているなら、参照で引き回すのが効率が良いです。この場合、呼び出された側は参照を保持し続けたり非同期処理に受け渡すなどは基本的にNGだと考えるべきです。呼び出された側が左辺値参照で渡された引数をムーブするなんてのももちろん避けるべきです。

void eatNormalObj(NormalObj& no)
{
    // データを使って何かする

    // 左辺値参照でうけとったということは壊していい約束ではないので
    // ムーブは絶対にしない
}
int main()
{
    NormalObj no;
    eatNormalObj(no); // 確実に生存範囲内
    return 0;
}

ムーブして引き回す

関数間で所有権を引き回すときにムーブで引き回すのが効率が良いことがあります。右辺値参照や戻り値を使ってムーブをつないでいくイメージになります。この場合でも生存期間を所有している関数がライフサイクルを管理できるのであれば、その期間内では左辺値参照を使って引き回すことが可能です。

MovableObj foo(){
    rerurn MovableObj(100);
}

void bar(MovableObj& m){
    // なにか処理
}

void boo(){
    auto m = foo(); // fはfooからムーブされる
    bar(m); // オブジェクトの生存期間が確保できていれば参照渡ししても良い
    // ここでやっとImmobilizedObjのデストラクタが呼ばれる
}

unique_ptr を引き回す

上記のムーブのイメージに近いですが、 unique_ptr で宣言されたものはオブジェクト自体をムーブする代わりに、 unique_ptr をムーブして引き回すことになります。ムーブで引き回している場合同様、生存期間を所有している関数がライフサイクルを管理できるのであれば、その期間内では左辺値参照を使って引き回すことが可能です。

unique_ptr を引き回す例
std::unique_ptr<ImmobilizedObj> foo(){
    return std::make_unique<ImmobilizedObj>(100);
}

void bar(ImmobilizedObj& m){
    // なにか処理
}

void boo(){
    auto f = foo(); // fはfooからムーブされる
    std::cout << f->getNum() << std::endl; // fのメンバ値を出力
    bar(*f); // オブジェクトの生存期間が確保できていれば参照渡ししても良い
    // ここでやっとImmobilizedObjのデストラクタが呼ばれる
}

shared_ptr を引き回す

shared_ptrunique_ptr と異なり、コピーに融通が効くので適当に管理したくなります。しかし、頻繁にコピーを繰り返すことは性能劣化の要因になりえます。shared_ptr 自体の参照を渡したり、ムーブするなどして必要最小限のコピーにとどめるべきだとされます。また、ムーブや unique_ptr で引き回している場合同様、生存期間を所有している関数がライフサイクルを管理できるのであれば、その期間内では左辺値参照を使って引き回すことが可能です。

make_sharedの引き回し方
std::shared_ptr<ImmobilizedObj> foo()
{
    return std::make_shared<ImmobilizedObj>(100);
}

void bar(ImmobilizedObj& m)
{
    // なにか処理
}

class ParentObj
{
    private:
    std::shared_ptr<ImmobilizedObj> ptr = nullptr;
    public:
    void setImmobilized(const shared_ptr<ImmobilizedObj> & ptr)
    :ptr(ptr)
    {
    }
    void someOperation()
    {
        // ここではImmolizedObjのインスタンスの所有権を確実に握れているので
        // 参照で引き回せる
        bar(*(this->ptr));
    }
    ~ParentObj()
    {
        this.ptr = nullptr;
    }
};

void boo()
{
    ParentObj p1, p2;
    {
        auto f = foo(); // fはfooからムーブされる
        p1.setImmobilized(f); // fをp1に対してコピー
        p2.setImmobilized(f); // fをp2に対してコピー
    }
    p1.someOperation();
    p1.setImmobilized(nullptr);
    // この時点ではImmobilizedObjのインスタンスは生きている
    p2.someOperation();
    p2.setImmobilized(nullptr);
    // すべてのshared_ptrのインスタンスが消えたので
    // ImmobilizedObjのデストラクタが呼ばれ破棄される

    // ここでfのスコープが切れるが、shared_ptrもImmobilizedObjも残っていない
}

setImmolibizedObj の引数は shared_ptr への参照になっていますが、この実装方法には2つの考慮があります。一つは shared_ptr の不用意な複製を防ぐ意味です。もう一つは、ムーブでの受け渡しが基本である unique_ptr の引き回しの記述とのバランスを取る意味です。

その他、ModernC++っぽい言語要素

ムーブセマンティクスとオブジェクト構築や引き回しの方法を説明したので、だいたいこれでModernC++としてはOKなのですが、「こういう言語機能を意識しているとナウいよ!(あえてモダンと言わない)」というシャレオツ言語機能について軽く説明していきます。

それぞれ、ざっと流し読んでもらえれば、という感じです。コードを書くときにふと思い出して使ったり、人のコードを読んでいるときに「あ、これ、xxxで出たやつだ!」となるように情報を羅列するだけです。

auto推論

ここまでにすでに多用していますが、 auto を書くことで型宣言を省略できます。 auto は基本的に「推論対象から参照を取り払った型」を示します。

ローカル変数には型指定をしたい場合を除いて auto を使うのが基本になります。

void foo()
{
    auto v = std::vector<int>{...};
    auto f = foo();
}

関数戻り値を auto にすることも可能です。ただし、省略することでシグニチャが読みづらくなることもあるので、使用の是非には議論があると思います。

auto foo1()
{
    return std::make_unique<FooType>();
}

メンバー変数やグローバルなコンテキストでの変数に auto を使うことは通常、ないと思います。

戻り値型の後置

auto の説明をしたので、ついでに戻り値型の後置も説明しておきます。auto キーワードで戻り値型を省略したあと、-> を付与することで戻り値型を後置することができます。

auto foo2() -> std::unique_ptr<FooType>
{
    return std::make_unique<FooType>();
}

まぁ、これはテンプレートを書くときに使う機能なので、当面使うことはないかも知れないですね。

アプリケーションでのコーディングで使う可能性があるのは後述するラムダ式での戻り値型指定です。ラムダ式では戻り値の型指定は後置でしかできません。

auto foo3() = []() -> std::unique_ptr<FooType> {
    return std::make_unique<FooType>();
}

constexpr / nullptr

コンパイル時に決定する定数については constexpr を使うのが正しい作法になっています。コンパイル時に決定する定数に const を使うのはModernC++では実質的に誤りです。

constexpr double pi_div_2 = std::numbers::pi / 2.0;
// const double pi_div_2 = std::numbers::pi / 2.0; は適切でない

また、同じようなノリで NULL を使わずに nullptr を使用するのが正しい作法になりました。これもすでに多用していますね。nullptr は今やほとんど使用しない生ポインタに対しても使えるほか、 unique_ptrshared_ptr に対しても使えます。

char* raw_ptr = nullptr;
unique_ptr<int> u_ptr = nullptr;
shared_ptr<int> s_ptr = nullptr;

イテレータと範囲for

C++では以前からイテレータの概念があり、for ループはイテレータで記述するのが基本ですが、構文がメンドクサイのもあって int で回しちゃう例も多いですよね。

#include <iostream>
#include <vector>

struct Obj
{
    public:
    int value;
    Obj(int i)
    :value(i)
    {
    }
};

std::vector<Obj> v{Obj(1),Obj(2),Obj(3)};

int main()
{
    for(std::vector<Obj>::iterator it = v.begin();it!=v.end();it++) // めんどくさすぎるでしょ。。。
    {
        std::cout << it->value << std::endl;
    }

   return 0;
}

範囲forはループの記述を大幅に改善します。

int main()
{
    for(const auto& item : v) // ループ構文がメチャクチャ簡単!
    {
        std::cout << item.value << std::endl;
    }
}

お約束として、範囲forでイテレータを受ける型の記述はコンテナに含まれる値の型に対して、おおまかに次のようになります。

  • プリミティブ型 → const autoauto

  • クラスオブジェクト

    • const auto& → リードオンリーのとき

    • auto& → 対象のオブジェクトを書き換えるとき/コンテナ側からデータをムーブアウトするとき

まぁ、だいたい関数の引数と同じですね。データをコンテナからムーブしてしまっていいかどうかはコンテナや対象のデータの特性を確認する必要があります。

using

おっさんは using と言われると有名な575の句である using namespace std; を思い浮かべると思うのですが、ModernC++では using の主な用途は typedef に相当する機能で、型エイリアスなどと呼称します。

using str_gen_func = std::string (*)(int); // typedef std::string (*str_gen_func)(int); とほぼ同じ

その他、どうしても名前空間を外したいものをピンポイントで名指しします。

#include <iostream>
#include <chrono>

int main()
{
    // cout , endlだけは名前空間外したい
    using std::cout, std::endl;
    // ユーザ定義リテラルの名前空間外し:後述します
    using std::literals::chrono_literals::operator"" h;

    // :
}

てか、基本的に名前空間using したりしません。std とかは std::string みたいな感じで、名前空間つけたまま使っちゃいます。まどろっこしいパッケージ名に対しては名前空間をパカッとおっぴろげにするのではなく、短い名称に割り付けなおしたりします。

namespace fs = std::experimental::filesystem;

if(fs::create_directory("new_directory") != true){
    // ....
}

override / delete / default

class / structメンバ関数の構成に関するキーワード群です。

override

virtual 関数を使わざるを得ない場合、オーバーライドする側の class / struct 定義に override をつけておくと実装漏れをエラーで検出してくれます。

class MyBaseClass
{
    public :
    virtual void foo() = 0;
}
class MyClass : public MyBaseClass
{
    virtual void foo() : override;
}

// MyClass::foo() の実装がなければエラー

delete

過去のC++ではデフォルト実装を拒否して実装しないコンストラクタや演算子private にしてお茶を濁したりしていましたが、ModernC++では delete キーワードを使って実装しないことを明示します。

class MyClass
{
    // コピー代入演算子のデフォルト実装を拒否するケース
    bool operator=(const MyClass&) const = delete;
}

default

コンストラクタや代入演算子をデフォルト実装にすることを明示するキーワードです。

class MyClass
{
    // コピー代入演算子のデフォルト実装を明示的に行うケース
    bool operator=(const MyClass&) const = default;
}

noexcept

noexcept を関数のシグニチャにつけると例外を発生しない旨をコンパイラに伝えられるのでより最適化されたコードを生成できます。また、noexceptシグニチャがついた関数だけで noexcept 関数を構成すれば安心です。ただし、noexcept を指定したにもかかわらず例外を発生すると std::terminate() が即座に呼ばれるということなので計画的なご利用が必要です。

class NoExceptClass
{
    void foo() noexcept;
}

オブジェクト指向言語」の隆盛とともに鳴り物入りで登場した例外機構ですが、時代を経てその管理の厄介さや性能面での問題が明確に認識されてきています。noexcept キーワードはそのような時流を受け、出るべくして出てきた言語機構であるとも言えそうです。

ラムダ式とキャプチャ

コールバックなどを要求する関数に、名前のついた関数を渡す代わりに匿名の関数であるラムダ式を直接渡せるのは本当に便利です。その場にロジックを書けることでコードの可読性を大幅に向上できることがあります。

std::for_each() 関数の引数として渡す例を示します。

std::vector<int> v;
std::for_each( v.begin(), v.end(), [](auto i){
    std::cout << i << ",";
});

ラムダ式std::for_each() に代表されるイテレーション系の処理のほか、非同期処理、コールバックなどに多用されます。これらの機能では関数だけではなく、周辺環境(コンテキスト)の情報ごと渡したいことが多く、関数ポインタだけでは不満なことが多いのでした。

ラムダは呼び出し元のコンテキストを「捕まえる」ためにキャプチャという機能を持っています。ラムダを活用するにはこのキャプチャについて理解しておく必要があります。

参照キャプチャ

ラムダ式側から現在のコンテキストに対する参照を掴ませる指定です。最初の []& を指定しておくとラムダからコンテキストをいじるときに参照経由でアクセスします。

std::vector<int> v;
int count = 0;

std::for_each( v.begin(), v.end(), [&](auto i){
    std::cout << i << ",";
    count++; // countへの参照にアクセスしている
});

std::cout << "total count is :" << count << std::endl;

ただ、非同期処理やコールバックを設定する場合、ローカル変数への参照を掴ませるとか恐ろしくてできないですね。

コピーキャプチャ

ラムダ式側から現在のコンテキストのデータをコピーする指定です。最初の []= を指定しておくとラムダ式インスタンス生成時点でのコンテキストをコピーします。

次の例では、同期処理の例となっていますが、コピーキャプチャは非同期処理などで現在のコンテキストからラムダ式にデータを受け渡したいときに便利です。

std::vector<int> v;
int offset = 3;

std::for_each( v.begin(), v.end(), [=](auto& i){
    i += offset; // offsetがラムダのコンテキストにコピーされている
});

コピーキャプチャされた値には暗黙に const 修飾されるので基本的に書き込めません。あまり書き込む用事もないと思いますが書こうとして驚かないで。。。

初期化キャプチャ

キャプチャ時にムーブすることもできます。初期化キャプチャという機能を使います。これは非同期処理にオブジェクトを渡したいときに便利です。変数名を指定して受け渡しの処理を指定することができます。

int main()
{
    MovableObj1 m1;
    auto f = std::async( std::launch::async, [m1 = std::move(m1)](){
        MovableObj2 m2;
        // なにかMovableObj(m1)を使った処理
        return m2;
    });
    auto m2 = f.get(); // ちなみにm2の型はMovableObj2
}

初期化キャプチャを使えば参照、コピー、ムーブなど自由自在です。

std::vector<int> v;

MovableObj m;
CopiableObj c;
ReferencableObj r;

std::for_each( v.begin(), v.end(), [m=std::move(m),c=c,&r=r](auto i){
    m; // mはムーブされている
    c; // cはコピーされた
    r; // rは参照である
});

キャプチャについては初期化キャプチャを使えば他に何も考えなくていいじゃん、と思いますが、他にも this キャプチャなど細かい仕様があります。細かいことを説明すると長くなるので気になった方は cppreference などの参考文献でも見てください。

リテラル

コードを読んでいると、リテラル関係の記述もそこそこ多く感じられますので簡単にだけ触れます。

生文字列リテラル

C++のコードを読んでいると R から始まる文字列をよく見ます。これは生文字列リテラルというものです。ダブルクオートやバックスラッシュなどをそのまま文字列の中に入れられます。UTF-8文字列をそのまま埋め込みたいときは u8R になります。

std::string s1 = R"(this string contains "double quote".)";
std::u8string s_utf8 = u8R"(this string contains "double quote"(utf-8).)";

#細かいところは気になったらいろいろ調べてみてください。

ユーザー定義リテラル

ユーザー定義リテラルと呼ばれるものも時々使用されます。もっともよく使用されるのは時刻ライブラリですね。つまり、主に単位の概念として使用されるのを想定した機構です。

#include <chrono>

using std::literals::chrono_literals::operator"" h;

auto hours = 4.5h; // hoursはstd::chrono::hours相当の型

ユーザー定義リテラルoperator"" suf という形で定義されます。これで suf というサフィックスが使えるようになります。

メンバ関数の修飾

これは別に新しい機能というわけでもないのですが、メンバ関数に対して実質的な第一引数である *this に制約をかけることができます。constvolatile などの制約が可能でしたが、左辺値、右辺値の区別も行えるようになっています。

class X
{
    public:
    void foo_1 (int i)
    {}
    void foo_2 (int i) const
    {}
    void foo_3 (int i) & // 左辺値のときだけ c.f. static foo_3s(X& x,int i)
    {}
    void foo_4 (int i) && // 右辺値のときだけ c.f. static foo_3s(X&& x,int i)
    {}
};

X lv;
lv.foo_3(0); // OK
// lv.foo_4(0); // NG
// X().foo_3(0); // NG
X().foo_4(0); // OK

便利なライブラリ機能

ここまで来ると言語機能的にはもう相当にシャレオツなのですが、その状態でC言語も使わないのに #include <string.h> とかしてしまうともう本当に見てられないので、こういうライブラリ機能を使えばいいよ!というのをピックアップして紹介してみます。

この辺はもう、タイトルをみて気になったものを眺めてもらうだけでいいと思います。

コレクション

動的にサイズが決定するような配列はほとんど使わず、std::vector を使うのが基本なので、これで運用していきましょう。想定する大きさがあれば std::vector::reserve() で予め大きめの領域を確保して作業しましょう。こうすることでリアロケーションによる実行コストを回避できます。

std::vector<int> v;

v.reserve(10);

for(int i=0;i<5;i++)
{
    v.push_back(i);
}

集合やハッシュマップの概念は std::set / std::map があります。もう説明するだけ無駄かも知れませんが、順序や重複可否などのバリエーションを静的に設定できるのがC++の良さでもあります。<set> / <map> あたりの仕様を確認してから使い始めると良いでしょう。

拡張forも使えて便利です。

#include <set>
#include <iostream>

std::set<int> s;

void print_the_set()
{
    for(const auto& item : s)
    {
        std::cout << "item : " << item << std::endl;
    }
}

bool has_a_member(int item)
{
    // return s.contains(item); // これはc++20以降
    return s.find(item) != s.end();
}

map についても範囲for文が使えますがこれについてはpair / tupleのセクションを見てください。

pair / tuple

2つ、あるいは3つ以上のデータをセットで扱う仕組みです。他のプログラミング言語では当たり前にあったものがC++には長く存在しませんでした。 std::make_pair / std::make_tuple を使用して構築したり、型推論が効いているところでは初期化リスト {} を使って構築することも可能です。

#include <tuple>

std::pair<int,std::string> p1{2,"aaa"};

auto p2 = std::make_pair(2,"aaa");

std::pair<int,std::string> get_p3()
{
    return {2,"aaa"};
}

std::tuple<int,std::string,std::string> t{3,"aaa","bbb"};

auto t2 = std::make_tuple(3,"aaa","bbb");

std::tuple<int,std::string,std::string> get_t3()
{
    return {3,"aaa","bbb"};
}

C++17から pair / tuple を簡単に分解する言語機構が使えるようになりました。構造化束縛といいます。値としての束縛も、参照としての束縛もできます。

auto [k1,v11] = get_p3(); // 値として束縛

auto t3 = get_t3();
auto& [k2,v21,v22] = t3; // 参照として束縛

構造化束縛によって map の範囲for文が書きやすくなりました。

std::map<int,std::string> a_map {
    {3,"aaa"},{2,"bbb"}
};

for(auto const& [key, value] : a_map)
{
    std::cout << "key is : " << key << " value is : " << value << std::endl;
}

構造化束縛によって、やっと pair / tuple を便利に使える土台ができたという感じがします。ただし、構造化束縛は今のところネストできません。また、値で束縛するか参照で束縛するかを項目ごとに選ぶこともできません。

文字列

このガイドの執筆時点での文字列の標準的な型は std::string つまり std::basic_string<char> です。なので、当面はこれを基本的な文字列の型として使用することになるでしょう。(Windows環境だと std::wstring が標準になりますが、、、)

C++20以降は std::u8string つまり std::basic_string<char8_t> が主流になっていくものと思われます。これらは依存ライブラリなどの互換性を考慮して決めていくことになると思われます。(*)

また、C++17で部分文字列など、リソースをシェアした文字列の考え方として std::string_view が導入されています。部分文字列を引き回したりする場合には std::string& を使うより、std::string_view を使うほうが便利かも知れません。また、 std::string& から std::string_view には暗黙のキャストがあるので、関数のインターフェースに std::string_view を使用すると std::string でも std::string_view でも使用できるようになります。

std::string s1 = "string";
char s2[] = "char array";

void print(std::string_view sv)
{
    // coutはstring_viewを受け付けるのでこれ自体は意味ないが
    std::cout << sv << std::endl;
}

int main()
{
    print(s1);
    print(std::string_view(s1));
    print(s2);
}

なお、string_view についてもC++20以降は std::string 同様に std::u8string_view に移行していくものと思われます。

(*) ところで、<iostream>std::coutC++20においても std::u8string に対応していないそうです。標準ライブラリにはない、文字列変換ライブラリを通してから std::cout に渡しなさいということだそうです。なんだそれ。30年以上にわたる非欧米系文字圏のみなさんの文字コードに関する怒りが、C++標準化委員会にはまだ完全には通じていないようです。

正規表現

正規表現が標準化され、比較的便利に使用できるようになっています。正規表現は文字列から作りますが、生文字列リテラルを使用したほうが読みやすくなるため、生文字列リテラルを使用することが多いようです。

#include <iostream>
#include <regex>

int main()
{
    const char s[] = R"(She said "He is hungry.")";
    const std::regex re(R"("(\w+) is (\w+).")");

    std::cmatch m;
    if (std::regex_search(s, m, re))
    {
        for (std::size_t i = 0, n = m.size(); i < n; ++i)
        {
            std::cout << i << ":'" << m.str(i) << "\', "
                << "position = " << m.position(i) << ", "
                << "length = " << m.length(i) << std::endl;
        }
    }
}
結果
0:'"He is hungry."', position = 9, length = 15
1:'He', position = 10, length = 2
2:'hungry', position = 16, length = 6

ただし、2021年時点においても、非ASCII文字に対しては標準ライブラリの実装はロクに動かないことが多いようです。筆者も SRELL(*) という互換ライブラリを使用することが多いです。

SRELLのサイトのサンプルをそのまま引用します。ほぼ、標準のregexと使い方が同じだとわかると思います。

SRELLの使い方
//  Example 01:
#include <cstdio>
#include <string>
#include <iostream>
#include "srell.hpp"

int main()
{
    srell::regex e;     //  正規表現オブジェクト。
    srell::cmatch m;    //  結果を納めるオブジェクト。

    e = "\\d+[^-\\d]+"; //  正規表現をコンパイル。
    if (srell::regex_search("1234-5678-90ab-cdef", m, e))
    {
        //  printfを使うなら。
        const std::string s(m[0].first, m[0].second);
            //  上は下のどちらかでも良い。
            //  const std::string s(m[0].str());
            //  const std::string s(m.str(0));
        std::printf("result: %s\n", s.c_str());

        //  iostreamを使うなら。
        std::cout << "result: " << m[0] << std::endl;
    }
    return 0;
}

std::u8string が当たり前になった世界では標準ライブラリの正規表現もきちんと動くようになると思われます。

時刻

<chrono> ヘッダのライブラリを使用すると現在時刻や時刻差分が扱えます。プラットフォーム非依存で時刻取得が行えます。一般的な時刻管理の用途であればこれで十分でしょう。

#include <chrono>
#include <iostream>

// この一行を書くのにちょっと憂鬱になるというのは内緒
using std::literals::chrono_literals::operator"" h;

int main()
{
    auto now = std::chrono::system_clock::now();
    auto after_4_5 = std::chrono::time_point_cast<std::chrono::minutes>(now + 4.5h);

    std::time_t t0 = std::chrono::system_clock::to_time_t(now);
    std::cout << "current time is " << std::ctime(&t0) << std::endl;
    std::time_t t1 = std::chrono::system_clock::to_time_t(after_4_5);
    std::cout << "after 4.5h'll be " << std::ctime(&t1) << std::endl;
}
結果
current time is Wed Mar 10 23:05:44 2021

after 4.5h'll be Thu Mar 11 03:35:00 2021

ファイルシステム

ファイルシステムを取り扱う標準ライブラリ機能はこれまで貧弱、というかありませんでした。C++17以降、std::filesystem が導入されて、ディレクトリ操作も含めた互換性の高いファイルシステム操作が行えるようになりました。これはクロスプラットフォームで開発するときにはとてもありがたい機構で、これまでPOSIXWindowsAPIラッパを書いていた方々がメンテナンスから開放される日も近いのではと思います。

#include <iostream>
#include <filesystem>
#include <fstream>

namespace fs = std::filesystem;

int main()
{
    const std::string dir0("dir0"), dir1("dir1");
    const std::string file0("file0.txt"), file1("file1.txt");
    constexpr auto& SEP = fs::path::preferred_separator;

    // dir0/file0.txt を作成
    fs::create_directory(dir0);
    std::ofstream(dir0 + SEP + file0);
    // dir0/dir1/file1.txt を作成
    fs::create_directory(dir0 + SEP + dir1);
    std::ofstream(dir0 + SEP + dir1 + SEP + file1);

    // ディレクトリ内をイテレータで走査する (※型定義はautoだが、明示するためにあえて記述)
    for (const fs::directory_entry& x : fs::directory_iterator(dir0)) {
        if(x.is_regular_file())
        {
            std::cout << x.path() << " is a regular file." << std::endl;
        }
        else if(x.is_directory())
        {
            std::cout << x.path() << " is a directory." << std::endl;
        }
        else
        {
            std::cout << x.path() << " is an unknown entry." << std::endl;
        }
    }
}
結果
"dir0/file0.txt" is a regular file.
"dir0/dir1" is a directory.

コンパイラのバージョンによっては experimental名前空間に入っているのでエイリアスしてあげる必要があることがあります。

#include <experimental/filesystem>

namespace fs = std::experimental::filesystem;

if(fs::create_directory("new_directory") != true){
    // ....
}

async

非同期処理で計算結果を得たいなど、ワンショットで処理を非同期に出したいだけであれば st::async が便利です。いわゆる「Futureパターン」を提供します。

結果を取得する際に std::future::get() を使用しますが、参照型と実体型のいずれも使用できます。実体型ではムーブが優先されます。(*)

#include <future>

int main()
{
    MovableObj1 m1;
    // fの型は std::future<MovableObj2>
    auto f = std::async( std::launch::async, [m1 = std::move(m1)](){
        MovableObj2 m2;
        // なにかMovableObj(m1)を使った処理
        return m2;
    });
    auto& mr2 = f.get(); // ちなみにmr2の型はMovableObj2&
    // auto m2 = f.get(); // ムーブ/コピー可能であれば値で受けることも可能。
    // ただし、future::get()が呼べるのはひとつのオブジェクトあたり一回のみ
}

(*) ムーブが「優先」というのは std::future::get() の戻り値が std::move() が修飾されているのと同等、という意味です。ムーブがあればムーブで、コピーがあればコピーで返され、どちらもなければコンパイルエラーになります。この辺の感覚は普通の戻り値に近いですが、関数の戻り値は一度 future によって確保され、その後受け渡されることを頭に入れておくと良いと思われます。

mutex

かなり説明を省略するのでこのセクションはPOSIXのpthreadの概念に慣れていないと意味不明かも知れません。興味のない人は読み飛ばしてもらってOKです。

排他処理と条件変数を実現する機能として std::thread / std::mutex / std::condition_variable があります。pthreadに相当する機能です。C++ではmutexロックの取得と開放をスコープと紐付けられるので開放漏れにビクビクする必要がありません。

#include <thread>
#include <mutex>
#include <condition_variable>
#include <memory>
#include <optional>

class SynchronizedData {
    private:
    std::mutex m;
    std::condition_variable cv;

    bool data_produced = false;
    std::optional<MovableObj> mo = std::nullopt;

    public:
    void produce()
    {
        // データの準備処理
        mo = std::make_optional<MovableObj>();

        // mutexでロックを実施(左辺値のスコープ切れと同時にロック開放)
        std::unique_lock<std::mutex> lock(m);
        data_produced = true;
        cv.notify_all();
    }

    void consume()
    {
        // mutexでロックを実施(左辺値のスコープ切れと同時にロック開放)
        std::unique_lock<std::mutex> lock(m);
        // 条件変数でデータの準備ができるまで待機
        cv.wait(lock, [this] { return data_produced; });

        // データ処理の実施
        // mo->...
    }
};

int main()
{
    SynchronizedData d;

    std::thread t1([&] { d.consume(); });
    std::thread t2([&] { d.produce(); });

    t1.join();
    t2.join();

    return 0;
}

なお、これだけの例を挙げておいてなんですが、そもそも、この例に挙げているくらいのことしかやらないのであれば前のセクションで紹介した future の適用を考えるべきです。プロデューサー側のスレッドは std::async で生成し、コンシューマー側のスレッドに future をムーブ渡しすればいいのです。std::mutex とか std::condition_variable のことを考える必要はありません。

#include <future>

MovableObj produce()
{
    MovableObj m;
    // データの準備処理
    return m;
}

void consume(MovableObj& m)
{
    // データ処理の実施
}

int main()
{
    SynchronizedData d;

    auto f1 = std::async(std::launch::async, [] { return produce(); });
    auto f2 = std::async(std::launch::async, [f=std::move(f)] { consume(f.get()); });

    // f1はムーブ済みのため、f2のラムダの終了に伴って回収される
    f2.wait();
    // f2はmainのスコープ切れによって回収される
    return 0;
}

もうちょっとだけ細かい、だけどざっくりした話

右辺値参照

右辺値参照をどう捉えるか、というのは本当にとらえどころのない話だと思いますが、普段の使い方としてはやはり、「関数引数におけるマッチングの用途に使う」と考えておくのが妥当そうです。

右辺値参照は「右辺値を束縛し、生存期間を延長する」用途にも使える、過去からある仕様です。

std::vector<int>&& iv1 = {1,3,3}; // 初期化子の寿命は切れるが、ivが生存期間を延長
std::vector<int>&& iv2 = get_some_vector(); // 戻り値の生存期間を延長

しかし、ModernC++ではムーブセマンティクスやRVOなどがあるので、値束縛で十分だとも考えられます。

std::vector<int> iv3 = get_some_vector(); // RVO効いてるならこれで十分

右辺値参照は型のように見えるかも知れませんが、型と考えず、関数呼び出し時のパターンマッチと捉えるのが気楽だと思います。

実際、右辺値参照もすべて左辺値として扱われます。つまり、型の上では T&&T& と同じと扱われます。オーバーロード解決時にパターンマッチの要素として左辺値か右辺値かが問われるだけで、その参照が T&& として定義されたか、T& として定義されたかは問題にはなりません。

void foo(int&){}
void foo(int&&){}
void boo(int&& i){
    foo(i); // booの内部においてもfoo(int&)とマッチする。
}

int main()
{
    int&& i = 3; // iは右辺値参照として定義したが、i自体は左辺値
    foo(i); // iは左辺値なので、foo(int&)にマッチする。定義が int&& とされたこととは関係しない。
    boo(std::move(i)); // boo(int&&)とマッチするにはstd::moveが必要

    return 0;
}

テンプレートの読み書きをしない限り、関数引数、戻り値以外の右辺値参照は必要ないと言って良いでしょう。

std::move とは実際なんなのか

ここまでの立ち位置では std::move はただの魔法のキーワードで一体なんなのか、わからないままかも知れません。std::move がやっていることは、実際には TT&T&& にキャストしているだけです。

なぜそれで右辺値になるのか、ということなのですが、、、左辺値とか右辺値といった「値カテゴリ」というものが決まっています。C++17での値カテゴリの分類を右辺値/左辺値の区別の観点からざっくり説明すると下記です。

  • lvalue(左辺値)

    • 名前のあるものやその一部を表すもの(一部とは、例えばメンバ変数など)

    • 左辺値参照を返す関数(戻り値)/演算子の結果

    • 左辺値参照へのキャスト式

  • rvalue(右辺値)

    • prvalue(いわゆる生粋の右辺値)

      • 参照でない値を返す関数(戻り値)/演算子の結果

      • 参照でない型へのキャスト式

      • リテラル

    • xvalue(強制されてできた右辺値)

      • 右辺値参照を返す関数(戻り値)/演算子の結果

      • 右辺値参照へのキャスト式

この分類をどう理解するか、ということですが、、、もともと左辺値、右辺値の概念があり、そこに参照をどう扱うかという考え方が追加されたというイメージです。追加されたような部分とは、lvalueの左辺値参照関係の仕様とxvalueの右辺値参照関係の仕様ですね。

std::move の話に戻ると、上記の分類から std::move はその引数を強制的に右辺値参照にキャストし、式全体としてはxvalueと認識されます。結果として右辺値と認識された結果、右辺値参照の引数にマッチするわけです。ややこしいですがそういうことです。

さて。ここから先に踏み込もうとすると、C++17とそれ以前の違いを強く意識することになります。C++17では左辺値と右辺値の定義が見直されただけでなく、初期化や戻り値の意味についても整理されています。この中ではRVOの位置づけなどもより明確になりますが、「ひとまずModernC++を読み書きできるようになる」目的からは大きく外れるように思われますので、本ガイドでは説明しないことにします。

(*)文字列リテラルだけは特別に左辺値扱いです。これは理解の妨げになりかねませんが、こういう特別な仕様があるということは頭の片隅においておいて損はないかも知れません。

さらに良いコードを書くために

正直、ここまでの文章を納得して理解できる方はModernC++をそれなりに読み書きできるようになっているのではと思います。また、「完全転送」とか「ユニバーサル参照」といった言葉をちらつかせ、混乱させつつマウントを取ってくる人には「私はアプリケーションプログラマだから関係ない!」と毅然とした態度を取れるようにもなったと思います。

が、しかし、必要に応じて言語仕様やライブラリ仕様を確認する必要が出ることも出てくるでしょう。また、テンプレートを書きたいとなった場合にはもっとツッコんだ理解が必要になってきます。手っ取り早く規格に基づいた解説を得たければ、C++ referenceを参照するというのが一つの選択肢です。

本ガイドと比較的似た目的をもつ有名かつ素晴らしい書籍として Effective Modern C++があります。このガイドと Effective Modern C++の大きな違いは、日常的に使用するメンタルモデルを作ることを最優先にし詳細とか実際に書き始めてからのハマりポイントなどは大幅に端折っていることです。(いや、何をどう比べてるんですか?比べられる立ち位置ですか?)

本ガイドよりもっと言語の広い範囲をカバーして学びたいという要求やゼロからC++について学びたい要求への対応としては江添さんのC++関連書籍を参考にするのもよいのではないかと思います。

今後の動向

モジュール対応

C++コンパイルはなぜ遅いのか?そりゃヘッダファイルを実装ファイルごとに処理してるからだよ、という問答が容易に成り立つわけです。ヘッダファイルなんて考え方はそろそろ卒業しましょう、モジュールというコンパイル単位で考えていきましょう、ということでC++20からモジュールの機能が導入されました。

利用側(インポートする側)
import some_mod;

int main() {
    some_lib::SomeObj s;
    some_lib::some_func(s);
}
ライブラリ側(エクスポートする側)
// 外部へエクスポートするモジュール名
export module some_mod;

// 名前空間とモジュール名は特に関係がない
namespace some_lib
{
    // some_lib::SomeObjがモジュールsome_modとしてエクスポートされる
    export class SomeObj
    {
        int x;
    };
    // some_lib::some_funcがモジュールsome_modとしてエクスポートされる
    export void some_func(SomeObj& s)
    {
       // ...
    }
};

このようなモジュール機構は他のプログラミング言語でも一般的にあるものですが、C++においてはいにしえからの #include ディレクティブとの関係を強く意識した考え方になってはいます。言語機能的にはRust言語でのcrateの概念の影響を強く受けているようにも思われます。

標準ライブラリのモジュール化はC++23以降で検討されるとのことで、2021年時点ではヘッダファイルの #include がなくなることはなさそうです。

コンセプト

イテレータへの対応では関連する begin 関数と end 関数を持っていることなどが条件として求められますが、こういった条件を言語仕様上明示的に取り扱う仕組みがありませんでした。こういった概念を明示的に取り扱うのがコンセプトです。

コンセプトは、テンプレート引数の型が満たすべき制約事項を明示します。

#include <functional>

// 標準ライブラリのiteratorに似た感じのコンセプトを作る
template <class T>
concept Iter = requires (T& x) {
    // これだけのメンバ関数を持っていないといけないという制約
    x.begin();
    x.end();
    x.operator++();
    x.operator*();
};

// TをコンセプトIterにより制約する
template <Iter T,typename ret,typename UnaryOperation>
void iterate(T& x, UnaryOperation f) {
    for(auto i = x.begin(), e = x.end();i!=e;i++)
    {
        f(*i);
    }
}

あっ、しかし、このガイドはテンプレート書く人のためのガイドではないのでした。ただ、これまでテンプレートを書く際にはテンプレートコード内のさまざまな型制約をミスマッチしてはバックトラックするという仕組みで動作しており(*)、型制約を表現する方法がありませんでした。コンセプトはこの問題を解決し、エラーメッセージを読みやすくする効果が期待されています。

まあ、ざっくりいうとRust言語のTraitみたいな機能ですね。(しかしTraitの方が概念として洗練されていると感じます。。。)

(*)この仕組みはSFINAE(Substitution failure is not an error)と呼ばれます。

さいごに

ここまで読んでくださった愛しきおっさんへ。C++は現時点ではネイティブコードにコンパイルされる高級言語の中で常にC言語の次に考慮される言語です。これは下記のような要因があると思います。

  • C言語との親和性が非常に高いこと

  • g++やclangといった形でC言語コンパイラとセットになって広く提供されていること

  • 過去のC++からの学習差分が比較的小さくで済むこと

  • ModernC++が過去のC++とほぼ別の言語になっていることを知らず、学ぶ必要性があることに気づいていない人が相当数いること

「だからなんなのか?」というと、、、多少の誤解はあるとはいえ、C++が重要であることには変わらないですね。だからこそ、このガイドをわざわざ読んでくれたのだと思いますし、ここで知った内容を活かしてC++の開発を進めていっていただければ幸いです。

ただ、C++の言語開発の動向を軽く追っていくと、言語の考え方がどんどんRust言語に近づいているなという印象を受けます。また、標準規格の策定の過程は実質的に大規模な社会実験となっており、その中で捨てられてゴミになってしまった仕様や仕組みが利用者を混乱させているというのも事実だと思います。

だから、あなたが今ModernC++を学んだ上で、より安全でスマートなプログラミングを目指したいというのであれば、C++と同様の目的を持ちながら、よりシンプルで洗練された仕組みを持つRust言語を学ぶというのも今後の一つの選択肢だと思います。ModernC++でできることの多くはRust言語でも実現できます。さらにいうと、ModernC++の概念の理解はRust言語を理解するための大きな助けになるでしょう。

つまり、このガイドを読んだことはきっと無駄にはならないだろう、ということですね。

(以上、おわりです)

# えっ、そんな結びでいいの?

あとがき的な

C++は「最低限のリテラシー」のしきい値が不明確なプログラミング言語で、人によって理解のレベルにかなりばらつきがあります。C++を読み書きするにあたって、最低限度のムーブセマンティクスに対する理解は必須だと思います。しかし、実際問題として、2021年時点で「C++が書けますか?」という質問に対して「書けます」と回答する人の多くはムーブセマンティクスを理解していないと思われます。

日々、我も彼もプログラミング言語以外の問題に忙殺される中で、こういった言語に関する基本的理解のギャップを埋めていくのはなかなかに大変です。「C言語が書けるならC++も書けるだろう」といった邪悪な推測があることも考えると、コードのメンテナンスを人にお願いする際などは不安でたまりません。

そして、いっぽうで、C++を学ぶ上では雑音も多いと感じます。そもそも言語機能が細かく、多く、難解です。それから、意図的かどうかは別として、いきなり本人の理解から遠く離れたキーワード(たとえば、「完全転送」とか、prvalue/xvalueが、とか)を投げつけて混乱させてしまっている例も多々見受けられます。

プログラミング言語の習得の鍵になるものはメンタルモデルの確立ではないか、ということをずっと以前から考えていました。構文からはじめて習熟しながら覚えていく、という外国語学習のようなやり方は効率が悪いのではないかと考えていました。このようなやり方自体を否定するわけではないのですが、少なくとも、既存のコーディング経験者が他の言語を覚えるときにはこの手順よりももっと近道があるのではないかと思っていました。

このガイド(の前半)の記載にあたっては、「C++のコーディングに最低限必要なメンタルモデル」に絞って短時間で読めるように配慮しながら書いたつもりです。この試行が実際にうまく、みんなをハッピーにできる、一つの解になれば良いなと考えています。