3. 参照・ポインタと寿命

第 2 章 では、値をコピーして関数へ渡す方法(値渡し)を学びました。 この章では、コピーを作らずに元の変数を使うための参照とポインタを学び、どの変数を読み書きしているか、その変数がいつまで使えるかを追えるようにします。 MISO では、設定や格子を参照で受け取って持ち続けたり、配列の場所をポインタで持つ軽い「View」を計算に渡したりしています。

記号の意味だけを確かめたいときは 記号の早見表 も使えます。

目次

1. コピーと参照

1.1 値のコピーと参照

同じ値から始めることと、同じ変数を使うことを区別します。

double a = 1.0;
double b = a;   // b is a separate variable holding a copy of a
double &r = a;  // r is another name (reference) for a

b = 2.0;        // only b becomes 2.0
r = 3.0;        // r is a itself, so a becomes 3.0
// a = 3.0, b = 2.0, r = 3.0

double &r = a; の r は、新しい double の変数ではなく、a に付けた別の名前です。 このような別名を参照(reference)と呼び、宣言では型の後に & を付けます。 double& r と double &r は同じ意味で、MISO のソースは double &r の書き方(& を名前に寄せる)に揃えています。

参照は、宣言するときに必ず対象を決めます。double &r; のように対象なしでは作れません。 また、後から別の変数へ付け替えることはできません。上の状態で r = b; と書くと、a に b の値が代入されるだけです。

1.2 参照渡し

関数の引数を参照にすると、呼び出し元の変数そのものを関数の中で使えます。

void set_copy(double value) {      // pass by value: value is a copy
  value = 5.0;
}

void set_original(double &value) { // pass by reference: value is an alias of the caller's variable
  value = 5.0;
}

double c = 1.0;
set_copy(c);      // c stays 1.0
set_original(c);  // c becomes 5.0

呼び出す側はどちらも set_copy(c)・set_original(c) と書き、見た目は同じです。 違いは関数を定義する側の double value と double &value にあります。 このように引数を参照で受け取る方法を参照渡し(pass by reference)と呼びます。

Fortran では、引数は通常、呼び出し元の変数そのものとして扱われ、サブルーチンの中で代入すると呼び出し元が変わります。 Fortran の引数に近いのは、C++ の値渡しではなく参照渡しの方です。 Fortran で intent(in)、intent(out)、intent(inout) を書き分けるように、C++ では引数の型で受け取り方を書き分けます(1.5 節)。

1.3 出力引数

関数の戻り値は 1 つですが、参照引数を使えば複数の結果を呼び出し元へ渡せます。 Fortran のサブルーチンが intent(out) の引数に結果を書くのと同じ使い方です。

void sum_and_difference(double a, double b, double &total, double &difference) {
  total = a + b;       // writes to the caller's total
  difference = a - b;  // writes to the caller's difference
}

double total = 0.0;
double difference = 0.0;
sum_and_difference(3.0, 1.0, total, difference);
// total is 4.0, difference is 2.0

a と b は値渡しで計算に使う値を受け取り、total と difference は参照で結果を書き込む先を受け取っています。 結果を受け取るための引数を出力引数と呼びます。

MISO の実例 — 第 2 章 の switch で見た miso/include/miso/boundary_condition.hpp の symmetric_index の全体です。

__host__ __device__ inline void symmetric_index(int i, int i_total, int i_margin,
                                                int &i_ghst, int &i_trgt,
                                                Side side) {
  switch (side) {
  case Side::INNER:
    i_ghst = i;
    i_trgt = 2 * i_margin - i - 1;
    break;
  case Side::OUTER:
    i_ghst = i_total - i_margin + i;
    i_trgt = i_total - i_margin - i - 1;
    break;
  }
}

先頭の __host__ __device__ は CPU と GPU の両方から呼べる印で(第 9 章)、CPU ビルドでは空に置き換わります(第 2 章 のプリプロセッサ)。 i, i_total, i_margin, side は値渡しの入力、int &i_ghst と int &i_trgt は出力引数です。 呼び出す側で用意した 2 つの int に、ゴーストセルの添字と、値を写す元の添字が書き込まれます。

#include <iostream>

#include <miso/core.hpp>

int main() {
  const int i_total = 8;   // number of grid points including ghost cells
  const int i_margin = 2;  // width of the ghost cells
  for (int i = 0; i < i_margin; ++i) {
    int i_ghst = -1;
    int i_trgt = -1;
    miso::boundary_condition::symmetric_index(i, i_total, i_margin, i_ghst,
                                              i_trgt, miso::Side::OUTER);
    std::cout << i << ": i_ghst = " << i_ghst << ", i_trgt = " << i_trgt << "\n";
  }
}
0: i_ghst = 6, i_trgt = 5
1: i_ghst = 7, i_trgt = 4

第 1 章 の、MISO::MISO をリンクする CMake 設定でビルドできます(miso/core.hpp が MPI と yaml-cpp のヘッダを読むためです)。 外側の境界では、ゴーストセル 6, 7 に、境界をはさんで鏡に映した位置 5, 4 の値を写すことが分かります。

1.4 読むための const 参照

元の変数をコピーせずに使いながら、関数の中では書き換えないようにするには、const を付けて const T & と書きます(T は型を表す仮の名前です)。

double twice(const double &value) {
  return 2.0 * value;  // reading is allowed
  // value = 5.0;      // error: cannot modify through a const reference
}

const double &value は「double を読むためだけの参照」です。Fortran の intent(in) に当たります。 禁止されるのはこの参照を通した書き換えで、呼び出し元の変数が書き換え禁止になるわけではありません。 const 参照には twice(1.0) のように数値を直接渡すこともできます(5 節)。書き換え用の double & には数値を渡せません。

第 2 章 の catch (const std::runtime_error &e) も、受け止めた例外を const 参照で受け取る書き方です。

MISO の実例 — miso/include/miso/config.hpp の merge_yaml は、設定ファイルの内容 target に、既定値 defaults のうち足りない項目を書き足す関数です(宣言部分)。

inline void merge_yaml(YAML::Node &target, const YAML::Node &defaults) {

YAML::Node は yaml-cpp の型で、YAML の木構造を表します。 書き足す先の target は YAML::Node &(書き換える)、既定値の defaults は const YAML::Node &(読むだけ)です。 宣言を見るだけで、どちらの引数が変わるかが分かります。

miso/include/miso/time.hpp の Time は、設定を const Config &config で受け取って、終了時刻などを読み出します(クラスのコンストラクタという仕組みで、第 4 章 で説明します)。

Time(const Config &config)

1.5 引数の受け取り方の使い分け

関数でしたいこと

宣言

Fortran で近いもの

数値 1 個など、コピーの軽い値を使う

T value

intent(in)(値を使うだけ)

コピーに手間がかかるデータを読む

const T &value

intent(in)

呼び出し元の変数を書き換える

T &value

intent(out), intent(inout)

int や double 1 個を読むだけなら、値渡しが基本です。 数値 1 個のコピーは安く、参照にしても速くなるとは限りません。 std::string、std::vector、Config のように中身が大きくなりうる型を読むだけなら const T & にします。 書き換えるなら、数値 1 個でも T & にします。 この使い分けは C++ Core Guidelines の F.16 と F.17 でも勧められています。

値渡しの引数に const を付けた const T value も書けます。これは「関数の中でこのコピーを書き換えない」という印で、呼び出し元には影響しません。

MISO の実例 — miso/include/miso/time.hpp の Time::update は、時刻刻み dt を const Real dt(書き換えないコピー)で受け取ります。

void update(const Real dt) {
  time += dt;
  n_step++;
};

demo/mhd2d_vortex/src/main.cpp の Model は、設定を Config &config で受け取ります。

Model(Config &config) : ModelBase(config), eos(config), ic(eos), bc(), src() {}

const が付いていないのは、受け取った config を基底クラス ModelBase(第 5 章)に渡し、そこで書き換え可能な参照として持ち続けるためです(5 節)。 : の後ろの並びは、メンバの初期化(第 4 章)です。ここでは「config をコピーせずに、そのまま渡している」と読めば十分です。

2. ポインタ

2.1 アドレスとポインタ

変数はメモリ上のどこかに置かれています。その場所を表す値をアドレスと呼び、アドレスを保持する変数をポインタ(pointer)と呼びます。

double a = 1.0;   // variable holding a number
double *p = &a;   // pointer p holding the address of a; we say "p points to a"

部分

意味

double *

「double を指すポインタ」という型

p

ポインタ変数の名前

&a

a のアドレスを取り出す式

ポインタが指す先の値を使うには、式の中で *p と書きます(間接参照)。

double copied = *p;  // reads the value of a, which p points to; copied is 1.0
*p = 2.0;            // modifies a, which p points to; a becomes 2.0

a は数値 1.0 を、p は「その 1.0 が置かれている場所」を持っています。 アドレスの数値そのものを覚える必要はなく、どの変数を指しているかを追います。

Fortran にも pointer 属性がありますが、Fortran のポインタは名前を書くだけで指す先の値になります。 C++ のポインタは、p と書けばアドレス、*p と書けば指す先の値で、両者を書き分けます。

2.2 宣言と式で変わる * と &

参照とポインタの両方に & と * が出てきます。同じ記号でも、宣言に書く場合と式に書く場合で意味が変わります。

double a = 1.0;
double &r = a;   // & in a declaration: makes r a reference (alias) to a
double *p = &a;  // * in a declaration: makes p a pointer. & in an expression: address of a
*p = 2.0;        // * in an expression: assigns to what p points to (a). a and r are both 2.0
r = 3.0;         // assigns to a. a and *p are both 3.0

書く場所

例

意味

宣言の &

double &r = a;

r は a の参照

式の &

&a

a のアドレス

宣言の *

double *p = &a;

p は double を指すポインタ

式の *

*p

p が指す先の値

2 つの値の間の *, &

a * b, i & 1

掛け算、ビットごとの AND

r は a の別名なので &r も a のアドレスで、&a, &r, p はすべて同じ場所を表します。 宣言の double *p は「*p と書くと double になるような p」と読むと、式の *p とのつながりが分かります。

宣言でもう 1 つ注意する点があります。* と & は型ではなく、それぞれの名前に付きます。

double *p, q;   // p is a pointer, q is just a double
double *x = nullptr, *y = nullptr;  // x and y are both pointers

MISO の実例 — miso/include/miso/grid.hpp の GridView は、座標の配列をポインタで持っています(抜粋)。

/// @brief coordinate in x/y/z direction
Real *x = nullptr, *y = nullptr, *z = nullptr;

3 つとも Real * で、名前ごとに * を書いています。nullptr は 2.4 節 で説明します。 GridView が何を指すかは 4 節 で読みます。

コラム:宣言を使い方に似せる C の設計と、参照の & の由来

double *p の * と *p の * が同じ記号なのは、C の設計者 Ritchie が「宣言は、その変数を式で使うときの形に似せる」と決めたためです。 double *p は「*p が double である」という宣言として読めます。 出典:D. M. Ritchie, The Development of the C Language("Embryonic C" の節)。

参照は C++ で加わった機能です。Stroustrup は、参照を導入した主な理由として演算子のオーバーロード(行列 A + B を、大きなデータをコピーせずに書くこと)を挙げています。 同じ節には、Fortran の関数に値を渡すために const 参照を使う例も出てきます。 出典:B. Stroustrup, A History of C++: 1979–1991, 3.3.4 節。 ただし、参照の記号に & を選んだ理由そのものを述べた設計者の説明は、これらの資料には見当たりません。アドレスと関わりの深い機能なので既存の & を使った、というのは推測です。

宣言の * を型に寄せて double* p と書くか、名前に寄せて double *p と書くかは書式の好みで、意味は同じです。 Stroustrup は型を強調する int* p を好むと述べ、1 行に 1 つずつ宣言することを勧めています。 出典:Stroustrup, C++ Style and Technique FAQ。 MISO は clang-format の設定(.clang-format)で double *p の形に揃えています。

2.3 指す先の付け替え

参照と違い、ポインタは後から別の変数を指すように変えられます。

double a = 1.0;
double b = 4.0;
double *p = &a;  // p points to a
p = &b;          // changes what p points to, to b. The values of a and b do not change
*p = 5.0;        // p now points to b, so b becomes 5.0. a stays 1.0

p = &b; はポインタが持つアドレスを変える代入、*p = 5.0; は指す先の値を変える代入です。どちらを変えているかを、* の有無で見分けます。

2.4 nullptr

nullptr(ヌルポインタ)は、どこも指していないことを表すポインタの値です。Fortran の nullify(p) した状態、associated(p) が偽の状態に当たります。

double *q = nullptr;  // points to nothing yet
if (q != nullptr) {   // use it only when it points to something
  *q = 1.0;
}

nullptr のまま *q を読み書きしてはいけません。結果は未定義動作(言語が動作を保証しないこと)で、すぐ止まるとは限らず、誤った値で計算が進むこともあります。 関数の中で double *q; とだけ書いたポインタは、自動では nullptr になりません。初期値を決められないときは nullptr で初期化します。

MISO の実例 — miso/include/miso/array3d.hpp の Array3DView は、データを指すポインタを nullptr で初期化しています(抜粋)。

T *data_ = nullptr;

T は要素の型(Real など)を表すテンプレート引数です(第 6 章)。ここでは Real *data_ = nullptr; と読めます。

2.5 ポインタを受け取る関数

関数がポインタを受け取ると、呼び出し元の変数のアドレスを通して値を書き込めます。参照引数と同じ働きを、C の書き方で表したものです。

void get_rank(int *rank) {  // receives the address to write to
  *rank = 3;                // writes to what it points to
}

int rank = -1;
get_rank(&rank);            // pass the address of rank; rank becomes 3

参照引数との違いは、呼び出す側で &rank とアドレスを明示すること、そして nullptr も渡せることです。 C++ で新しく書く関数では、書き込む先が必ずあるなら参照引数を使うのが基本です。

MISO の実例 — MPI は C の関数として作られているので、結果をポインタ引数で返します。miso/include/miso/env.hpp の mpi::Env::setup より抜粋します。

int is_mpi_initialized = false;
MPI_Initialized(&is_mpi_initialized);
// ...
MPI_Comm_rank(world::comm, &world::rank);
MPI_Comm_size(world::comm, &world::size);

MPI_Comm_rank は、第 2 引数に渡したアドレスへ自分のランク番号を書き込みます。 &world::rank は、第 2 章 で見た名前空間 miso::mpi::world の変数 rank のアドレスです。 Fortran の call MPI_Comm_rank(comm, rank, ierr) では引数がそのまま書き換えられますが、C の MPI ではアドレスを渡します(MPI は 第 8 章 で扱います)。

同じファイルの mpi::Env には、次の 2 行があります(クラスのコンストラクタで、第 4 章 で説明します)。

Env() { setup(nullptr, nullptr); }
Env(int &argc, char **&argv) { setup(&argc, &argv); }

setup は void setup(int *argc, char ***argv) と宣言され、MPI_Init(argc, argv) にそのまま渡します。 引数なしで作る場合は「コマンドライン引数はない」ことを nullptr で表し、main の argc, argv を受け取る場合はそのアドレスを渡しています。 char **&argv は「char ** 型の変数 argv への参照」です。char ** は 4 節 で読みます。

3. const の位置

ポインタには、**ポインタ自身(どこを指すか)**と、指す先の値の 2 つがあり、const でどちらを固定するかを選べます。 * を境目にして、const が左右どちらにあるかを見ます。

  • * より左の const は指す先に掛かります。 const double *p は「読むだけの double を指すポインタ」で、*p = 2.0; ができません。

  • * より右の const はポインタ自身に掛かります。 double *const p は「指す先を変えられないポインタ」で、p = &b; ができません。

double a = 1.0;
double b = 4.0;

const double *p = &a;  // only reads what it points to
// *p = 2.0;           // error: assignment of read-only location '* p'
p = &b;                // OK: p itself is not const

double *const q = &a;  // fixes what it points to
*q = 2.0;              // OK: a becomes 2.0
// q = &b;             // error: assignment of read-only variable 'q'

宣言

*p = 2.0;(指す先の値を変える)

p = &b;(指す先を変える)

double *p

できる

できる

const double *p

できない

できる

double *const p

できる

できない

const double *const p

できない

できない

const double *p と double const *p は同じ意味です(どちらも const が * の左)。 const double &r は、const double *p と同じく「この参照を通しては読むだけ」という意味です。参照はもともと付け替えられないので、右側の const に当たるものはありません。 どの場合も、元の変数 a を a = 5.0; と直接書き換えることは禁止されません。

MISO の実例 — miso/include/miso/array1d.hpp の Array1DView は、データの先頭を返す関数を 2 つ持っています(抜粋)。

/// @brief Return pointer to the data.
__host__ __device__ T *data() noexcept { return data_; }

/// @brief Return const pointer to the data.
__host__ __device__ const T *data() const noexcept { return data_; }

1 つ目は書き換えてよいポインタ T * を、2 つ目は読むだけのポインタ const T * を返します。 同じ名前で 2 つあり、読むだけの場面では 2 つ目が選ばれます。どちらが選ばれるかを決める末尾の const は 第 4 章、noexcept(例外を投げない印)も 第 4 章 で扱います。 ここでは、戻り値の型の T * と const T * の違いが、「書き換えられるか、読むだけか」の違いだと読めれば十分です。

4. 配列とポインタ

C++ の組込みの配列は、同じ型の値をメモリ上に連続して並べたものです。

double raw[4] = {0.0, 1.0, 2.0, 3.0};  // 4 doubles; indices 0 to 3
double *p = raw;                        // the array name becomes the address of the first element raw[0]
std::cout << *(p + 2) << " " << p[2] << "\n";  // both are raw[2], so 2 2
  • 配列の名前 raw を式で使うと、ほとんどの場面で先頭要素のアドレス(&raw[0])になります。

  • p + 2 は「p から要素 2 個分先のアドレス」です(ポインタ演算)。バイト数ではなく要素の個数で数えます。

  • p[i] は *(p + i) と同じ意味です。添字が 0 から始まるのは、先頭が「0 個先」だからです。

そのため、関数に配列を渡すと、実際に渡るのは先頭のアドレスだけで、要素数は伝わりません。要素数は別の引数で渡します。

double sum(const double *values, int n) {  // address of the first element and the number of elements
  double total = 0.0;
  for (int i = 0; i < n; ++i) {
    total += values[i];
  }
  return total;
}

sum(raw, 4);      // 0 + 1 + 2 + 3 = 6
sum(raw + 1, 2);  // 2 elements from raw[1]: 1 + 2 = 3

raw + 1 を渡すと、配列の途中から始まる部分を渡せます。Fortran で call sub(a(2), 2) と書いて途中から渡す使い方に似ています。 const double * なので、sum の中で配列を書き換えることはできません。

範囲を「先頭のアドレス」と「最後の要素の 1 つ後ろのアドレス」の組で表すこともよくあります。p から p + n までは 0 <= i < n と同じ範囲です。

std::vector も要素を連続して並べており、v.data() で先頭のアドレスを得られます。

#include <algorithm>  // std::fill
std::vector<double> v(5, 1.0);                  // 5 copies of 1.0
std::fill(v.data(), v.data() + v.size(), 0.0);  // fills [begin, begin + 5) with 0.0

MISO の実例 1 — miso/include/miso/utility.hpp の util::clear_array は、配列を 0 で埋めます(template と using T = ... の行は 第 6 章 の内容なので、T を Real と読みます)。

// @brief Clear array (zero-fill)
template <typename VectorLike> inline void clear_array(VectorLike &arr) {
  using T = std::decay_t<decltype(*arr.data())>;
  std::fill(arr.data(), arr.data() + arr.size(), T(0));
}

arr.data() から arr.data() + arr.size() までを T(0)(0 を要素の型にした値)で埋めています。上の std::vector の例と同じ形です。

MISO の実例 2 — miso/include/miso/array3d.hpp の Array3DView は、3 次元配列を、1 次元に並んだデータの先頭を指すポインタ data_ と、各方向の大きさ shape_[3] で表しています。 a(i0, i1, i2) と書いたときに要素を返す部分は次のとおりです(operator() の仕組みは 第 4 章、assert の行は省略しました)。

return data_[(i0 * shape_[1] + i1) * shape_[2] + i2];

3 つの添字から「先頭から何個目か」を計算し、data_[...] で 1 次元のデータを読んでいます。 最後の添字 i2 が 1 増えると 1 個隣、i1 が 1 増えると shape_[2] 個先です。最後の添字の方向に連続して並ぶので、Fortran(最初の添字の方向に連続)とは逆です。この並びが計算速度に関わることは 第 7 章 で扱います。

MISO の実例 3 — miso/include/miso/grid.hpp の Grid::view は、std::vector の座標 x(第 2 章)の先頭アドレス x.data() を GridView に渡します(抜粋)。

return GridView<Real>(i_total, j_total, k_total, is, js, ks, i_margin,
                      j_margin, k_margin, min_dxyz, x.data(), y.data(),
                      z.data(), dx.data(), dy.data(), dz.data(), dxi.data(),
                      dyi.data(), dzi.data());

GridView の x は 2.2 節 で見た Real *x です。 第 2 章 の初期条件で書いた grid.x[i] は、このポインタに添字を付けて、Grid が持つ std::vector の要素を読んでいたことになります。 GridView は座標のコピーを持たず、場所だけを持つ軽い型です。このように、データを所有せずに場所だけを持つ型を MISO では View と呼びます(第 4 章)。

main の char **argv — char * は 1 文字目を指すポインタで、C の文字列(末尾に終端の文字 '\0' が付いた文字の並び)を表します。 char **argv は「char * を指すポインタ」、つまり文字列の配列の先頭です。argv[i] が i 番目のコマンドライン引数の文字列です。 第 2 章 の parse_config_filepath では、std::string_view arg(argv[i]); で i 番目の引数を読んでいました。

5. 変数の寿命とスコープ

関数の中で宣言した変数は、宣言した場所から、その変数を囲む { ... } の終わりまで使えます。この範囲をスコープと呼びます。 そして、通常の変数の実体は、そのブロックを抜けるときに消えます。変数が存在する期間を寿命と呼びます。

double *p = nullptr;   // p is in the outer block
{
  double local = 1.0;  // local exists only inside this block
  p = &local;          // stores the address of local in p
}                      // the lifetime of local ends. p remains
// do not read or write *p here

ポインタや参照が残っていても、指す先の寿命は延びません。寿命が終わった変数を指すポインタや参照を、ぶら下がりポインタ・ぶら下がり参照(dangling pointer / reference)と呼びます。 これを読み書きすると未定義動作です。正しそうな値がたまたま読めることもあるので、気づきにくい誤りになります。

Fortran でも、サブルーチンの局所変数(save 属性のないもの)は、サブルーチンを抜けると値が保証されません。C++ では、for の中や if の中のブロックでも同じことが起こります。

関数の中の変数への参照やポインタを返すのも、同じ誤りです。

double &bad() {
  double local = 1.0;
  return local;  // local is destroyed when the function returns
}
warning: reference to local variable 'local' returned [-Wreturn-local-addr]

g++ は -Wall で警告を出しますが、どの場合でも検出できるわけではありません。値を持ち帰るときは、double good() のように値で返します(C++ Core Guidelines F.43)。

const 参照の引数に数値や計算結果を渡すと、その値を入れた一時的な実体が作られます。twice(1.0) の 1.0 は、その文(; まで)が終わるまで存在します。関数の中で、その参照をどこかに保存して後で使ってはいけません。

同じブロックの中で宣言した変数は、宣言と逆の順に消えます。後から宣言した変数が先に消えるので、先に宣言した変数を後の変数が参照で持つのは安全です。

MISO の実例 1 — miso/include/miso/mhd_model_base.hpp の ModelBase は、設定 Config を参照のメンバとして持ち続けます(抜粋)。

Config &config;

参照をメンバに持つ型の変数は、参照先(ここでは Config の変数)より長生きしてはいけません。 demo/mhd2d_vortex/src/main.cpp の main では、config を先に、model を後に宣言しています(第 2 章)。

Config config(config_path.value_or("./config.yaml"));

// Run simulation
Model model(config);
model.run();

main の終わりでは、後に宣言した model が先に消え、その後に config が消えます。model が使っている間、config は必ず存在します。

MISO の実例 2 — miso/include/miso/mhd_integrator_host.hpp の Integrator も、格子を参照のメンバとして持ちます(抜粋)。

/// @brief Spatial grid
Grid<Real, backend::Host> &grid;

Integrator は時間積分のたびに格子を使いますが、格子のコピーは持ちません。格子の本体は ModelBase の grid で、Integrator はそれより長生きしない位置に置かれています。 <Real, backend::Host> はテンプレート引数(第 6 章)です。

MISO の実例 3 — 4 節 の GridView は、Grid の std::vector の要素を指すポインタを持つだけです。 したがって GridView を使えるのは、元の Grid が存在する間だけです。 また、std::vector は要素数を増やす(push_back など)と別の場所に確保し直すことがあり、そのとき古い data() のアドレスは使えなくなります。 MISO では、格子を作った後に座標の要素数を変えないので、この問題は起こりません。 所有する配列と、それを指す View の関係は 第 4 章 で詳しく扱います。

6. new・delete とスマートポインタ

ブロックの寿命に縛られず、必要な大きさのメモリを実行時に確保したいことがあります。 C++ の基本の道具は new と delete です。Fortran の allocate と deallocate に当たります。

int n = 4;
double *raw = new double[n];  // allocates n doubles and puts the address of the first one in raw
for (int i = 0; i < n; ++i) {
  raw[i] = i;
}
delete[] raw;                 // release it yourself (delete[] for arrays)
raw = nullptr;

new で確保したメモリは、delete するまで残ります。これを直接書くと、次の誤りが起こりやすくなります。

  • 解放し忘れる(メモリリーク)。途中で return したり例外が投げられたりすると、delete の行に届きません。

  • 解放した後のメモリを使う、同じメモリを 2 回解放する。

  • new[] に対して delete([] なし)を使う。

Fortran の allocatable 配列は、スコープを抜けると自動で解放されますが、C++ のポインタにはその仕組みがありません。 そのため、現在の C++ では new と delete を直接書かず、解放を自動で行う型を使うのが基本です(C++ Core Guidelines R.11)。

  • std::vector:要素の並びが欲しいだけなら、まずこれを使います(第 2 章)。

  • std::unique_ptr(<memory>):確保したメモリのただ 1 人の持ち主になるポインタです。unique_ptr の変数の寿命が終わると、自動で delete します。コピーはできません。

  • std::shared_ptr(<memory>):複数の持ち主で共有するポインタです。持ち主の数を数えていて、最後の持ち主が消えたときに delete します。

#include <memory>

int n = 4;
std::unique_ptr<double[]> u = std::make_unique<double[]>(n);  // n elements, initialized to 0
u[1] = 1.5;

std::shared_ptr<double> s1 = std::make_shared<double>(2.0);
std::shared_ptr<double> s2 = s1;   // shares the same double
*s2 = 3.0;                         // *s1 is also 3.0
// s1.use_count() is 2 (two owners)
// u, s1, s2 are released automatically at the end of the block. No delete needed

<double[]> などの <> はテンプレート引数(第 6 章)です。make_unique・make_shared は、確保とポインタの作成を一度に行う関数です。

MISO の実例 — MISO の配列 Array1D〜Array4D は、中で new[] と delete[] を使っています。miso/include/miso/array1d.hpp の Array1D<T, backend::Host> より抜粋します。

Array1D(int nx0) : size_(nx0) {
  assert(size_ > 0);
  data_ = new T[size_];
}

~Array1D() { delete[] data_; }

1 つ目は配列を作るときに実行される部分(コンストラクタ)で、nx0 個の要素を確保します。2 つ目は配列の変数の寿命が終わるときに自動で実行される部分(デストラクタ)で、確保したメモリを解放します。 new と delete の組を配列の型の中に閉じ込めることで、使う側は delete を書かずに済み、std::vector と同じように寿命が終われば解放されます。この仕組み(RAII)は 第 4 章 で学びます。 GPU 用の Array1D<T, backend::CUDA> では、同じ場所で GPU のメモリを確保・解放しています(第 9 章)。

MISO 本体では std::unique_ptr は使っていません。std::shared_ptr も、GPU 用の縮約の作業領域を保持する miso/include/miso/mhd_resistivity.hpp の 1 か所だけです。メモリの確保は配列の型にまとめ、それ以外の場所は参照と View で受け渡しています。

7. 練習

練習 1 次のコードを順に実行した後の a, b, r, *p の値を書いてください。また、a を変えたのはどの行かを説明してください。

double a = 1.0;
double b = a;
double &r = a;
double *p = &a;
b = 2.0;
r = 3.0;
*p = 4.0;
p = &b;
*p = 5.0;

練習 2 miso/include/miso/boundary_condition.hpp の symmetric_index で、int &i_ghst を int i_ghst に変えると、1.3 節 の確認プログラムの出力がどうなるかを説明してください(実際に変更する必要はありません)。

練習 3 次の 3 つの関数の宣言を、引数を通して何ができるか(読む・書き換える・指す先を変える)の観点で説明してください。

void f1(const double *values, int n);
void f2(double *const values, int n);
void f3(const YAML::Node &defaults);

練習 4 次の関数の問題点を説明し、正しく直してください。

int *make_counts(int n) {
  int counts[100];
  for (int i = 0; i < n; ++i) {
    counts[i] = 0;
  }
  return counts;
}
解答・解説

練習 1 最後は a = 4.0, b = 5.0, r = 4.0, *p = 5.0 です。 b = a; で作った b は独立したコピーです。r = 3.0; で a が 3.0 に、*p = 4.0; で(p が a を指しているので)4.0 になります。 p = &b; で p は b を指すようになり、*p = 5.0; は b を変えます。r は a の別名のままなので 4.0 です。

練習 2 i_ghst が値渡しになるので、関数の中の代入はコピーにしか届きません。呼び出し元の i_ghst は初期値の -1 のままで、出力の i_ghst はどちらの行も -1 になります。i_trgt は参照のままなので、5 と 4 が表示されます。

練習 3 f1 は values の指す先を読むだけで書き換えられません(const が * の左)。関数の中で values 自身を別のアドレスに変えることはできます(呼び出し元には影響しません)。 f2 は指す先を書き換えられますが、values 自身を別のアドレスに変えられません(const が * の右)。 f3 は defaults をコピーせずに読むだけです。merge_yaml の 2 つ目の引数と同じです。

練習 4 counts は関数の中の配列なので、関数を抜けると寿命が終わります。返したアドレスはぶら下がりポインタになります(5 節)。また、n が 100 を超えると配列の外に書き込みます。 std::vector を値で返すように直します。

#include <vector>

std::vector<int> make_counts(int n) {
  std::vector<int> counts(n, 0);  // n elements, all 0
  return counts;
}

std::vector を値で返しても、要素が 1 つずつコピーされることは通常ありません(中身を移すだけで済むように最適化されます。ムーブは 第 4 章 で扱います)。

8. 記号のまとめ

書き方

種類

意味

T &r = a;

宣言

r は a の参照(別名)。付け替えられない

const T &r = a;

宣言

r を通しては読むだけの参照

T *p = &a;

宣言

p は T を指すポインタ。&a は a のアドレス

*p

式

p が指す先の値

p[i]

式

*(p + i)。p から i 個先の要素

const T *p

宣言

指す先を読むだけ。p 自身は付け替えられる

T *const p

宣言

p 自身を付け替えられない。指す先は書き換えられる

nullptr

値

どこも指していないポインタ

void f(T v)

引数

値渡し(コピー)

void f(T &v)

引数

参照渡し。呼び出し元を書き換えられる

void f(const T &v)

引数

コピーせずに読むだけ

ほかの記号(::, <>, -> など)も含めた一覧は 記号の早見表 にあります。 参照・ポインタの規則の詳細は、C++17 規格草案 N4659 の 参照、ポインタ、参照の初期化 で確認できます。