C++ 的模板是 C++ 的一个重要的语言特性,我们使用的 STL 就是 Standard Template Library 的缩写,但是在很多情况下,开发者都对其敬而远之,有些团队甚至是直接在工程中禁用模板。模板常被当作洪水猛兽的一个原因是许多人提起模板就要提 C++ 模板图灵完备,甚至还要再秀一段编译期排序,这种表现模板强大的方式不仅不会让人觉得模板有用,反而让人觉得模板难以理解而且不应该使用。在这篇文章里,我将聊一下最近实际工程中的一些模板的应用,希望可以让更多人了解到模板并不是一个可怕的存在,以及一些常见的使用方式。

按版本号过滤配置

我所在的项目组前后台的复杂配置现在都用 protobuf 进行承载,然后生成 Excel 进行配置,生成 C++ 代码进行加载。例如这样的 message:

message ConfigItem1 {
  int32 id = 1;
  string text = 2;
}

message Config {
  repeated ConfigItem1 items1 = 1;
}

这里的 Config 会被映射为一个 Excel,里面有一个表 items1,其中,这个表有两列,一列 id,一列 text。这个表的每一行都是一个具体的配置项。也就是我们可以这样获取配置:

cout << cfg.items1(0).id() << ": " << cfg.items1(0).text();

现在有个需求是这样的,在加载某些配置的时候,前台需要根据版本号进行配置的过滤,部分配置项只会在某些版本中可见,例如这样:

message VersionRange {
  int32 lo = 1;
  int32 hi = 2;
}

message ConfigItem2 {
  repeated VersionRange version_ranges = 1;
  int32 id = 2;
  int32 value = 3;
}

message Config {
  repeated ConfigItem1 items1 = 1;
  repeated ConfigItem2 items2 = 2;
}

加载的时候大概有这样的代码:

// 加载配置时进行过滤
for (auto iter = cfg.items2().begin(); iter != cfg.items2().end();) {
  if (!IsAvailableVersion(*iter, ver)) {
    iter = cfg.mutable_items2()->erase(iter);
  } else {
    iter++;
  } 
}

这个 IsAvailableVersion 要怎么实现呢?我们当然可以对每个配置项类型都实现一个函数重载,但是我们也可以使用函数模板来生成这些代码,非常简单:

template<class CfgItem>
bool IsAvailableVersion(CfgItem const &item, int ver) {
  auto const &ranges = item.version_ranges();
  if (std::begin(ranges) == std::end(ranges)) {
    return true; // 如果 version range 列表为空,默认返回 true
  }
  for (auto const& range : ranges) {
    if (ver >= range.lo() && ver <= range.hi()) {
      return true; // 如果当前版本在范围内,就返回 true
    }
  }
  return false; // 如果 version range 列表不为空,默认返回 false
}

但这里有个问题,不是每一个配置项的类型里都有 version_range 字段,例如 ConfigItem1 就没有。这就导致了 IsAvailableVersion 不能对所有的配置项对象进行使用,这不利于我们统一 code gen 上面加载配置时进行过滤的代码。

这时候,我们想要做的,是对 IsAvailableVersion 的类型参数进行限制,根据这个类型是否带有 version_range 字段来决定是否进行过滤:

template<class CfgWithVerRange>
bool IsAvailableVersion(CfgWithVerRange const &item, int ver) { /* 实现同上 */ }

template<class CfgWithoutVerRange>
bool IsAvailableVersion(CfgWithoutVerRange const &item, int ver) { return true; }

可惜编译器没法通过类型参数的名字明白我们的意图,因此我们需要用一些技巧达到我们的目的:

template<class CfgItem, class = void>
struct IsAvailableVersionHelper {
  static bool Check(CfgItem const&, int) { return true; }
};

template<class CfgItem>
struct IsAvailableVersionHelper<
  CfgItem,
  lib::void_t<
    decltype(std::begin(std::declval<CfgItem>().version_ranges())->lo()),
    decltype(std::begin(std::declval<CfgItem>().version_ranges())->hi())
  >
> { /* 实现同上 */ };

template<class CfgItem>
bool IsAvailableVersion(CfgItem const &item, int ver) {
  return IsAvailableVersionHelper<CfgItem>::Check(item, ver);
}

这时候我们就可以放心地写 IsAvailableVersion(*iter, ver) 了,如果传入的是 ConfigItem1,则使用的是上面原始的实现,而 ConfigItem2 则使用的是下面特化的实现。

这是如何做到的呢?我们知道,C++ 的模板有个规则是 SFINAE,这不是一个单词,而是 Substitution Failure Is Not An Error 的缩写,也就是说,编译器在基于模板生成代码时,如果将模板的类型参数置换为给定的类型时,如果失败,编译器不会报错,而是将这个结果从可选的集合里丢弃,并从剩下的中进行选择。

当我们将 ConfigItem1 放入时,上面的版本能够正确替换,而下面的版本则因为 ConfigItem1 没有 version_range 字段而失败,此时,编译器会将这个失败的版本抛弃,由于只剩下原始版本了,因此选择了原始版本。

这里的 lib::void_t 是什么?std::void_t 是 C++ 17 之后才在 STL 中提供的模板,它很简单也非常有用,功能是将任意的类型序列映射到 void 上,也就是忽略掉这些类型。由于我们在使用 C++ 11,因此需要自己实现一下:

// C++11 中这样简单实现可能会有 bug,参考 en.cppreference.com/w/cpp/types/void_t
// template<class...>
// using void_t = void;
template<class... Ts> struct make_void { using type = void; };
template<class... Ts> using void_t = typename make_void<Ts...>::type;

这里使用 void_t 将多个类型声明忽略掉以适应 template<class CfgItem, class = void> 中的第二个类型参数:

decltype(std::begin(std::declval<CfgItem>().version_ranges())->lo()),
decltype(std::begin(std::declval<CfgItem>().version_ranges())->hi())

虽然说这两个类型声明被忽略了,但是它们还是会参与替换,decltype 可以根据括号里的表达式计算出其类型,而 std::declval<T>() 则相反,给定一个类型,它可以获得该类型的值,虽然这个值并不是有效的,但是在这个类型声明里我们可以用它来填写表达式。如果一个类型没有带 version_ranges 字段,则 std::declval<CfgItem>().version_ranges() 会失败,如果这个 version_range() 返回的对象不支持 std::begin,则 std::begin(...) 会失败,若这个 std::begin 计算出来的迭代器不支持 lo 函数,则 std::begin(...)->lo() 会失败,这里的结合就确保了 CfgItem 类型必须有 version_range,且每一个 version_range 都是可迭代的,且每一个 range 都有 lo 成员。下面的 hi 也是类似的。当然,我们还可以通过 std::is_same 之类的 type trait 进一步确保 lohi 返回的类型,这个就不在此演示了,对于我们的需求而言,这样就足够了。

那说回来,如果我们填入的是 ConfigItem2 会怎样?在这个时候,两个类型替换都会成功,但由于原始版本中,第二个类型参数是默认值 void,而特化版本中,则填入了自定义的一个类型 lib::void_t...,虽然这个类型最后计算出来的类型还是 void,但它依然是比原始版本更「特殊」的版本,因此编译器会选择这个版本,这就达到了我们的目的。

Data blob 操作辅助类

在公司中,我们有自己的 NoSQL 数据库服务,我们在使用的过程中常常有这样的模式:

MyDataBlob data{};
data.key1 = ...;
data.key2 = ...;
DbApi api(...);
int const res = api.Get("tablename-x", &data, sizeof(data));
if (res == RSP_ERROR && api.GetDbErr() == NOT_EXIST) {
  LOGDBG(...); // 数据不存在,打印调试日志
} else if (res != 0) {
  LOGERR(...); // 其他错误,打印错误日志,返回错误
  return ERR_DB_GET_FAIL;
} 
// 正常逻辑,使用 data ...

这里先创建一个空白的数据对象,填入它的 key 值,然后调用 API 拉取数据。由于 DB 会将拉取不存在的数据这种情况也认为是一个错误,而数据不存在对于业务而言又往往不是一个错误,因此我们一般是要对这种情况单独进行处理。

这种重复的工作显然可以抽象一个更加方便的 API 类型出来,希望能更轻松地进行使用。一个简单的想法是这样的:

template<class Db>
struct Result {
  int code{};
  int subCode{};
  Db data{};
  
  bool IsError() { return code != 0 && subCode != NOT_EXIST; }
  bool NoExist() { return code != 0 && subCode == NOT_EXIST; }
}

template<class Db>
struct NewDbApi {
  Result Get() {
    DbApi api(...);
    Result res{};
    res.data.SetKey(???); // 1
    res.code = api.Get(res.data.TableName(), &res.data, sizeof(res.data)); // 2
    res.subCode = api.GetDbErr();
    if (res.IsError()) {
      LOGERR(...);
    }
    return res;
  }
}

这里我们碰到了一点麻烦的问题,首先,在 1 处,这个 data.SetKey() 我们不知道应该怎么填。当然,我们可以像原先一样在外部自行设置 key,然后再将 data 传进来,但是我们更加希望能够免去这一个步骤,直接通过 Get 函数的参数传入对应的 key,然后转交给 data。但我们又不知道这个 Db 类型的 key 是什么,那我们该怎么办呢?也许我们可以这样做:

template<class ... Args>
Result Get(Args &&...args) {
  ...
  res.data.SetKey(std::forward<Args>(args)...); // 1
  ...
}

呃……这确实可以实现我们要的效果,但是这个实现方法并不好,它带来了不必要的复杂度。最让人难受的一点是,我们丢失了 data.SetKey 所需参数的类型信息,这让调用者完全不知道这里应该填什么数据。为了解决这个问题,我们可以添加一层抽象,让 Db 类型告诉我们 key 的类型是什么:

Result Get(typename Db::key_type const &key) {
  res.data.SetKey(key); // 1
  ...
}

这样简单多了,Get 函数的调用者可以获知对应的 key 的类型。

另外一个问题是,1 和 2 处我们直接调用了 dataSetKeyTableName 成员函数,但是我们的 MyDataBlob 是一个用另外一个工具基于 XML 描述生成出来的代码,主要实现的是序列化和反序列化功能,我们没法去通过修改这个工具来添加新的接口。所以我们只能使用 adapter 模式解决这个问题:

struct MyDataBlobAdapter {
  using key_type = ...;
  void SetKey(key_type const &) { ... }
  std::string TableName() const { ... }
  MyDataBlob myDataBlob{};
}

这就可以解决上面提到的问题了,给 Get 函数的实现提供了 SetKeyTableName。我们可以发现,Result 里的数据类型不再是 MyDataBlob 了,而是 MyDataBlobAdapter,使用者拿到了这个对象后,使用的方法不再是 res.data 而是 res.data.myDataBlob 了。这在大多数情况下不是什么大问题,但是,如果一个数据不只是被这一个接口操作,而是被多个接口操作那该怎么办呢?这时候,为了适配多个接口,我们可能需要多个 adapter。例如:

NewDbApi<MyDataBlobAdapter> api1{};
OtherAPI<OtherAdapter> api2{};

Result res = api1.Get(...);
OtherAdapter other(res.data.myDataBlob);
api2.Modify(&other);
res.data.myDataBlob = other.myDataBlob;
api1.Put(res.data);

这不仅麻烦,而且会造成比较大的开销,在这里,为了适配两个接口,我们不得不进行两次数据的复制。我们能否做得更好呢?

首先注意到 TableName 这个函数其实和对象无关,我们可以实现为一个静态的函数:

struct MyDataBlobAdapter {
  static std::string TableName() const { ... }
  ...
}

所以上面 2 处的代码可以改为:

res.code = api.Get(Db::TableName(), ...); // 2

类似地,对于 SetKey,我们也可以进行类似的改造,虽然它需要操作自己的成员变量,但是,我们可以将 this 指针手动传递一下,也就是这样:

struct MyDataBlobAdapter {
  static void SetKey(key_type const &key, MyDataBlobAdapter *adapter) { ... }
  ...
}

对于使用者,1 处的代码可以改为:

Db::SetKey(key, &res.data);

这个时候,我们可以发现,这里 SetKey 的第二个参数根本不需要是 MyDataBlobAdapter*,我们可以直接将其换为 MyDataBlob*!同时,类似于 key_type,为了能告诉使用者这个数据的类型,我们加一个 type 类型声明,结果是这样:

struct MyDataBlobAdapter {
  using type = MyDataBlob;
  static void SetKey(key_type const &key, type *blob) { ... }
  ...
}

这时我们重新回来看一下 NewDbApi 的实现:

template<class Db>
struct Result {
  typename Db::type data{};
  ...
}

template<class Db>
struct NewDbApi {
  Result Get(typename Db::key_type const &k, typename Db::type *db) {
    ...
    Db::SetKey(k, db); // 1
    res.code = api.Get(Db::TableName(), &res.data, sizeof(res.data)); // 2
    ...
  }
}

使用的时候,只需要这样写:

NewDbApi<MyDataBlobAdapter> api1{};
OtherAPI<OtherAdapter> api2{};

Result res = api1.Get(...);
api2.Modify(&res.data);
api1.Put(res.data);

这样一来,我们就实现了既能直接操作数据,又避免给原始类型添加新接口,而且还做到了足够的泛化灵活,甚至不需要用到虚函数导致性能损失。

不过,这种形式的实现有个小缺点,这里的 Db 类型的约束非常不明确,对于使用者而言,可能会碰到非常难读的编译错误,这可能是许多人害怕模板的另一个原因。到 C++ 20,我们才能用上 Concept,能够直接指名模板参数的约束,但现实情况是,我们可能将长期被锁在 C++ 11 里,在这种情况下,我们也可以尽力去给使用者清晰的提示:

// 示例:
// struct LegalDb {
//   struct type;
//   struct key_type;
//   static void SetKey(key_type const &key, type *db);
//   static std::string TableName();
// }
template<class Db>
struct NewDbApi {
  ...
  static_assert(IsLegalDb<Db>::value,
                "Db must match requirements of LegalDb, see comments above");
}

这样一来,一旦使用者填入了不合法的类型,编译期立刻就能收到上面的提示,并且可以基于示例来了解应该如何实现这个类型。这个 IsLegalDb 的实现也用到了 SFINAE,大致可以实现为这样:

template<class T, class = void>
struct IsLegalDb: std::false_type {}; // 3

template<class T>
struct IsLegalDb<T,
  lib::void_t<
    typename T::type,
    typename T::key_type,
    decltype(T::SetKey(std::declval<typename T::key_type const &>(),
                       std::declval<typename T::type *>())),
    typename std::enable_if<
      std::is_convertible<decltype(T::TableName()), std::string>::value>
    >::type // 4
>: std::true_type {}; // 5

这里也用到了前面实现的 void_t,总体思路是类似的,也是基于类型声明来让编译器选择我们想要的模板实现,这里可能和上一个例子不太一样的有两点。第一是我们这里的类型在 3 和 5 处继承了 std::true_typestd::false_type,这两个类型可以认为是类型级别的 truefalse,在头文件 <type_traits> 里有很多 is_ 开头的模板就是基于这两个类的,如果一个类型符合它的约束,它就是 true_type 否则就是 false_type。这里用到的 std::is_convertible 就是这样的 type trait,它判定的是第一个类型参数能被转换为第二个类型参数。我们可以用 value 成员的来获得它们对应的 bool 值。这里用到了另一个基础工具是 std::enable_if,它可以接受一个编译期计算出来的 bool 值,如果这个值为 true,那么我们就能获得其 type 成员类型,否则就获取不到,可能直接用一个简单实现来说明更加方便:

template<bool B, class T = void>
struct enable_if {};
 
template<class T>
struct enable_if<true, T> { using type = T; };

所以说,4 处的代码的实现了如果 std::is_convertible 判定为 true,那么 std::enable_if 里就会有 type,那么模板的类型置换就会成功,否则则是失败,这就实现了我们想要的判定 T::TableName() 返回类型可以转换为 std::string 的效果。

IsLegalDb 的实现相对而言可能会有点麻烦,但是它可以带来清晰的错误提示,是一个很好的文档,因此对于一个有特定约束的模板类型参数,尤其是无法从名字上直接看出来约束内容的模板类型参数,最好配套加上这样一个检查,配合注释说明,给使用者明确的约束,以方便使用者实现合法的类型。

强类型别名

我们经常会碰到一个函数带有几个类型相同的参数的情况。以扑克牌举例,一种表示方式是基于花色和数字的表示,使用一个 uint8_t 表示花色,同时一个 uint8_t 表示数字,另一种是直接基于牌编码的方式,也就是将牌从 0 编号到 54,只需要一个 uint8_t 就能实现。那么,如果不同地方使用到了不同的表示方式,就需要有类似这样的转换函数:

uint8_t ConvertCardToCode(uint8_t shape, uint8_t number);

这个函数本身是没什么问题,但在使用的时候经常一不小心就写歪了:

auto const num = uint8_t(13);
auto const shp = uint8_t(2);
auto const code = ConvertCardToCode(num, shp); // num 和 shp 的位置写反了

我们可以通过类型别名声明来使得函数类型更加明晰:

using CardCode = uint8_t;
using Shape = uint8_t;
using Number = uint8_t;

CardCode ConvertCardToCode(Shape shape, Number number);

这个写法看起来很不错,但是在函数调用处,我们仍然无法避免出现这种情况:

auto const num = Number(13);
auto const shp = Shape(2);
auto const code = ConvertCardToCode(num, shp); // 仍然能正常编译

虽然我们声明了类型别名,但是这个类型别名的本质上还是原来的类型,我们仍然无法避免出现前面的错误。在 Go 语言中,「type alias」(type T = xxx)和「type definition」(type T xxx)是两种不同的语法,如果我们使用前者,则依然会遇到上面说的这个问题,但如果我们使用后者,则可以让编译器帮我们避免它:

type CardCode uint8;
type Shape uint8;
type Number uint8;

func ConvertCardToCode(shape Shape, number Number) CardCode { /*...*/ }
num := Number(13)
shp := Shape(2)
code := ConvertCardToCode(num, shp); // 编译失败

这样的强类型别名非常好,使得函数签名本来就成为了注释的一部分,想要在 C++ 中实现类似的效果,我们可以不是用 using 起别名而是直接将类型包裹一层:

struct Shape {
  Shape() = default;
  explicit Shape(uint8_t val): v{val} {}
  uint8_t v;
};

struct Number {
  Number() = default;
  explicit Number(uint8_t val): v{val} {}
  uint8_t v{};
};

using CardCode = uint8_t;

CardCode ConvertCardToCode(Shape shape, Number number);

此时,函数调用者如果传错了参数,就完全没法编译通过了:

auto const num = Number(13);
auto const shp = Shape(2);
auto const code = ConvertCardToCode(num, shp); // 编译出错

可以发现这两个类型是很类似的,我们会考虑用模板来使得这个过程更加便利:

template<class T>
struct StrongAlias {
  StrongAlias() = default;
  explicit StrongAlias(T val): v{std::move(val)} {}
  T v{};
};

using Shape = StrongAlias<uint8_t>;
using Number = StrongAlias<uint8_t>;
using CardCode = uint8_t;
CardCode ConvertCardToCode(Shape shape, Number number);

但很可惜的是,这样并不能达到我们想要的效果,因为 StrongAlias<uint8_t>StrongAlias<uint8_t> 是同一个类型,所以使用 using 来声明的 ShapeNumber 也依然是同一个类型。因此我们需要用另一个标记将两个类型完全区分开来,我们可以在类型参数列表里加多一个类型参数来做到这一点,这个类型参数的唯一作用就是用来实现类型的区分:

template<class T, class Tag>
struct StrongAlias {
  StrongAlias() = default;
  explicit StrongAlias(T val): v{std::move(val)} {}
  T v{};
};

using Shape = StrongAlias<uint8_t, struct ShapeTag>;
using Number = StrongAlias<uint8_t, struct NumberTag>;
using CardCode = uint8_t;
CardCode ConvertCardToCode(Shape shape, Number number);

这个实现已经很实用了,但是我们可以让它更好用一点,目前而言它的不足之处在于,我们包裹的类型往往是一些基础类型,这些基础类型自带了一些操作符,比如我们之前想比较两张牌是否相等的时候可以写:

if (card1.shape == card2.shape && card1.number == card2.number) { ... }

但现在需要写:

if (card1.shape.v == card2.shape.v && card1.number.v == card2.number.v) { ... }

更为麻烦的是,如果我们想将类型别名作为 std::map 的 key 时就会直接报错:

// using Number = uint8_t;
std::map<Number, int> cardNumCount{}; // 编译通过
// using Number = StrongAlias<uint8_t, struct NumberTag>;
std::map<Number, int> cardNumCount{}; // 编译出错

这是因为 std::map 要求 key 能够使用 < 进行比较,而当我们直接使用 using 起类型别名时,这个 < 就是 uint8_t<,而 StrongAlias<uint8_t, struct NumberTag> 则没有这个运算符。我们当然可以对每一个类型别名都自己实现一次所需的运算符,但我们还可以做得更加简单:

template<class T, class Tag, template<class> class Op>
struct StrongAliasType: public Op<StrongAliasType<T, Tag, Op>> {
    StrongAliasType() = default;
    explicit StrongAliasType(T const &value): v(value) {}
    T v{};
};

template<class T>
struct Lt { 
  bool operator<(T const &other) const { 
    return static_cast<T const &>(*this).v < other.v; 
  } 
};

这里用到了一个 C++ 里的一个惯用法——奇异递归模板模式,这个模式里派生类被作为基类的模板参数,这个声明看着有点吓人,但是它实现的效果是很妙的:

using Number = StrongAlias<uint8_t, struct NumberTag, Lt>;

可以看到 StrongAlias<uint8_t, struct NumberTag, Lt> 本身继承了 Lt<StrongAlias<uint8_t, struct NumberTag, Lt>>,这意味着 Number 就继承了 Lt 中的 < 运算符,而 Lt< 实现中,使用了 T::v< 运算符进行比较,因此 Number 就可以使用 uint8_t< 运算符了。

当然,有时候我们可能不止需要这一个运算符,所以 Op 可能不止一个,要想要支持更多运算符,这里可以使用模板参数包来实现,使用 ... 来标识一个参数包,然后再用 ... 展开:

template<class T, class Tag, template<class> class... Ops>
struct StrongAliasType: public Ops<StrongAliasType<T, Tag, Ops...>>... {
    StrongAliasType() = default;
    explicit StrongAliasType(T const &value): v(value) {}
    T v{};
};

这里的 StrongAliasType 继承了类型参数中的每一个 Ops。然后,类似上面 Lt 的实现,我们可以实现一组这样的运算符模板:

template<class T>
struct Eq { bool operator==(T const &other) const { /*...*/ } };

template<class T>
struct Ne { bool operator!=(T const &other) const { /*...*/ } };

template<class T>
struct Lt { bool operator<(T const &other) const { /*...*/ } };

template<class T>
struct Le { bool operator<=(T const &other) const { /*...*/ } };

template<class T>
struct Gt { bool operator>(T const &other) const { /*...*/ } };

template<class T>
struct Ge { bool operator>=(T const &other) const { /*...*/ } };

有了这些运算符模板,使用者就可以按需选择自己需要的来进行使用了,例如:

using Number = StrongAlias<uint8_t, struct NumberTag, Eq, Ne, Lt, Le, Gt, Ge>;

这样,我们就拥有了更加好用的强类型别名了。

小结

在这篇文章里,我们看到了在实际工程中 C++ 模板的一些应用。很显然,这些功能脱离了模板的能力是非常难以实现的。对于 C++ 开发者而言,不应该盲目地拒绝模板,而是应该将它应用在正确的地方,以获得更好的性能和更清晰可靠的代码。