LoginSignup
16
6

More than 3 years have passed since last update.

C++20 Approach to Units of Measurement

Last updated at Posted at 2020-11-30

この記事はなんですか

こんにちは。キャディでアルゴリズム開発をしている「いなむのみたまのかみ」です。
この記事は CADDi Advent Calendar 1日目の記事です。

ここ数年は型で単位をつけるライブラリを作るのが趣味になってしまいずっとそればかりやっている。
最近では、最強の単位計算ライブラリを作りながらC++20の勉強をしている。
そこでライブラリの機能紹介をしながら、勉強したC++20の機能や技法も紹介する記事を書くことにした。

ライブラリ概要

リポジトリはここ
単なる数値に単位の型が付いて型安全で単位つき計算できるライブラリである。

intro.cpp
#include <mitama/dimensional/prelude.hpp>
#include <mitama/dimensional/systems/si/all.hpp>
#include <iostream>

using namespace mitama::dimensional;

void test(refine::area auto area) { // <- refine::areaは引数を面積の次元に制約するコンセプト
  std::cout << "area = " << area.value << " m^2" << std::endl;
}

int main() {
  // `value * si::meters` によって
  // x, yは長さという次元とメートルという単位の情報が付加され
  // quantity型になる
  constexpr quantity x = 3.0 * si::meters;
  constexpr quantity y = 2.0 * si::meters;

  test(x * y); // <- 長さ × 長さ = 面積

  // 次のような呼び出しは、testが面積を要求しているのに対して長さを渡しているのでコンパイルエラーになる
  // test(x)
}

コンセプトと制約式

ライブラリの説明もほとんどしていないが、C++20の話がしたい。
void test(refine::area auto area); という書き方について解説する。

Abbreviated function template

今までのテンプレートでは

template <class T> void test(T);

というというふうに、テンプレート宣言が必須だった。
C++20からは

void test(auto);

のように書くことで、関数テンプレートが宣言できるようになった。
この文法をAbbreviated function templateと呼ぶらしい。

次のようにplaceholder typeを2つ使うと、別々の型として認識される。

void test(auto, auto); // same as template <class T1, class T2> void test(T1, T2);

テンプレート宣言と組み合わせて使うこともできる。

template <class T> void test(auto, T, T); // same as template <class T1, class T2> void test(T1, T2, T2);

コンセプト

C++20ではコンセプトによってテンプレートパラメータを制約できる。
この機能を使用することでドキュメントで記述していた事前要件をコードで記述したり、オーバーロード解決に利用したりできる。

コンセプトはテンプレートの一種でテンプレート引数をとってbool値を返すものである。
基本的なものは<concepts>ヘッダにて標準ライブラリで提供される。
例えば、std::integral<T>Tが整数型ならばtrueを返し、そうでなければfalseを返す。
コンセプトはconcept コンセプト名 = boolを返す式;のようにして作る事ができる。
boolを返す式ならば何を書いてもよいが、コンセプトを記述するための機能としてrequires expressionというものがある。
requires expressionを用いると型特性を簡単に書くことができる。

template <class T>
concept has_value = requires (T& x) {
  x.value; // 型Tに要求する操作をセミコロン区切りで列挙する。
           // ここでは、メンバ変数valueを持っていてアクセス可能であることを要求している。
};

コンセプトでテンプレートを制約する文法はいくつかある。

1つめに紹介するのは、テンプレート宣言の後にrequires clauseを用いて制約を記述する文法である。
requires clauseは基本的にテンプレートパラメタ宣言の直後に記述する。
これはクラスと関数の両方に使える文法である。

template <class T> requires std::integral<T>
void test(T); // Tが整数型のときのみオーバーロードに参加する
template <class T> requires std::integral<T>
struct Test; // Tが整数型のときのみ実体化される

requires clauseは関数テンプレート(とラムダ式)の場合には変数リストの宣言の後にも書くことができる。

template <class T> 
void test(T) requires std::integral<T>;

2つめに紹介するのは、テンプレート宣言のclassやtypenameをコンセプト名に置き換えて制約を記述する文法である。

template <std::integral T>
void test(T); // Tが整数型のときのみオーバーロードに参加する
template <std::integral T>
struct Test; // Tが整数型のときのみ実体化される

これは、1つめに紹介した文法のシンタックスシュガーであると考えて良い。
コンセプト名で宣言されたテンプレートパラメタはコンセプトの1つめのパラメタ(テンプレートヘッドというらしい)に適用してrequires clauseを書いたものと同じ働きをする。

// same as `template <class T> requires std::integral<T>`
template <std::integral T>
void test(T);

これは複数のパラメタをとるコンセプトでも利用可能なので、例えばboolに変換できる型を要求する関数テンプレートは次のように書くことができる。

// same as `template <class T> requires std::convertible_to<T, bool>`
template <std::convertible_to<bool> T>
void test(T);

この文法はテンプレートパラメタだけでなくautoが書けるところにも使える
よってabbreviated function templateでも利用できるため、次のような記述が可能となっている。

// same as:
// template <class T> requires refine::area<T>
// void test(T area);
void test(refine::area auto area);

他にも次のような使い方が可能ということになる。

std::integral auto f();   // 戻り値に制約をつける
std::integral auto i = 1; // 変数宣言に制約をつける

機能紹介その1: quantity型

quantity型は算術型ValueTypeに幽霊型unitがくっついて、次元や単位が区別できるようになったクラステンプレートである。

mitama/dimensional/quantity.hppから抜粋
  template </* Unit type */ class, /* Value type */ core::arithmetic = double>
  struct quantity;

  template <class System, class Dimension, class ValueType>
  struct quantity<
    /* Phantom type */ core::unit<Dimension, System>,
    /*  Value type  */ ValueType>
  {
    ...(略)
    ValueType value;
  };

quantity型の設計

qunatity型は幽霊型としてunit型を持ち、unit型はDimension型とSystem型のペアである。
Dimension型は次元の情報しか持っていない。
Dimension型に単位系を表すSystem型をあわせることによって単位を表現している。
SI単位系の長さという具合である。

機能紹介その2: unit型

Systemは単位系の区別するための型とだけ言っておく。
ここでは次元の部分について解説する。

unit型の宣言は次のとおりである。

mitama/dimensional/core/core.hppから抜粋
template <class Dim, class System>
struct unit;

Dimの部分にはtype_listが渡される。

mitama/dimensional/core/type_list.hppより抜粋
template <class... ElemTypes> struct type_list {};

type_listにはdim型が格納される。
dim型は基本次元型とその次元の指数がペアになった型である。

mitama/dimensional/core/core.hppから抜粋
template <class Base, core::rational Ratio = std::ratio<1>>
struct dim;

メートルを例にすると、長さの基本次元型はlength型であるので、unitは次のようになる。

unit< type_list< dim< length, std::ratio<1> > >, (SI単位系を表す型) >

面積であれば指数部分が2となり、次のようになる。

unit< type_list< dim< length, std::ratio<2> > >, (SI単位系を表す型) >

機能紹介その3: quantity型同士の演算と単位の組み立て

quantity型同士の引き算や足し算は(単位系が同じであれば)ただの足し算と引き算である。
しかし、掛け算とわり算はそうではない。
次の例では長さと長さが掛け算された結果、面積という組立単位が出来上がる。

constexpr quantity x = 3.0 * si::meters;
constexpr quantity y = 2.0 * si::meters;

x * y; // <- 長さ × 長さ = 面積

単位の組み立ての実装

この掛け算の実装は次のようになっている。

mitama/dimensional/arithmetic.cppより抜粋
// mul
template <quantity_type T, quantity_type U>
    requires std::same_as<typename T::system_type, typename U::system_type>
          && requires (T t, U u) { { t.value * u.value } -> core::arithmetic; }
inline constexpr auto operator*(T const& lhs, U const& rhs)
    -> quantity<core::unit_add<typename T::unit_type, typename U::unit_type>,
                std::common_type_t<typename T::value_type, typename U::value_type>>
{ return {lhs.value * rhs.value}; }

なにやらごちゃごちゃと書いてあるが、重要なのは戻り値の部分。
unit_add<typename T::unit_type, typename U::unit_type>で新しい単位の型を組み立てている。

    -> quantity<core::unit_add<typename T::unit_type, typename U::unit_type>,
             //^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
             // 単位を組み立てるメタ関数の適用

掛け算による次元の組み立ては指数の足し算であるので、各次元について指数を足し合わせればよい。

$L^1 \times L^1 = L^{1+1} = L^2$

ようするにベクトルの足し算を(型上で)すればよい。
ただし次元は単純なベクトルのようにはなっておらず、次元によって添字付けられた指数の集合(つまりstd::map<次元, 指数>のようなもの)になっている。
それでも話は簡単である。
ある次元AとBを掛け算した結果を求めるには、おおよそ次のような操作が必要だ(ただし型上で)。

イメージ
std::set<次元> 基本次元の集合 = {...};
std::map<次元, 指数> A, B;
std::map<次元, 指数> C;

for (auto 基本次元: 基本次元の集合) C[基本次元] = A[基本次元] + B[基本次元];

これを型上で実装すると、次のようになった。

mitama/dimensional/core/core.hppより抜粋
// internal only
namespace _secrets {
  template <class, class> struct unit_add_impl;
  template <class ...LeftDims, class ...RightDims, class ...Bases>
  struct unit_add_impl<unit<type_list<LeftDims...>, homogeneous_system<Bases...>>,
                       unit<type_list<RightDims...>, homogeneous_system<Bases...>>>
  : std::type_identity<
    unit<
      filtered<
        []<class _, rational E>(std::type_identity<dim<_, E>>){ return E::num != 0; },
        type_list<
          dim< // -[Expanding dim]-------------------------------------------------------------------------+
            typename Bases::dimension_type,                                                             // |
            std::ratio_add<                                                                             // | 
              get_or_default<typename Bases::dimension_type, std::ratio<0>, type_list<LeftDims...>>,    // |
              get_or_default<typename Bases::dimension_type, std::ratio<0>, type_list<RightDims...>>    // |
            >                                                                                           // |
          >... // <----------------------------------------------------------------------------------------+
        >
      >,
      homogeneous_system<Bases...>
    >
  >
  {};
}

template <unit_type L, unit_type R>
using unit_add = _secrets::unit_add_impl<typename L::unit_type, typename R::unit_type>::type;

homogeneous_system<Bases...>というのはSystem型の正体である。
ある単位系に閉じたquantity型のSystem型はhomogeneous_systemという基本単位の集合になっていて、基本単位から基本次元を取得できるようになっている。

get_or_default<ToFind, Default, List>はmapのoperator[]と同じことをしていて、type_list<TT<K, V>...>(TTは型を2つとるtemplate template)の形になっているListに対して初めて現れる型ToFindKから探して見つかれば型Vを返し、見つからなければdefaultとして指定された型(この場合はstd::ratio<0>)を返す。
指数がゼロの次元はいらないので、filteredによって取り除いて最終的な組み立て単位のリストが完成する。

C++20のラムダ式

C++20のラムダ式が使われているので、C++20のラムダ式の新機能を3つ紹介する。

明示的なラムダ式のテンプレート指定

C++20から、ラムダ式で明示的なテンプレートパラメタの利用が可能になった。
これによって関数テンプレートとラムダ式の機能の差がほとんどなくなったので喜んでいる。

// C++20: explicit template
[]<class T>(std::vector<T> const&){};
//^^^^^^^^^

キャプチャなしラムダ式が非型テンプレートパラメタとして使えるようになった

type_listの操作にfilteredというクラスが使われているが、条件式としてラムダ式が使われているのにお気づきになられただろうか?
そう、C++20ではラムダ式はキャプチャをしていない限り非型テンプレートパラメタとして使えるのである。
再利用するわけでもない条件式を定義してから使うとなるとコードが無駄に増えるので便利である。

未評価文脈でのラムダ式の利用

C++20からはdecltypeやnoexceptなどの未評価文脈内の式としてラムダ式が利用できる。
C++17まではSFINAEが適用されると悪用されるため禁止されていたが、C++20からは未評価文脈でラムダ式が実体化に失敗した場合は即コンパイルエラーになることになったため、利用できるようになった。

using closure = decltype([]{}); // OK since C++20, error until C++17

template <typename T>
auto f(T) -> decltype([]() { T::invalid; } ());
void f(...);
f(0); // invalid expression not part of the immediate context, hard error

単位変換

単位変換については、実装途中である。
最初、変換コンストラクタか変換演算子を実装する予定であった。
しかし、次のような問題を指摘されたため別の実装をすることになった。

void f(quantity<cgs::length>) {}

int main() {
  quantity<si::length> len = 1.0 * si::meter;
  f(len); // メートルがセンチメートルに自動で変換されるが、これはユーザーが意図したものなのか?
}

into

選ばれた実装は、intoを介してユーザーが明示的に変換を指示するというものだ。
intoの使い方には2つのオプション

  • パイプラインを使う/関数呼び出しをする
  • 明示的に変換先を指定する/指定せずに代入先に自動変換させる

があり、これらを組み合わせた4通りはそれぞれ次のように書ける。

{ // explicit unit conversion with pipeline operator
  quantity _ = 3.0 * si::milli * si::meters | into<si::meter>;
}

{ // implicit unit conversion with pipeline operator
  millimeter = std::remove_cvref_t<decltype(si::milli * si::meter)>;
  quantity<millimeter> _ = 3.0 * si::meters | into<>;
}

{ // explicit unit conversion with function call
  constexpr quantity _ = into<si::meter>(3.0 * si::milli * si::meters);
}

{ // implicit unit conversion with function call
  constexpr quantity<millimeter> _ = into(3.0 * si::meters);
}

intoを使うと、曖昧な呼び出しは書けなくなるので、安全である。

void f(qunatity<cgs::length>) {}

int main() {
  quantity<si::length> len = 1.0 * si::meter;
  f(len | into<>); // intoが使われているので、これはユーザーが意図したもの!
  // f(len); これはエラー
}

intoの実装

60行もない実装なので、完全なコードを貼り付けてみた。
60行もないコードなのに、無駄にテクニカルである。

#pragma once
#include <concepts>
#include <functional>
#include <type_traits>
#include <mitama/dimensional/quantity.hpp>
#include <mitama/dimensional/core/into_trait.hpp>

namespace mitama::dimensional::core {
  // for implicit unit conversion
  template <quantity_type From>
  struct into_closure {
    From from;

    template <quantity_type To>
          requires std::convertible_to<typename std::remove_cvref_t<From>::value_type, typename To::value_type>
    constexpr operator To() const {
      using trait = into_trait<typename std::remove_cvref_t<From>::unit_type, typename To::unit_type>;
      static_assert(braced_initializable<typename std::remove_cvref_t<From>::value_type, typename To::value_type>,
              "Error: conversion from 'From::value_type' to 'To::value_type' requires a narrowing conversion.");
      return { std::invoke(trait{}, from.value) };
    }
  };

  // for tag dispatch
  template <class Unit>
  struct into_result {};

  // class for ADL magic
  struct into_impl {
    template <quantity_type From, class To>
    friend constexpr auto operator|(From const& from, into_result<To>(*)(into_impl)) {
      if constexpr (std::is_null_pointer_v<To>) {
        return into_closure{from};
      } else {
        using ret_t = quantity<To, typename std::remove_cvref_t<From>::value_type>;
        return ret_t{ std::invoke(into_trait<typename std::remove_cvref_t<From>::unit_type, To>{}, from.value) };
      }
    }
  };
  // Constraints for into_result
  template <class T>
  concept unit_type_or_nullptr = unit_type<T> || std::is_null_pointer_v<T>;
}

namespace mitama::dimensional {
  // Usage: `quantity | into<Unit Constant>` or `quantity | into<>`
  template <core::unit_type_or_nullptr auto To = nullptr>
  inline constexpr auto into(core::into_impl = {})
    -> core::into_result<std::remove_cvref_t<decltype(To)>>
    { return {}; }

  // Usage: `into<Unit Constant>(quantity)` or `into(quantity)`
  template <core::unit_type_or_nullptr auto To = nullptr, quantity_type Quantity>
  inline constexpr auto into(Quantity const& from)
    { return from | into<To>; }
}

解説

関数呼び出しされる場合から考える。
関数呼び出しの場合は一番下の関数が呼ばれる。

  // Usage: `into<Unit Constant>(quantity)` or `into(quantity)`
  template <core::unit_type_or_nullptr auto To = nullptr, quantity_type Quantity>
  inline constexpr auto into(Quantity const& from)
    { return from | into<To>; }

これは、自動的にパイプライン呼び出しを行う。

次に、パイプラインを使う場合を考える。

関数呼び出しの場合は一番下のひとつ上の関数が使われる
この関数はquantity | into<Unit Constant>quantity | into<>の形で使われるので、実際に呼び出されることはなく、オーバーロードの解決や変換先の単位の情報を伝播するために存在する。
変換先の単位が指定されなかった場合、は一旦変換先をnullptrとしておく。

  // Usage: `quantity | into<Unit Constant>` or `quantity | into<>`
  template <core::unit_type_or_nullptr auto To = nullptr>
  inline constexpr auto into(core::into_impl = {})
    -> core::into_result<std::remove_cvref_t<decltype(To)>>
    { return {}; }

オーバーロード解決で呼ばれる関数はinto_implクラスのfriend関数テンプレートである。
この関数は変換先が指定されていない(nullptr)の場合にはinto_closureというクラステンプレートに変換前の量を包んで返す。
明示的に変換先の単位が指定されている場合は単位変換を行い、変換後の量を返す。

  // class for ADL magic
  struct into_impl {
    template <quantity_type From, class To>
    friend constexpr auto operator|(From const& from, into_result<To>(*)(into_impl)) {
      if constexpr (std::is_null_pointer_v<To>) {
        return into_closure{from};
      } else {
        using ret_t = quantity<To, typename std::remove_cvref_t<From>::value_type>;
        return ret_t{ std::invoke(into_trait<typename std::remove_cvref_t<From>::unit_type, To>{}, from.value) };
      }
    }
  };

into_closureはquantity型に対する変換演算子を持っている型なので、変換先のquantity型に合わせて自動で変換が走るという実装になっている。

  // for implicit unit conversion
  template <quantity_type From>
  struct into_closure {
    From from;

    template <quantity_type To>
          requires std::convertible_to<typename std::remove_cvref_t<From>::value_type, typename To::value_type>
    constexpr operator To() const {
      using trait = into_trait<typename std::remove_cvref_t<From>::unit_type, typename To::unit_type>;
      static_assert(braced_initializable<typename std::remove_cvref_t<From>::value_type, typename To::value_type>,
              "Error: conversion from 'From::value_type' to 'To::value_type' requires a narrowing conversion.");
      return { std::invoke(trait{}, from.value) };
    }
  };

C++に詳しい人向け

テンプレートライブラリに多少通じているC++プログラマにintoの説明をすると、into=関数オブジェクトのインスタンスだと思うらしい。
たしかに、そうするのが常套手段である。
実際には、into=関数テンプレートである。

次の呼び出しを見れば(少なくとも私にとっては)明らかである。

{ // implicit unit conversion with function call
  constexpr quantity<millimeter> _ = into(3.0 * si::meters);
}

なぜならば、関数オブジェクト型の変数テンプレートとして実装されていれば、次のように呼び出さなければならないはずだからである。

{ // implicit unit conversion with function call
  constexpr quantity<millimeter> _ = into<>(3.0 * si::meters);
  //                                     ^^ これが必要なはず!
}

この<>を省略できるという点から関数テンプレートが選ばれた。

into_trait

into_traitというのは具体的な単位変換の方法を知っているクラスである。
From, Toに対してこのクラスを特殊化して、変換方法を記述することにより変換が可能になる。

namespace mitama::dimensional {
  // This class is a trait class for describing the rules of unit conversion.
  // In order to describe new conversion rules,
  // you can partially or fully specialize this class to implement new conversion rules with the following signature.
  //
  // [ -- Example: impl trait for conversion from meter to millimeter.
  //      template <>
  //      struct into_trait<meter, millimeter> {
  //          constexpr auto operator()(core::arithmetic auto from) { return from * 1000; };
  //      };
  //  -- end example ]
  template <class /* From */, class /* To */>
  struct into_trait;

intoの解説冒頭でミリメートルとメートルの変換をしているコードが出てきた。

{ // implicit unit conversion with pipeline operator
  millimeter = std::remove_cvref_t<decltype(si::milli * si::meter)>;
  quantity<millimeter> _ = 3.0 * si::meters | into<>;
}

実はこれを記述したルールは次のようになっている。

namespace mitama::dimensional {
  template <>
  struct into_trait<std::remove_cvref_t<decltype(si::milli * si::meters)>, si::length> {
    constexpr auto operator ()(core::arithmetic auto from) const {
      return from / 1000;
    }
  };
  template <>
  struct into_trait<si::length, std::remove_cvref_t<decltype(si::milli * si::meters)>> {
    constexpr auto operator ()(core::arithmetic auto from) const {
      return from * 1000;
    }
  };
}

このようにひとつひとつルールを書くのは馬鹿らしいが、単位変換のルールは複雑なため、よい方法は現在思案中である。

スペシャルサンクス

  • 実装の方針を示してアドバイスをくれた、Azaikaさん
  • 多数のバグを発見しリファクタリングのヒントをくれた、Koryさん
  • よりよい記事になるようにアドバイスしてくれた、agate-prisさん
  • 誤字脱字を指摘してくれた、hsjoihsさん、シンジさん、koileさん

ありがとうございました。

2日目の CADDi Advent Calendar は、村上さんによる 「良いチーム開発」を実現するための「組織的学習」と「自己組織化」の話です。

16
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
6