C++ 模板:以简御繁-1/5

英文原文:  https://victor-istomin.github.io/c-with-crosses/posts/templates-are-easy/ 

C++ 模板:以简御繁-1/5

C++ 模板:以简御繁-2/5

C++ 模板:以简御繁-3/5

C++ 模板:以简御繁-4/5

C++ 模板:以简御繁-5/5

这篇系列文章从一个简单的需求开始,介绍了如何一步一步,从简到繁用模板来实现,仔细阅读,你一定会有收获。

想象一下有一种能力将所有内容转换为字符串:整数、向量、布尔值 - 所有这些都被转换成一条信息,以输出到我们想要的任何地方。这个要求看起来很方便,也不太难,所以让我们深入研究它——这是我为我的宠物项目编写调试变量观察器时的第一个想法。

尽管期望让事情变得简单可能会导致几个不眠之夜,但我还是鼓励读者通过基本的模板编程概念踏上这段旅程。

预期的先决条件是:

一个对 C++20 有良好支持的编译器:MSVC 2022、clang 14 或 gcc 11 会很好,因为我们的目标是面向未来的体验,而不是纠结于遗留代码。

对 C++ 模板语法有基本的了解:我们都写过类似 template <typename T> T min(T a, T b); 的东西,不是吗?

在整个过程中,我们将从简单的尝试逐步发展到令人满意的解决方案,并重点介绍过程中遇到的陷阱和技巧。所以,如果代码在早期迭代中看起来不太好,请不要惊慌——这都是计划的一部分。

还会有一个参考文献部分,包含文章的所有链接。我会把它当作推荐阅读材料。

1. 问题的提出:

假设有一个 T 类型的变量,创建一个函数 makeString(T t),该函数将生成 t 的字符串表示。由于我们无法预先知道如何将任意类型的对象转换为字符串,因此需要考虑使这个函数易于扩展,以适应任何用户定义的类。

关于可扩展性,我希望 makeString 能够使用对象的 std::string to_string() const 成员函数(如果存在的话)将该对象转换为字符串。但这并非总是可行,因此如果需要,可以考虑使用重载或特化。

2. 一些一开始就需要做的事情

2.1创建一个工程

我们需要一个空项目来开始。现在已经是2023年了,所以我用CMake。你也可以用你最喜欢的IDE创建一个控制台应用程序项目,但如果你在漫长而快乐的C++语言职业生涯中计划编写跨平台代码,CMake绝对值得一试。

cmake_minimum_required(VERSION 3.12)
project(makeString VERSION 0.1.0)
add_executable(makeString main.cpp)

set(CMAKE_CXX_EXTENSIONS OFF)      # no vendor-specific extensions
set_property(TARGET makeString PROPERTY CXX_STANDARD 20)

# Ask a compiler to be more demanding(对编译器更高的要求)
if (MSVC)
    target_compile_options(makeString PUBLIC /W4 /WX /Za /permissive-)
else()
    target_compile_options(makeString PUBLIC -Wall -Wextra -pedantic -Werror)
endif()

2.2 一些测试代码来理解目标

让我们添加几个用例:假设我们有结构体 A 和 B。A 是一个没有数据的标签,而 B 包含一个整数。这里使用结构体而不是类的唯一原因是为了节省一些 public: 可见性说明符所占用的屏幕空间。

#include <iostream>
#include "makeString.hpp"

struct A
{
    std::string to_string() const { return "A"; }
};

struct B
{
    int m_i = 0;
    std::string to_string() const { return "B{" + std::to_string(m_i) + "}"; }
};

int main()
{
    A a;
    B b = {1}; // 复制列表初始化将m_i 初始化为1

    std::cout << "a: " << makeString(a) << "; b: " << makeString(b)
              << std::endl;
}

2.3 makeString.hpp 中的一个简单模板

最后,一个简单的模板。

#pragma once
#include <string>

template <typename Object>
std::string makeString(const Object& object)
{
    return object.to_string();
}

现在我们可以构建它、运行它并看到输出:a:A;b:B{1}。

这段代码其实存在一些问题,现在我们假装没有发现任何问题,直到它们在文章后续部分的实际使用过程中出现。

3. 函数模板的特化

如果我们想将 int 转换为字符串该怎么办?

int main()
{
    A a;
    B b = {1};

    std::cout << "a: " << makeString(a) << "; b: " << makeString(b)
              << "; pi: " << makeString(3)
              << std::endl;
}

当然,上面的代码会导致编译错误,因为没有针对整数的 to_string() 方法。幸运的是,我们可以提供一个模板特化,指定 makeString<int> 应该有一个特殊的实现。我们来试试:

// rather an attempt, than a solution
template <> std::string makeString(int i)
{
    return std::to_string(i);
}

对吗?不对。编译器找不到匹配的模板,因为模板参数中唯一允许特化的部分是 Object 参数

template <typename Object> std::string makeString(const Object& object)

可以用 int 替换 Object,但不能删除 const 和&引用限定符。因此,正确的做法可能是:

// template specialization: Object = int
template <> std::string makeString(const int& i)
{
    return std::to_string(i);
}

好吧,虽然上面的函数能正常工作,但它违反了“按值传递低成本复制类型”的准则。按值传递不仅成本更低,而且更容易优化。查看 Arthur O’Dwyer 的博客,了解更多关于 string_view 示例的见解。

4. 模板函数的重载

模板函数和非模板函数都参与重载。函数模板参考资料会提供很多见解,函数模板调用的重载解析一文中有几个简单的例子,但目前总结一下就足够了:在重载解析过程中,为了找到最佳匹配,模板函数和非模板函数都会被考虑。

// not a template. This will be the best match for makeString(3),
// unless we explicitly specify that we want a template: makeString<int>(3)
std::string makeString(int i)
{
    return std::to_string(i);
}

现在我们可以放弃 const int& 特化,编译、运行并享受重载的 makeString(int) 和 pi 的近似值:a: A; b: B{1}; pi: 3

对于那些需要精确度的人,我们可以添加更多的重载:

// main.cpp
// ...
    std::cout << "a: " << makeString(a) << "; b: " << makeString(b)
              << "; pi: " << makeString(3) << "; pi(double): " << makeString(3.1415926)
              << std::endl;

在下面的文件中进行函数重载

// makeString.hpp
//...
std::string makeString(float f)       { return std::to_string(f); }
std::string makeString(double d)      { return std::to_string(d); }
std::string makeString(long double d) { return std::to_string(d); }
// ... 5 more specializations ...

5. 从多个实现中选择一个模板

5.1 问题

在编写上述复制粘贴代码的过程中,读者可能会想到创建一个单独的模板。然而,当编译器不确定在像 makeString(3) 这样的调用中应该实例化哪个模板时,就会出现问题,从而导致编译失败。如果有一个模板和普通函数都可以matrch的话,不会出现问题,优先选择普通函数。

template <typename Object>
std::string makeString(const Object& object)
{
    return object.to_string();
}

template <typename Numeric>
std::string makeString(Numeric value)
{
    return std::to_string(value);
}

/*
main.cpp:21:40: error: call of overloaded ‘makeString(int)’ is ambiguous
 |               << "; pi: " << makeString(3)
 |                              ~~~~~~~~~~^~~
  note: candidate: std::string makeString(const Object&) [with Object = int;]
  note: candidate: std::string makeString(Numeric) [with Numeric = int;]
*/

编译错误,这是因为makeString(3)既可以match makeString(const Object&) [with Object = int;],也可以match makeString(Numeric) [with Numeric = int;]。

在重载解析期间,编译器仅检查函数声明,要求开发人员提供一种仅使用函数声明来区分重载的方法。

作为一般规则,我会考虑限制所有模板函数,以防止编译器为不兼容的类型实例化它们。遵循此规则可以避免在添加新的模板函数重载时出现可扩展性问题。

剧透:如果我们提前阅读了concept(概念)部分,我们几乎可以像使用上面的代码一样使用。

// concepts HasToString, IsNumeric, and IsString should be defined above

template <HasToString Object>
std::string makeString(Object&& object)
{
    return std::forward<Object>(object).to_string();
}

std::string makeString(IsNumeric auto value)
{
    return std::to_string(value);
}

template <IsString String>
std::string makeString(String&& s)
{
    return std::string(std::forward<String>(s));
}

为了更深入地了解模板,我们将继续使用 SFINAE:这是一种更详细的解决方案,在 C 20 中出现这些concept(概念)之前就很流行。但是,急于继续阅读的读者可以跳过本节,直接进入完美转发章节,然后再了解concept(概念)。

5.2 SFINAE,一种更难的、C++20 之前的方法

在重载解析过程中,如果无法将模板参数替换为一个具体的参数,编译器将忽略该模板声明。这个概念被称为“替换失败并非错误”。

运用智慧和一点想象力,想出两个解决方案:

  • 将 object.to_string() 成员函数调用结合到 std::string makeString(const Object& object) 的模板声明中,以阻止这次替换 [Object = int]

  • 确保 std::string makeString(Numeric value) 的声明涉及 std::to_string(value),以阻止在不支持 std::to_string 重载的情况下进行类型替换,而不是让编译器在后续的编译函数体的时候报错。

几种可能的解决方案

5.2.1. 让类型参数依赖to_string()成员函数的结果

// will match only types with to_string() member function
template <typename Object,
          typename DummyType = decltype(std::declval<Object>().to_string())>
std::string makeString(const Object& object)
{
    return object.to_string();
}

上面的代码使用了与 Object::to_string() 成员函数类型相同的 DummyType。如果这个类型不存在 Object::to_string() ,替换将失败,从而将这个模板函数排除在重载解析之外。

5.2.2. 后置返回类型:在替换期间访问函数参数名称

有人可能会问,为什么不使用参数变量名来强制执行 SFINAE?因为从语法上讲,我们在函数参数定义之前就强制执行了它。使用后置返回类型,我们可以将 SFINAE 推迟到函数参数声明的位置。

这是我最喜欢的使用 C++ 17 的 SFINAE 方法

// makeString.hpp
#pragma once
#include <string>

template <typename Object>
auto makeString(const Object& object) -> decltype(object.to_string())
{
    return object.to_string();
}

template <typename Numeric>
auto makeString(Numeric value) -> decltype(std::to_string(value))
{
    return std::to_string(value);
}

这里,自动返回类型强制编译器检查后置的返回类型,因此它会检测 -> 右侧表达式的类型,例如 decltype(object.to_string())。如果替换失败,该函数将从重载候选列表中删除。就这么简单。

有一个 std::enable_if 模板可以解决同样的问题,但在我看来,它更冗长。因此,我建议等到真正需要的时候再使用它。

再次编译、运行,然后尽情享受吧:a: A; b: B{1}; pi: 3; pi(double): 3.141593。我们花了一些时间,它看起来运行良好,我认为这是一个好的开始。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值