C++のコンストラクタでの初期化方法について

AI実装検定のご案内

C++のコンストラクタでは、オブジェクトを生成するときにメンバ変数や基底クラスを初期化します。

主な初期化方法には、次のようなものがあります。

  • コンストラクタ初期化子リスト
  • デフォルトメンバ初期化
  • 委譲コンストラクタ
  • 基底クラスの初期化
  • コピーコンストラクタ
  • ムーブコンストラクタ

C++を学ぶうえで特に重要なのは、初期化と代入は異なる処理であるという点です。

目次

コンストラクタ初期化子リスト

コンストラクタ初期化子リストは、メンバ変数や基底クラスを直接初期化するための仕組みです。

#include <string>

class User {
private:
    int id_;
    std::string name_;

public:
    User(int id, const std::string& name)
        : id_{id},
          name_{name}
    {
    }
};

次の部分がコンストラクタ初期化子リストです。

: id_{id},
  name_{name}

メンバ変数は、コンストラクタ本体の処理が始まる前に初期化されます。

丸括弧と波括弧の違い

初期化子リストでは、丸括弧と波括弧の両方を使用できます。

User(int id, const std::string& name)
    : id_(id),
      name_(name)
{
}

または、次のように記述できます。

User(int id, const std::string& name)
    : id_{id},
      name_{name}
{
}

波括弧を使用すると、意図しない縮小変換を検出しやすくなります。

class Sample {
private:
    int value_;

public:
    explicit Sample(double value)
        : value_{value}
    {
    }
};

このコードでは、doubleからintへの縮小変換が発生するため、コンパイルエラーになります。

一方、丸括弧を使用した場合は、暗黙的な型変換が行われることがあります。

class Sample {
private:
    int value_;

public:
    explicit Sample(double value)
        : value_(value)
    {
    }
};

この場合、小数部分が失われる可能性があります。

ただし、波括弧が常に最適とは限りません。

例えば、std::vectorでは丸括弧と波括弧で意味が異なります。

#include <vector>

std::vector<int> first(10, 20);
std::vector<int> second{10, 20};

firstは、値20の要素を10個持つベクターです。

一方、secondは、10と20という2つの要素を持つベクターです。

そのため、波括弧は縮小変換を防げる一方、std::initializer_listを受け取るコンストラクタが存在する型では注意が必要です。

初期化と代入の違い

コンストラクタ初期化子リストによる処理と、コンストラクタ本体での代入は同じではありません。

初期化子リストを使用する場合

#include <string>

class Product {
private:
    std::string name_;

public:
    explicit Product(const std::string& name)
        : name_{name}
    {
    }
};

この場合、name_は引数の値を使って直接構築されます。

コンストラクタ本体で代入する場合

#include <string>

class Product {
private:
    std::string name_;

public:
    explicit Product(const std::string& name)
    {
        name_ = name;
    }
};

この場合は、次の順序で処理されます。

  1. name_がデフォルト構築される
  2. コンストラクタ本体が実行される
  3. 引数の値がname_へ代入される

概念的には、次のような処理になります。

std::string name_;
name_ = name;

初期化子リストを使用した場合は、次のように直接構築されます。

std::string name_{name};

そのため、メンバ変数は原則としてコンストラクタ初期化子リストで初期化するのが適切です。

初期化子リストが必要になるケース

メンバの種類によっては、コンストラクタ本体での代入では対応できません。

constメンバ

constメンバは、オブジェクトの構築時に初期化する必要があります。

class Config {
private:
    const int maxCount_;

public:
    explicit Config(int count)
        : maxCount_{count}
    {
    }
};

次のように、コンストラクタ本体で代入することはできません。

class Config {
private:
    const int maxCount_;

public:
    explicit Config(int count)
    {
        maxCount_ = count;
    }
};

ただし、C++11以降ではデフォルトメンバ初期化を使用することもできます。

class Config {
private:
    const int maxCount_{100};

public:
    Config() = default;
};

つまり、constメンバは構築時の初期化が必要ですが、必ずしもコンストラクタ初期化子リストだけが唯一の方法ではありません。

参照メンバ

参照メンバも、構築時に参照先を決める必要があります。

class Wrapper {
private:
    int& value_;

public:
    explicit Wrapper(int& value)
        : value_{value}
    {
    }
};

次のコードは、参照先を設定しているのではありません。

class Wrapper {
private:
    int& value_;

public:
    explicit Wrapper(int& value)
    {
        value_ = value;
    }
};

参照メンバがすでに初期化されている場合、value_ = valueは参照先の値を書き換える処理です。

参照メンバもデフォルトメンバ初期化を使用できますが、通常はコンストラクタ初期化子リストで参照先を指定します。

デフォルトコンストラクタを持たないメンバ

引数なしで構築できないクラス型メンバは、初期化子リストで明示的に初期化する必要があります。

class Engine {
private:
    int power_;

public:
    explicit Engine(int power)
        : power_{power}
    {
    }
};

このEngineをメンバとして持つクラスは、次のように記述します。

class Car {
private:
    Engine engine_;

public:
    explicit Car(int power)
        : engine_{power}
    {
    }
};

次の書き方では、先にEngineを引数なしで構築しようとするため、コンパイルできません。

class Car {
private:
    Engine engine_;

public:
    explicit Car(int power)
    {
        engine_ = Engine{power};
    }
};

基底クラスの初期化

派生クラスから基底クラスのコンストラクタを呼び出す場合は、初期化子リストに記述します。

#include <string>
#include <utility>

class Person {
protected:
    std::string name_;

public:
    explicit Person(std::string name)
        : name_{std::move(name)}
    {
    }
};

class Employee : public Person {
private:
    int employeeId_;

public:
    Employee(std::string name, int employeeId)
        : Person{std::move(name)},
          employeeId_{employeeId}
    {
    }
};

このコードでは、Employeeのメンバを初期化する前に、Personのコンストラクタが呼び出されます。

基底クラスの初期化を省略できる場合

基底クラスに利用可能なデフォルトコンストラクタがある場合は、明示的な記述を省略できます。

class Base {
public:
    Base() = default;
};

class Derived : public Base {
public:
    Derived() = default;
};

この場合、Derivedの構築時にBaseは自動的にデフォルト初期化されます。

一方、基底クラスにデフォルトコンストラクタがない場合は、初期化子リストで明示的にコンストラクタを呼び出す必要があります。

メンバ変数の初期化順序

メンバ変数は、初期化子リストに書かれた順番ではなく、クラス内で宣言された順番に初期化されます。

class Example {
private:
    int first_;
    int second_;

public:
    Example()
        : second_{20},
          first_{10}
    {
    }
};

このコードでは、初期化子リストにsecond_が先に書かれています。

しかし、実際の初期化順序は次のとおりです。

  1. first_
  2. second_

クラス内でfirst_が先に宣言されているためです。

未初期化メンバを参照する危険な例

class Example {
private:
    int first_;
    int second_;

public:
    explicit Example(int value)
        : second_{value},
          first_{second_}
    {
    }
};

実際にはfirst_が先に初期化されます。

その時点ではsecond_はまだ初期化されていないため、正しいコードではありません。

安全な書き方は次のとおりです。

class Example {
private:
    int first_;
    int second_;

public:
    explicit Example(int value)
        : first_{value},
          second_{first_}
    {
    }
};

初期化子リストは、クラス内のメンバ宣言順と同じ順序で記述するのが基本です。

デフォルトメンバ初期化

C++11以降では、メンバ変数の宣言時に初期値を指定できます。

#include <string>

class Settings {
private:
    int timeout_{30};
    bool enabled_{true};
    std::string mode_{"normal"};

public:
    Settings() = default;
};

この方法は、デフォルトメンバ初期化と呼ばれます。

複数のコンストラクタで共通値を利用する場合

class Connection {
private:
    int timeout_{30};
    int retryCount_{3};
    bool secure_{true};

public:
    Connection() = default;

    explicit Connection(int timeout)
        : timeout_{timeout}
    {
    }

    Connection(int timeout, int retryCount)
        : timeout_{timeout},
          retryCount_{retryCount}
    {
    }
};

次のコンストラクタでは、timeout_だけが初期化子リストによって指定されます。

explicit Connection(int timeout)
    : timeout_{timeout}
{
}

ほかのメンバには、デフォルトメンバ初期化で指定した値が使用されます。

timeout_    → 引数の値
retryCount_ → 3
secure_     → true

初期化子リストが優先される

コンストラクタ初期化子リストで値が指定されている場合、デフォルトメンバ初期化は使用されません。

class Sample {
private:
    int value_{10};

public:
    Sample()
        : value_{20}
    {
    }
};

この場合、value_はいったん10になった後で20へ変更されるわけではありません。

デフォルト値の10は使用されず、最初から20で初期化されます。

デフォルト初期化と値初期化

C++では、オブジェクトの生成方法によって初期化方法が変わります。

組み込み型を初期化しない場合

int value;

ローカル変数として宣言した場合、valueは自動的に0にはなりません。

クラスのメンバも、初期値を指定しなければ未初期化になる場合があります。

class Counter {
private:
    int count_;

public:
    Counter() = default;
};

次のように生成すると、count_は未初期化になる可能性があります。

Counter counter;

安全にするためには、メンバ自身に初期値を指定します。

class Counter {
private:
    int count_{0};

public:
    Counter() = default;
};

空の波括弧による初期化

組み込み型に空の波括弧を使用すると、ゼロ相当の値で初期化されます。

int value{};
double rate{};
bool enabled{};
int* pointer{};

それぞれ、次のような値になります。

value   → 0
rate    → 0.0
enabled → false
pointer → ヌルポインタ値

クラスのメンバにも使用できます。

class Data {
private:
    int count_{};
    double total_{};
    bool valid_{};
};

この場合、各メンバはゼロ相当の値で初期化されます。

委譲コンストラクタ

C++11以降では、同じクラスの別のコンストラクタへ初期化処理を委譲できます。

#include <string>
#include <utility>

class User {
private:
    int id_;
    std::string name_;
    bool active_;

public:
    User(int id, std::string name, bool active)
        : id_{id},
          name_{std::move(name)},
          active_{active}
    {
    }

    User(int id, std::string name)
        : User{id, std::move(name), true}
    {
    }

    User()
        : User{0, "Guest", false}
    {
    }
};

この仕組みを委譲コンストラクタと呼びます。

複数のコンストラクタで共通する初期化処理を1か所へまとめられるため、処理の重複を減らせます。

委譲コンストラクタの制限

委譲先のコンストラクタを指定した場合、同じ初期化子リストでほかのメンバを追加初期化することはできません。

User()
    : User{0, "Guest", false},
      active_{true}
{
}

このコードはコンパイルエラーになります。

委譲先のコンストラクタが、オブジェクト全体の初期化を担当します。

コンストラクタのオーバーロード

引数の異なる複数のコンストラクタを定義できます。

class Rectangle {
private:
    int width_;
    int height_;

public:
    Rectangle()
        : width_{0},
          height_{0}
    {
    }

    explicit Rectangle(int size)
        : width_{size},
          height_{size}
    {
    }

    Rectangle(int width, int height)
        : width_{width},
          height_{height}
    {
    }
};

使用例は次のとおりです。

Rectangle empty;
Rectangle square{10};
Rectangle rectangle{10, 20};

それぞれ異なるコンストラクタが呼び出されます。

explicitコンストラクタ

explicitは、意図しない暗黙変換を防ぐために使用します。

class Distance {
private:
    double meters_;

public:
    explicit Distance(double meters)
        : meters_{meters}
    {
    }
};

explicitを付けない場合、次のような暗黙変換が可能になることがあります。

Distance distance = 100.0;

explicitを付けた場合は、明示的に構築する必要があります。

Distance distance{100.0};

デフォルト引数を持つコンストラクタにも注意する

explicitは、引数が1個だけのコンストラクタに限られるものではありません。

class Sample {
public:
    Sample(int value, bool enabled = true);
};

このコンストラクタは、2番目の引数にデフォルト値があるため、1個の引数だけで呼び出せます。

Sample sample = 10;

暗黙変換を禁止したい場合は、次のように記述します。

class Sample {
public:
    explicit Sample(int value, bool enabled = true);
};

暗黙変換を意図していないコンストラクタには、原則としてexplicitを検討すると安全です。

デフォルトコンストラクタ

デフォルトコンストラクタとは、引数を指定せずに呼び出せるコンストラクタです。

class Point {
private:
    int x_;
    int y_;

public:
    Point()
        : x_{0},
          y_{0}
    {
    }
};

コンパイラに生成させる場合は、= defaultを使用できます。

class Point {
private:
    int x_{0};
    int y_{0};

public:
    Point() = default;
};

すべての引数にデフォルト値があるコンストラクタも、引数なしで呼び出せるため、デフォルトコンストラクタに含まれます。

class Sample {
public:
    Sample(int value = 0)
    {
    }
};

コンストラクタを削除する方法

特定の初期化方法を禁止したい場合は、= deleteを使用できます。

class UserId {
private:
    int value_;

public:
    UserId() = delete;

    explicit UserId(int value)
        : value_{value}
    {
    }
};

次のコードはコンパイルエラーになります。

UserId id;

値を指定して生成する必要があります。

UserId id{100};

無効な初期状態のオブジェクトを作らせたくない場合に有効です。

コピーコンストラクタ

コピーコンストラクタは、同じ型の別のオブジェクトから新しいオブジェクトを作るためのコンストラクタです。

#include <string>
#include <utility>

class Person {
private:
    std::string name_;
    int age_;

public:
    Person(std::string name, int age)
        : name_{std::move(name)},
          age_{age}
    {
    }

    Person(const Person& other)
        : name_{other.name_},
          age_{other.age_}
    {
    }
};

使用例は次のとおりです。

Person first{"Taro", 30};
Person second{first};

ただし、このように単純なメンバだけで構成されている場合は、独自のコピーコンストラクタを書く必要はありません。

Person(const Person&) = default;

または、コピーコンストラクタ自体を宣言せず、コンパイラの自動生成に任せることもできます。

ムーブコンストラクタ

ムーブコンストラクタは、一時オブジェクトや今後使用しないオブジェクトからリソースを引き継ぐために使用します。

#include <utility>
#include <vector>

class Buffer {
private:
    std::vector<int> data_;

public:
    explicit Buffer(std::vector<int> data)
        : data_{std::move(data)}
    {
    }

    Buffer(Buffer&& other) noexcept
        : data_{std::move(other.data_)}
    {
    }
};

std::moveの役割

std::move自体がリソースを移動するわけではありません。

std::moveは、対象のオブジェクトを右辺値として扱える形へ変換します。

その結果、ムーブコンストラクタやムーブ代入演算子が選択可能になります。

std::vector<int> source{1, 2, 3};
std::vector<int> destination{std::move(source)};

実際にリソースを移動するのは、std::vectorのムーブコンストラクタです。

std::moveを書いても必ずムーブされるとは限らない

例えば、constオブジェクトにstd::moveを使用した場合、コピーが選択されることがあります。

#include <string>
#include <utility>

const std::string source{"Hello"};
std::string destination{std::move(source)};

一般的なムーブコンストラクタは、非constの右辺値参照を受け取ります。

そのため、constオブジェクトではムーブコンストラクタを呼び出せず、コピーになる場合があります。

ムーブ後のオブジェクト

標準ライブラリ型は、特別な規定がない限り、ムーブ後も有効なオブジェクトとして扱えます。

ただし、内部状態は未規定です。

std::vector<int> source{1, 2, 3};
std::vector<int> destination{std::move(source)};

ムーブ後のsourceについて、空になっていると決めつけることはできません。

ただし、再代入や破棄などは可能です。

source.clear();
source = {4, 5, 6};

ユーザー定義型については、ムーブ後の状態はその型の実装や仕様によって異なります。

値渡しとstd::moveを組み合わせる方法

文字列などをクラス内に保存する場合、引数を値渡しで受け取り、メンバへムーブする方法があります。

#include <string>
#include <utility>

class User {
private:
    std::string name_;

public:
    explicit User(std::string name)
        : name_{std::move(name)}
    {
    }
};

左辺値を渡す場合は、引数へコピーされた後、メンバへムーブされます。

std::string name{"Taro"};
User user{name};

右辺値を渡す場合は、コピーを避けやすくなります。

User user{"Taro"};

一方、左辺値からの構築が多い場合は、const参照で受け取る方法もあります。

class User {
private:
    std::string name_;

public:
    explicit User(const std::string& name)
        : name_{name}
    {
    }
};

どちらが適しているかは、クラスの用途や呼び出し方によって異なります。

基底クラスとメンバの初期化順序

オブジェクト全体の初期化順序は、概ね次のようになります。

  1. 仮想基底クラス
  2. 直接基底クラス
  3. 非静的データメンバ
  4. コンストラクタ本体
#include <iostream>

class Base {
public:
    Base()
    {
        std::cout << "Base\n";
    }
};

class Member {
public:
    Member()
    {
        std::cout << "Member\n";
    }
};

class Derived : public Base {
private:
    Member member_;

public:
    Derived()
    {
        std::cout << "Derived\n";
    }
};

この場合、出力順序は次のようになります。

Base
Member
Derived

Derivedのコンストラクタ本体が実行される時点では、基底クラスとメンバはすでに初期化されています。

配列メンバの初期化

組み込み配列も初期化子リストで初期化できます。

class Data {
private:
    int values_[3];

public:
    Data()
        : values_{1, 2, 3}
    {
    }
};

ただし、現代的なC++ではstd::arrayを使用すると扱いやすくなります。

#include <array>

class Data {
private:
    std::array<int, 3> values_{1, 2, 3};
};

std::arrayには、次のような利点があります。

  • size()を利用できる
  • 代入や比較ができる
  • イテレータを使用できる
  • 標準アルゴリズムと組み合わせやすい

スマートポインタの初期化

動的にオブジェクトを生成する場合は、生ポインタよりスマートポインタを使用するのが一般的です。

#include <memory>

class Engine {
};

class Car {
private:
    std::unique_ptr<Engine> engine_;

public:
    Car()
        : engine_{std::make_unique<Engine>()}
    {
    }
};

std::unique_ptrはC++11で導入されました。

一方、std::make_uniqueが利用できるのはC++14以降です。

デフォルトメンバ初期化として記述することもできます。

#include <memory>

class Engine {
};

class Car {
private:
    std::unique_ptr<Engine> engine_{
        std::make_unique<Engine>()
    };
};

所有権が1か所に限定される場合は、std::unique_ptrを使用すると設計意図を明確にできます。

初期化中に例外が発生した場合

メンバや基底クラスの構築中に例外が発生した場合、それまでに構築が完了している部分オブジェクトは自動的に破棄されます。

#include <string>

class Resource {
public:
    Resource()
    {
    }

    ~Resource()
    {
    }
};

class Service {
private:
    Resource resource_;
    std::string name_;

public:
    explicit Service(const std::string& name)
        : resource_{},
          name_{name}
    {
    }
};

resource_の構築に成功した後、name_の構築中に例外が発生した場合、resource_のデストラクタは自動的に呼び出されます。

ただし、Service自体の構築は完了していないため、Serviceのデストラクタは呼び出されません。

破棄されるのは、構築済みの基底クラスやメンバです。

この仕組みは、RAIIによる安全なリソース管理と深く関係しています。

よくある間違い

コンストラクタ本体で初期化しようとする

class User {
private:
    std::string name_;

public:
    explicit User(const std::string& name)
    {
        name_ = name;
    }
};

これは初期化ではなく代入です。

基本的には次のように記述します。

class User {
private:
    std::string name_;

public:
    explicit User(const std::string& name)
        : name_{name}
    {
    }
};

初期化子リストの順番で初期化されると思う

class Sample {
private:
    int first_;
    int second_;

public:
    Sample()
        : second_{10},
          first_{second_}
    {
    }
};

メンバは宣言順に初期化されるため、first_の初期化時点ではsecond_はまだ初期化されていません。

次のように修正します。

class Sample {
private:
    int first_;
    int second_;

public:
    Sample()
        : first_{10},
          second_{first_}
    {
    }
};

組み込み型を初期化し忘れる

class Counter {
private:
    int count_;

public:
    Counter() = default;
};

count_が未初期化になる可能性があります。

次のように、初期値を明示します。

class Counter {
private:
    int count_{0};

public:
    Counter() = default;
};

std::moveを書けば必ずムーブされると思う

const std::string name{"Taro"};
std::string copied{std::move(name)};

nameconstであるため、ムーブではなくコピーが選択される可能性があります。

std::moveは、ムーブ処理そのものではなく、右辺値として扱うための変換です。

実用的な推奨パターン

実務では、次のような構成が分かりやすく、安全です。

#include <string>
#include <utility>

class User {
private:
    int id_{0};
    std::string name_{"Guest"};
    bool active_{false};

public:
    User() = default;

    User(int id, std::string name)
        : User{id, std::move(name), true}
    {
    }

    User(int id, std::string name, bool active)
        : id_{id},
          name_{std::move(name)},
          active_{active}
    {
    }
};

このコードでは、次の方針を採用しています。

  • 標準状態はデフォルトメンバ初期化で定義する
  • 引数からメンバを設定する場合は初期化子リストを使う
  • メンバの宣言順と初期化子リストの順番を一致させる
  • 所有する文字列は値渡しで受け取り、メンバへムーブする
  • 共通の初期化処理は委譲コンストラクタへまとめる
  • 引数なしコンストラクタは= defaultを使用する

C++のコンストラクタ初期化における重要ポイント

C++のコンストラクタ初期化では、次の点を押さえておくことが重要です。

  • メンバ変数は原則として初期化子リストで初期化する
  • コンストラクタ本体での設定は初期化ではなく代入である
  • constメンバや参照メンバは構築時に初期化する必要がある
  • デフォルトコンストラクタを持たないメンバは明示的に初期化する
  • 基底クラスに引数を渡す場合は初期化子リストを使用する
  • メンバの実際の初期化順序はクラス内の宣言順である
  • 共通の初期値はデフォルトメンバ初期化にまとめる
  • コンストラクタの重複は委譲コンストラクタで減らせる
  • 組み込み型は明示的に初期化する
  • 意図しない暗黙変換を防ぐためにexplicitを検討する
  • std::move自体がリソースを移動するわけではない
  • std::make_uniqueはC++14以降で利用できる

最も基本的な形は、次のようになります。

#include <string>
#include <utility>

class Sample {
private:
    int number_{0};
    std::string text_;

public:
    Sample(int number, std::string text)
        : number_{number},
          text_{std::move(text)}
    {
    }
};

この形を基本として、必要に応じてデフォルトメンバ初期化、委譲コンストラクタ、コピーやムーブを組み合わせることで、安全で分かりやすいクラスを設計できます。

以上、C++のコンストラクタでの初期化方法についてでした。

最後までお読みいただき、ありがとうございました。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次