在课程《类模板特化》中,我们曾研究过一个简单的模板化 Storage
类,并为其 double
类型提供了特化版本:
#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 i { 5 };
Storage d { 6.7 }; // 将隐式实例化 Storage<double>
// 打印值
i.print(); // 调用 Storage<int>::print(由 Storage<T> 实例化)
d.print(); // 调用 Storage<double>::print(来自 Storage<double>::print 的显式特化)
}
然而,尽管该类看似简洁,却存在一个隐藏缺陷:当 T 是指针类型时,代码能够编译但行为错误。例如:
int main()
{
double d { 1.2 };
double *ptr { &d };
Storage s { ptr };
s.print();
return 0;
}
在笔者的机器上,输出结果为:
0x7ffe164e0f50
发生了什么?因为 ptr 的类型为 double*
,所以 s 的类型为 Storage<double*>
,这意味着 m_value
的类型是 double*
。构造函数执行时,m_value
获得了 ptr 所保存地址的副本;随后在调用 print()
成员函数时,打印的正是该地址。
如何解决此问题?
一种方案是为 double*
类型添加一个完全特化:
#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;
}
现在输出正确:
1.200000e+00
然而,这仅在 T
为 double*
时有效;若 T
为 int*
、char*
或任何其他指针类型,又该怎么办?
我们当然不希望为每种指针类型都提供完全特化——事实上也不可能,因为用户随时可能传入指向程序自定义类型的指针。
指针的部分模板特化
你可能会想到尝试编写一个针对 T*
的模板函数重载:
// 无效
template<typename T>
void Storage<T*>::print()
{
if (m_value)
std::cout << std::scientific << *m_value << '\n';
}
该函数是一个部分特化模板函数,因为它对类型 T
施加了限制(要求其为指针类型),但 T
仍是类型模板参数。
遗憾的是,这种做法行不通,原因很简单:截至本文撰写时(C++23),函数模板不允许部分特化。正如在课程《部分模板特化》中所述,只有类模板才能部分特化。
因此,我们选择对 Storage
类进行部分特化:
#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*
。另需提醒:部分特化 Storage<T*>
必须定义在主模板类 Storage<T>
之后。
所有权与生存期问题
上述部分特化的 Storage<T*>
仍存在潜在缺陷。由于 m_value
的类型为 T*
,它指向传入的对象;若该对象随后被销毁,Storage<T*>
将产生悬空指针。
核心矛盾在于:Storage<T>
的语义为拷贝语义(即复制初始化值),而 Storage<T*>
的语义为引用语义(即引用初始化值)。这种不一致极易导致错误。
可采用以下几种应对策略(按复杂度递增排序):
- 明确告知
Storage<T*>
是一个视图类(采用引用语义),由调用方保证被指向对象在Storage<T*>
生存期内始终有效。然而,由于部分特化类必须与主模板同名,我们无法将其命名为StorageView
,只能依赖注释或其他易被忽略的手段。此方案并不理想。 - 彻底禁止
Storage<T*>
的使用。实际上,我们可能并不需要Storage<T*>
,因为调用方完全可以在实例化时解引用指针,从而使用Storage<T>
并复制该值(这对存储类而言语义正确)。 虽然可以删除重载函数,但 C++(截至 C++23)尚不支持删除一个类。直观的做法是对Storage<T*>
进行部分特化,并在实例化时使其无法通过编译(例如使用static_assert
)。然而,该策略存在一个重大缺陷:std::nullptr_t
并非指针类型,因此Storage<std::nullptr_t>
不会匹配Storage<T*>
! - 更优的方案是完全放弃部分特化,而在主模板内使用
static_assert
,确保T
为我们可接受的类型。示例如下:
#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*> 与 Storage<nullptr> 被禁用");
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
(详见在课程《std::unique_ptr》中):
#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*> 与 Storage<nullptr> 被禁用");
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) } // 亦可选择 value 为空时抛出异常
{
}
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,将 d 的值复制到堆上
s2.print();
return 0;
}
通过部分模板类特化,为指针与非指针类型分别实现同一个类的不同版本,在需要以完全透明的方式向终端用户隐藏差异时,极具实用价值。