指针的部分模板特化
本节阅读量:
在上一课中,我们看了一个简单的模板类,以及针对double类型的特化:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
#include <iostream>
template <typename T>
class Storage
{
private:
T m_value {};
public:
Storage(T value)
: m_value { value }
{
}
void print()
{
std::cout << m_value << '\n';
}
};
template<>
void Storage<double>::print() // 针对double的完全特化
{
std::cout << std::scientific << m_value << '\n';
}
int main()
{
// 定义一些 Storage
Storage i { 5 };
Storage d { 6.7 }; // 会导致 Storage<double> 被隐式实例化
// 进行打印
i.print(); // 调用 Storage<int>::print (从 Storage<T> 实例化)
d.print(); // 调用 Storage<double>::print (从特化的 Storage<double>::print() 实例化)
}
|
然而,尽管这个类很简单,它仍有一个隐藏缺陷:当T是指针类型时,代码可以编译,但打印结果会比较奇怪。例如:
1
2
3
4
5
6
7
8
9
10
|
int main()
{
double d { 1.2 };
double *ptr { &d };
Storage s { ptr };
s.print();
return 0;
}
|
在作者的机器上,它产生了以下结果:
发生了什么?由于ptr是double*,因此s的类型为Storage<double*>,这意味着m_value的类型为double*。调用构造函数时,m_value接收ptr所持地址的副本;调用print()成员函数时,打印的就是这个地址。
那么我们如何解决这个问题呢?
一种选择是为类型double*添加完全特化:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
#include <iostream>
template <typename T>
class Storage
{
private:
T m_value {};
public:
Storage(T value)
: m_value { value }
{
}
void print()
{
std::cout << m_value << '\n';
}
};
template<>
void Storage<double*>::print() // 针对 double* 的完全特化
{
if (m_value)
std::cout << std::scientific << *m_value << '\n';
}
template<>
void Storage<double>::print() // 针对 double 的完全特化
{
std::cout << std::scientific << m_value << '\n';
}
int main()
{
double d { 1.2 };
double *ptr { &d };
Storage s { ptr };
s.print(); // 调用 Storage<double*>::print()
return 0;
}
|
现在会打印正确的结果:
但这只解决了T为double*类型时的问题。当T是int*、char*或其他指针类型时,又该怎么办?
我们显然不想为每个指针类型创建完全特化。事实上,这也不可能做到,因为用户总是可以传入指向自定义类型的指针。
指针的部分模板特化
您可能会考虑创建一个针对类型T*重载的模板函数:
1
2
3
4
5
6
7
|
// 无法按预期执行
template<typename T>
void Storage<T*>::print()
{
if (m_value)
std::cout << std::scientific << *m_value << '\n';
}
|
这样的函数是部分特化的模板函数,因为它将T的类型限制为指针类型,但T本身仍然是类型模板参数。
不幸的是,这行不通,原因很简单:函数不能部分特化。正如我们之前提到的,只有类可以部分特化。
因此,让我们部分特化Storage类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
#include <iostream>
template <typename T>
class Storage // 这是基础的模板类
{
private:
T m_value {};
public:
Storage(T value)
: m_value { value }
{
}
void print()
{
std::cout << m_value << '\n';
}
};
template <typename T> // 仍然有一个类型模板参数
class Storage<T*> // 部分特化为 T*
{
private:
T* m_value {};
public:
Storage(T* value)
: m_value { value }
{
}
void print();
};
template <typename T>
void Storage<T*>::print() // Storage<T*> 的成员函数
{
if (m_value)
std::cout << std::scientific << *m_value << '\n';
}
int main()
{
double d { 1.2 };
double *ptr { &d };
Storage s { ptr }; // 从部分特化的 Storage<double*> 进行实例化
s.print(); // 调用 Storage<double*>::print()
return 0;
}
|
我们在类之外定义Storage<T*>::print(),只是为了说明这种写法,并表明该定义与上面无法工作的部分特化函数Storage<T*>::print()看起来相同。然而,既然Storage<T*>是一个部分特化的类,Storage<T*>::print()就不再是部分特化函数——它是一个非特化函数,因此允许这样定义。
值得注意的是,我们的类型模板参数被定义为T,而不是T*。这意味着T会被推导为非指针类型,因此在希望表示“指向T的指针”的地方,必须使用T*。还需要提醒的是,部分特化Storage<T*>必须定义在基础模板类Storage<T>之后。
所有权和生命周期问题
上述部分特化类Storage<T*>还有另一个潜在问题。因为m_value是T*,所以它是一个指向传入对象的指针。如果该对象随后被销毁,Storage<T*>就会悬空。
核心问题是,我们的Storage<T>实现具有复制语义(这意味着它会复制初始值),但Storage<T*>具有引用语义(这表示它引用初始值所指向的对象)。这种不一致性可能导致错误。
我们可以通过几种不同的方法来处理这些问题(按照复杂性的增加顺序):
首先,可以让Storage<T*>成为查看器(引用语义),由调用方确保在Storage<T*>存在期间,实际对象保持有效。不幸的是,部分特化类的名称必须与基础类一致,所以我们不能将其命名为StorageView。因此只能添加一些非强制性的注释来提醒使用者。
或者,避免使用Storage<T*>。调用方始终可以解引用指针,传递对象的一份拷贝(这对于Storage类来说语义更合适)。然而,虽然可以删除重载函数,但C++不允许删除类。显而易见的解决方案是部分特化Storage<T*>,然后在模板被实例化时做一些事情让它无法编译(例如static_assert)。这种方法有一个主要缺点:std::nullptr_t不是指针类型,因此Storage<std::nullptr_t>不会匹配Storage<T*>!
更好的解决方案是完全避免部分特化,并在基础模板上使用static_assert,确保T是我们可以接受的类型。下面是该方法的示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
#include <iostream>
#include <type_traits> // 引入 std::is_pointer_v 和 std::is_null_pointer_v
template <typename T>
class Storage
{
// 确保 T 不是指针,也不是 std::nullptr_t
static_assert(!std::is_pointer_v<T> && !std::is_null_pointer_v<T>, "Storage<T*> and Storage<nullptr> disallowed");
private:
T m_value {};
public:
Storage(T value)
: m_value { value }
{
}
void print()
{
std::cout << m_value << '\n';
}
};
int main()
{
double d { 1.2 };
Storage s1 { d }; // ok
s1.print();
Storage s2 { &d }; // static_assert 触发,因为 T 是指针
s2.print();
Storage s3 { nullptr }; // static_assert 触发,因为 T 是 nullptr
s3.print();
return 0;
}
|
又或者,让Storage<T*>在堆空间中制作原始对象的一份拷贝。比较简单的方式是使用std::unique_ptr来自动管理生命周期:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
#include <iostream>
#include <type_traits> // 引入 std::is_pointer_v 和 std::is_null_pointer_v
#include <memory>
template <typename T>
class Storage
{
// 确保 T 不是指针,也不是 std::nullptr_t
static_assert(!std::is_pointer_v<T> && !std::is_null_pointer_v<T>, "Storage<T*> and Storage<nullptr> disallowed");
private:
T m_value {};
public:
Storage(T value)
: m_value { value }
{
}
void print()
{
std::cout << m_value << '\n';
}
};
template <typename T>
class Storage<T*>
{
private:
std::unique_ptr<T> m_value {}; // 使用 std::unique_ptr 自动管理存储对象的生命周期
public:
Storage(T* value)
: m_value { std::make_unique<T>(value ? *value : 0) }
{
}
void print()
{
if (m_value)
std::cout << *m_value << '\n';
}
};
int main()
{
double d { 1.2 };
Storage s1 { d }; // ok
s1.print();
Storage s2 { &d }; // ok, 制作拷贝
s2.print();
return 0;
}
|
总结
当您希望模板类以不同方式处理指针和非指针类型,并且希望这种差异对最终用户完全透明时,模板类部分特化会非常有用。