第17回
スマートポインタ
「誰が解放するのか」を、コメントではなく型で表す。
誰がdeleteするのか
Sensor* sensor = new IMU();
sensor->update();
delete sensor;newで確保したものはdeleteで返す。単純な規則だが、現場では次の形で破綻する。
| 失敗 | 何が起きるか |
|---|---|
deleteを書き忘れる | メモリリーク。動き続けるうちにRAMが尽きる |
2回deleteする | ヒープの管理情報が壊れる |
deleteしたポインタを使う | すでに他人のものになった領域を読み書きする |
途中でreturnする | deleteの行に辿り着かない |
根本の問題は、生ポインタには「所有者」なのか「借りているだけ」なのかが書かれていないことである。
void doSomething(Sensor* sensor);このsensorは、関数の中でdeleteしてよいのか。答えは関数の名前からは分からない。コメントで約束するしかない。スマートポインタは、この約束を型そのものに書き込む道具である。
std::unique_ptr
#include <memory>
void doSomething() {
std::unique_ptr<Sensor> sensor = std::make_unique<IMU>();
sensor->update();
} // ここで自動的に delete される->で中身を使う書き方は生ポインタと変わらない。違うのは、スコープを抜けるときに必ず解放される点である。途中でreturnしても解放される。
unique_ptrは名前のとおり唯一の所有者を表す。だからコピーできない。
std::unique_ptr<Sensor> a = std::make_unique<IMU>();
std::unique_ptr<Sensor> b = a; // コンパイルエラー
std::unique_ptr<Sensor> c = std::move(a); // OK(所有権がcに移り、aは空になる)「2人が同じものをdeleteする」という事故が、コンパイル時に止まる。std::move()と書いたときだけ所有権が移る。
std::shared_ptr
所有者が1人に決められない場合はshared_ptrを使う。内部に参照カウントを持ち、最後の持ち主が消えたときに解放する。
std::shared_ptr<Sensor> a = std::make_shared<IMU>();
{
std::shared_ptr<Sensor> b = a; // コピーできる。カウントが2になる
Serial.println(a.use_count()); // 2
} // bが消えてカウントが1に戻る
Serial.println(a.use_count()); // 1std::weak_ptr
shared_ptrが持っているものを、所有せずに見たいときに使う。カウントを増やさないので、weak_ptrがあっても解放は妨げられない。
std::shared_ptr<Sensor> owner = std::make_shared<IMU>();
std::weak_ptr<Sensor> watcher = owner;
// 使うときは lock() で shared_ptr に戻す。空なら相手はもう消えている
if (std::shared_ptr<Sensor> sensor = watcher.lock()) {
sensor->update();
}「相手が生きているかどうか確かめてから使える」のが生ポインタとの違いである。第18回のクラス間の相互参照で、この道具が必要になる。
使い分け
| 型 | 所有するか | 使う場面 |
|---|---|---|
T* / T& | しない | 他人が持っているものを借りて使う |
std::unique_ptr<T> | する(1人だけ) | 所有者がはっきり1人に決まる。既定の選択 |
std::shared_ptr<T> | する(複数) | どれが最後に手を離すか決められない |
std::weak_ptr<T> | しない | shared_ptrの相手を、生死を確認しつつ見る |
vectorと組み合わせる
第16回のstd::vectorと組み合わせると、センサ一式の生成から解放までを1か所で扱える。
#include <memory>
#include <vector>
std::vector<std::unique_ptr<Sensor>> sensors;
void setup() {
sensors.push_back(std::make_unique<IMU>());
sensors.push_back(std::make_unique<GPS>());
sensors.push_back(std::make_unique<ToF>(6));
}
void loop() {
for (const std::unique_ptr<Sensor>& sensor : sensors) {
sensor->update();
sensor->print();
}
}deleteを1行も書いていないが、sensorsが壊れるときに全要素が解放される。型が長いのでautoで受けてもよい。
for (const auto& sensor : sensors) {
sensor->update();
}RAII
「確保はコンストラクタで、解放はデストラクタで」という考え方をRAII(Resource Acquisition Is Initialization)と呼ぶ。unique_ptrはこれをメモリに対して実装した型である。
自分で管理する RAII
────────────── ──────
new ←→ delete オブジェクトを作る
書き忘れる ↓
順序を間違える スコープを抜ける
returnで飛ばす ↓
必ず解放される対象はメモリに限らない。ファイル、通信路、Wire.begin()とWire.end()のような対になる処理は、すべて同じ形で書ける。解放を忘れないよう気をつけるのではなく、忘れられない形にするのが狙いである。
マイコンで使うときの注意
| 型 | 大きさ | 実行時のコスト |
|---|---|---|
T* | ポインタ1つ分 | なし |
std::unique_ptr<T> | ポインタ1つ分 | 解放処理が走るだけ |
std::shared_ptr<T> | ポインタ2つ分 | カウント用の領域を別に確保し、コピー・破棄のたびに増減する |
unique_ptrは生ポインタと同じ大きさ・同じ速さで使えるので、newを使うなら素直に置き換えてよい。一方shared_ptrは確保が増え、マルチスレッド対応のためカウント操作が原子的に行われるため、安易には使わない。
そのうえで、組み込みの原則はそもそもnewを使わないことである。
// 個数が決まっているなら、これで足りる。スマートポインタも new も不要
IMU imu;
GPS gps;
ToF tof(6);- 種類と個数がコンパイル時に決まるなら、そのまま変数として置く
newが必要でもsetup()までに済ませるloop()や割り込みの中で確保・解放を繰り返さない(第16回の断片化と同じ理由)
動かして確かめる
演習に入る前に、まずこのサンプルを動かして結果を見る。追加の部品は要らない。光るのはボードに載っている内蔵LEDで、それ以外はシリアルモニタの出力で確かめられる。code/examples/17-smart-pointer/を開き、pio run -t uploadで書き込んでからpio device monitorで出力を見る(手順と、外付けのLEDなどをつなぐ場合は開発環境にまとめてある)。
// 第17回:誰が解放するのかを型で表す
//
// USE_SMART_POINTER を false にして書き込むと、
// シリアルモニタの free heap が1秒ごとに減り続ける(リーク)。
// true に戻すと値が一定のまま変わらない。delete は1行も書いていない。
#include <Arduino.h>
#include <memory>
#include "IMU.hpp"
#include "Sensor.hpp"
const bool USE_SMART_POINTER = true;
/**
* @brief 生ポインタで確保し、解放を忘れた場合
* @note delete を書く場所が「ここ」だと決まっていないのが問題の根っこ。
*/
void readWithRawPointer() {
Sensor* sensor = new IMU();
sensor->update();
sensor->print();
// delete sensor; ← わざと書いていない。これがリークになる
}
/**
* @brief unique_ptr で確保した場合
* @note スコープを抜けるときに必ず解放される。途中で return しても同じ。
*/
void readWithUniquePtr() {
std::unique_ptr<Sensor> sensor = std::make_unique<IMU>();
sensor->update();
sensor->print();
}
void setup() {
Serial.begin(115200);
delay(1000);
Serial.print("USE_SMART_POINTER = ");
Serial.println(USE_SMART_POINTER);
Serial.print("free heap at start: ");
Serial.println(ESP.getFreeHeap());
}
void loop() {
if (USE_SMART_POINTER) {
readWithUniquePtr();
} else {
readWithRawPointer();
}
Serial.print("free heap: ");
Serial.println(ESP.getFreeHeap());
delay(1000);
}; ESP32-C5-DevKitC-1-N8R8 向け。
; ESP32-C5 は公式の platform-espressif32 では未対応なので、pioarduino のフォークを使う。
; (C5のArduino対応は arduino-esp32 3.3系 / ESP-IDF 5.5 以降)
[env:esp32-c5-devkitc-1]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip
; N8R8 用のボード定義はまだないので、フラッシュ容量(8MB)とPSRAM有りが一致する
; N8R4 を使う。PSRAMの容量は起動時に検出されるため実害はない。
; PSRAMで問題が出たら board = esp32-c5-devkitc-1(4MB・PSRAMなし)に下げる。
board = esp32-c5-devkitc1-n8r4
framework = arduino
monitor_speed = 115200
build_flags = -Wall
; 第17回の std::make_unique は C++14 以降の機能。
; ESP-IDF 5.5 は C++ を -std=gnu++2b(C++23)でビルドするので、
; このボードでは規格の引き上げは要らない。
; 古い環境で「make_unique is not a member of std」になった場合だけ、
; build_unflags = -std=gnu++11
; build_flags = -Wall -std=gnu++17
; のように上書きする。USE_SMART_POINTER = 1
free heap at start: 294512
IMU roll=0.50 pitch=0.20
free heap: 294512
IMU roll=0.50 pitch=0.20
free heap: 294512
IMU roll=0.50 pitch=0.20
free heap: 294512確かめること。
free heapが動かない。deleteは1行も書いていないのに解放されているUSE_SMART_POINTERをfalseにして書き込み直すと、free heapが1秒ごとにIMU1個分ずつ減り続ける(具体的な値はボードによる)。エラーも警告も出ないまま、時間をかけて死ぬのがリークの厄介なところrollが毎回0.50なのは、毎周新しいIMUを作って捨てているからreadWithUniquePtr()の途中にreturn;を入れても解放される。readWithRawPointer()では解放されないplatformio.iniで規格をgnu++17に上げている。外すとstd::make_uniqueが見つからない環境がある
演習:センサの所有をunique_ptrに任せる
newとdeleteでセンサを1つ確保・解放するコードを書き、deleteを忘れたときに何が起きるかを確認する- それを
std::unique_ptrに書き換え、deleteが消えることを確かめる std::vector<std::unique_ptr<Sensor>>に3種類入れて一括更新する- このプログラムで
shared_ptrにする必要があるか、理由を付けて答える