第18回

クラス間の相互参照

互いを知りたいクラスを、壊れない形で書く。

互いを知りたくなる場面

クラスが増えると、必ずこの形にぶつかる。RobotはControllerを部品として持っている。一方Controllerは、判断のためにRobot全体の状態を知りたい。

  Robot  ──── 持っている ────→  Controller
    ↑                                │
    └──────── 状態を聞きたい ─────────┘

素直に書くと、両方のヘッダが相手を#includeすることになる。

両方includeすると何が起きるか

Robot.hpp
#pragma once

#include "Controller.hpp"   // Controllerの中身が必要

class Robot {
    // ...
};
Controller.hpp
#pragma once

#include "Robot.hpp"        // Robotの中身が必要

class Controller {
    // ...
};

#pragma onceがあるので無限に展開されることはない。代わりに「片方が先に読まれ、もう片方はまだ存在しない」という状態でコンパイルが進む。

main.cpp が Robot.hpp を読む
   ↓
Robot.hpp が Controller.hpp を読む
   ↓
Controller.hpp が Robot.hpp を読む
   ↓
   #pragma once により2回目は読まれない
   ↓
Controller の定義に入る        ← このときまだ class Robot が無い
   ↓
error: 'Robot' has not been declared

このエラーは「includeを足せば直る」ように見えるため、足した結果さらに循環が深くなることが多い。include を増やす方向では解けない。

前方宣言

解決は逆方向である。Controllerが本当に必要なのは「Robotというクラスが存在する」という事実だけで、中身ではない。それだけを伝えるのが前方宣言である。

Controller.hpp
#pragma once

class Robot;  // 前方宣言:Robotというクラスがある、とだけ伝える

class Controller {
public:
    explicit Controller(Robot& robot);

    void update();

private:
    Robot& robot;
};

#include "Robot.hpp"が消えたので、循環が切れる。Robot.hpp側は今までどおり#include "Controller.hpp"してよい。

修正前                        修正後
──────                        ──────
Robot.hpp  ⇄  Controller.hpp   Robot.hpp  →  Controller.hpp
   (循環)                                      ↓
                                          class Robot; (前方宣言)

前方宣言でできること・できないこと

前方宣言されただけの型を不完全型という。大きさも中身も分からないので、できることは限られる。

書き方理由
Robot* robot;○ポインタの大きさは相手に依らない
Robot& robot;○同じ
void f(Robot& r);の宣言○宣言するだけなら中身は不要
Robot robot;×何バイト必要か分からない
robot.update();×そのメンバがあるか分からない
class X : public Robot×親の中身が分からない

中身を使うのは.cppで

Controller.cpp
#include "Controller.hpp"

#include "Robot.hpp"  // ここで初めてRobotの中身が必要になる

Controller::Controller(Robot& robot)
    : robot(robot) {
}

void Controller::update() {
    if (robot.isObstacleAhead()) {
        robot.stop();
    }
}

.cppは誰からもincludeされないので、ここで#include "Robot.hpp"しても循環は起きない。

所有関係を決める

相互参照を書く前に決めなければならないことがある。どちらが相手を所有しているかである。両方が所有するように書くと、どちらが先に消えるのか決まらなくなる。

向き関係書き方
Robot → Controller所有する(親→子)値のメンバ、またはstd::unique_ptr
Controller → Robot参照するだけ(子→親)Robot& または Robot*
Robot.hpp
#pragma once

#include "Controller.hpp"
#include "Motor.hpp"

class Robot {
public:
    Robot();

    void update();

    bool isObstacleAhead() const;
    void stop();

private:
    Motor motor;
    Controller controller;  // Robotが所有する
};
Robot.cpp
#include "Robot.hpp"

Robot::Robot()
    : motor(24), controller(*this) {  // 自分自身を渡す
}

void Robot::update() {
    controller.update();
}

shared_ptrで双方向に持つと解放されない

第17回のshared_ptrを、双方向にそのまま使うと静かにリークする。

// 良くない例
class Robot {
public:
    std::shared_ptr<Controller> controller;
};

class Controller {
public:
    std::shared_ptr<Robot> robot;  // 親を shared_ptr で持ってしまった
};
  Robot ───── shared_ptr ─────→ Controller
    ↑                                │
    └───────── shared_ptr ───────────┘

外から両方手を離しても、互いが相手のカウントを1に保つ
→ どちらのカウントも0にならない → どちらも解放されない

これを循環参照という。解決は所有関係のとおりに型を選ぶこと、つまり子から親への向きをweak_ptrにすることである。

class Controller {
public:
    void update();

private:
    std::weak_ptr<Robot> robot;  // 所有しない
};

void Controller::update() {
    if (std::shared_ptr<Robot> owner = robot.lock()) {
        if (owner->isObstacleAhead()) {
            owner->stop();
        }
    }
}

そもそも相互参照を減らす

前方宣言は相互参照を書けるようにする道具であって、相互参照を良いものにする道具ではない。互いを知っているクラスは片方だけを切り出して読むことができず、テストもしにくい。まず依存の向きを一方向に揃えられないかを考える。

案1:必要な情報だけ引数で渡す

// Controller は Robot を知らない。必要なのは距離と状態だけだった
class Controller {
public:
    RobotState decide(float distance, RobotState current);
};

ControllerからRobotへの参照が消え、Controller単体で動作を確かめられるようになる。相互参照の多くは、本当は「相手の一部が欲しいだけ」である。

案2:抽象クラスで向きを変える

どうしても相手に問い合わせたい場合は、第12回の抽象クラスを挟んで、依存の向きを変える。

ObstacleSource.hpp
#pragma once

class ObstacleSource {
public:
    virtual ~ObstacleSource() = default;

    virtual bool isObstacleAhead() const = 0;
};
class Controller {
public:
    explicit Controller(ObstacleSource& source);

private:
    ObstacleSource& source;  // Robotではなく「約束ごと」を知る
};

class Robot : public ObstacleSource {
public:
    bool isObstacleAhead() const override;
};
相互参照                      一方向
────────                      ──────
Robot ⇄ Controller            Robot ──→ Controller
                                 │          │
                                 ↓          ↓
                                ObstacleSource
                                (両方がこれを向く)

ControllerはRobotを知らなくなり、別のクラスを渡してテストできるようになる。

判断の順番

  1. 本当に相手全体が必要か。必要な値を引数で渡せないか
  2. 渡せないなら、抽象クラスを挟んで向きを一方向にできないか
  3. それでも相互に必要なら、前方宣言で書く。所有は片方だけに持たせる

動かして確かめる

演習に入る前に、まずこのサンプルを動かして結果を見る。追加の部品は要らない。光るのはボードに載っている内蔵LEDで、それ以外はシリアルモニタの出力で確かめられる。code/examples/18-relations/を開き、pio run -t uploadで書き込んでからpio device monitorで出力を見る(手順と、外付けのLEDなどをつなぐ場合は開発環境にまとめてある)。

code/examples/18-relations/
├── include/
│   ├── Robot.hpp        Controller.hpp を include する(所有する側)
│   ├── Controller.hpp   class Robot; の前方宣言だけ(参照する側)
│   └── Motor.hpp
└── src/
    ├── Robot.cpp
    ├── Controller.cpp   ここで Robot.hpp を include する
    ├── Motor.cpp
    └── main.cpp
code/examples/18-relations/include/Controller.hpp
// 第18回:相手を「指すだけ」なので、ヘッダでは前方宣言で足りる

#pragma once

class Robot;  // 前方宣言:Robotというクラスがある、とだけ伝える

class Controller {
public:
    /**
     * @brief コンストラクタ
     * @param robot 判断のときに状態を聞く相手
     * @note 受け取った参照は保存するだけ。ここでは使わない
     *       (Robotのメンバがまだ初期化されていない)。
     */
    explicit Controller(Robot& robot);

    /**
     * @brief 状態を見て、止めるかどうかを決める
     */
    void update();

private:
    Robot& robot;  // 参照するだけ。所有はしない
};
code/examples/18-relations/src/Controller.cpp
#include <Arduino.h>

#include "Controller.hpp"

#include "Robot.hpp"  // 中身を使うのはここから。.cppは誰からもincludeされないので循環しない

Controller::Controller(Robot& robot)
    : robot(robot) {
    // ここで robot.isObstacleAhead() を呼んではいけない。
    // Robot のメンバはまだ初期化されていない。
}

void Controller::update() {
    if (robot.isObstacleAhead()) {
        Serial.println("Controller: obstacle -> stop");
        robot.stop();
    } else {
        Serial.println("Controller: clear -> go");
        robot.go();
    }
}
シリアルモニタの出力
distance=350.00
Controller: clear -> go
  motor speed=60

distance=200.00
Controller: clear -> go
  motor speed=60

distance=150.00
Controller: clear -> go
  motor speed=60

distance=100.00
Controller: obstacle -> stop
  motor speed=0

確かめること。

  • Controller.hppに#include "Robot.hpp"がない。class Robot;の1行で足りている
  • Controller.hppの前方宣言を#include "Robot.hpp"に書き換えるとビルドが止まり、'Robot' does not name a typeと出る。一度自分で出しておく
  • 距離が150mmを切ったところでobstacle -> stopに変わる。判断はController、状態はRobotが持っている
  • Controllerのコンストラクタでrobot.isObstacleAhead()を呼ぶと何が起きるかを考える(Robotのメンバはまだ初期化されていない)
  • ControllerがRobotから取っているのは「障害物があるか」だけ。引数で渡す形にすれば相互参照が消せないかを考える

演習:RobotとControllerの相互参照を解く

  • Robot.hppとController.hppが互いを#includeするコードを書き、出るエラーを読む
  • Controller.hppを前方宣言に直し、Controller.cppでRobot.hppをincludeして通す
  • 所有はRobot側だけに持たせ、ControllerはRobot&を保存するだけにする
  • さらに、必要な値だけを引数で渡す形に書き換え、相互参照そのものを消せるかを試す