指针的部分模板特化

在课程《类模板特化》中,我们曾研究过一个简单的模板化 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

然而,这仅在 Tdouble* 时有效;若 Tint*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*> 的语义为引用语义(即引用初始化值)。这种不一致极易导致错误。

可采用以下几种应对策略(按复杂度递增排序):

  1. 明确告知 Storage<T*> 是一个视图类(采用引用语义),由调用方保证被指向对象在 Storage<T*> 生存期内始终有效。然而,由于部分特化类必须与主模板同名,我们无法将其命名为 StorageView,只能依赖注释或其他易被忽略的手段。此方案并不理想。
  2. 彻底禁止 Storage<T*> 的使用。实际上,我们可能并不需要 Storage<T*>,因为调用方完全可以在实例化时解引用指针,从而使用 Storage<T> 并复制该值(这对存储类而言语义正确)。 虽然可以删除重载函数,但 C++(截至 C++23)尚不支持删除一个类。直观的做法是对 Storage<T*> 进行部分特化,并在实例化时使其无法通过编译(例如使用 static_assert)。然而,该策略存在一个重大缺陷:std::nullptr_t 并非指针类型,因此 Storage<std::nullptr_t> 不会匹配 Storage<T*>
  3. 更优的方案是完全放弃部分特化,而在主模板内使用 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;
}
  1. 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;
}

通过部分模板类特化,为指针与非指针类型分别实现同一个类的不同版本,在需要以完全透明的方式向终端用户隐藏差异时,极具实用价值。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"