C++
C++Day 17

C++テンプレートライブラリ書き方Tips

はじめに

前日の記事は C++のエラー処理との付き合い方 です。
明日の記事は C++03/C++11(14)/C++17でのmember detector です。

おはようごじゃいます、いなむのみたまです。
C++14 LibraryとしてずっとCranberriesを書いていました。
C++17 LibraryはMitamaと命名しました。

Rustから機能をパクってResult型を作った

std::expected 相当の型は std::variant をラッピングすることで作ることができますが、独自実装は将来における負債となりうるので、微妙なところではあります。
--- 風露

申し訳ない😢
将来における負債となりうる独自実装expectedで、直和型のようなvariantのラッパーを作ってしまったよ😢

ちょっと仕事で使いたいがために作りました。
ひっそり公開しています。

つなみにこれまで、僕のGitHubはコードの墓場でした。
今回は普通にDoxygenでリファレンス吐いてます。
コードの墓場をやめたいです。

そんなことよりテンプレートライブラリの書き方をお届けしたい

今回の記事はライブラリの宣伝がメインじゃないです。
ライブラリの書き方講座みたいなものです。

C++17時代に突入し、色々と変遷しているあれこれもついでに共有したいと思います(いつも言ってる気がする)。

Mitama.Resultについて二言三言

C++17を使って書かれています。
Result<T, E>という2つの型の値(同じでも良い)を取りうるクラスとそれに付属するモナディックメンバ関数的ななにかで構成されています。メンバ関数はRustのResultにあるものがあったりなかったりします。

Resultは[[nodiscard]]属性がついており、Result型の戻り値は無視できません。

普通にvariantをラップするとResult<int, int>variant<int, int>になってしまい、使い勝手が悪かった。
そこでResult<T, E>は内部でvariant<Ok<T>, Err<E>>という型でvariantを保持することにした。

Resultクラスの定義を見てみよう!

エイヨット!

template <class T, class E>
class[[nodiscard]] Result<T, E,
                          trait::where<std::is_destructible<T>,
                                       std::is_destructible<E>>>
    : private mitamagic::perfect_trait_copy_move<
          std::conjunction_v<std::is_copy_constructible<T>, std::is_copy_constructible<E>>,
          std::conjunction_v<std::is_copy_constructible<T>, std::is_copy_assignable<T>, std::is_copy_constructible<E>, std::is_copy_assignable<E>>,
          std::conjunction_v<std::is_move_constructible<T>, std::is_move_constructible<E>>,
          std::conjunction_v<std::is_move_constructible<T>, std::is_move_assignable<T>, std::is_move_constructible<E>, std::is_move_assignable<E>>,
          Result<T, E>>,
      public result::printer_friend_injector<Result<T, E>>
{
...
};

なんじゃこりゃですよね。
流石にね。

detection idiomによるSFINAE技法で選択的な実体化

まず、3つめのテンプレート引数について解説しよう。
まず、variantの構築にはデストラクタが呼び出せることが要求される。
よって、Resultの構築にもデストラクタが呼び出せることを要求する。

そもそも、destructibileでないテンプレート引数を与えた場合に実体化をプライマリテンプレートに飛ばしてコンパイルエラーにする。
そのために、detection idiomを使う。

C++のテンプレートの実体化は完全な特殊化が優先される。
その次に部分的特殊化が優先される。
どれもダメだった場合、プライマリテンプレートを実体化する。

次のコードを見てみる。
ResultにテンプレートパラメータT,Eが与えられたとき、コンパイラはまず下の部分的特殊化の実体化を行おうとする。

template <class T, class E, class /* for detection idiom */ = void>
class Result;

template <class T, class E>
class[[nodiscard]] Result<T, E,
                          std::enable_if_t<std::is_destructible_v<T> && std::is_destructible_v<E>>>
(以下略)

ここで、コンパイラは

std::enable_if_t<std::is_destructible_v<T> && std::is_destructible_v<E>>

を評価する。
これは、TとEが両方destructibleな場合のみ実体化に成功し、voidとなる。

実体化に失敗した場合はプライマリテンプレートが実体化されるが、定義はないので失敗する。
そしてコンパイルエラーになる。

テンプレートパラメータに与えられた型のコピー/ムーブ特性の再現(継承?)

Result<NonCopyable, std::string>のようにコピー不可能な型を扱う場合、
Resultもコピー不可能にならなければならない。

これは、std::is_copy_constructible_v<Result<NonCopyable, std::string>>falseにならなければならないということだ。

現行のC++でこれを行うのが非常に厳しい。
C++20のrequires式が渇望される。

愚痴になってしまった。

さて、次にprivate継承されている謎にテンプレートパラメータが羅列されている型を見ていこう。
ちなみに、mitamagicという名前空間はBoostで言うところのdetailである。
mitamagicには実装の詳細があり、魔術めいたものがうごめいている。

閑話休題。

perfect_trait_copy_moveクラスは4つのnon-templateパラメータをもつ。
それぞれ、コピーコンストラクト可能か、コピー代入可能か、ムーブコンストラクト可能か、ムーブ代入可能かを表している。

emplate <bool Copy, bool CopyAssignment,
          bool Move, bool MoveAssignment,
          class Tag = void>
struct perfect_trait_copy_move
{
};

代入演算子はコンストラクタと同時に提供されるという前提にしている(コンストラクタのみ or コンストラクタ + 代入演算子)。

最後のResult<T, E>はただのタグなので無視して構わない。

private mitamagic::perfect_trait_copy_move<
          std::conjunction_v<std::is_copy_constructible<T>, std::is_copy_constructible<E>>,
          std::conjunction_v<std::is_copy_constructible<T>, std::is_copy_assignable<T>, std::is_copy_constructible<E>, std::is_copy_assignable<E>>,
          std::conjunction_v<std::is_move_constructible<T>, std::is_move_constructible<E>>,
          std::conjunction_v<std::is_move_constructible<T>, std::is_move_assignable<T>, std::is_move_constructible<E>, std::is_move_assignable<E>>,
          Result<T, E>>

さて、perfect_trait_copy_moveの役割を説明する前に、特殊化を見てみよう。

template <class Tag>
struct perfect_trait_copy_move<false, true, true, true, Tag>
{
  constexpr perfect_trait_copy_move() noexcept = default;
  constexpr perfect_trait_copy_move(perfect_trait_copy_move const &) noexcept = delete;
  constexpr perfect_trait_copy_move(perfect_trait_copy_move &&) noexcept = default;
  perfect_trait_copy_move &
  operator=(perfect_trait_copy_move const &) noexcept = default;
  perfect_trait_copy_move &
  operator=(perfect_trait_copy_move &&) noexcept = default;
};

(中略)

template <class Tag>
struct perfect_trait_copy_move<false, false, false, false, Tag>
{
  constexpr perfect_trait_copy_move() noexcept = default;
  constexpr perfect_trait_copy_move(perfect_trait_copy_move const &) noexcept = delete;
  constexpr perfect_trait_copy_move(perfect_trait_copy_move &&) noexcept = delete;
  perfect_trait_copy_move &
  operator=(perfect_trait_copy_move const &) noexcept = delete;
  perfect_trait_copy_move &
  operator=(perfect_trait_copy_move &&) noexcept = delete;
};

コンストラクタと代入演算子のexplicit defaulted/deleted definitionの組み合わせがひたすら全通り並んだボイラープレートである......

このクラスにテンプレートパラメータで与えられた型のコピー/ムーブ特性をnon-templateパラメータで与えて継承する。
すると、コピー/ムーブ特性ををそのまま再現したクラスが継承される。

例えば、デフォルトコピーコンストラクタは基底クラスのデフォルトコピーコンストラクタを呼ぶ
つまり、コピーコンストラクト不可能の型がテンプレートパラメータになっている場合、
基底クラスでコピーコンストラクタをexplicit deleted definitionにしておけば、Result型もコピーコンストラクト不可能となる(基底クラスのコピーコンストラクタが削除されているため)。

これは非常にイケてないコードだと思うが、おそらくC++17の範囲内ではこれよりマシな方法がない。

テンプレートパラメータの型特性によるメンバ関数のオーバーロード制御

Result<T, E>にはunwrap_or_default()というメンバ関数を用意している。
これは、T型がデフォルト構築可能な場合のみに提供される。

色々と方法を考えた結果めんどくさくなって、SFINAEでごまかすことにした。
ああ、C++20のrequires式があれば!!!

ものすごく雑な方法だが、デフォルトテンプレートパラメータを用意してSFINAEする。
U=Tが不要な気がするが、まあいいや。

  template <class U = T,
            std::enable_if_t<std::is_same_v<T, U> && (std::is_default_constructible_v<U> || std::is_aggregate_v<U>), bool> = true>
  T unwrap_or_default() const
  {
    if constexpr (std::is_aggregate_v<T>){
      return is_ok() ? unwrap() : T{};
    }
    else {
      return is_ok() ? unwrap() : T();
    }
  }

コーナーケース(Aggregate initialization)への対応

実はデフォルトコンストラクタが削除されているクラスでもデフォルト構築ができる場合がある。
Aggregate initializationである。
次のコードのようにデフォルトコンストラクタが削除されているとしても、集成体初期化が可能である。
ただし、C++20ではAggregateにはexplicit defaulted/deleted constructorsは許可されないよう変更される見通しである。

struct NonDefaultConstructible{
    NonDefaultConstructible() = delete;
};

int main(){
    NonDefaultConstructible{}; // it is allowed in C++17
}

よって、unwrap_or_defaultでもTがAggregateの場合は集成体初期化を用いる。

ちなみに

これはC++17のif constexprがまだない太古の時代には次のように書かなければならなかった。

  template <class U = T, std::enable_if_t<std::is_same_v<T, U> && !std::is_default_constructible_v<U> && std::is_aggregate_v<U>, std::nullptr_t> = nullptr>
  T unwrap_or_default() const
  {
    return is_ok() ? unwrap() : T{};
  }

  template <class U = T, std::enable_if_t<std::is_same_v<T, U> && std::is_default_constructible_v<U>, std::nullptr_t> = nullptr>
  T unwrap_or_default() const &
  {
    return is_ok() ? unwrap() : T();
  }

複数のコンパイラによる継続的インテグレーション

MitamaではCIを全力でサボることにした。
CIのバックエンドにはWandboxを使う。
cbtを使ってテストコードを送りつけて結果だけなんとなく見てます。
これで、コンパイラとか用意する必要もないし、なんならWandboxはHEADが毎日ビルドされてるのでHEADの変更にも追いつける。

cbtについては去年書いた記事を見てください。
ところで、CircleCI 2.1になってすごくいい感じになりましたね。

version: 2.1
executors:
  default:
    docker:
      - image: buildpack-deps:bionic-curl

jobs:
  test:
    description: Testing the library using wandbox
    parameters:
      compiler:
        description: "compiler-version"
        default: "clang-head"
        type: string
    executor: default
    steps:
      - checkout
      - run: curl -sLJO https://github.com/LoliGothick/cbt/releases/download/v0.5.2/cbt_linux_amd64.tar
      - run: tar -vzxf ./cbt_linux_amd64.tar
      - run: chmod +x ./cbt
      - run: ./cbt wandbox cpp test/Result_Test.cpp --compiler=<< parameters.compiler >> --std=c++17 --boost=1.65.1 --warning --pedantic=errors
      - store_artifacts:
          path: /results/permlink
          destination: raw-test-output
      - store_artifacts:
          path: /results

workflows:
  build:
    jobs:
      - test:
          compiler: clang-head
      - test:
          compiler: clang-7.0.0
      - test:
          compiler: clang-6.0.1
      - test:
          compiler: clang-6.0.0
      - test:
          compiler: gcc-8.2.0
      - test:
          compiler: gcc-8.1.0
      - test:
          compiler: gcc-7.3.0
      - test:
          compiler: gcc-7.2.0
      - test:
          compiler: gcc-7.1.0