型レベルの単位チェック (基本編)

はじめに

この記事は社内勉強会で発表したtypes-for-units-of-measurementの続きである。

メタプログラミングがどのように役に立つのか、弊社で使われている型レベルの単位つき演算を例に説明していく。 本記事ではメタプログラミングを主眼においているため、実際にコンパイルできるコード例をたくさん書くことを心がけている。しかし、メタプログラミングC++17で書くと膨大になるため、コード量削減のためC++20を使って書かれている。

量の次元について

丸投げも甚だしいが、量の次元に関してはWikipeadia に書いてあることを一読すれば分かるので読んでいただきたい。

導入

次のようなミスはコードを書いていてありがちである:

// calc volume of box
double volume = x * y;
//              ^~~~~~
// correct code: x * y * z;

物理学をかじった経験がある者ならば、量の次元が間違っていると思うだろう。 本当は3つの辺の長さを掛け合わせるところを2辺だけを掛けてしまっている。

このようなミスをどうやって防ぐのか? コードレビューで防ぐのか、はたまたユニットテストで防ぐのか。

本記事では3つ目の方法、「型レベルで防ぐ」方法について解説をしていく。 いわゆる「単位付き(次元付き)演算」はライブラリとして割と各言語に存在しており、謎の需要があるものと思われる(その割に真面目に使われているという話はあまりきいたことがない)。

弊社で使われているのはuomというクレートである。

しかしながら、Rustでメタプログラミングをするのはつらすぎるため本記事では通してC++で説明する (なぜRustはつらくてC++だとマシであるかについては後述する)。

基本的アイデア

厳密に型を区別したいという場合に使う技法といえば、幽霊型である。 実際に値を持たない、ただ次元を区別するためだけの型(幽霊型)を用意する。

namespace units {
// 次元のための幽霊型
struct length{};
struct volume{};
}

template <class Q, class UnderlyingType>
struct quantity {
  UnderlyingType value;
};

int main() {
  quantity<units::length, double> x={1}, y={2}, z={3};
  quantity<units::volume, double> volume = x * y * z;
}

ここで問題になるのは、quantity<units::length, double>を3回乗算した結果をどうやってquantity<units::volume, double>にするのかという事案である。 量というのは基本量から生成される有限生成群であり、要するに無限に単位を組み立てることができる。 実際に使われる次元だけでも100以上はあり、いちいち変換を定義するのはナンセンスである。

一番簡単な方法

ある量体系における任意の量$[Q]$はベクトルの基底(量体系における基本量)$d_i$を用いて

[ Q ] = a_1 d_1 + a_2 d_2 + a_3 d_3 + ... + a_n d_n \\ (i = 1, 2, 3, ..., n) \\

という基底の元の有限線型結合として表すことができるベクトルである。

このことから、MKS単位を例にし

\begin{aligned} d_1 &= length \\ d_2 &= mass \\ d_3 &= time \end{aligned}

とすると

namespace units {
template <int Length, int Mass, int Time>
struct MKS {};

using length = MKS<1,0,0>;
using area = MKS<2,0,0>;
using volume = MKS<3,0,0>;

template <class, class> struct add;

template <int L1, int L2, int M1, int M2, int T1, int T2>
struct add<MKS<L1, M1, T1>, MKS<L2, M2, T2>>
    { using type = MKS<L1+L2, M1+M2, T1+T2>; };

template <class T, class U>
using add_t = add<T, U>::type;
}

template <class Q, class UnderlyingType>
struct quantity {
  UnderlyingType value;
};

#include <concepts>
template <class D1, class T1, class D2, class T2>
    requires std::common_with <T1, T2>
auto operator*(quantity<D1, T1> lhs, quantity<D2, T2> rhs)
    -> quantity<units::add_t<D1, D2>, std::common_type_t<T1, T2>>
    { return { lhs.value * rhs.value }; }

int main() {
  quantity<units::length, double> x={1}, y={2}, z={3};
  quantity<units::volume, double> volume = x * y * z;
}

のように書くことができる。 異なる量の掛け算においては、それぞれの次元標数を足し合わせ、新しい型を作ればいいのである。 割り算に関しては逆数を定義しておいて掛け算に変換すればよい。 よく使う量の次元に関しては上記のコードのようにエイリアスを定義しておけばユーザーエクスペリエンスに関しても隙がない。

有理数の次元標数

量体系がベクトルであると述べた際、係数が何であるかということについて言及せずになんとなく整数ということにしてしまっていた。 よく考えると、分数の場合もある、べき乗根だ。

次元のべき乗根にも対応する。 そうすると、コンパイル時に有理数を扱う必要がある。

ゼロから構成するとなると

  1. 型レベル整数を構成する
  2. 型レベル整数に演算を実装して体を構成する
  3. 型レベル整数を2つ使って型レベル有理数を構成する

という壮大な作業が必要になる。 明らかにやる気にならない。

C++の利点をここでひとつ明らかにしよう。 C++では整数リテラルを型に直接埋め込む事ができる。 この言語機能を利用して、標準ライブラリに型レベル有理数が存在している。 サードパーティのライブラリすら必要ないのである!

次のように改良すればよい。

#include <ratio>
namespace units {
template <
    class Length = std::ratio<0>,
    class Mass   = std::ratio<0>,
    class Time   = std::ratio<0>>
struct MKS;

template <int N1, int D1, int N2, int D2, int N3, int D3>
struct MKS<
    std::ratio<N1, D1> // Length
  , std::ratio<N2, D2> // Mass
  , std::ratio<N3, D3> // Time
> {};

using length = MKS<std::ratio<1>>;
using area = MKS<std::ratio<2>>;
using volume = MKS<std::ratio<3>>;

template <class> struct sqrt;

template <class L, class M, class T>
struct sqrt<MKS<L, M, T>>
    { using type = MKS<std::ratio_divide<L, std::ratio<2>>, std::ratio_divide<M, std::ratio<2>>, std::ratio_divide<T, std::ratio<2>>>; };

template <class T>
using sqrt_t = sqrt<T>::type;
}

template <class Q, class UnderlyingType>
struct quantity {
  UnderlyingType value;
};

#include <concepts>
#include <cmath>

template <class D, class T>
    requires requires (T x) { std::sqrt(x); }
auto sqrt(quantity<D, T> x)
    -> quantity<units::sqrt_t<D>, decltype(std::sqrt(std::declval<T>()))>
    { return { std::sqrt(x.value) }; }

int main() {
    quantity<units::length, double> x = {1};
    quantity sqrt_x = sqrt(x);
}

これだけでも十分実用性がある用に見えるが、実際にはそうでもない。

次回、上級編

実際に用いられているライブラリの設計がどうなっているのかを紹介し、なぜそのような設計になったのかを考察する。 そして、それを可能にするメタプログラミング技法について解説する。