この記事は Safie Engineers' Blog! Advent Calendar 2023 の11日目の記事です。
はじめに
セーフィー株式会社の AI Vision グループでテックリードを務めます橋本貴博です。セーフィーではネットワークカメラ上で動作するエッジアプリケーションの開発にC++を利用しています。C++11以降、C++の言語標準は3年ごとに策定されていますが、本記事ではコンパイラ対応が進んでいるC++20 標準について活用したいおすすめの仕様を紹介したいと思います。
今回取り上げる仕様 1 は以下の通りです。
- Concepts
- Ranges
- Modules
- Spaceship operator
環境構築
パッケージマネージャーを使った環境構築が簡単です。Ubuntuのバージョンと対応するリポジトリで手に入る最新のGCCバージョンを表にまとめました。version 11 以降であれば今回のトピックが全て実行できるため、Ubuntu 22.04 LTS 以降で試すのが良いと思います。
Ubuntu version | Repository name | GCC version | |
---|---|---|---|
23.04 | Lunar | 13 | |
22.10 | Kinetic | 12 | |
22.04 LTS | Jammy | 12 | 今回扱う機能にすべて対応 |
20.04 LTS | Focal | 10 | Modules のみ非対応 |
18.04 LTS | Bionic | 8 |
GCC versionごとにサポートされる仕様については C++ Standards Support in GCC を、パッケージマネージャーでインストール可能なパッケージについては Ubuntu パッケージ検索 を参照してください。
Concepts
Concepts は、一定の特徴を持ったクラスの範囲を表すことができる概念です。例えば、テンプレート関数で、一定の範囲のクラスだけテンプレートパラメータに代入できることを明示したりできます。拘束条件付きのテンプレート関数を、constrained template functionと言います。静的解析によってコンパイル前にエラーが判明するほか、コンパイル時間の短縮もされるらしいです。
以下のコードでは、拘束条件付きのテンプレート関数と、従来のテンプレート関数を使って、引数に非対応の型を入力した場合の挙動を比較します。
ここでは、テンプレート引数を "+" オペレータが定義された型に限定しています。オペレータが定義されていない std::vector<int>
を引数に入力した場合、拘束条件付きのテンプレート関数では Intellisense などの静的解析でエラーが出るので便利です。一方で、従来のテンプレート関数はコンパイルして初めてエラーが分かります。
#include <iostream>
#include <concepts>
#include <vector>
// Concept "Addable" を宣言する
template <typename T>
concept Addable = requires(T a, T b)
{
// { a + b }: テンプレートTのplaceholderを使った表現
// ->: 表現の結果得られるconceptを指定する
// std::same_as<T>: STLライブラリに含まれるconcept
{ a + b } -> std::same_as<T>;
};
// 通常のテンプレート関数
template <typename T>
T add(const T a, const T b)
{
return a + b;
}
// 先ほど定義した concept Addable を使って、テンプレートクラスに constraint を加える
template <Addable T>
T add_with_constraint(const T a, const T b)
{
return a + b;
}
int main()
{
// Addableをみたす型を使った場合、問題なくテンプレート関数が使用できる
const int a = 3;
const int b = 4;
std::cout << "add(a, b): " << add(a, b) << std::endl;
std::cout << "add_with_constraint(a, b): " << add_with_constraint(a, b) << std::endl;
// ためしに、constraintをみたさないクラスでテンプレート関数を使用すると
const std::vector<int> c = {1, 2, 3};
const std::vector<int> d = {4, 5, 6};
const std::vector<int> e = add(c, d); // コンパイル時にエラー
const std::vector<int> f = add_with_constraint(c, d); // 静的解析でエラー
return EXIT_SUCCESS;
}
C++20ではいろいろなConceptが定義されています。他の例も見てみましょう。以下のコードでは、Hashable concept を使って、テンプレート関数に入力する型をHashable に制限しました。
#include <string>
#include <cstddef>
#include <concepts>
// Concept "Hashable" を宣言する
template<typename T>
concept Hashable = requires(T a)
{
// { std::hash<T>{}(a) }: Hash オブジェクト (hashを生成する関数) に引数 a を渡す
// ->: 表現の結果得られるconceptを指定する
// std::convertible_to<std::size_t>: std::size_t に cast できることを主張するコンセプト
{ std::hash<T>{}(a) } -> std::convertible_to<std::size_t>;
};
// 拘束条件付き関数テンプレート(中身は空)
template<Hashable T>
void f(T) {}
// Hashableコンセプトをみたさない適当なクラス
struct nyanchu {};
int main()
{
const std::string test = "abc";
f(test); // std::string は Hashable コンセプトをみたすのでオッケー
f(nyanchu{}); // nyanchuはコンセプトをみたさないのでエラー
}
仕様の詳細は、Constraints and concepts (since C++20) を参照してください。
Ranges
begin()
と end()
をメンバメソッドとして持つ任意のクラスを示すコンセプトを range と言います。例えば、std::vector<T>
などです。C++20では、rangeを引数に取る一連のテンプレート関数が導入されており、これにより配列に対する演算を効率的に行うことが可能になりました。
主要な概念は以下の通りです。
-
Range adaptor
- 入力されたrangeに対してviewを作成するオブジェクト
-
View
- データ自体を保持せず、データを操作した結果をデータにアクセスしたときに計算する(遅延評価する)オブジェクト
- Pipe syntax
- range もしくは、view に対して range adaptorを作用させる構文
以下のコードでは、int型のベクトル numbers = {1, 2, ..., 9}
に対して、偶数を抽出する range adoptorと、二乗を計算する range adoptor を作用させて、偶数を二乗したベクトル {4, 16, 36, 64}
を計算します。
#include <iostream>
#include <vector>
#include <ranges>
int main()
{
// サンプルとしてint型のベクトルを定義
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9};
auto even = [](int i) { return i % 2 == 0; };
auto square = [](int i) { return i * i; };
// --- Example 1 ---
// numbers に偶数だけからなる view を作成する range adaptor を適用
auto even_numbers = numbers | std::views::filter(even);
// 偶数の view に、2乗した view を作成する range adaptor を適用
auto squared_even_numbers = even_numbers | std::views::transform(square);
// 結果を標準出力にプリント
std::cout << "Squared even numbers: ";
for (int i : squared_even_numbers) // 4 16 36 64
std::cout << i << " ";
std::cout << std::endl;
...
上記のように range adopter を1つずつ適用することも可能ですが、pipe syntax をチェインすることでまとめて書くことも可能です。コード量が減りすっきりしました。
...
// --- Example 2 ---
// numbers から squared_even_numbers を作成する処理をまとめてパイプで書くことも可能
std::cout << "Squared even numbers: ";
for (int i : numbers | std::views::filter(even) | std::views::transform(square))
std::cout << i << " "; // 4 16 36 64
std::cout << std::endl;
return EXIT_SUCCESS;
}
仕様の詳細は、Ranges library (C++20) を参照してください。
Modules
Modules はコンパイルの効率化に関係する仕様です。従来のインクルード文は、プリプロセッサが展開したコードがコンパイラに入力されるので、コンパイラは展開前のインクルード文を認識できませんでした。したがって、複数の翻訳単位があるとき、他の翻訳単位ですでにヘッダをコンパイルしていたとしてもコンパイラは認識できず、同じヘッダを重複してコンパイルせざるを得ませんでした。
C++20で導入された Modules はプリプロセッサで処理されず、コンパイラ自身が import/export を認識して1回だけコンパイルが行われるため、コンパイル時間が短縮されます。GCC 11 以降で利用できます。
Hello World
モジュールの利用方法を簡単に見ていきましょう。ここでは、モジュールファイルの拡張子を .ixx 2 とします。geometry module を作成して、main.cpp で利用するには以下のようにします。
export module geometry;
export double pi = 3.14159265358979323846;
import <iostream>;
import geometry;
int main()
{
std::cout << pi << std::endl;
}
Submodule
Submodule を作って階層構造を持たせることが可能です。geometry の下位構造として、circleと、rectangle を定義するには以下のようにします。下位のモジュールにアクセスするときは、dot (.) 演算子を使います。
export module geometry.circle;
import geometry;
export double area(double radius) { return pi * radius * radius; }
export module geometry.rectangle;
export double area(double width, double height) { return width * height; }
import geometry.circle;
import geometry.rectangle;
int main()
{
double circle_area = geometry::circle::area(5);
double rectangle_area = geometry::rectangle::area(4, 6);
return EXIT_SUCCESS;
}
名前解決
同名のシンボルが異なるモジュールで定義されている場合の名前解決は次のように行われます。
export module module1;
export int same_name_function(int a, int b) { return a + b; }
export module module2;
export int same_name_function(int a, int b) { return a * b; }
import module1;
import module2;
int main()
{
int sum = module1::same_name_function(2, 3); // Call the function from module1
int product = module2::same_name_function(2, 3); // Call the function from module2
return EXIT_SUCCESS;
}
仕様の詳細は、Modules (since C++20) を参照してください。
Spaceship operator
3-way comparison とも言います。既存の二項演算子 (<
, >
, <=
, >=
, ==
, !=
) は boolを返すoperatorですが、spaceship operator (<=>
) は less
、equal
、greater
の3種類の結果を返します。面白い名前なので調べたことがある方も多いかもしれません。
Spaceship operator を使うことで二項演算子の定義をDRYに書くことができます。
以下のコードは、2次元座標を表す struct Point
の大小関係を spaceship operator を使って定義します。ここでは、まず、x座標の大きさを比較して大小が決まるか試し、決まらない場合はy座標の大きさを比較することにします。次に、6つの二項演算子を spaceship operator を使って定義します。
#include <compare>
#include <iostream>
#include <iostream>
// 2次元座標を表すstruct
struct Point
{
Point(const double x_, const double y_) : x(x_), y(y_) {}
double x;
double y;
};
// xの大きさで大小を判断する。xの大きさが同じであればyの大きさで大小を判断する。
auto operator<=>(const Point &lhs, const Point &rhs)
{
if (auto cmp = lhs.x <=> rhs.x; cmp != 0)
return cmp;
return lhs.y <=> rhs.y;
}
// 大小関係は任意実装できる。以下のような例も考えられる。
// auto operator<=>(const Point &lhs, const Point &rhs)
// {
// return lhs.x + lhs.y <=> rhs.x + rhs.y;
// }
// 二項演算子の定義(spaceship operator で書けるので個別の実装がほとんどいらない)
bool operator==(const Point &lhs, const Point &rhs) { return (lhs <=> rhs) == 0; }
bool operator!=(const Point &lhs, const Point &rhs) { return !(lhs == rhs); }
bool operator<(const Point &lhs, const Point &rhs) { return (lhs <=> rhs) < 0; }
bool operator>(const Point &lhs, const Point &rhs) { return (lhs <=> rhs) > 0; }
bool operator<=(const Point &lhs, const Point &rhs) { return !(lhs > rhs); }
bool operator>=(const Point &lhs, const Point &rhs) { return !(lhs < rhs); }
...
spaceship operator と二項演算子が正しく定義されているか確認してみます。
...
int main()
{
// a < b < c をみたす Point a, b, c
const Point a(1, 1);
const Point b(1, 2);
const Point c(2, 1);
std::cout << std::boolalpha;
// a、b、cの大小関係をspaceship operatorで比較(両方 true)
std::cout << ((a <=> b) < 0) << std::endl;
std::cout << ((b <=> c) < 0) << std::endl;
// a、b、cの大小関係を二項演算子で比較(両方 true)
std::cout << (b > a) << std::endl;
std::cout << (c > b) << std::endl;
return EXIT_SUCCESS;
}
仕様の詳細は Default comparisons (since C++20) を参照してください。
むすび
今回取り上げた仕様は、C++20 標準が使える開発環境であれば積極的に使っていきたいと考えています。当社のエッジアプリケーション開発では、ターゲットとするエッジデバイスのSDKごとに、クロスコンパイラの対応状況がまちまちなため、アプリケーションの移植性などを考慮して採用判断をしています。