配列 | Programming Place Plus Modern C++編【言語解説】 第16章

トップページModern C++編

Modern C++編は作りかけで、更新が停止しています。代わりに、C++14 をベースにして、その他の方針についても見直しを行った、新C++編を作成しています。
Modern C++編は削除される予定です。

この章の概要

この章の概要です。


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章)、これを使う方法もありますが、それほど便利ではないかもしれません。

std::vector

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

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

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(を要素の型で表現したもの)で初期化しようとします。これらの初期化が不可能なら、コンパイルエラーになります。

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

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

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

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

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

#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});
}


イテレータ

複数の要素が集まった構造(データ構造)には、配列、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文は使えません。

練習問題

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

#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 に保存 Facebook でシェア
X で ポストフォロー LINE で送る noteで書く
rss1.0 取得ボタン RSS 管理者情報 プライバシーポリシー
先頭へ戻る