Modern C++編【言語解説】 第16章 配列

先頭へ戻る

この章の概要

この章の概要です。


new[] と delete[]

前の章では、new演算子を使って、単一のオブジェクトを動的生成できることを説明しました。今度は、オブジェクトの配列を生成する方法を見ていきます。

前の章で確認した通り、new演算子が行っている仕事は2つあります。メモリ領域の確保と、オブジェクトの生成です。オブジェクトの配列を生成する場合も同じで、指定した要素数分のオブジェクトを記憶できるメモリ領域の確保が行われ、それから、オブジェクト1つ1つの生成が行われます。

オブジェクトの配列を動的に生成するには、new演算子を次のように使用します。

int* nums = new int[10];         // 10個の整数
MyClass* objs = new MyClass[5];  // 5個のオブジェクト

つまり、生成する型名の後ろに [] を補って、要素数を指定します。今後、この使用方法を new[] と記述します。

new[] によって得られるものは、生成された配列の先頭要素を指すポインタです。これも前の章で確認したことですが、失敗してもヌルポインタが返される訳ではありません。

new[] でオブジェクトを生成する場合、引数付きのコンストラクタを呼び出すことができません。これは、普通に配列を宣言する場合でも同じです。必ず、アクセス可能なデフォルトコンストラクタが必要です。

解放については、delete [] を使います。こちらは要素数の指定はなく、空の [] を使います。

#include <iostream>

class MyClass {
public:
    MyClass()
    {
        std::cout << "Constructor" << std::endl;
    }
    ~MyClass()
    {
        std::cout << "Destructor" << std::endl;
    }
};

int main()
{
    int* nums = new int[5];           // 5個の整数
    MyClass* objs = new MyClass[3];  // 3個のオブジェクト

    delete [] nums;
    delete [] objs;
}

実行結果:

Constructor
Constructor
Constructor
Destructor
Destructor
Destructor

new で得られたポインタは delete でなければ解放できず、new[] で得られたポインタは delete[] でなければ解放できないことに注意して下さい。誤った方法で解放しようとしたときの動作は未定義です。

new と delete[] の組み合わせや、new[] と delete の組み合わせで使ってしまっても、コンパイルエラーになりません。事故を防ぐためには、まず、new/delete に関しては、前の章のアドバイスの通り、スマートポインタを使用しましょう。これで、誤って delete[] を使ってしまうことは無いはずです。

new[]/delete[] については、この章で安全な代替策を取り上げていきます。

std::unique_ptr は配列を扱えるので(【標準ライブラリ】第3章)、これを使う方法もありますが、それほど便利では無いかもしれません。また、C++17 からは std::shared_ptr でも配列を扱えます(【標準ライブラリ】第4章)。

std::vector

動的に配列を生成するのであれば、標準ライブラリの std::vector を使うと良いでしょう。

std::vector は、クラステンプレートになっており、任意の型の動的配列を表現しています。要素数を挿入したとき、配列が足りなくなったら自動的にメモリを確保し直し、デストラクタでは確実に delete[] を呼び出してくれます。安全性が大きく向上するほか、多数の機能を備えていて便利なので、積極的に使うようにしましょう。詳細は、【標準ライブラリ】第6章を参照して下さい。

文字列を扱う場合は、より専門特化した std::basic_string(【標準ライブラリ】第10章)を使うと良いです。文字列については、第18章で改めて取り上げます。

std::array

std::vector は非常に便利ですが、配列の要素数がコンパイル時点で分かっているのであれば、動的にメモリ確保させるのは非効率かもしれません。

そこで、固定長の配列を表現する std::array があります。std::array は、std::vector に似た機能が多数揃っており、配列を便利で安全に操作できます。生の配列を使うよりも、std::array を使った方が良いでしょう。

詳細は、【標準ライブラリ】第7章を参照して下さい。

リスト初期化

リスト初期化は、{ } で初期値を指定する構文を使って、初期化を行う方法です。初期化時に「=」を用いるかどうかによって、以下の2つの形式があります。

int a1[] = {0, 1, 2};
int a2[] {0, 1, 2};

a1 の方は、コピーリスト初期化、a2 の方は、直接リスト初期化と呼ばれます。

配列、あるいは、以下の条件を満たしたクラスは、リスト初期化の構文で初期化できます。

これらの条件を満たしているクラス、あるいは配列は、集成体(アグリゲート)と呼ばれます。

以下は、リスト初期化の例です。

#include <iostream>

struct Point3d {
    double x, y, z;
};

int main()
{
    int array[] = {0, 1, 2, 3, 4};    // 配列のリスト初期化
    Point3d point = {2.5, 0.0, -1.0}; // 集成体クラスのリスト初期化

    for (int v : array) {
        std::cout << v << std::endl;
    }

    std::cout << point.x << ", " << point.y << ", " << point.z << std::endl;
}

実行結果:

0
1
2
3
4
2.5, 0, -1

{ } の内側の要素数が多すぎる場合は、コンパイルエラーになります。

逆に、{ } の要素数の方が少ない場合は、不足している部分は、デフォルトの値が埋められます。具体的には、アクセス可能なデフォルトコンストラクタがあるのならこれを使い、無いのなら、0(を要素の型で表現したもの)で初期化しようとします。これらの初期化が不可能なら、コンパイルエラーになります。

なお、{ } の中身が空であっても構いません。

C++14 (集成体クラスの条件の緩和)

C++14 からは、「非staticなメンバ変数の初期化子を持たない」という条件は削除されています。

初期化子を持ったメンバ変数があるクラスをリスト初期化した場合、リスト初期化で指定した初期値の方が使われます。初期化を2重に行って非効率になるということはありません。

リスト初期化で指定した要素が不足しており、初期化子を持ったメンバ変数に初期値が与えられなかった場合は、メンバ変数の宣言側の初期化子が使われます。

#include <iostream>

struct Point3d {
    double x = 0;
    double y = 0;
    double z = 0;
};

void Print(const Point3d& point)
{
    std::cout << point.x << ", " << point.y << ", " << point.z << std::endl;
}

int main()
{
    Point3d point1 = { 2.5, 0.0, -1.0 };
    Point3d point2 = { 2.5 };
    Point3d point3 = {};

    Print(point1);
    Print(point2);
    Print(point3);
}

実行結果:

2.5, 0, -1
2.5, 0, 0
0, 0, 0

リスト初期化では、初期化子の値が縮小変換されることはなく、コンパイルエラーとなります。これは、意図せずに情報が失われることを防いでおり、安全性が高い初期化方法であると言えます。

縮小変換とみなされるのは、例えば、浮動小数点型から整数型への変換や、大きな整数型から小さな整数型のように、情報の一部を失わずに表現できることが保証されない変換です。

ただし、初期値が定数であり、以下のケースのいずれかに該当する場合は、利便性を損なわないために、縮小変換とはみなされません。

以下のプログラムは、リスト初期化が可能であるか、コンパイルエラーになるかの事例を示すものです。

#include <iostream>

int main()
{
    long long lln = 0LL;
    double d = 0;

    int a1[] = { lln };  // 縮小変換のためエラー
    int a2[] = { 0LL };  // 縮小変換ではないので OK
    int a3[] = { d };    // 縮小変換のためエラー
    int a4[] = { 0.0 };  // 縮小変換のためエラー
}

long long型から int型への変換も、double型から int型への変換も、情報を失う可能性はあります。しかし、0LL という定数を指定する例では、整数型どうしなので縮小変換ではありません。
一方、0.0 を指定する例は、浮動小数点型から整数型への変換になるため、定数であっても縮小変換とみなされます。

集成体でないクラスをリスト初期化できるようにする

集成体でないクラスであっても、リスト初期化を行えるようにする方法があります。そのためには、std::initializer_list を使用します。std::initializer_list については、【標準ライブラリ】第8章で解説しているので、まずはそちらを参照して下さい。

仮引数の型が std::initializer_list のコンストラクタを宣言したクラスは、集成体の要件を満たしていなくても、リスト初期化を行えます。

リスト初期化の構文を使って初期化すると、その初期化子を使って、std::initializer_list が生成されます。すべての初期化子の型と、std::initializer_list のテンプレート実引数は一致していなければなりません。

例えば、次のようにできます。

#include <initializer_list>
#include <iostream>

class MyClass {
public:
    MyClass(std::initializer_list<int> lst) :
        mCount(0), mSum(0)
    {
        for (int v : lst) {
            ++mCount;
            mSum += v;
        }
    }

    int GetCount() const
    {
        return mCount;
    }
    int GetSum() const
    {
        return mSum;
    }

private:
    int  mCount;
    int  mSum;
};

int main()
{
    MyClass mc = {7, 5, 5, 4};

    std::cout << mc.GetCount() << "\n"
              << mc.GetSum() << std::endl;
}

実行結果:

4
21

なお、std::initializer_list を仮引数に持つコンストラクタと、デフォルトコンストラクタがあるとき、空の初期化リストを渡して初期化すると、デフォルトコンストラクタの方が呼び出されます。

#include <initializer_list>
#include <iostream>

class MyClass {
public:
    MyClass()
    {
        std::cout << "default constructor" << std::endl;
    }

    MyClass(std::initializer_list<int> lst)
    {
        std::cout << "initializer_list constructor" << std::endl;
    }
};

int main()
{
    MyClass mc1;
    MyClass mc2 {};
    MyClass mc3 {0};
}

実行結果:

default constructor
default constructor
initializer_list constructor

リスト初期化の構文は、単一の変数の初期化の際にも使えます。これは、あらゆる初期化を同一の構文で行えるように考えられた仕様で、統一初期化構文だとか一様初期化などと呼ばれています。この話題は、第20章で改めて取り上げます。

リスト初期化の構文は至る所で使用できます。例えば、関数の仮引数や戻り値型が、リスト初期化を受け付けられるのなら、次のように記述できます。

// Point3d は集成体
struct Point3d {
    double x, y, z;
};

void f1(const Point3d& point)
{
}

Point3d f2()
{
    return {0, 0, 0};  // return文で使う
}

int main()
{
    f1({0, 1, 2});  // 実引数で使う
    Point3d point = f2();
}

また、new演算子との組み合わせも可能です。

#include <memory>

// Point3d は集成体
struct Point3d {
    double x, y, z;
};

int main()
{
    std::unique_ptr<Point3d> point(new Point3d {0, 0, 0});
}

C++14 (直接リスト初期化時に重複する { } の省略を許可)

C++14

C++11 では、集成体クラスのメンバ変数が集成体であるときに、直接リスト初期化を行う場合は、{ } を重複して書かなければなりませんでした。例えば、std::array を使うときに問題になります(【標準ライブラリ】第7章)。

#include <array>

int main()
{
    std::array<int, 3> a1 {0, 1, 2};      // C++11 ではコンパイルエラー
    std::array<int, 3> a2 {{0, 1, 2}};    // OK
    std::array<int, 3> a3 = {0, 1, 2};    // OK
    std::array<int, 3> a4 = {{0, 1, 2}};  // OK
}

C++14 では、このような場合に、{} の片方を省略できるようになりました。

#include <array>

int main()
{
    // C++14 では、いずれも OK
    std::array<int, 3> a1 {0, 1, 2};
    std::array<int, 3> a2 {{0, 1, 2}};
    std::array<int, 3> a3 = {0, 1, 2};
    std::array<int, 3> a4 = {{0, 1, 2}};
}


イテレータ

複数の要素が集まった構造(データ構造)には、配列、std::vector、std::array のような配列がベースになっているもの以外にも、リスト構造(アルゴリズムとデータ構造編【データ構造】第3章)や木構造(アルゴリズムとデータ構造編【データ構造】第7章)などがあります。これらのデータ構造に共通して行われる最も基本的な操作は、特定の要素へのアクセスでしょう。

データ構造が具体的にどんな形で実装されているとしても、同じ方法で要素へのアクセスが行えると便利です。単に、データ構造ごとの方法を覚えなくていいという面もありますし、コードが同じにできるのなら、関数テンプレートを使って、共通化を図りやすくもなります。

要素へのアクセスを抽象化する仕組みが、イテレータ(反復子)です。イテレータは、標準ライブラリ内でも非常に重要な機能となっています。詳細は、【標準ライブラリ】第9章で解説していますので、そちらを参照して下さい。

範囲for文の詳細

実はイテレータは、範囲for文を実現するためにも使われています。範囲for文を使ったプログラムは、コンパイラによって、イテレータを使ったプログラムに変換されています。

例えば、次のように範囲for文を使ったプログラムがあるとします。

std::vector<int> v = {0, 1, 2};
for (int n : v) {
    std::cout << n << std::endl;
}

このプログラムは、イテレータを直接使った次のプログラムと同等です。

std::vector<int> v = {0, 1, 2};
for (std::vector<int>::iterator it = std::begin(v); it != std::end(v); ++it) {
    std::cout << *it << std::endl;
}

範囲for文を使ってプログラムを書いても、実質的に同じコードが生成されます。実際、範囲for文は次のように展開されることになっています。まだ解説していない言語機能が登場します。

auto および、auto&& については、第20章で解説します。

{
    auto&& __range = 範囲for文の範囲;

    for (auto __begin = begin(__range), __end = end(__range); __begin != __end; ++__begin) {
        範囲for文の変数宣言 = *__begin;
        範囲for文が実行する文
    }
}

先程の範囲for文の例でいえば、「範囲for文の範囲」には「v」が、「範囲for文の変数宣言」には「int n」が、「範囲for文が実行する文」には「std::cout << n << std::endl;」が入ります。

begin関数と end関数は、対象のデータ構造が beginメンバ関数や endメンバ関数を持っていればそれを呼び、持っていなければ、関連する名前空間内から begin関数や end関数を探しています。どちらも見つからなければ、コンパイルエラーとなり、範囲for文は使えないということになります。

C++17 (範囲for文に対応させるための制約の緩和)

先程示した、範囲for文から展開されるコードには、微妙な問題がありました。それは、2つの変数 __begin と __end が、いずれも同じ型であるということです。このように展開されるという規定になっているが故に、begin関数と end関数の戻り値型も同一でなければならないという制約が生まれてしまっています。これは不必要な制約と言えます。

例えば、begin関数が返すイテレータは大抵の場合、++演算子や *演算子を適用しますし、もっと複雑な処理を行えるようにする可能性もあります。一方、end関数が返すイテレータは、単に終端を示す意味しかなく、!=演算子が適用される程度でしょう。このように、begin関数と end関数が返す型は、異なるものが使えた方が便利なケースもあります。

そこで、C++17 からは、範囲for文は次のようなコードに展開されることになりました。

{
    auto&& __range = 範囲for文の範囲;
    auto __begin = begin(__range);
    auto __end = end(__range);
    for (; __begin != __end; ++__begin) {
        範囲for文の変数宣言 = *__begin;
        範囲for文が実行する文
    }
}

要するに、__begin と __end を分けて宣言することで、型が異なっても良いように規定を修正したということです。

練習問題

問題① 次のプログラムは動作するでしょうか?

#include <iostream>

int main()
{
    for (int v : {0, 1, 2, 3, 4}) {
        std::cout << v << std::endl;
    }
}

問題② 次のような、動的配列を扱うクラステンプレートがあるとします。

template <typename T>
class FixedSizeVector {
public:
    explicit FixedSizeVector(std::size_t size);
    ~FixedSizeVector();

private:
    T*  mData;
};

要素数をコンストラクタの実引数で指定するとして、コンストラクタとデストラクタを実装して下さい。

問題③ 問題②のクラステンプレートに、以下の機能を追加して下さい。

問題④ 問題③のクラステンプレートのメンバ変数の動的配列を、std::vector に置き換えてください。


解答ページはこちら

参考リンク



更新履歴

'2018/7/13 サイト全体で表記を統一(「静的メンバ」-->「staticメンバ」)

'2018/1/7 関数名のスペルミスを修正。

'2018/1/5 コンパイラの対応状況について、対応している場合は明記しない方針にした。

'2017/12/22 新規作成。



前の章へ(第15章 動的なオブジェクトの生成)

次の章へ(第17章 文字)

Modern C++編のトップページへ

Programming Place Plus のトップページへ


はてなブックマーク Pocket に保存 Twitter でツイート Twitter をフォロー
Facebook でシェア Google+ で共有 LINE で送る rss1.0 取得ボタン RSS
管理者情報 プライバシーポリシー