はじめに
私はこれまで、実務ではC++17までしか使用していません。しかし、近年はC++20やC++23への移行が徐々に進んでおり、便利かつ強力な新機能が多数追加されています。そこで今回は、自分自身の勉強を兼ねて、従来(C++17以前) と比較しながらC++20/23の代表的な機能をご紹介し、それらがもたらすメリットや導入効果について解説します。
2023年にJetBrainsが実施したC++開発者エコシステム調査によると、C++プロジェクトで使用される標準バージョンは次のようになっています:
- C++17: 43%(最も広く使用されている)
- C++20: 29%(前年から採用率が増加)
- C++23: 10%(最新の標準)
- C++14: 21%(減少傾向)
- C++11: 27%(減少傾向)
- C++98/03: 8%(依然少数ではあるが存在)
(数値を合計すると100%を超えていますが、これは複数のバージョンが同時に使用されているプロジェクトがあるためと考えられます。例えば、プロジェクトの主要部分ではC++17を使いながら、サブモジュールでC++11を利用しているケースなどです。)
このデータから、最新のC++20やC++23に移行しているプロジェクトは増えつつあるものの、まだC++17やそれ以前の標準を使い続けている現場も多いことがわかります。私自身も含め、C++20以降の新機能をまだ本格的に活用していない方が少なくないのではないでしょうか。
1. Concepts(コンセプト)でテンプレートエラーを解消【C++20】
従来(C++17以前)の課題
- テンプレート関数に誤った型を渡すと、非常に複雑なコンパイルエラーメッセージが出る。
- どこがエラー原因なのか判断しづらく、デバッグに時間を要することが多い。
サンプルコード(従来)
template <typename T>
void add(T a, T b) {
std::cout << a + b << std::endl;
}
int main() {
add(1, 2); // OK: 整数同士の加算
add("a", "b"); // OK: 文字列リテラル同士の連結
add(1, "a"); // 複雑なコンパイルエラーが起きる!
}
C++20適用後
- Conceptsを使うことでテンプレート引数に「整数型のみ」などの制約を設けられる。
- 間違った型が渡されると、より明確でわかりやすいエラーがコンパイル時に報告される。
- SystemCのようにテンプレートを多用するライブラリ・フレームワークでは、コンセプトの恩恵を特に受けやすいと考えられます。(実際に自分も SystemC でエラーの原因がすぐにはわからない、という状況に何回も遭遇しました。)
サンプルコード(C++20)
#include <concepts>
template <std::integral T>
void add(T a, T b) {
std::cout << a + b << std::endl;
}
int main() {
add(1, 2); // OK
add("a", "b"); // コンパイルエラー(わかりやすい型の不一致)
}
効果
- テンプレートの型安全性が大幅に向上。
- バグを早期に発見でき、デバッグ時間の削減につながる。
2. コルーチン(Coroutines)で非同期処理を簡潔化【C++20】
従来(C++17以前)の課題
- 非同期処理には
std::thread
やstd::async
などを組み合わせる必要があり、スレッド管理や同期方法が複雑化。 - 処理の流れが追いにくく、コードが煩雑になりやすい。
サンプルコード(従来)
#include <future>
#include <iostream>
#include <thread>
void task() {
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "Task completed" << std::endl;
}
int main() {
auto future = std::async(std::launch::async, task);
future.wait(); // 終了待機
return 0;
}
C++20適用後
- コルーチンを使うと、「一時停止(co_await)」「再開(resume)」がソースコード上で明示され、非同期処理をシンプルに表現できる。
- スレッドやタスクの管理が抽象化され、可読性が向上。
サンプルコード(C++20)
#include <coroutine>
#include <iostream>
#include <thread>
struct Awaiter {
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<> h) const noexcept {
std::thread([h] {
std::this_thread::sleep_for(std::chrono::seconds(1));
h.resume(); // 1秒後に再開
}).detach();
}
void await_resume() const noexcept {}
};
struct Task {
struct promise_type {
Task get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() {}
};
};
Task example_coroutine() {
std::cout << "Task started\n";
co_await Awaiter{};
std::cout << "Task completed after 1 second\n";
}
int main() {
example_coroutine();
std::this_thread::sleep_for(std::chrono::seconds(2));
return 0;
}
効果
- 非同期コードが直感的に記述でき、ロジックを追いやすい。
- スレッド管理に伴うボイラープレート(定型コード)が減り、メンテナンス性が向上。
3. Rangeライブラリでデータ操作を簡素化【C++20】
従来(C++17以前)の課題
- イテレータや手動ループを用いたフィルタリング・変換処理が長文化し、意図が読み取りにくい。
- ロジックが複雑化し、拡張・修正時にミスが起きやすい。
サンプルコード(従来)
#include <vector>
#include <iostream>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6};
std::vector<int> result;
for (int n : numbers) {
if (n % 2 == 0) {
result.push_back(n * n); // 偶数の2乗
}
}
for (int n : result) {
std::cout << n << " "; // 4 16 36
}
}
C++20適用後
-
Rangeライブラリ(
std::ranges
,std::views
)でパイプライン式に操作を記述可能。 - フィルタや変換などの処理をシンプルかつ明確に表現できる。
サンプルコード(C++20)
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6};
auto even_squares = numbers
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * n; });
for (int n : even_squares) {
std::cout << n << " "; // 4 16 36
}
}
効果
-
|
演算子を使ったパイプライン表記で、意図が読みやすい。 - 修正や拡張が容易で、コーディングミスを減らしやすい。
4. std::formatで文字列操作を快適に【C++20】
従来(C++17以前)の課題
- 文字列整形にはC言語の
printf
やC++のstd::ostringstream
を利用するが、書式指定子などの細かい管理が必要。 - 型の不一致や変数の順番ミスが原因で、バグやセキュリティリスクが発生する可能性がある。
サンプルコード(従来)
#include <cstdio>
#include <string>
#include <iostream>
#include <sstream>
int main() {
std::string name = "Alice";
int age = 30;
// printfスタイル
std::printf("Name: %s, Age: %d\n", name.c_str(), age);
// ostringstreamスタイル
std::ostringstream oss;
oss << "Name: " << name << ", Age: " << age << "\n";
std::cout << oss.str();
}
C++20適用後
-
std::formatを使えば、Pythonの
format
のような位置引数・型安全なフォーマットが可能。 - コードがシンプルになり、可読性や保守性が向上。
サンプルコード(C++20)
#include <format>
#include <iostream>
#include <string>
int main() {
std::string name = "Alice";
int age = 30;
std::string result = std::format("Name: {}, Age: {}", name, age);
std::cout << result << std::endl;
}
効果
- フォーマットの指定が簡潔でミスが起きにくい。
- ログ出力やメッセージ生成時のコード量を削減できる。
5. モジュール(Modules)でヘッダーファイル地獄を回避【C++20】
従来(C++17以前)の課題
- 大規模プロジェクトでは、ヘッダーファイルが乱立し、依存関係やビルド時間が膨大になる。
- インクルードガードやプリコンパイルヘッダなどを駆使しても、管理が煩雑になりがち。
サンプルコード(従来)
// hello.h
#ifndef HELLO_H
#define HELLO_H
#include <string>
#include <iostream>
void say_hello(const std::string& name);
#endif // HELLO_H
// hello.cpp
#include "hello.h"
void say_hello(const std::string& name) {
std::cout << "Hello, " << name << "!\n";
}
C++20適用後
-
モジュール機能を活用すれば、
import
によってコードをモジュール単位で分割できる。 - ヘッダーファイルに依存しないため、ビルド時間の短縮が期待でき、依存関係管理もシンプルになる。
サンプルコード(C++20)
// hello.ixx (モジュールインターフェースファイル)
export module hello;
import <string>;
import <iostream>;
export void say_hello(const std::string& name) {
std::cout << "Hello, " << name << "!\n";
}
// main.cpp
import hello; // モジュールをインポート
int main() {
say_hello("Alice");
return 0;
}
効果
- ビルド時間の短縮や、ヘッダーファイル地獄からの解放。
- プロジェクトの構造をわかりやすく保ち、メンテナンス性を高める。
6. std::expectedで例外を減らす【C++23】
従来(C++17以前)の課題
- 例外(
throw
/catch
)は強力だが、複数の関数をまたぐと例外の伝播ルートが複雑化しやすい。 - 関数が返すエラーを明示的に示しづらく、コードの追跡が面倒。
サンプルコード(従来)
#include <iostream>
#include <stdexcept>
int divide(int a, int b) {
if (b == 0) throw std::runtime_error("Division by zero");
return a / b;
}
int main() {
try {
std::cout << divide(10, 0) << "\n";
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << "\n";
}
}
C++23適用後
- std::expectedを使えば、成功時の値とエラー情報を同居させられ、例外を使わないエラー処理が可能。
- 呼び出し側が戻り値でエラーを明確にチェックできるため、コードフローを把握しやすい。
サンプルコード(C++23)
#include <expected>
#include <iostream>
#include <string>
std::expected<int, std::string> divide(int a, int b) {
if (b == 0) {
return std::unexpected("Division by zero");
}
return a / b;
}
int main() {
auto result = divide(10, 0);
if (!result) {
std::cerr << "Error: " << result.error() << "\n";
} else {
std::cout << "Result: " << *result << "\n";
}
}
効果
- エラーの戻り方が明示的になり、バグ発生箇所を絞りやすい。
- 例外を使わないコードパスを設計でき、読みやすくなる。
7. 追加:三方比較演算子(Spaceship Operator <=>)とconstexpr関連【C++20】
7.1 三方比較演算子(Spaceship Operator <=>)
- 従来は
==, !=, <, >, <=, >=
といった演算子を個別にオーバーロードして比較を定義する必要があった。 -
C++20では
<=>
演算子を定義することで、これらの比較演算を一括して処理できるようになり、宣言がシンプル化する。
サンプルコード(C++20)
#include <compare>
#include <string>
struct Person {
std::string name;
int age;
auto operator<=>(const Person&) const = default; // 自動生成
};
int main() {
Person alice{"Alice", 30};
Person bob{"Bob", 25};
if (alice < bob) {
// aliceがbobより小さい(年齢が若い)か比較
}
}
効果
- 比較演算子の定義にかかるボイラープレートを削減。
- 規定値(
= default;
)により、標準的な比較ロジックを自動生成できる。
7.2 constexpr関連の拡張
-
C++20では
constexpr
で定義できる要素が増え、コンパイル時に評価可能な式の範囲が広がった。 - ランタイム処理をコンパイル時に実行できるため、パフォーマンス向上が期待できるケースもある。
まとめ
1. 最新標準の採用状況
- 2023年時点ではC++17が43%と依然として最多だが、C++20が29%、C++23が10%と徐々に新標準への移行が進んでいる。
- C++14やC++11など古い標準の利用率は減少傾向。
2. C++20/23の主な新機能と効果
-
Concepts
- テンプレートの型安全性を高め、複雑なエラーメッセージから解放。
- SystemCのようにテンプレートを多用する場面でも特に恩恵が大きい。
-
Coroutines
- 非同期処理を直感的に書け、コード量を削減。
-
Rangeライブラリ
- パイプライン式でデータ操作しやすく、可読性向上。
-
std::format
- 文字列フォーマットが型安全かつシンプルに。
-
モジュール(Modules)
- ヘッダーファイル依存を減らし、ビルド時間を短縮。
-
std::expected
- 例外を使わないエラー処理で、コードフローを明確化。
-
三方比較演算子(<=>)やconstexpr強化
- 比較演算子の簡素化と、コンパイル時処理の柔軟性向上。
3. 筆者の所感・今後の展望
- 実務でC++17までしか使ったことがなかった筆者としては、C++20/23の新機能を調べる中で、安全性や可読性、生産性の向上に寄与する要素が多数あると感じました。
- 特にConceptsやRangeライブラリ、Coroutinesはテンプレートエラーや非同期処理まわりのコードを劇的に見やすくしてくれます。
- 今後、コンパイラやライブラリの対応が進むにつれ、モジュール機能の普及やビルド時間の短縮も期待できます。
- まだC++17以前を使うプロジェクトでも、必要な機能から移行を試していくことで、新標準がもたらす恩恵を着実に享受できるのではないでしょうか。
以上、C++17までしか実務経験のない私が、勉強を兼ねてまとめたC++20/23の新機能と、その効果についての紹介記事でした。 少しでも皆さんの参考になれば幸いです。