第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());      // 1

std::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などをつなぐ場合は開発環境にまとめてある)。

code/examples/17-smart-pointer/src/main.cpp
// 第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);
}
code/examples/17-smart-pointer/platformio.ini
; 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にする必要があるか、理由を付けて答える