C++05_模板元编程

模板函数

为什么需要模板函数(template)

  • 避免重复写代码

    int twice(int i) {
        return i * 2;
    }
    
    float twice(float f) {
        return f * 2;
    }
    
    double twice(double d) {
        return d * 2;
    }
    
    int main() {
        std::cout << twice(21) << std::endl;
        std::cout << twice(3.14f) << std::endl;
        std::cout << twice(2.718) << std::endl;
        return 0;
    }
    

模板函数:定义

  • 使用 template

  • 其中 T 可以变成任意类型。

  • 调用时 twice 即可将 T 替换为 int。

    template<class T>
    T twice(T i) {
        return i * 2;
    }
    
    int main() {
        std::cout << twice<int>(21) << std::endl;
        std::cout << twice<float>(3.14f) << std::endl;
        std::cout << twice<double>(2.718) << std::endl;
        return 0;
    }
    

注意有的教材上写做:template <typename T>,是完全等价的,只是个人喜好不同。

模板函数:自动推导参数类型

那这样需要手动写 用起来还不如重载方便了?别担心,C++ 规定:
当模板类型参数 T 作为函数参数时,则可以省略该模板参数。自动根据调用者的参数判断。

template<class T>
T twice(T i) {
    return i * 2;
}

int main() {
    std::cout << twice(21) << std::endl;
    std::cout << twice(3.14f) << std::endl;
    std::cout << twice(2.718) << std::endl;
    return 0;
}

模板函数:特化的重载

有时候,一个统一的实现(比如 t * 2)满足不了某些特殊情况。比如 std::string 就不能用乘法来重复,这时候我们需要用 t + t 来替代,怎么办呢?
没关系,只需添加一个 twice(std::string) 即可,他会自动和已有的模板 twice(T) 之间相互重载

template<class T>
T twice(T i) {
    return i * 2;
}

std::string twice(std::string s) {
    return s + s;
}

int main() {
    std::cout << twice(21) << std::endl;
    std::cout << twice(3.14f) << std::endl;
    std::cout << twice(2.718) << std::endl;
    std::cout << twice("hello") << std::endl;
    return 0;
}

但是这样也有一个问题,那就是如果我用 twice(“hello”) 这样去调用,他不会自动隐式转换到 std::string 并调用那个特化函数,而是会去调用模板函数 twice<char *>(“hello”),从而出错:

./t01_template.cpp:9:14: error: invalid operands to binary expression ('const char *' and 'int')
    return i * 2;
           ~ ^ ~
./t01_template.cpp:20:18: note: in instantiation of function template specialization 'twice<const char *>' requested here
    std::cout << twice("hello") << std::endl;
                 ^

可能的解决方案:SFINAE

模板函数:默认参数类型

如果模板类型参数 T 没有出现在函数的参数中,那么编译器就无法推断,就不得不手动指定了。
但是,可以通过 template <class T = int> 表示调用者没有指定时,T 默认为 int。

template<class T = int>
T two() {
    return 2;
}

int main() {
    std::cout << two<int>() << std::endl;
    std::cout << two() << std::endl;    // 等价于two<int>()
    return 0;
}

模板参数:整数也可以作为参数

template <class T>可以声明类型 T 作为模板尖括号里的参数。除了类型,任意整数也可以作为模板参数:template <int N>来声明一个整数 N 作为模板参数。

template<int N>
void show_times(std::string msg) {
    for (int i = 0; i < N; ++i) {
        std::cout << msg << std::endl;
    }
}

int main() {
    show_times<2>("two");
    show_times<3>("three");
    return 0;
}

不过模板参数只支持整数类型(包括 enum)。浮点类型、指针类型,不能声明为模板参数。自定义类型也不可以,比如:template <float F, glm::vec3 V> 错误!

模板参数:多个模板参数

int N 和 class T 可以一起使用。你只需要指定其中一部分参数即可,会自动根据参数类型(T msg)、默认值(int N = 1),推断尖括号里没有指定的那些参数。

模板参数:参数部分特化

func(T t) 完全让参数类型取决于调用者。func(vector<T> t) 这样则可以限定仅仅为 vector 类型的参数。

template<class T>
T sum(std::vector<T> const& arr) {
    T res = 0;
    for (int i = 0; i < arr.size(); ++i) {
        res += arr[i];
    }
    return res;
}

int main() {
    std::vector<int> a = {1, 2, 3, 4};
    std::cout << sum(a) << std::endl;
    std::vector<float> b = {3.14f, 2.718f};
    std::cout << sum(b) << std::endl;
    return 0;
}

这里用了 const & 避免不必要的的拷贝。不过,这种部分特化也不支持隐式转换。

为什么要支持整数作为模板参数:因为是编译期常量

你可能会想,模板只需要支持 class T 不就行了?反正 int N 可以作为函数的参数传入,模板还不支持浮点。template <int N> void func();void func(int N);
一个是模板参数,一个是函数参数,有什么区别?有很大区别!

template <int N> 传入的 N,是一个编译期常量,每个不同的 N,编译器都会单独生成一份代码,从而可以对他做单独的优化。
func(int N),则变成运行期常量,编译器无法自动优化,只能运行时根据被调用参数 N 的不同。比如 show_times<0>() 编译器就可以自动优化为一个空函数。因此模板元编程对高性能编程很重要。
通常来说,模板的内部实现需要被暴露出来,除非使用特殊的手段,否则,定义和实现都必须放在头文件里。但也正因如此,如果过度使用模板,会导致生成的二进制文件大小剧增,编译变得很慢等。

模板的难题:编译期常量的限制

编译期常量的限制就在于他不能通过运行时变量组成的表达式来指定。比如:if constexpr (i % 2)。这里在 if constexpr的表达式里用到了运行时变量,从而无法作为编译期分支的条件。

除了 if constexpr 的表达式不能用运行时变量,模板尖括号内的参数也不能:

bool debug = true;
// 可以在 bool debug 变量的定义前面加上 constexpr 来解决:
// constexpr bool debug = true; 但这样 debug = 右边的值也必须为编译期常量,否则出错
std::cout << sumto<debug>(4) << std::endl;

模板的难题:编译期常函数

编译期 constexpr 的表达式,一般是无法调用其他函数的。

bool isnegative(int n) {
    return n < 0;
}

template <bool debug>
int sumto(int n) {
    int res = 0;
    for (int i = 0; i < n; ++i) {
        res += i;
        if constexpr (debug) {
            std::cout << i << "-th: " << res << std::endl;
        }
    }
}

int main() {
    constexpr bool debug = isnegative(-1);
    std::cout << sumto<debug>(4) << std::endl;
    return 0;
}

解决:如果能保证 isnegative 里都可以在编译期求值,将他前面也标上 constexpr 即可。

constexpr bool isnegative(int n) {
    return n < 0;
}

注意:constexpr 函数不能调用 non-constexpr 函数。而且 constexpr 函数必须是内联(inline)的,不能分离声明和定义在另一个文件里。标准库的很多函数如 std::min 也是 constexpr 函数,可以放心大胆在模板尖括号内使用。

模板的难题:移到另一个文件中定义

如果我们试着像传统函数那样分离模板函数的声明与实现:

sumto.h

template <bool debug>
int sumto(int n);

sumto.cpp

template <bool debug>
int sumto(int n) {
    int res = 0;
    for (int i = 0; i < n; ++i) {
        res += i;
        if constexpr (debug) {
            std::cout << i << "-th: " << res << std::endl;
        }
    }
}

main.cpp

int main() {
    constexpr bool debug = true;
    std::cout << sumto<debug>(4) << std::endl;
    return 0;
}

编译会报错:undefined reference

这是因为编译器对模板的编译是惰性的,即只有当前 .cpp 文件用到了这个模板,该模板里的函数才会被定义。而我们的 sumto.cpp 中没有用到 sumto<> 函数的任何一份定义,所以 main.cpp 里只看到 sumto<> 函数的两份声明,从而出错。

解决:在看得见 sumto<> 定义的 sumto.cpp 里,增加两个显式编译模板的声明:

sumto.cpp

template int sumto<true>(int n);
template int sumto<false>(int n);

一般来说,我会建议模板不要分离声明和定义,直接写在头文件里即可。如果分离还要罗列出所有模板参数的排列组合,违背了开-闭原则。

自动类型推导

为什么需要自动类型推导(auto)

没有 auto 的话,需要声明一个变量,必须重复一遍他的类型,非常麻烦:

因此 C++11 引入了 auto,使用 auto 定义的变量,其类型会自动根据等号右边的值来确定:

struct MyClassWithVeryLongTimeName { };

int main() {
    std::shared_ptr<MyClassWithVeryLongTimeName> p = std::make_shared<MyClassWithVeryLongTimeName>();
    auto p2 = std::make_shared<MyClassWithVeryLongTimeName>();
    return 0;
}

自动类型推导:一些局限性

不过 auto 也并非万能,它也有很多限制。

因为需要等号右边的类型信息,所以没有 = 单独声明一个 auto 变量是不行的:

而且,类成员也不可以定义为 auto。

自动类型推导:函数返回值

除了可以用于定义变量,还可以用作函数的返回类型:

auto func() {
    return std::make_shared<MyClassWithVeryLongTimeName>();
}

使用 auto 以后,会自动被推导为 return 右边的类型。
不过也有三点注意事项:

  1. 当函数有多条 return 语句时,所有语句的返回类型必须一致,否则 auto 会报错。
  2. 当函数没有 return 语句时,auto 会被推导为 void。
  3. 如果声明和实现分离了,则不能声明为 auto。比如:auto func(); 错误

自动类型推导:定义引用(auto &)、定义常引用(auto const &)

当然,auto 也可以用来定义引用、常引用,只需要改成 auto &、auto const & 即可。

当然,函数的返回类型也可以是 auto & 或者 auto const &。比如懒汉单例模式。

理解右值:即将消失的,不长时间存在于内存中的值

引用又称为左值(l-value)。左值通常对应着一个长时间存在于内存中的变量。

除了左值之外,还有右值(r-value)。右值通常是一个表达式,代表计算过程中临时生成的中间变量。因此有的教材又称之为消亡引用。

得名原因:左值常常位于等号的左边,而右值只能位于等号右边。如:a = 1;

已知:int a; int *p;
左值类型:int &,int const &
左值例子:a, *p, p[a]
右值类型:int &&
右值例子:1, a + 1, *p + 1

理解 const:右值引用自动转换常值修饰符

与 & 修饰符不同,int const 和 int 可以看做两个不同的类型。不过 int const 是不可写入的。

因此 int const & 无非是另一个类型 int const 的引用罢了。这个引用不可写入。

唯一特殊之处,就在于 C++ 规定 int && 能自动转换成 int const &,但不能转换成 int &

例如,尽管 3 是右值 int &&,但却能传到类型为 int const & 的参数上:

void func(int const &i);
func(3);

而 int & 的参数:

void func(int &i);
func(3);

就会报错。

一个方便查看类型名的小工具

#include <iostream>
#include <vector>
#include <cstdlib>
#if defined(__GNUC__) || defined(__clang__)
#include <cxxabi.h>
#endif

template <class T>
std::string cpp_type_name() {
    const char* name = typeid(T).name();
#if defined(__GNUC__) || defined(__clang__)
    int status;
    char* p = abi::__cxa_demangle(name, 0, 0, &status);
    std::string s = p;
    std::free(p);
#else
    std::string s = name;
#endif
    if (std::is_const_v<std::remove_reference_t<T>>)
        s += " const";
    if (std::is_volatile_v<std::remove_reference_t<T>>)
        s += " volatile";
    if (std::is_lvalue_reference_v<T>)
        s += " &";
    if (std::is_rvalue_reference_v<T>)
        s += " &&";
    return s;
}

#define SHOW_TYPE(T) std::cout << cpp_type_name<T>() << std::endl

int main() {
    typedef const float * const& MyType;
    SHOW_TYPE(int);
    SHOW_TYPE(const int&);
    SHOW_TYPE(MyType);

    return 0;
}

输出:

int
int const &
float const* const &

获取变量的类型:decltype

可以通过 decltype(变量名) 获取变量定义时候的类型。

int a = 1;
int& b = a;
int const& c = a;
SHOW_TYPE(decltype(a));
SHOW_TYPE(decltype(b));
SHOW_TYPE(decltype(c));

获取表达式的类型:decltype

可以通过 decltype(表达式) 获取表达式的类型。注意 decltype(变量名) 和 decltype(表达式) 是不同的。可以通过 decltype((a)) 来强制编译器使用后者,从而得到 int &。

int main() {
    int a, *p;
    SHOW_TYPE(decltype(3.14f + a));
    SHOW_TYPE(decltype(100));
    SHOW_TYPE(decltype(&a));
    SHOW_TYPE(decltype(p[0]));
    SHOW_TYPE(decltype('a'));

    SHOW_TYPE(decltype(a));     // int
    SHOW_TYPE(decltype((a)));   // int &
    // 后者由于额外套了层括号,所以变成了decltype(表达式)

    return 0;
}

自动类型推导:万能推导(decltype(auto))

如果一个表达式,我不知道他是个可变引用(int &),常引用(int const &),右值引用(int &&),还是一个普通的值(int)。但我就是想要定义一个和表达式返回类型一样的变量,这时候可以用:

decltype(auto) p = func();

会自动推导为 func() 的返回类型。和下面这种方式等价:

decltype(func()) p = func();

在代理模式中,用于完美转发函数返回值。比如:

decltype(auto) at(size_t i) const {
		return m_internal_class.at(i);
}

using:创建类型别名

除了 typedef 外,还可以用 using 创建类型别名:

  • typedef std::vector<int> VecInt;
  • using VecInt = std::vector<int>;

以上是等价的。

  • typedef int (*PFunc)(int);*
  • using **PFunc = int()(int);

以上是等价的。

decltype:实现不同类型vector元素相加的函数

template <class T1, class T2>
auto add(std::vector<T1> const& a, std::vector<T2> const& b) {
    using T0 = decltype(T1{} + T2{});
    std::vector<T0> res;
    for (int i = 0; i < std::min(a.size(), b.size()); ++i) {
        res.push_back(a[i] + b[i]);
    }
    return res;
}

int main() {
    std::vector<int> a = {1, 2, 3};
    std::vector<float> b = {0.5f, 1.0f, 1.2f};
    auto c = add(a, b);
    for (int i = 0; i < c.size(); ++i) {
        std::cout << c[i] << std::endl;
    }
    return 0;
}

这是一个实现将两个不同类型 vector 逐元素相加的函数。

decltype(T1{} * T2{}) 算出 T1 和 T2 类型相加以后的结果,并做为返回的 vector 容器中的数据类型。


Reference:

posted @ 2022-09-20 23:42  吹不散的流云  阅读(115)  评论(0编辑  收藏  举报