C++テンプレートを再考する

レトリバ製品開発部の酒好き武闘派エンジニア田村です。 仕事でC++ばかり書いてるのに何故かセミナーで趣味のElixirを話しましたが、文字だと勢いで押しきれない気がするのでC++を取り上げることにしました。

C++20(またはC++2a)の規格化が来年近づいてきていますが、C++20では、ついに何度も棚上げされてきたコンセプトが採択されました。g++ -std=c++2a で試そうかと思ったのですが、コンパイラがまだ対応していない悲しみの中、まずはテンプレートを見直そうと勉強したところ、思った以上にだいぶ深かったので、今回は単純なコンテナの型指定や関数の引数の型指定以外でのtemplateのテクニックを取り上げたいと思います。帰ってこれなくならない程度の深さで…。基本・応用テクニックを中心にC++20の新機能、コンセプトもコンパイルできないため規格書ベースですが、少しだけ触れられればと思います。普段コンテナの型指定にしかtemplateを使っていなかった人やC++興味あってもあまり触れていない人の刺激になれば幸いです。

導入

templateとは

C++を主戦場にしてない人のために簡単に説明するとtemplateはジェネリックプログラミングの一種で要は型を指定せずに汎用的なコードが書ける構文になります。クラスや関数の雛形を作るようなイメージで、型を指定された時点でコンパイラがその型を使って実体化して利用されます。C++erの人でもコンテナの一般化や異なる数値型を扱う関数の共通化などに使うもの、と思ってる人が多いかも知れませんが、テンプレートには多くの可能性があり、Lokiなどのライブラリでは熟練者も驚くような使い方がされたり、type_traitsでも便利な実装がされています。 一番良く使うテンプレートはコンテナの vector<T> でしょう。T型を格納する可変長配列です。STL(Standard Template Library)では便利なデータ構造やアルゴリズムがテンプレートとして提供されていてお世話になっている人も多いでしょう。関数テンプレートではcast関数(static_cast<float>()など)は使う機会が多いと思います。

テンプレートの利点

簡単に思いつくのは、複数の型に対する処理をまとめて記述できることだと思います。テンプレートを処理やクラスの実体化の観点で見た時、コンパイル時に評価され、利用する型がわかった時点で実体化されるという特徴があります。これはコンパイル時の実行と考えることができ、曖昧さを多分に含む実行時のエラーを予め捕捉することができます。また、コンパイル時の実行ということは、テンプレートパラメータで処理が完結する(実行時のパラメータでなく)のであれば実行ファイルには既に計算済みの結果が入ることになります。これは実行時に計算が不要ということで、パフォーマンスの点でも非常に嬉しいことです。

C++20のコンセプト・契約

コンセプトや制約は引数や指定の型の事前・事後の約束事(契約)を利用者が守っていることの確認が簡単にできると有用であるため、これらの機能はC++0x時代から導入が検討されていましたが、先送りになっていました。繰り返しになりますが、コンパイル時に前提の確認ができるということはそれだけ実行時のエラーを事前に潰すことができます。また、実行時に契約を遵守させることで、その後に未定義(undefined)な処理が行われる可能性をなくす事ができます。これまでは、コンパイル時はstatic_assert、実行時はassertもしくは例外を投げることで確認が可能でしたが、より簡潔に表現できるようになります。static_assert同様、コンセプトに反するテンプレートの利用はコンパイルエラーになります。契約属性ではexceptionが投げられることになっているので、独自に例外処理を行うより楽ですし、assertではstd::abortが呼ばれてプログラムが終了してしまうので、コードのクリーンさでも可用性でも恩恵が得られます。

今回はtemplateと併せて使われるconceptのみをC++20のWorking Draftベースに簡単に取り上げたいと思います。

基本的なテクニック

特殊化

テンプレートのジェネリックなコードに加え、特定の型では違う処理をしたい、といった時に特殊化という手法が用いられます。特殊化には全てを特殊化する完全特殊化と一部を特殊化する部分特殊化があり、完全特殊化のサンプルに関しては、テンプレートメタプログラミングの項目で利用しているので、そちらでご確認ください。また、一部だけ特殊化を行う部分特殊化は、例えば、

template <typename T, typename U>
struct Widget;

template <typename T>
struct Widget <T, std::string> { ... };

と言ったように、Uが std::string の場合のみ処理を変える、というような手法です。 また、ポインタの場合処理を変えたい、というのは部分特殊化のように見えますが、こちらはオーバーロードという扱いになります(関数テンプレートは部分特殊化が許可されていません)。

template<typename T>
void Log(T t) {
  std::cout << t << std::endl;
}

template<typename T>
void Log(T* t) {
  std::cout << *t << std::endl;
}
...
int n = 10;
int* p = &n;
Log(n); // 10
Log(p); // 10

この場合、1個目の関数テンプレートでポインタのままだと、参照先アドレスが表示されてしまうので、2個目のオーバーロードで値を出力するように処理を切り分けています。

型リスト

std::vector<std::vector<float>> のようにテンプレートはネストして使えます。 これを利用してタイプを列挙したタイプのリストを作ることができます。

struct NullType;
template <typename Head, typename Tail>
struct TList; 

using MyNums = TList<short, TList<int, TList<long, NullType>>>;

この例では、2つずつ型を指定する必要があるので、最後を示すNullTypeというものを定義して利用しています。MyList<short, MyList<int, long>>;とすることも可能ですが、後述のメタプログラミングやポリシーなどの手法に使う場合、再帰の終了条件として、こういった構造体を定義した方が都合のいいことが多いです。

パターンマッチングによる型の取得

templateでは他のtemplateのパターンマッチングができるため、テンプレートパラメータに指定された型を取得することができます。例えば、std::array<T, N>(Tは型、Nはサイズを指定する、固定長配列)のサイズを取得したい場合に、

template <typename T, size_t N>
size_t Size(std::array<T, N>) {
  return N;
}

とパターンマッチングさせて返すことができます。引数名を指定せずにテンプレートパラメータだけを使ってプログラミングが可能なのは面白いです。C形式の配列でも下記のやり方で取得できます。

template<typename T, size_t N>
size_t Size(const T (&array)[N]) {
  return N;
}

エイリアスによる型の定義

c++0x系ではtypedefc++11以上では using でのエイリアスが薦められている型の定義をtemplateのstructで呼ぶことで、templateに渡した型に紐づく判定結果の型をコンパイル時に定義し参照できるようにする方法があり、 type_traits ヘッダの中やいろんなライブラリで利用されています。

template <typename T, typename U>
struct IsSameImpl {
  using value = std::false_type;
};

template <typename T>
struct IsSameImpl <T, T> {
  using value = std::true_type;
};
template <typename T, typename U>
struct IsSame : public IsSameImpl<T, U>::value {};
...
std::cout << IsSame<int, int>::value << std::endl; // 1
std::cout << IsSame<int, long>::value << std::endl; // 0

テンプレートパラメータパック

可変長の引数を扱う方法です。

template <typename... Args>
Widget Create(MyEnum type, Args... args) {
  if (type == MyEnum::F1) {
   return f1(std::forward<Args>(args)...);
   ...
}

また、1つずつ取り出す再帰処理として

struct NullType {};
// 再帰的に探す
template <typename T, typename Head, typename... Tail>
struct Find {
  static const bool value = Find<T, Tail...>::value;
};
// 発見!
template <typename T, typename... Tail>
struct Find <T, T, Tail...> {
  static const bool value = true;
};
// 最後まで見つからない場合
template <typename T>
struct Find <T, NullType> {
  static const bool value = false;
};
...
std::cout << Find<int, char, long, double, NullType>::value << std::endl; // 0
std::cout << Find<int, char, long, int, double, NullType>::value << std::endl; // 1

というような書き方もできます。関数言語でリストを扱うのに似た処理です。

応用テクニック

テンプレートメタプログラミング

テンプレートでコンパイル時に処理が終わるようなプログラムを書いてしまう手法です。 色々な手法がありますが、ここでは簡単に再帰と高階メタ関数に関して取り上げます。

再帰

例えば、

long Fibonacci(int n) {
  if (n < 2) return n;
  return Fibonacci(n - 2) + Fibonacci(n - 1);
}

という計算量O(2n)でn=50程度でも実行結果が来るまでにお手洗いを済ませられる(他言語なら漫画が1冊読めるかも知れない)、積み重ねるほどに不幸になる代表的な再帰処理をテンプレートでの再帰としてそのまま書いてみると、

template <int N>
struct Fibonacci {
  static const long value = Fibonacci<N-2>::value + Fibonacci<N-1>::value;
};

template <>
struct Fibonacci<0> {
  static const long value = 0;
};

template <>
struct Fibonacci<1> {
  static const long value = 1;
};

となります。ここで、Fibonacci<0>Fibonacci<1> の特殊化を行っています。再帰のbase caseは特殊化によってストップさせます。こちらも再帰なのですが、計算はコンパイル時に行われます。そうなるとコンパイルに時間がかかると思うかも知れませんが、実際はテンプレートの実体は1回しか作られないので、N個の実体ができれば残りは実際を参照するだけで計算ができます。50程度でも普通の再帰では結果がなかなか返ってきませんが、メタプログラミングの場合、コンパイル後は Fibonacci<50>12586269025 に置き換わっているので、実行時の計算のコストはありませんし、コンパイルも一瞬です。

テンプレートメタプログラミングはほぼチューリング完全(実際はコンパイル時間の問題で標準で再帰は1024回までと規定されている)との証明があり(Veldhuizen, 2013)再帰回数の制限を満たすあらゆる処理が可能とされています。

お気づきの方もいるかと思いますが、パターンマッチングが行われていて、ループを再帰で表現するなど、関数型言語と近い考え方で書けると思います。

高階メタ関数

関数もtemplateで渡せます。例えば型のわからない2つのvectorに対して計算する関数を渡す場合、

template <typename T, typename BinaryFunction>
std::vector<T> Execute(std::vector<T>& a, std::vector<T>& b, BinaryFunction&& func) {
  std::vector<T> result;
  for (size_t i = 0, size = a.size(); i < size; ++i) {
    result.push_back(func(a[i], b[i]));
  }
  return result;
}

という風に書けます。

テンプレートメタプログラミングのメリットとデメリット

メリットはコンパイル時に計算が終わっているので、実行時のコストが0であるのと、それぞれの段階での実体化が1回しか行われないので通常の再帰では無駄な処理が行われる部分が自動的に再利用されて、簡潔でも速度のでないアルゴリズムが高速化できます。 デメリットは可読性が低いことと、コンパイル時に定数でtemplateを定義しなくてはいけない(例えば Fibonacci<N> のNの部分はコンパイル時の評価のために定数である必要がある)ということです。プログラムに問題がある場合ですが、エラーが読みにくいという面もあります。

テンプレートメタプログラミングは闇と言われたりもしますが、使いこなせばかなりの可能性を秘めています(保守性は周りに頑張ってもらうしかないですが)。また、C++11から constexprメタプログラミングに使えます。そちらを使ったコンパイル時レイトレーサーなど様々な処理が中3女子で有名な村上原野さんのSproutというライブラリに入っているので、興味ある方はそちらも是非御覧ください。

SFINAE(スフィネェ)

さて、もう少し深く潜ってみたいと思いますが、まだ息が続いているでしょうか。 まだまだ余裕であれば、次は発音がよくわからないテクニック、SFINAEを軽く触れたいと思います。SFINAEとはSubstitution Failure Is Not An Errorの省略で、「templateでの置き換えの失敗はエラーではない」というコンパイラの作用の原則で、置き換えの失敗でオーバーロードを一意にする(1つ以外失敗するように書く)ことで実体化の際に処理を選択(特性の特定)をしてしまうテクニックです。よく使われるのはtype_traitsヘッダで定義されているstd::true_typestd::false_typeを設定した構造体を返して、それを継承したクラスを定義する方法です。true_typeはstaticな固定値の変数valueにtrueを持っており、false_typeはfalseを持っているので、これでクラスの特性を判定できます。

例えば、T型とU型で変換できるか確認したい場合、

struct IsConvertableImpl {
  template <class T, class U>
  static auto check(T*, U*) ->
      decltype(std::declval<T&>() = std::declval<U&>(), std::true_type());
  template <class T, class U>
  static auto check(...) -> std::false_type;
};

template <class T, class U>
struct IsConvertable :
    public decltype(IsConvertableImpl::check<T, U>(nullptr, nullptr)) {};

int main() {
  std::cout << "int <- long:" << IsConvertable<int, long>::value << std::endl;
  std::cout << "vector<string> <- int:" 
            << IsConvertable<std::vector<std::string>, int>::value << std::endl;

  return 0;
}
// int <- long:1
// vector<string> <- int:0

これは何が起こっているかと言うと、 decltype という変数や関数の戻り値の型を取得する関数と declval という評価されない式を作成する関数の組み合わせで置き換えが成功するか確認をしています。 std::declval<T&>() = std::declval<U&>() はU型をT型への暗黙の変換もしくは代入できる場合に置き換えが成功し、カンマ演算子は最後のものが評価されるため、static auto check(T*, U*) -> decltype(std::declval<T&>() = std::declval<U&>(), std::true_type()) これはstd::true_type 型を返す関数checkとなります。置き換えに失敗した場合、オーバーライドしたstatic auto check(...) -> std::false_type; が実体化され、判定ができるようになります。実体がないのにコンパイルができるのが面白いですね。decltypeオペランド評価されないオペランドとされているので、戻りが定義できれば実装は必要ありません。

SFINAEの実現には置き換えに成功するもの、失敗するもので切り分けられればいいので、上記の例のように戻り値の指定ではなく、関数呼び出し、引数や、type_traits に用意されたメタ関数enable_if を使って、テンプレート引数によるオーバーライドなども可能です。

ポリシー

Policy-based designはModern C++ DesignでAndrei Alexandrescu氏が提唱した手法で、特定のポリシーに対する統一された動作をテンプレートで選択し、テンプレートパラメータで処理するクラスや構造体を注入する、一種のデザインパターンのようなものです。ポリシーを組み合わせることでクラスが設計できるため、クラス定義だけで設計の思想が伝わる利点もあります。 ポリシーの手法には大きく分けると下記3つのパターンがあります。

staticメンバ関数を利用

struct Bold {
  static std::string Deco(const std::string& str) {
     std::string tag_str = "<b>";
     tag_str.append(str);
     tag_str.append("</b>");
     return tag_str;
   }
 };
 
 template <class DecoratePolicy>
 struct Widget {
   std::string str;
   void Print() {
     std::cout << DecoratePolicy::Deco(str) << std::endl;
   }
 };
 ...
 Widget<Bold> w{"aaa"};
 w.Print();  // <b>aaa</b>

Printを呼ぶと指定されたポリシーで装飾されて出力されます。

非staticメンバ関数を利用

template <class DecoratePolicy>
struct Widget {
  DecoratePolicy policy;
  std::string str;
  void Print() { std::cout << policy.Deco(str) << std::endl; }
};

メンバ変数にポリシーを持つことで処理を実現します。

継承を利用

template <class DecoratePolicy>
struct Widget : public DecoratePolicy {
  std::string str;
  void Print() { std::cout << this->Deco(str) << std::endl; }
};

親クラスをテンプレートで指定して、任意の箇所で関数を呼び出して利用します。

ポリシーを複数設定する

上記のDecoratePolicyは複数組み合わせて使いたいと思います。例えば、ここでの例としては太字の斜字体とかです。これは型リストを利用すれば簡単に実現できます。

struct NullType {};
template <typename T, typename U>
struct TypeList {
};

template <class TList>
struct Decoration {
  static std::string Deco(std::string str);
};
// 再帰で最後から処理する
template <typename Head, typename Tail>
struct Decoration <TypeList <Head, Tail>> {
  static std::string Deco(std::string str) {
   return  Head::Deco(Decoration<Tail>::Deco(str));
  }
};
// 最後はそのまま返す
template <>
struct Decoration <NullType> {
  static std::string Deco(std::string str) {
    return str;
  }
};

struct Bold {
  static std::string Deco(std::string str) {
    return std::string{"<b>"} + str + std::string{"</b>"};
  }
};
struct Italic {
  static std::string Deco(std::string str) {
    return std::string{"<i>"} + str + std::string{"</i>"};
  }
};

template <typename DecorationPolicy>
struct Context {
  std::string str;
  void Print() { std::cout << DecorationPolicy::Deco(str) << std::endl; }
};
...
  Context<Decoration<TypeList<Italic, TypeList<Bold, NullType>>>> context{"test"};
  context.Print();  // <i><b>test</b></i>

これで<i><b>test</b></i>とネストして適用ができました。ただ、型リストはそのままだと美しくないので、Modern C++ Designでもやっているようにマクロを定義するのがいいと思います。

#define TYPELIST_1(T1) TypeList<T1, NullType>
#define TYPELIST_2(T1, T2) TypeList<T1, TYPELIST_1(T2)>
#define TYPE_LIST_3(T1, T2, T3) TypeList<T1, TYPE_LIST_2(T2, T3)>
...
  Context<Decoration<TYPELIST_2(Italic, Bold)>> context{"test"};
  context.Print();

これでスッキリ見やすくなりました。 ポリシーは本1冊使うくらい深い手法なので、面白いと感じた方はModern C++ DesignやライブラリLokiを読んでみてください。

型消去

テンプレートと継承を利用して型を隠してデータを保持したり特定の関数を呼び出すことができます。

ここではデータの型を隠す(型指定なしに何でも持てる)例を紹介しますが、これは非テンプレートクラスを継承したテンプレートクラスにデータを保持し、基底クラスのポインタを持たすことで実現できます。コンストラクタにテンプレートを使うことで、型の指定をせずに生成できるため、値を保持する時には型を意識する必要がありません。つまり、前述のようになんでも入れられる型を作れます。ただし、値を取得するときには明示的に型を指定する必要があります。型が違うとdynamic_castnullptrが返るので、チェックを入れたほうが安全ですが、今回は省いています。

class Data {
 public:
  template<typename T>
  Data(const T& data) {
    data_ = std::make_shared<Derived<T>>(data);
  }
  template <typename T>
  T GetData() const { return dynamic_cast<Derived<T>*>(data_.get())->GetValue(); }

 private:
  class Base {
   public:
    virtual ~Base() noexcept = default;
  };
  
  template <class T>
  class Derived : public Base {
    T value_;
   public:
    Derived(const T& value) : value_(value) {}
    virtual ~Derived() noexcept = default;
    T GetValue() const { return value_; }
  };
  std::shared_ptr<Base> data_;
};
...
  Data d = 123.45;
  std::cout << d.GetData<double>() << std::endl;  // 123.45
  Data d2 = 123;
  std::cout << d2.GetData<int>() << std::endl;  // 123

この例ではインナークラスのBaseを継承したホルダークラスとなるDerivedのスマートポインタを持ったData型を使うことで、 Data(123.45) でも Data(123) でも生成できています。 蛇足ですが、上記では型推論doubleとして扱われているため、floatで格納したい場合、123.45fと型を指定します。

テンプレート型演算子

struct IsXXX {
  bool flg;
  bool operator() const { return flg; }
};

operator関数で上記のように型変換が定義できますが、テンプレートを使うとかなり強力な型変換ができます。

template <typename T>
struct Number {
  static_assert(std::is_arithmetic<T>::value, "Specify integral types.");

  T num;
  template <typename U>
  operator U() const { return static_cast<U>(num); }
};

...
  Number<int> i{10};
  Number<float> f{123.45};
  // Number<std::string> s{"abc"}; // static_assertによるエラー
  float f2 = i;
  int i2 = f;
  char c = f;
  
  std::cout << f2 << std::endl;  // 10
  std::cout << i2 << std::endl;  // 123
  std::cout << c  << std::endl;  // {

このNumber型は算術演算ができる型を値に持ち、値が変換可能な全ての型に変換します。不可能な方の場合、static_assertコンパイルエラーが発生します。std::is_arithmetic<T>はtype_traitsヘッダに組まれる、算術演算可能かの判定テンプレートです。C++17以降はエイリアスis_arithmetic_v<T>を使うと::valueを省略できます。

コンセプト

C++17まで

C++17まではstatic_assertを用いることでコンセプトの確認が実現できました。

struct EqualityComparableImpl {
  template <class T, class U>
  static auto check(T*, U*) ->
      decltype(std::declval<T&>() == std::declval<U&>(), std::true_type());
  template <class T, class U>
  static auto check(...) -> std::false_type;
};
template <class T, class U>
struct EqualityComparable :
    public decltype(EqualityComparableImpl::check<T, U>(nullptr, nullptr)) {};

template <typename T, typename U>
bool IsValid(const T& a, const U& b) {
  static_assert(EqualityComparable<T, U>::value, "T and U must be equality-comparable.");
  return true;
}
...
  int i = 10;
  long l = 10;
  char c = 10;
  std::string str = "10";
  std::cout << IsValid(i, l) << std::endl;
  std::cout << IsValid(i, c) << std::endl;
  std::cout << IsValid(l, c) << std::endl;
//  std::cout << IsValid(i, str) << std::endl; コンパイルエラー

type_traitsで用意されたテンプレート以外だとチェックのための部分が非常に長い上に関数ごとにstatic_assertを入れるとなると同じ条件の複数の関数があった場合、抜け漏れが心配です。できなくはないですが、static_assertをまとめたテンプレート関数というのも、少し気持ち悪い気がしますし、テンプレートパラメータ数ごとに用意しなくてはいけません。

C++20でのconcept

ここでは2019年4月現在の最新のWorking DraftであるN4810に基づいて書きたいと思います。まだ規格化まで時間があるので、変更が入る可能性が多分にあります。 現状コンセプトライブラリに関しては下記の4カテゴリのコンセプトが組み込まれる予定になっています。

  • 言語関連コンセプト(Language-related concepts)
  • 比較コンセプト(Comparison concepts)
  • オブジェクトコンセプト(Object concepts)
  • 呼び出しコンセプト(Callable concepts)

それぞれのコンセプトの詳細はWorking Draftを御覧ください。

コンセプトの定義方法

コンセプトの構文は

template <class T>
 concept コンセプト名 = 条件;

となりますが、条件に関しては組み込みのコンセプトに加え、requiresで個別の指定ができます。 例えば、コピー構築可能かつ等値比較ができるコンセプトを、組み込みのCopyConstructible コンセプトを使って次のように書くことができます。、

template <class T>
  concept C = CopyConstructible &&
    requires(const remove_reference_t<T>& a, const remove_reference_t<T>& b) {
      {a == b} -> Boolean;  // a == bの結果がBooleanコンセプトを満たす
    };

とするとCopyConstructibleかつ独自条件(上記の例では{...}->Boolean部分)が求められます。ちなみに引数に適用されているremove_reference_tはtype_traitsヘッダ内にあるremove_reference<T>::valueエイリアスでTに右辺参照、左辺参照が付いている場合でもデフォルトの型に変換できるテンプレートです。標準ライブラリで定義されたコンセプトではtype_traitsヘッダでの型変換が多数用いられています。 また、requires内でコンセプトが再利用でき、{...} ->でつなぐと、結果で特殊化ができるようです。Working Draftでも下記のように複合要件(Compound requirements)の説明がありますが、

requires {
  { E1 } -> C;
  { E2 } -> D<A1, ... An>;

は以下と同義とのことです。

requires {
  E1; requires C <decltype((E1))>;
  E2; requires D <decletype((E2)), A1, ..., An>;
};

requireは上記のようにネストすることもできます。

また、例外を投げない条件も指定でき、その場合は下記のように指定します。

requires (T x) {
  { f(x) } noexcept;
}

コンセプトの実体の書き方

実際にコンセプト適用した関数やクラスを実装するには2種類の書き方があります。

template <typename T> requires C <T> // (1)
T f1(T x) { return x; }

template <C T> // (2)
T f2(T x) { return x; }

(1) はrequiresでコンセプトであることを明示する方法で、(2)はテンプレートパラメータで直接Tを縛っています。コンセプトを指定の際に特殊化することはN4810の時点ではできません。 コンセプトのパラメータにはある程度の自由度があり、sizeofdecltypeなど、コンパイル時間数の利用が可能です。

まとめ

C++20のコンセプトに関しては、それぞれの関数内にstatic_assertとして埋め込んでいた検証を統一した形で特性のようなまとまりで用いることができるようになった、という印象を受けました。併せて、C++標準ライブラリそのものも制約が整理され、見やすく明確になっています。 規格書を見ると、<memory> <functional> <iterator>を始め多くの標準ライブラリでコンセプトを利用するように変更が入っている(全体的に"Assertion/note, pre-/post-conditinon"を記載したrequirementsの表が置き換わっている)ので、もしかすると、多くのC++プログラマコンパイルエラーで初めてコンセプトを目にすることになるかも知れません。そもそも、普段のC++プログラミングで型操作を行う機会は少ないので(使いこなせると技術の幅は広がりますが)、SFINAEやtype_traitsの理解がないと独自conceptを自在に書くのはハードルが高い気もします。前述の通り、標準ライブラリでも全体的にコンセプトを使う形で変更が入るようなので、C++20を導入するまでには(おそらくたいていの企業ではかなり先でしょうが)templateのテクニックに馴染んでおいた方が幸せになれるでしょう。

今回、コンセプトの理解以上にtemplateを学び直したことの価値は高かったのではないかと感じています。C++17までの規格書でも標準ライブラリはtemplateとconstexprが組み合わさったようなものが多く、これらの技術を使いこなすことはC++の理解を深める上で非常に役立つでしょう。

C++20の規格化まで1年位あるので、今後の変更も注目していきたいと思います。

[参考資料]