0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C++の戻り値、コピー・参照・ポインタは何が違う?

0
Posted at

C++で関数を書いていると、戻り値をTにするか、T&にするか、T*にするかで迷うことがあります。

std::vector<double> calculate();
std::vector<double>& getData();
double* findSensor(int index);

どれも何らかの値を呼び出し側へ渡していますが、意味は同じではありません。

先に結論を書くと、私は次のように考えると整理しやすくなりました。

戻り値 何を返すか 向いている場面
T 新しい値 計算結果を返す
T& すでに存在するオブジェクト 必ず対象があり、コピーせず参照させたい
const T& すでに存在する読み取り専用のオブジェクト コピーせず読ませたいが、変更はさせたくない
T* すでに存在するオブジェクトの場所、またはnullptr 対象が見つからない場合がある

迷ったときは、まずTで返せないかを考えます。T&やT*は、返した先でも元のオブジェクトの寿命を気にする必要があるからです。

この記事では、制御計算のデータを簡略化した例を使って、それぞれの違いを確認します。

コード例はC++20を前提にしています。

T:計算結果を新しい値として返す

入力されたPVからMVを計算し、新しいstd::vector<double>を返す関数を考えます。

#include <span>
#include <stdexcept>
#include <vector>

constexpr double min_pv = 0.0;
constexpr double max_pv = 10000.0;
constexpr double sv = 0.3;

std::vector<double> calculateMv(
    std::span<const double> pv,
    double kp)
{
    if (pv.empty())
    {
        throw std::invalid_argument("pv must not be empty");
    }

    std::vector<double> mv;
    mv.reserve(pv.size());

    for (double value : pv)
    {
        const double normalized =
            (value - min_pv) / (max_pv - min_pv);

        mv.push_back(kp * (normalized - sv));
    }

    return mv;
}

この関数が返すのは、関数内で作った新しいmvです。呼び出し側は、自分のstd::vector<double>として結果を受け取れます。

std::vector<double> pv = {1000.0, 2000.0, 3000.0};
std::vector<double> mv = calculateMv(pv, 2.0);

std::vectorを値で返すと、大きなコピーが毎回発生しそうに見えます。しかし、戻り値のコピーはコンパイラによって省略される場合があり、省略されなくてもムーブが使われる場合があります。そのため、計算結果として新しいオブジェクトを返したいなら、まず値で返す書き方を選べます。

ここでreturn std::move(mv);と書く必要はありません。名前付きのローカル変数をそのままreturnすればよく、明示的なstd::moveは戻り値最適化を妨げることがあります。

引数のstd::span<const double>は何をしているのか

この例では、引数にstd::span<const double>を使いました。

std::spanは、連続して並んでいるデータの範囲を参照するための型です。データそのものは所有せず、コピーもしません。const doubleなので、この関数から元の要素を変更することもできません。

そのため、std::vectorとC配列のどちらも同じ関数へ渡せます。

std::vector<double> vectorData = {10.0, 20.0, 30.0};
double arrayData[] = {40.0, 50.0, 60.0};

calculateMv(vectorData, 2.0);
calculateMv(arrayData, 2.0);

std::spanは戻り値の話ではありませんが、「データを所有せずに参照する」という点は、これから出てくる参照やポインタにも共通します。

T&:元のオブジェクトそのものを返す

次は、クラスが持っている配列の要素を参照で返します。

#include <array>
#include <cstddef>

class Controller
{
private:
    std::array<double, 100> sensorValues{};

public:
    double& operator[](std::size_t index)
    {
        return sensorValues.at(index);
    }
};

operator[]の戻り値はdouble&です。これは数値のコピーではなく、sensorValuesの要素そのものを返しています。

Controller controller;

controller[0] = 100.0;

double& value = controller[0];
value = 200.0;

valueは別のdoubleではありません。controller[0]の別名です。そのため、valueへ代入すると、クラス内部の要素も変わります。

std::cout << controller[0] << '\n';  // 200

一方、受け取り側で&を付けなければコピーになります。

double copiedValue = controller[0];
copiedValue = 300.0;

std::cout << controller[0] << '\n';  // 200のまま

関数が参照を返していても、呼び出し側が必ず参照として受け取るわけではありません。受け取り側の型がdoubleなら値がコピーされ、double&なら参照として受け取ります。

const T&:コピーせずに読ませる

クラス内部のログを外から読みたいものの、変更はさせたくない場合はconst T&を返せます。

#include <vector>

class Controller
{
private:
    std::vector<double> logData = {1.0, 2.0, 3.0};

public:
    const std::vector<double>& getLogData() const
    {
        return logData;
    }
};

この宣言にはconstが2つあります。

const std::vector<double>& getLogData() const

先頭のconstは、返したstd::vectorをこの参照経由で変更できない、という意味です。

末尾のconstは、このメンバー関数内でController自身を変更しない、という意味です。

呼び出し側でもコピーを避けたいなら、参照で受け取ります。

Controller controller;

const std::vector<double>& log = controller.getLogData();

ここは、最初に私が混乱したところでした。関数側がconst std::vector<double>&を返していても、次のように受け取ればコピーになります。

std::vector<double> log = controller.getLogData();

autoを使った場合も同じです。

auto log1 = controller.getLogData();         // コピー
const auto& log2 = controller.getLogData();  // 参照

「関数が参照を返すこと」と「呼び出し側が参照で受け取ること」は別々に決まります。

const T&を返せば常に安全、ではない

参照先はControllerのメンバーなので、参照を使っている間は元のControllerが生きている必要があります。

const std::vector<double>& bad()
{
    Controller controller;
    return controller.getLogData();
}

この関数が終了すると、ローカル変数のcontrollerは破棄されます。戻された参照は、すでに存在しないオブジェクトを指すダングリング参照になります。

参照を返すときは、「参照先が呼び出し側で使われている間も生きているか」を確認する必要があります。

T*:見つからない場合をnullptrで表す

ポインタは、対象が存在しない場合を表したいときに使えます。

#include <array>
#include <cstddef>

class Controller
{
private:
    std::array<double, 100> sensorValues{};

public:
    double* findSensor(std::size_t index)
    {
        if (index >= sensorValues.size())
        {
            return nullptr;
        }

        return &sensorValues[index];
    }
};

有効な位置なら要素のアドレスを返し、範囲外ならnullptrを返します。

Controller controller;

if (double* sensor = controller.findSensor(0))
{
    *sensor = 50.0;
    std::cout << *sensor << '\n';
}

このdouble*は、クラス内部の要素を一時的に参照するためのポインタです。呼び出し側がdeleteやfreeをしてはいけません。要素を所有しているのは、あくまでControllerだからです。

参照との違いは、nullptrを返せることです。

  • 必ず対象が存在するならT&
  • 対象が存在しない場合もあるならT*

この区別にすると、関数の宣言を見ただけで使い方を想像しやすくなります。

mallocした領域をT*で返すと何が難しいのか

次のように、関数内でメモリを確保して先頭アドレスを返すことも、文法上は可能です。

#include <cstdlib>

double* createValues(std::size_t size)
{
    return static_cast<double*>(
        std::malloc(sizeof(double) * size)
    );
}

しかし、この宣言だけを見ても、呼び出し側には次のことが分かりません。

  • 誰がメモリを解放するのか
  • delete[]とfreeのどちらを使うのか
  • 要素数はいくつなのか
  • nullptrは「確保失敗」なのか、「結果なし」なのか

呼び出し側がfreeを忘れれば、メモリリークになります。

double* values = createValues(100);

// 使用後に必要
std::free(values);

計算結果の配列を返すだけなら、通常はstd::vector<double>を値で返した方が、所有者と要素数が明確です。

std::vector<double> createValues(std::size_t size)
{
    return std::vector<double>(size);
}

動的に作ったオブジェクトの所有権をどうしてもポインタで渡す必要がある場合は、裸のT*ではなくstd::unique_ptr<T>などを検討します。

裸のT*は、「所有権を渡すもの」ではなく、「所有者は別にいて、その場所を一時的に指すもの」と考えると混乱が減ります。

やってはいけない例:ローカル変数の参照やポインタを返す

関数内のローカル変数は、関数を抜けると破棄されます。その参照やアドレスを返してはいけません。

double& badReference()
{
    double value = 10.0;
    return value;
}

double* badPointer()
{
    double value = 10.0;
    return &value;
}

どちらも、関数を出た時点で参照先がなくなります。コンパイラが警告してくれることもありますが、書かないことが大切です。

一方、Tで値を返す場合は問題ありません。

double goodValue()
{
    double value = 10.0;
    return value;
}

valueの中身が戻り値へ渡されるため、ローカル変数の寿命には依存しません。

迷ったときの決め方

私は次の順番で考えるようにしています。

1. 計算して作った結果か

新しい結果ならTで返します。

std::vector<double> calculateMv(...);

2. すでに存在するものを返すのか

コピーせずに元のオブジェクトを返し、必ず対象が存在するならT&です。変更させたくないならconst T&にします。

double& operator[](std::size_t index);
const std::vector<double>& getLogData() const;

3. 「見つからない」があるか

対象がない場合にnullptrを返したいならT*を使えます。

double* findSensor(std::size_t index);

4. 参照先の寿命は十分か

T&とT*では、参照先が先に破棄されないことを確認します。ローカル変数の参照やアドレスは返しません。

まとめ

T、T&、T*は、単なる書き方の違いではありません。戻り値を誰が持つのか、対象が存在しない場合があるのか、元の値を変更できるのかを表しています。

  • Tは、新しい値を返す
  • T&は、必ず存在する元のオブジェクトを返す
  • const T&は、元のオブジェクトを読み取り専用で返す
  • T*は、元のオブジェクトの場所、またはnullptrを返す

特別な理由がなければ、まず値で返す方法を考える。参照やポインタを返すなら、参照先の寿命と所有者を確認する。この2点を意識するだけでも、戻り値の型をかなり選びやすくなりました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?