この記事は、大学のロボット開発サークルの新入生に向けて作成した資料を、Qiita向けに加筆・修正したものとなっています。
はじめに
プログラムを書いていると、「似たような処理を何度も書いている」「コードが長くなってきて何がどこにあるか分からなくなってきた」という経験をすることがあります。
オブジェクト指向(Object-Oriented Programming, OOP)は、こういった問題を解決するためのプログラミングの考え方です。「データ」と「それを操作する処理」をひとつのまとまり、オブジェクトとして扱うことで、コードを整理し、再利用しやすくします。
この記事では、OOPの基礎について解説しながら、最後にロボット開発におけるOOPの注意点について説明します。
1. クラスとインスタンス
クラスとは何か
OOPを理解するうえで最初に押さえるべきは、クラスとインスタンスについてです。
クラスとは、データと処理をまとめた設計図のようなものです。そしてインスタンスとは、その設計図をもとに実際に作られた実体です。
例として、ロボットのモータを表すクラスを考えてみましょう。
#include <iostream>
#include <string>
// クラスの定義 = 設計図
class Motor {
public:
std::string name;
double rpm; // 回転数
void rotate() {
std::cout << name << " が " << rpm << " rpm で回転中\n";
}
};
int main() {
// インスタンスの生成 = 設計図から実体を作る
Motor left_motor;
left_motor.name = "左モーター";
left_motor.rpm = 300.0;
Motor right_motor;
right_motor.name = "右モーター";
right_motor.rpm = 300.0;
left_motor.rotate(); // 左モーター が 300 rpm で回転中
right_motor.rotate(); // 右モーター が 300 rpm で回転中
}
Motor という設計図から、left_motor と right_motor という2つの実体を作りました。それぞれが独立した name と rpm を持っています。
クラスに定義された変数のことをメンバ変数(フィールド)、関数のことをメンバ関数(メソッド)と呼びます。
コンストラクタ
上の例では、インスタンスを作った後に name や rpm を手動で設定していました。これをコンストラクタを使ってまとめることができます。コンストラクタとは、インスタンスが生成されるときに自動的に呼ばれるメソッドです。
#include <iostream>
#include <string>
class Motor {
public:
std::string name;
double rpm;
// コンストラクタ: クラス名と同じ名前、戻り値なし
Motor(std::string name, double rpm) : name(name), rpm(rpm) {
std::cout << name << " を初期化しました\n";
}
void rotate() {
std::cout << name << " が " << rpm << " rpm で回転中\n";
}
};
int main() {
Motor left_motor("左モーター", 300.0); // 左モーター を初期化しました
Motor right_motor("右モーター", 300.0); // 右モーター を初期化しました
left_motor.rotate(); // 左モーター が 300 rpm で回転中
right_motor.rotate(); // 右モーター が 300 rpm で回転中
}
: name(name), rpm(rpm) の部分はメンバ初期化子リストと呼ばれ、メンバ変数を初期化するC++の書き方です。
2. カプセル化と隠蔽
カプセル化とは
先ほどの Motor クラスでは、name や rpm などのメンバ変数に外部から直接アクセスできます。これは一見便利そうですが、問題があります。
Motor left_motor("左モーター", 300.0);
// 外部から直接書き換えられてしまう
left_motor.rpm = -99999.0; // モーターの許容範囲を無視した値を代入できてしまう
ハードウェアを操作するコードでこれが起きると、モーターを壊してしまうかもしれません。
カプセル化とは、クラスの内部データと、それを操作する処理をひとつにまとめ、外部から直接データを触れないようにする考え方です。private キーワードを使って、メンバ変数へのアクセスを制限します。
#include <iostream>
#include <string>
#include <stdexcept>
class Motor {
private:
// private: クラスの外から直接アクセスできない
std::string name_;
double rpm_;
static constexpr double MAX_RPM = 500.0;
public:
// public: 外部からアクセスできる
Motor(std::string name, double rpm) : name_(name), rpm_(0.0) {
setRpm(rpm); // コンストラクタでもsetRpmを通して初期化
}
// rpmを変更したいときはこのメソッドを通じて行う
void setRpm(double rpm) {
if (rpm < 0.0 || rpm > MAX_RPM) {
throw std::out_of_range(name_ + ": rpmが範囲外です: " + std::to_string(rpm));
}
rpm_ = rpm;
}
double getRpm() const {
return rpm_;
}
void rotate() const {
std::cout << name_ << " が " << rpm_ << " rpm で回転中\n";
}
};
int main() {
Motor left_motor("左モーター", 300.0);
left_motor.setRpm(400.0); // OK
// left_motor.rpm_ = -99999.0; // コンパイルエラー! privateなのでアクセス不可
left_motor.setRpm(-99999.0); // 例外が投げられる: 左モーター: rpmが範囲外です: -99999.000000
}
これで不正な値の代入を防ぐことができます。
カプセル化のメリット
カプセル化によってデータを隠すことには、もうひとつ大きなメリットがあります。外部のインターフェースを変えずに、内部の実装を自由に変更できるということです。
例として、モーターの回転数を管理するクラスを考えます。最初は rpm_ をそのまま double で保存していたとします。
// 変更前: rpm をそのまま保存
class Motor {
private:
double rpm_;
public:
void setRpm(double rpm) { rpm_ = rpm; }
double getRpm() const { return rpm_; }
};
後になって、制御計算の都合で内部では角速度 [rad/s] で保存したいと思ったとします。カプセル化されていれば、setRpm と getRpm の中身だけを書き換えれば済みます。
#include <cmath>
// 変更後: 内部では角速度[rad/s]で保存するように変更
class Motor {
private:
double angular_velocity_; // 内部表現を rpm → rad/s に変更
static constexpr double RPM_TO_RADS = 2.0 * M_PI / 60.0;
public:
// インターフェースは rpm のまま変わらない
void setRpm(double rpm) { angular_velocity_ = rpm * RPM_TO_RADS; }
double getRpm() const { return angular_velocity_ / RPM_TO_RADS; }
};
外部のコードは setRpm(300.0) や getRpm() を呼んでいるだけで、内部が rpm で保存されているのか rad/s で保存されているのかを知りません。そのため、外部のコードを一行も変更せずに内部の実装を切り替えられます。
一方で、もとのメンバ変数rpm_を外部から直接アクセスさせていた場合、内部表現を変えた瞬間に、その変数を参照しているすべての箇所を探し出して修正しなければなりません。
隠蔽
複雑な内部処理を隠して、シンプルなインターフェイスだけを外部に見せることを隠蔽といいます。
例として、ロボットの起動シーケンスを考えてみましょう。ロボットを安全に起動するためには、センサーの初期化・キャリブレーション・通信確立・自己診断など、多くの処理を正しい順番で行う必要があります。
#include <iostream>
#include <stdexcept>
class Robot {
private:
bool imu_ready_ = false;
bool lidar_ready_ = false;
bool motors_ready_ = false;
// 各初期化処理は private に隠蔽する
void initImu() {
// ...IMUの初期化・キャリブレーション...
imu_ready_ = true;
std::cout << " IMU: キャリブレーション完了\n";
}
void initLidar() {
// ...LiDARの接続確認・スキャン開始...
lidar_ready_ = true;
std::cout << " LiDAR: スキャン開始\n";
}
void initMotors() {
// ...モータードライバの確認・原点復帰...
motors_ready_ = true;
std::cout << " モーター: 原点復帰完了\n";
}
void selfCheck() {
if (!imu_ready_ || !lidar_ready_ || !motors_ready_) {
throw std::runtime_error("自己診断失敗: 初期化されていないデバイスがあります");
}
std::cout << " 自己診断: 全デバイス正常\n";
}
public:
// 外部からはこれ一つを呼ぶだけでいい
void start() {
std::cout << "ロボット起動シーケンス開始\n";
initImu();
initLidar();
initMotors();
selfCheck();
std::cout << "起動完了\n";
}
};
int main() {
Robot robot;
robot.start(); // 内部の複雑さを一切知らなくてよい
}
出力
ロボット起動シーケンス開始
IMU: キャリブレーション完了
LiDAR: スキャン開始
モーター: 原点復帰完了
自己診断: 全デバイス正常
起動完了
robot.start() を呼ぶだけで、初期化の順序も自己診断のロジックも何も知らずに安全にロボットを起動できます。各デバイスの初期化処理は private に隠蔽されているため、外部から誤った順番で呼び出されたり、自己診断を飛ばされたりする心配もありません。
車のエンジンをかけるときだって、いちいちボンネットを開けて手を突っ込むのは億劫ですし、非常に危険です。
内部を直接触らせないということが、カプセル化と隠蔽の本質です。
3. 継承
共通部分を再利用する
ロボットにはIMU、カメラ、LiDARなど、様々なセンサーが搭載されています。それぞれのセンサークラスを実装するとき、「センサー名を持つ」「データを取得する」「センサーを起動・停止する」といった共通の処理は毎回書きたくありません。
継承を使うと、共通の処理を親クラス(基底クラス)にまとめ、子クラス(派生クラス)がそれを引き継ぐことができます。
#include <iostream>
#include <string>
// 基底クラス: すべてのセンサーに共通する処理をまとめる
class Sensor {
protected:
// protected: このクラスと子クラスからのみアクセス可能
std::string name_;
bool is_active_;
public:
Sensor(std::string name) : name_(name), is_active_(false) {}
virtual ~Sensor() = default; // 仮想デストラクタ(後述)
void start() {
is_active_ = true;
std::cout << name_ << " を起動しました\n";
}
void stop() {
is_active_ = false;
std::cout << name_ << " を停止しました\n";
}
std::string getName() const { return name_; }
bool isActive() const { return is_active_; }
};
// 派生クラス: Sensorを継承し、IMU固有の処理を追加する
class ImuSensor : public Sensor {
private:
double angular_velocity_; // 角速度
public:
ImuSensor(std::string name) : Sensor(name), angular_velocity_(0.0) {}
double getAngularVelocity() const { return angular_velocity_; }
// IMU固有の処理
void update(double angular_velocity) {
if (!is_active_) return;
angular_velocity_ = angular_velocity;
}
};
// 派生クラス: Sensorを継承し、LiDAR固有の処理を追加する
class LidarSensor : public Sensor {
private:
double range_; // 測距値
public:
LidarSensor(std::string name) : Sensor(name), range_(0.0) {}
double getRange() const { return range_; }
// LiDAR固有の処理
void scan(double range) {
if (!is_active_) return;
range_ = range;
}
};
int main() {
ImuSensor imu("IMU");
LidarSensor lidar("LiDAR");
// Sensorクラスで定義したstart/stopがそのまま使える
imu.start(); // IMU を起動しました
lidar.start(); // LiDAR を起動しました
imu.update(0.5);
lidar.scan(1.2);
std::cout << "角速度: " << imu.getAngularVelocity() << "\n"; // 角速度: 0.5
std::cout << "距離: " << lidar.getRange() << "\n"; // 距離: 1.2
}
start() や stop() を2回書かずに済みました。基底クラスの処理を変更すれば、すべての派生クラスに反映されます。
便利な機能ですが、「継承を使うな」と言われることもあるほど厄介なものでもあるので、使用には注意です。
Googleで「オブジェクト指向 継承」と調べると、トップに「継承を使うな - ノートの端の書き残し」が出てくるほどです。
また、実装の共通化を無理に進めてしまうと、後々の修正で大変なことになるので注意です。
参考: 単一責任原則で無責任な多目的クラスを爆殺する #設計 - Qiita
4. 抽象クラスとインターフェース
共通のメソッドを定義する
継承の節では ImuSensor と LidarSensor がそれぞれ start() や stop() を共通で持てるようになりました。次に、「ログを出力する」という処理を考えてみましょう。
IMU、LiDAR、モーターそれぞれがどんなログを出力するかは異なります。しかし「ログを出力するものはすべて .log() で呼び出せる」という約束を決めておけば、呼び出す側はその中身を気にしなくて済みます。
こういったとき、抽象クラス(インターフェース)を定義します。抽象クラスとは、 「このクラスを継承するなら、このメソッドは必ず実装してね」という約束を定義したクラスです。C++では virtual と = 0 を組み合わせた純粋仮想関数で表現します。
#include <iostream>
#include <string>
#include <vector>
#include <memory>
// 抽象クラス: log()という共通インターフェースを約束する
class Logger {
public:
// 純粋仮想関数: 派生クラスで必ず実装しなければならない
virtual void log() const = 0;
virtual ~Logger() = default; // 仮想デストラクタ(後述)
};
// IMUのログ出力
class ImuLogger : public Logger {
private:
double angular_velocity_; // 角速度 [rad/s]
double acceleration_; // 加速度 [m/s²]
public:
ImuLogger(double angular_velocity, double acceleration)
: angular_velocity_(angular_velocity), acceleration_(acceleration) {}
// log()の実装を提供する(必須)
void log() const override {
std::cout << "[IMU] 角速度: " << angular_velocity_ << " rad/s"
<< " 加速度: " << acceleration_ << " m/s²\n";
}
};
// LiDARのログ出力
class LidarLogger : public Logger {
private:
double range_; // 測距値 [m]
double angle_; // 測定角度 [deg]
public:
LidarLogger(double range, double angle)
: range_(range), angle_(angle) {}
void log() const override {
std::cout << "[LiDAR] 距離: " << range_ << " m"
<< " 角度: " << angle_ << " deg\n";
}
};
// モーターのログ出力
class MotorLogger : public Logger {
private:
double rpm_; // 回転数 [rpm]
double voltage_; // 印加電圧 [V]
public:
MotorLogger(double rpm, double voltage)
: rpm_(rpm), voltage_(voltage) {}
void log() const override {
std::cout << "[Motor] 回転数: " << rpm_ << " rpm"
<< " 電圧: " << voltage_ << " V\n";
}
};
int main() {
// Logger*型で、種類の違うLoggerをまとめて管理できる
std::vector<std::unique_ptr<Logger>> loggers;
loggers.push_back(std::make_unique<ImuLogger>(0.52, 9.81));
loggers.push_back(std::make_unique<LidarLogger>(1.24, 45.0));
loggers.push_back(std::make_unique<MotorLogger>(300.0, 12.0));
// 種類を気にせず、共通のlog()ですべて出力できる
for (const auto& logger : loggers) {
logger->log();
}
}
出力
[IMU] 角速度: 0.52 rad/s 加速度: 9.81 m/s²
[LiDAR] 距離: 1.24 m 角度: 45 deg
[Motor] 回転数: 300 rpm 電圧: 12 V
log() という共通のメソッドの実装を約束することで、呼び出す側は「何のロガーか」を一切知らなくてもすべてのログを出力できます。この仕組みが、次章のポリモーフィズムの土台になります。
仮想デストラクタについて
Logger* 型のポインターで派生クラスのインスタンスを管理するとき、virtual ~Logger() = default; と仮想デストラクタを定義していないと、delete 時に派生クラスのデストラクタが呼ばれないことがあります。メモリリークやリソースの解放漏れに繋がるため、継承を使うクラスでは仮想デストラクタを忘れずに定義してください。
5. 多態性(ポリモーフィズム)
同じインターフェース、異なる振る舞い
抽象クラスで定義した「共通の約束」があると、呼び出す側は実体が何であるかを知らなくても、同じ書き方で呼び出せるという状況が生まれます。これがポリモーフィズムです。
数学の演算を例に理解する
ポリモーフィズムを理解するのに最適な例として、数学のノルムを考えてみましょう。スカラー・ベクトル・行列ではそれぞれ定義が異なりますが、どれも大きさを求める操作として扱えます。
まず、「ノルムを持つ数学のオブジェクト」を表す抽象クラスを定義します。
#include <iostream>
#include <cmath>
#include <vector>
#include <memory>
// 抽象クラス
class MathObject {
public:
virtual double norm() const = 0; // 大きさを返す
virtual void print() const = 0; // 中身を表示する
virtual ~MathObject() = default;
};
次に、スカラーを実装します。スカラーのノルムは絶対値です。
class Scalar : public MathObject {
private:
double value_;
public:
Scalar(double value) : value_(value) {}
double norm() const override {
return std::abs(value_); // スカラーのノルム = 絶対値
}
void print() const override {
std::cout << "Scalar(" << value_ << ")\n";
}
};
続いて、2次元ベクトルを実装します。ベクトルのノルムはユークリッド距離です。
class Vector2D : public MathObject {
private:
double x_, y_;
public:
Vector2D(double x, double y) : x_(x), y_(y) {}
double norm() const override {
return std::sqrt(x_*x_ + y_*y_); // ベクトルのノルム = √(x²+y²)
}
void print() const override {
std::cout << "Vector2D(" << x_ << ", " << y_ << ")\n";
}
};
そして、2×2行列を実装します。行列のノルムはフロベニウスノルム(全要素の二乗和の平方根)です。
class Matrix2x2 : public MathObject {
private:
// [a, b]
// [c, d]
double a_, b_, c_, d_;
public:
Matrix2x2(double a, double b, double c, double d)
: a_(a), b_(b), c_(c), d_(d) {}
double norm() const override {
return std::sqrt(a_*a_ + b_*b_ + c_*c_ + d_*d_); // フロベニウスノルム
}
void print() const override {
std::cout << "Matrix2x2([" << a_ << ", " << b_ << "; "
<< c_ << ", " << d_ << "])\n";
}
};
そして、これらを同じインターフェースで扱うコードです。
int main() {
std::vector<std::unique_ptr<MathObject>> objects;
objects.push_back(std::make_unique<Scalar>(-3.0));
objects.push_back(std::make_unique<Vector2D>(3.0, 4.0));
objects.push_back(std::make_unique<Matrix2x2>(1, 2, 3, 4));
for (const auto& obj : objects) {
obj->print();
std::cout << " norm = " << obj->norm() << "\n"; // 呼び出す側は実体を知らなくていい
}
}
出力
Scalar(-3)
norm = 3
Vector2D(3, 4)
norm = 5
Matrix2x2([1, 2; 3, 4])
norm = 5.47723
obj->norm() という一行のコードが、スカラーなら絶対値を、ベクトルならユークリッド距離を、行列ならフロベニウスノルムを計算しています。呼び出す側は実体が何であるかを一切知らなくて済んでいます。これがポリモーフィズムです。
呼び出し側: obj->norm() という同じ書き方で呼び出す
↓
実行時に決まる: Scalar::norm() → 絶対値
Vector2D::norm() → ユークリッド距離
Matrix2x2::norm() → フロベニウスノルム
なぜこれが便利なのか
このように「呼び出す側が実体を知らなくていい」という性質があると、新しい数学オブジェクト(四元数など)を追加したくなったときに、MathObjectを継承してnorm() を実装した新しいクラスを作るだけで済みます。呼び出す側のコードには一切手を加える必要がありません。
例えば、以下のように「ノルムが最も大きいオブジェクトを探す」関数の引数にMathObjectの配列を割り当てておけば、「ノルムを計算できる」という抽象クラスによる約束によって、四元数などを追加しても実装を変更する必要が全くありません。
const MathObject* findMaxNormObject(const std::vector<std::unique_ptr<MathObject>>& objects) {
if (objects.empty()) {
return nullptr;
}
const MathObject* max_obj = objects.front().get();
double max_norm = max_obj->norm();
for (const auto& obj : objects) {
double current_norm = obj->norm();
if (current_norm > max_norm) {
max_norm = current_norm;
max_obj = obj.get();
}
}
return max_obj;
}
ロガーの例でも同じです。新しい種類のログ出力を追加したくなったとき、Logger を継承して log() を実装した新しいクラスを作るだけで、既存のログ出力コードはそのまま動き続けます。
6. ロボット開発におけるOOPの注意点
ここまでOOPの良い面を紹介してきましたが、ロボット開発では「OOPを正しく使う」ことと「OOPをうまくデバッグできる」ことの間に、思わぬ落とし穴が潜んでいます。
隠蔽しすぎるとデバッグで苦労する
カプセル化は非常に有効な手法ですが、隠蔽しすぎると「今何が起きているか」が見えにくくなります。
class MotorController {
private:
double target_rpm_;
double current_rpm_;
double voltage_; // 外部から見えない
bool is_fault_; // 外部から見えない
public:
void setTargetRpm(double rpm) { target_rpm_ = rpm; }
double getCurrentRpm() const { return current_rpm_; }
void update() {
// ...複雑な制御計算...
}
};
ロボットが思い通りに動かないとき、「voltage_ がどんな値になっているか」「なぜ is_fault_ が立ったのか」を確認しようとしても、全部 private で見えません。デバッグのたびにコードを書き直してビルドしなおす羽目になります。
一つの対策として、デバッグ用の状態出力メソッドを用意しておくことが有効です。
class MotorController {
private:
double target_rpm_;
double current_rpm_;
double voltage_;
bool is_fault_;
public:
// ...通常の操作用メソッド...
// デバッグ用: 内部状態をすべて出力する
void printDebugInfo() const {
std::cout << "[MotorController Debug]\n"
<< " target_rpm: " << target_rpm_ << "\n"
<< " current_rpm: " << current_rpm_ << "\n"
<< " voltage: " << voltage_ << "\n"
<< " is_fault: " << is_fault_ << "\n";
}
};
あるいは、いくつかの内部状態については protected にして派生クラスから参照できるようにしておく、という設計も選択肢の一つです。「何を隠すか」は慎重に判断する必要があります。
リアルタイム性との兼ね合い
ロボットの制御ループは、数十〜数百Hzで繰り返し実行されます。OOPの抽象化レイヤーが増えるほど、仮想関数の呼び出しコスト(vtable経由のポインタ解決)が積み重なります。
通常のアプリケーション開発では無視できる程度のオーバーヘッドですが、制御ループ内で何千回もの仮想関数呼び出しが毎サイクル発生するような設計になっていると、ループ周期に影響が出ることがあります。
制御周期が重要な場面では、仮想関数を使わないことや、テンプレートで静的ポリモーフィズムを使うといった選択肢も検討する必要があります。ただしこれは最初から気にしすぎる必要はなく、実際に遅いと確認されてから対処することが大切です。
汚いコードでも動けばOK
「オブジェクト指向を理解して綺麗なコードを書こう」というテーマが台無しですが、限られた時間で動くロボットを作り上げないといけないという制約上、しょうがないこともあります。
もちろん、普段はOKではありません。
OOPを学ぶと「共通化できそう」「抽象化できそう」という感覚が出てきます。しかし、ロボット開発では「綺麗に共通化された動かないコード」より「冗長でも動くコード」の方がずっと価値があります。
// 共通化しようとして複雑になったコード
class AbstractBehavior { /* ... */ };
class SharedLogicMixin { /* ... */ };
class ConcreteTask : public AbstractBehavior, SharedLogicMixin { /* ... */ }; // ...なぜか動かない...
// 冗長だが動くコード
void doTaskA() {
// A専用の処理を明示的に書く
}
void doTaskB() {
// B専用の処理を明示的に書く (Aとほぼ同じでも気にしない)
}
ロボット開発では、「センサー取得 -> 状態推定 -> 制御入力計算」という一連の流れが複雑に絡み合います。そこに無理な抽象化を持ち込むと、バグが起きたときに「どの層で何が起きているか」の追跡が一気に難しくなります。
共通化・継承は便利な道具ですが、 「コピーして明示的に書く」という愚直な選択をすることも、ロボット開発では重要かもしれません。
まとめ
| 章 | 要点 |
|---|---|
| 1章 | クラスは設計図、インスタンスはその実体。メンバ変数・メンバ関数でデータと処理をまとめる |
| 2章 | カプセル化でデータと処理をひとまとめにし、隠蔽で外部からの不正な操作を防ぐ |
| 3章 | 継承で共通処理を基底クラスにまとめ、派生クラスで再利用する |
| 4章 | 抽象クラスで「このメソッドは必ず実装する」という共通の約束を定義する |
| 5章 | ポリモーフィズムで、呼び出す側が実体を知らなくても同じインターフェースで呼び出せる |
| 6章 | 隠蔽しすぎ・継承しすぎはデバッグを困難にする。ロボット開発では「動くこと」を最優先に |
OOPは、うまく使えば大きなコードベースを整理する強力な道具となります。しかし道具は使い方次第です。特にロボット開発では、「綺麗な設計」と「デバッグしやすい設計」はが両立しないこともあります。ぜひ実際にコードを書きながら、そのバランス感覚を磨いていってください。