この記事の対象とする読者
-
C/C++で配列やポインタを少しは使ったことがある人
-
std::vectorは使えるが、関数の引数をどう設計するかを迷う人 -
C++で「誤用されにくいAPI」を作りたい人
等々
この記事で得られること
-
std::spanが何を解決する型なのか -
const T* data, std::size_t sizeの危うさ -
std::vector&ではなくstd::spanを使う理由 -
std::spanを使うべき場面と、使わない方がよい場面 - C++で安全なライブラリAPIを考えるときの視点について
はじめに
C++でバイナリデータや配列を扱うと、よく次のような関数を書きます
void send_packet(const uint8_t* data, std::size_t size);
通信パケッット、センサデータ、画像のピクセル列、ファイルの読み書きなど、低レイヤ寄りのコードではよく見る形かと思います。
しかし、このAPIには小さくない問題があります。
std::vector<uint8_t> packet = {0x01, 0x02, 0x03};
send_packet(packet.data(), packet.size()); // 正しい
send_packet(packet.data(), 100); // 間違いだがコンパイルは通る
packet の要素数は3個なのに、サイズとして100を渡せてしまいます。
この問題の本質は、ポインタとサイズが別々の引数になっていることだと考えられます
この関数をつかう人にとっては、
-
このポインタはどこまで読んでよいのか
-
サイズはバイト数なのか、要素数なのか
-
ポインタとサイズは本当に対応しているのか
を自分で正しく管理する必要があります。
C++では、このような利用者が気をつければ安全のような条件付きのAPIを減らすことが大事だと考えます。そこで使えるのが std::span です。
std::spanについて
std::span はC++20で追加された、連続したメモリ領域を所有せずに参照するための型です。
もう少しくだけて言うと、 配列を「借りる」ための型ともいえますね。
例えば、次のように使えます。
#include <cstdint>
#include <iostream>
#include <span>
#include <vector>
void dump_bytes(std::span<const uint8_t> bytes) {
for (uint8_t b : bytes) {
std::cout << static_cast<int>(b) << " ";
}
std::cout << "\n";
}
int main() {
std::vector<uint8_t> packet = {10, 20, 30};
dump_bytes(packet);
}
ここで、std::span<const uint8_t> は、uint8_t が連続して並んだ範囲を、読み取り専用で借りますという意味です。
重要なのは、dump_bytes が std::vector 専用の関数ではないことです。
#include <array>
#include <cstdint>
#include <span>
#include <vector>
void dump_bytes(std::span<const uint8_t> bytes);
int main() {
std::vector<uint8_t> v = {1, 2, 3};
std::array<uint8_t, 3> a = {4, 5, 6};
uint8_t raw[] = {7, 8, 9};
dump_bytes(v);
dump_bytes(a);
dump_bytes(raw);
}
std::vector でも、std::array でも、生配列でも渡せます。
関数が欲しいのは「vectorであること」ではなく、連続したバイト列であることです。std::span は、その意図をそのまま型にできます。
「所有する」と「借りる」を分けて考えてみる
std::span を理解するには、所有する型と借りる型を分けて考えると分かりやすいです。
std::vector はデータを所有します。
std::vector<int> values = {1, 2, 3};
この場合、values がメモリを確保し、要素を管理します。一方、std::span は所有しません。
std::span<int> view = values;
これは、values の中身を一時的に見ているだけです。
感覚としては、次の構造体に近いです。
struct SpanLike {
int* data;
std::size_t size;
};
ただし、std::span は標準ライブラリの型なので、範囲for文で回せたり、size() が使えたり、subspan() で一部だけを切り出せたりします。
std::vector&ではだめなのか
配列を受け取るなら、次のように書いてもよそうに考えるかもしれません。
void process(const std::vector<uint8_t>& data);
しかし、この関数は std::vector しか受け取れません。
std::array<uint8_t, 4> a = {1, 2, 3, 4};
process(a); // 渡せない
ここで考えたいのは、関数が本当に必要としているものです。多くの場合、関数が必要としているのは std::vector そのものではありません。
必要なのは、uint8_t が連続して並んでいて、その範囲を読めることです。
その場合、こう書いた方が自然です。
void process(std::span<const uint8_t> data);
このAPIは、利用者にこう伝えています。
「コンテナの種類は問いません。連続した uint8_t の範囲を渡してください。中身は読み取るだけです。」
関数が本当に必要としている条件だけを受け取る。 これは、誤用されにくいAPI設計の基本かと思います
例:通信パケットのパーサを書いてみる
std::span が役に立つ例として、通信パケットのパーサを考えます。
例えば、先頭4バイトをヘッダとして、
-
0バイト目: 種類
-
1バイト目: フラグ
-
2〜3バイト目: ペイロード長
として扱うとします。
C風に書くと、こうなります。
void parse_packet(const uint8_t* data, std::size_t length) {
if (length < 4) {
return;
}
uint8_t type = data[0];
uint8_t flags = data[1];
uint16_t payload_length =
(static_cast<uint16_t>(data[2]) << 8) | data[3];
// ....続く
}
これを std::span で書くと、こうなります。
#include <cstdint>
#include <iostream>
#include <span>
void parse_payload(std::span<const uint8_t> payload) {
std::cout << "payload size = " << payload.size() << "\n";
}
void parse_packet(std::span<const uint8_t> packet) {
if (packet.size() < 4) {
std::cout << "packet too short\n";
return;
}
uint8_t type = packet[0];
uint8_t flags = packet[1];
uint16_t payload_length =
(static_cast<uint16_t>(packet[2]) << 8) | packet[3];
auto payload = packet.subspan(4);
std::cout << "type=" << static_cast<int>(type) << ", flags=" << static_cast<int>(flags)
<< ", payload_length=" << payload_length
<< "\n";
parse_payload(payload);
}
呼び出し側はこう書けます。
std::vector<uint8_t> received = {
0x01, 0x00, 0x00, 0x03, 0xaa, 0xbb, 0xcc
};
parse_packet(received);
ここで parse_packet の型を見るだけで、次のことが分かります。
void parse_packet(std::span<const uint8_t> packet);
-
連続したバイト列を受け取る
-
サイズ情報も一緒に受け取る
-
中身は書き換えない
-
所有はしない
-
std::vectorでもstd::arrayでも生配列でもよい
コメントを書かなくても、APIの意図がかなり型に現れているのがわかります。
書き換えたいときは std::span
読み取り専用なら std::span<const T> を使います。
では、関数の中で値を書き換えたいときは、その場合は std::span<T> を使います。
#include <iostream>
#include <span>
#include <vector>
void zero_fill(std::span<int> values) {
for (int& value : values) {
value = 0;
}
}
int main() {
std::vector<int> data = {1, 2, 3};
zero_fill(data);
for (int v : data) {
std::cout << v << " ";
}
}
このとき、出力は次のようになります。
0 0 0
std::span<int> は、この範囲の中身を書き換える可能性があります、という意味になります。
一方で、std::span<const int> にすれば、関数内で書き換えることはできません。
void print_values(std::span<const int> values) {
for (int value : values) {
std::cout << value << "\n";
}
}
このように、const と組み合わせることで、読むだけなのか、書き換えるのかをAPIに表現できます。
std::span を使うとよい場面
std::span は、特に関数の引数として使うと効果的です。例えば、次のような場面です。
1. 配列の中身を読むだけの関数
void dump(std::span<const uint8_t> data);
2. 配列の中身を書き換える関数
void normalize(std::span<float> values);
3. vector / array / 生配列を同じ関数に渡したいとき
void process(std::span<const int> values);
4. ポインタとサイズを別々に渡したくないとき
// Before
void send(const uint8_t* data, std::size_t size);
// After
void send(std::span<const uint8_t> data);
私は、バッファやセンサデータ、通信パケットを扱う関数では、まず std::span を候補にします。
使わない方がよい場面
std::span は便利ですが、万能ではありません。
データを所有したい場合
データを所有したいなら、std::vector を使う方が自然です。
std::vector<int> make_values();
std::span は所有しない型なので、メモリ管理には向きません。
文字列を扱う場合
文字列を扱うなら、std::string_view や std::string の方が自然です。
void print_name(std::string_view name);
std::span<char> でも文字列っぽいものは表せますが、文字列としての意味を表したいなら std::string_view の方が読みやすいです。
長期間保持したい場合
std::span は参照先を所有しません。
そのため、メンバ変数として保持する場合は、参照先の寿命に注意が必要です。
class Parser {
public:
explicit Parser(std::span<const uint8_t> data)
: data_(data) {}
private:
std::span<const uint8_t> data_;
};
この設計が必ず悪いわけではありまんが、ただし、data_ が指している元データが Parser より先に破棄されると危険だ考えられます。
注意①std::span は寿命を延ばさない
std::span の最大の注意点は、参照先の寿命を延ばさないことです。
例えば次のコードは危険です。
#include <span>
#include <vector>
std::span<int> make_span() {
std::vector<int> values = {1, 2, 3};
return values;
}
values は関数を抜けると破棄されます。
返された std::span は、破棄済みのメモリを指してしまいます。
つまり、std::span は「所有しない」型なので、参照先の寿命は別途管理する必要があります。
基本的には、次のように関数の引数として一時的に借りる使い方が安全です。
void use_values(std::span<const int> values) {
for (int value : values) {
// valuesを読む
}
}
int main() {
std::vector<int> data = {1, 2, 3};
use_values(data);
}
注意②境界チェックを自動で保証するわけではない
std::span はサイズを持っていますが、operator[] が常に境界チェックをしてくれるわけではありません。
std::span<int> s = values;
int x = s[100]; // 範囲外アクセスの可能性
つまり、std::span を使えばメモリ安全性が完全に保証されるわけではありません。
std::span の役割は、ポインタとサイズをまとめて扱えるようにし、APIの誤用を減らすことです。
範囲外アクセスを防ぎたいなら、size() を使って明示的にチェックする必要があります。
void read_header(std::span<const uint8_t> packet) {
if (packet.size() < 4) {
return;
}
uint8_t type = packet[0];
uint8_t flags = packet[1];
}
std::span は魔法の安全装置ではありません。 しかし、安全なAPIを作るための土台になります。
まとめ
std::span は、C++20で追加された「連続したメモリ領域を借りる」ための型です。
従来のC風APIでは、ポインタとサイズを別々に渡すため、利用者が両者の対応関係を正しく管理する必要がありました。
void send(const uint8_t* data, std::size_t size);
これを std::span にすると、範囲を1つの値として扱えます。
void send(std::span<const uint8_t> data);
これにより、関数の意図が明確になり、std::vector、std::array、生配列などを統一的に受け取れます。
もちろん、std::span はメモリ安全性を完全に保証する型ではありません。 参照先の寿命は延ばしませんし、境界チェックも自動で保証されるわけではありません。
それでも、APIの入口で、この関数は、所有しない連続領域を一時的に借りるだけ、と表現できることには大きな価値があります。
C++の安全性は、言語機能だけで完全に解決できるものではありません。だからこそ、std::span のような型を使って、利用者が間違えにくいインターフェースを設計することが重要だと思います。
バッファや配列を受け取る関数を書いたときは、まずこう考えるべきかもしれませんね
その関数、本当にポインタとサイズを別々に受け取る必要がありますか?
もし答えが「いいえ」なら、std::span はよい選択肢になるのではないでしょうか?
参考
-
cppreference:
std::span -
C++ Core Guidelines: Bounds safety
-
C++20 標準ライブラリにおける
std::span