C++の標準テンプレートライブラリ(STL)において、後入れ先出し(LIFO: Last-In, First-Out)のデータ構造を管理する「std::stack」は、アルゴリズムの実装において非常に重要な役割を果たします。
関数のコールスタック、式の構文解析、深さ優先探索(DFS)など、コンピュータサイエンスの根幹を支える多くの場面でこのスタック構造が利用されています。
モダンC++への進化に伴い、std::stack自体のインターフェースはシンプルながらも、内部コンテナの適切な選定やムーブセマンティクスの活用によって、そのパフォーマンスと安全性はさらに向上しています。
本記事では、std::stackの基礎から、パフォーマンスを最適化するためのコンテナ選定、さらにはC++23以降の最新機能を含めた実践的な活用方法まで詳しく解説します。
std::stackの基本概念とコンテナアダプタの仕組み
C++のstd::stackは、それ自体がメモリ管理を行う独立したコンテナではなく、「コンテナアダプタ」と呼ばれる特殊なクラスです。
これは、既存のコンテナ(std::dequeやstd::vectorなど)をラップし、特定のインターフェース(LIFO)のみを公開する仕組みを指します。
LIFO(後入れ先出し)の原理
LIFOとは、最後に追加された要素が最初に削除される仕組みです。
現実世界の例では、積み上げられた皿や本をイメージするとわかりやすいでしょう。
上に乗せたものから順に取っていく操作が、スタックの基本動作となります。
コンテナアダプタとしての特徴
std::stackはデフォルトで内部コンテナにstd::dequeを使用します。
しかし、テンプレート引数を指定することで、他のコンテナへ切り替えることも可能です。
コンテナアダプタを利用する最大のメリットは、不適切な操作(スタックの途中の要素を書き換える、任意の位置に挿入するなど)をコンパイルレベルで禁止し、データ構造の整合性を強制できる点にあります。
std::stackの主要なメンバ関数と基本操作
std::stackを利用する上で必須となる操作は限られており、非常に習得しやすい設計になっています。
主要な関数を以下の表にまとめます。
| 関数名 | 説明 |
|---|---|
push() | 要素をスタックの最上位に追加する |
emplace() | 要素を直接構築してスタックの最上位に追加する |
pop() | 最上位の要素を削除する(戻り値はない) |
top() | 最上位の要素へアクセスする(参照を返す) |
empty() | スタックが空かどうかを確認する |
size() | スタック内の要素数を取得する |
基本的な実装例
以下のプログラムは、std::stackの基本的な操作の流れを示したものです。
#include <iostream>
#include <stack>
#include <string>
int main() {
// std::stackの宣言(デフォルトではstd::dequeを内部で使用)
std::stack<std::string> messages;
// 要素の追加
messages.push("First message");
messages.push("Second message");
messages.emplace("Third message"); // 直接構築
std::cout << "現在の要素数: " << messages.size() << std::endl;
// 要素の取り出しと削除
while (!messages.empty()) {
// top()で参照を取得し、pop()で削除する
std::cout << "取り出し: " << messages.top() << std::endl;
messages.pop();
}
return 0;
}
現在の要素数: 3
取り出し: Third message
取り出し: Second message
取り出し: First message
ここで注意すべき点は、pop()関数は削除のみを行い、値を返さないという点です。
値を取得したい場合は、必ずtop()で参照を取得してからpop()を呼び出す必要があります。
これは、例外安全性を確保するための設計上の判断です(戻り値としてコピーを返す際に例外が発生すると、要素が失われるリスクがあるため)。
モダンC++における効率化テクニック
C++11以降、そして最新のC++23/C++26といったモダンな環境では、スタック操作をより効率的に行うための機能が備わっています。
emplaceによるコピーコストの削減
push()は、オブジェクトをコピーまたはムーブしてスタックに追加します。
一方、emplace()を使用すると、スタックの内部でオブジェクトを直接生成するため、一時オブジェクトの作成や余計なコピー操作を回避できます。
struct Data {
int id;
std::string name;
Data(int i, std::string s) : id(i), name(std::move(s)) {
std::cout << "Data constructed\n";
}
};
// pushの場合
std::stack<Data> s;
s.push(Data(1, "test")); // 一時オブジェクトが生成される
// emplaceの場合
s.emplace(2, "modern"); // 直接内部で構築される
C++23:push_rangeによる一括挿入
C++23では、範囲(Range)を直接スタックに追加できるpush_range()が導入されました。
これにより、他のコンテナ(std::vectorなど)に含まれる要素をまとめてスタックに積む際のコードが簡潔になり、最適化の余地も広がります。
#include <vector>
#include <stack>
#include <ranges>
int main() {
std::stack<int> s;
std::vector<int> v = {10, 20, 30, 40, 50};
// C++23以降の書き方
s.push_range(v);
return 0;
}
適切な内部コンテナの選定
std::stackのテンプレート定義は以下のようになっています。
template<class T, class Container = std::deque<T>> class stack;
第2引数に渡すコンテナを変更することで、メモリレイアウトやパフォーマンス特性を最適化できます。
1. std::deque(デフォルト)
多くのケースでstd::dequeが最適です。
dequeは「double-ended queue」の略で、メモリを不連続なブロックとして管理します。
- メリット:要素が増えても再確保による全要素のコピーが発生しません。大規模なスタックでも安定したパフォーマンスを発揮します。
- デメリット:メモリが連続していないため、メモリアクセスの局所性はstd::vectorに劣ります。
2. std::vector
要素数が事前に予測できる場合や、メモリアクセスの速度を極限まで高めたい場合は、std::vectorを内部コンテナに指定します。
- メリット:要素が連続したメモリ領域に配置されるため、キャッシュ効率が非常に高いです。
- デメリット:容量が不足した際、メモリの再確保と全要素のムーブ(またはコピー)が発生し、一時的に処理が重くなる可能性があります。
// 内部コンテナをstd::vectorに変更する
std::stack<int, std::vector<int>> vStack;
3. std::list
要素の追加・削除ごとにメモリ確保を行いたい特殊なケースではstd::listも選択肢に入ります。
- メリット:個々の要素が独立してメモリ確保されるため、要素の追加によるイテレータの無効化(stackではあまり関係ありませんが)が発生しません。
- デメリット:ポインタ保持のためのオーバーヘッドが大きく、パフォーマンス面では他の2つに大きく劣ります。通常、stackでlistを選択するメリットはほとんどありません。
実践的な応用:逆ポーランド記法(RPN)の計算機
スタックの最も古典的かつ強力な応用例の一つが、逆ポーランド記法の計算機です。
数式を解析し、演算子が現れたらスタックから数値を取り出して計算し、結果を再び戻すという流れは、std::stackの特性を完璧に活かしています。
#include <iostream>
#include <stack>
#include <string>
#include <sstream>
#include <vector>
int evaluateRPN(const std::vector<std::string>& tokens) {
std::stack<int> values;
for (const std::string& token : tokens) {
if (token == "+" || token == "-" || token == "*" || token == "/") {
// 演算子の場合、スタックから2つ値を取り出す
int rhs = values.top(); values.pop();
int lhs = values.top(); values.pop();
if (token == "+") values.push(lhs + rhs);
else if (token == "-") values.push(lhs - rhs);
else if (token == "*") values.push(lhs * rhs);
else if (token == "/") values.push(lhs / rhs);
} else {
// 数値の場合、スタックに積む
values.push(std::stoi(token));
}
}
return values.top();
}
int main() {
// "15 7 1 1 + - / 3 * 2 11 + +" を想定
// 式: ((15 / (7 - (1 + 1))) * 3) + (2 + 11) = 22
std::vector<std::string> tokens = {"15", "7", "1", "1", "+", "-", "/", "3", "*", "2", "11", "+", "+"};
int result = evaluateRPN(tokens);
std::cout << "計算結果: " << result << std::endl;
return 0;
}
計算結果: 22
このプログラムでは、std::stack<int>を使用することで、数式の階層構造を意識することなく、フラットに計算を進めることができています。
パフォーマンスとメモリ管理の高度な考察
std::stackをプロフェッショナルな現場で使用する際、考慮すべきなのは「例外安全性」と「メモリの断片化」です。
例外安全性とデータ保護
先述の通り、pop()が値を返さないのは、要素をコピーして返す際に例外が発生した場合、その要素がすでにスタックから消えているという「データの消失」を防ぐためです。
モダンC++では、これに加えてstd::optionalを組み合わせたカスタムラッパーを作成し、より安全に値を取得する設計も一般化しています。
スレッド安全性について
残念ながら、std::stackを含むSTLコンテナアダプタはスレッドセーフではありません。複数のスレッドから同時にpushやpopを行うと、データ競合(Data Race)が発生し、プログラムがクラッシュしたり、不定な動作をしたりする恐れがあります。
マルチスレッド環境で使用する場合は、std::mutexによるロック制御が必要です。
独自のメモリアロケータの利用
極限までパフォーマンスを追求する場合、内部コンテナにカスタムアロケータを適用することができます。
例えば、スタックのメモリ確保をヒープではなく、事前に確保されたスタック領域(静的メモリ)から行うことで、システムコールのオーバーヘッドをゼロに近づけることが可能です。
#include <stack>
#include <vector>
#include <memory_resource>
// C++17以降の polymorphic_allocator を利用した例
char buffer[1024];
std::pmr::monotonic_buffer_resource res(buffer, sizeof(buffer));
std::pmr::vector<int> v{&res};
std::stack<int, std::pmr::vector<int>> fastStack{std::move(v)};
まとめ
std::stackは、シンプルながらも非常に強力なコンテナアダプタです。
その特性を正しく理解し、適切な内部コンテナを選択することで、プログラムの堅牢性と実行速度の両立が可能になります。
- 基本はLIFO操作:
top()で確認し、pop()で捨てる流れを徹底する。 - 効率化の鍵:
emplace()やC++23のpush_range()を積極的に活用する。 - コンテナの選択:標準の
std::dequeでバランスを取るか、速度重視のstd::vectorに切り替えるかを用途に応じて判断する。 - 注意点:スレッド安全性や例外安全性には常に気を配り、必要に応じてミューテックスなどによる保護を検討する。
データ構造の選択肢は多岐にわたりますが、特定の制約(LIFO)を課すことでバグを未然に防ぎ、コードの意図を明確にできるstd::stackは、モダンC++プログラミングにおいて欠かすことのできないツールです。
今後の開発において、本記事で紹介したテクニックをぜひ役立ててください。
