C++ 中的运行时类型泛型算法——通过类型擦除






4.87/5 (23投票s)
本文介绍了一种称为类型擦除的 C++ 技术,并展示了如何使用它来编写运行时类型的泛型算法。然后,通过类型兼容性的概念,探讨了类型擦除与其他多态形式的关系。
摘要
C++ 支持不同种类的多态。继承是面向对象编程的基础,而模板是泛型编程的基础。C++ 中的模板在编译时进行实例化,因此无法用于类型参数仅在运行时才能确定的情况。然而,在某些情况下,我们需要将某个算法以相同的方式应用于类型仅在运行时才能确定的值。在本文中,我们将探讨一种称为类型擦除的技术,并了解它如何使我们能够编写运行时类型的泛型算法。然后,我们将通过类型兼容性的概念,探讨类型擦除与其他多态形式的关系,并了解它如何帮助我们在继承和类型擦除之间进行选择,以实现运行时多态。
通过继承实现运行时多态
运行时多态涉及根据一个或多个参数的运行时类型来选择实现。在 C++ 中,这是通过继承和虚函数来实现的。派生类可以通过重写虚函数来扩展或修改基类的行为。想象一下,我们想为表示各种动物的类定义类。虽然每种动物都不同,但有些行为适用于所有动物。例如,所有动物都会发出声音,但每种动物发出的声音都不同。继承允许我们为动物定义一个通用的接口,同时让派生类定义每种动物发出的声音。请看以下示例。
class IAnimal {
public:
IAnimal() {}
virtual ~IAnimal() {}
virtual void makeSound() const = 0;
};
class Dog : public IAnimal {
public:
Dog() {}
virtual ~Dog() override {}
virtual void makeSound() const override
{
std::cout << "woof" << std::endl;
}
std::string toString() const
{
return "Dog";
}
};
class Cat : public IAnimal {
public:
Cat() {}
virtual ~Cat() override {}
virtual void makeSound() const override
{
std::cout << "meow" << std::endl;
}
};
std::unique_ptr<IAnimal> getAnimal(int which)
{
switch (which)
{
case 0:
return std::make_unique<Dog>();
case 1:
return std::make_unique<Cat>();
default:
throw std::runtime_error("Unknown animal type");
}
}
int main()
{
int which = -1;
std::cin >> which;
std::unique_ptr<IAnimal> animal = getAnimal(which);
animal->makeSound();
return 0;
}
animal
的类型在编译时是未知的——它取决于运行时用户输入。makeSound()
的调用会根据对象的运行时类型分派到适当的具体实现。通过继承,定义一个新的派生类不需要修改基类。因此,继承允许我们创建对扩展开放、对修改关闭的抽象(开放/封闭原则)。然而,它需要显式声明基类——派生类关系。如果我们想将算法应用于不属于继承层次结构的类型怎么办?让我们通过添加另一个类来使情况变得有趣。
class Robot {
public:
std::string toString() const
{
return "Robot";
}
};
假设我们想编写一个算法,对它的参数调用 toString()
并打印其 string
表示。Dog
和 Robot
类都有一个 toString()
方法,但它们不共享同一个基类。我们可以为它们添加一个 IHasToString
接口,但其中一些类型可能位于第三方库中,因此无法修改。此外,强制一组不相关的类型派生自同一个基类不一定能表达“is-a”关系,并且可能违反里氏替换原则。
因此,考虑到在某些情况下继承可能不是运行时多态的正确选择,是否存在一种替代方法可以将我们的组件强行纳入继承层次结构?
适配器模式有帮助吗?
适配器模式用于在对象的接口与某个算法所需的接口之间创建桥梁。让我们将适配器模式应用于我们的问题,看看会得到什么。
class IHasToString {
public:
virtual ~IHasToString() {}
virtual std::string toString() const = 0;
};
class DogAdapter : public IHasToString {
public:
DogAdapter(const Dog& obj)
: obj_(obj)
{ }
std::string toString() const override
{
return obj_.toString();
}
private:
const Dog& obj_;
};
class RobotAdapter : public IHasToString {
public:
RobotAdapter(const Robot& obj)
: obj_(obj)
{ }
std::string toString() const override
{
return obj_.toString();
}
private:
const Robot& obj_;
};
std::unique_ptr<IHasToString> getObject(int which)
{
switch (which)
{
case 0:
return std::make_unique<DogAdapter>((Dog()));
case 1:
return std::make_unique<RobotAdapter>((Robot()));
default:
throw std::runtime_error("Unknown object type");
}
}
int main()
{
int which = -1;
std::cin >> which;
std::unique_ptr<IHasToString> object = getObject(which);
std::cout << object->toString() << std::endl;
return 0;
}
好处是我们不必修改 Dog
和 Robot
类。然而,由于它们不属于继承层次结构,我们最终不得不为每个类定义一个单独的适配器。这是重复的、容易出错的,并且扩展性不好。
但是请注意,每个适配器都有相同的操作,只是操作的对象类型不同。这正是泛型编程旨在解决的问题。在 C++ 中,泛型函数使用函数模板定义,泛型数据类型使用类模板定义。然而,模板在编译时进行实例化,因此无法使用仅在运行时才知道的类型进行实例化。这时类型擦除模式就派上用场了。
类型擦除
在 C++ 中,类型擦除一词通常用于描述创建非侵入性适配器的技术,这些适配器基于被适配对象的属性而不是其实际类型来实现。我将要在这里描述的方法是值语义类型擦除,它通过利用 C++ 模板允许非模板类具有模板构造函数的特性来实现。通过示例可以最好地理解这一点。
class ThingWithToString {
public:
template<typename T>
ThingWithToString(const T& obj)
: inner_(std::make_unique<Holder<T> >(obj))
{
}
ThingWithToString(const ThingWithToString& that)
: inner_(that.inner_->clone())
{
}
ThingWithToString& operator=(const ThingWithToString& that)
{
if (this != &that) {
inner_ = that.inner_->clone();
}
return *this;
}
std::string toString() const
{
return inner_->toString();
}
private:
struct HolderBase {
virtual ~HolderBase() { }
virtual std::string toString() const = 0;
virtual std::unique_ptr<HolderBase> clone() const = 0;
};
template<typename T>
struct Holder : public HolderBase {
Holder(const T& obj)
: obj_(obj)
{
}
std::string toString() const override
{
return obj_.toString();
}
std::unique_ptr<HolderBase> clone() const override
{
return std::make_unique<Holder<T> >(obj_);
}
T obj_;
};
std::unique_ptr<HolderBase> inner_;
};
ThingWithToString getThingWithToString(int which)
{
switch (which)
{
case 0:
return ThingWithToString(Dog());
case 1:
return ThingWithToString(Robot());
default:
throw std::runtime_error("Unknown object type");
}
}
int main()
{
int which = -1;
std::cin >> which;
ThingWithToString object = getThingWithToString(which);
std::cout << object.toString() << std::endl;
return 0;
}
ThingWithToString
类本身不是模板,但它的构造函数是。构造函数接受参数,即被适配的对象,并将其存储在 Holder<T>
的实例中。但是,由于 ThingWithToString
不是模板,它无法直接存储 Holder<T>
的实例。它通过让 Holder<T>
派生自一个非模板基类 (HolderBase
) 并存储对基类的引用来解决这个问题。一旦构造完成,ThingWithToString
就不知道被适配对象的类型。换句话说,在编译时,被适配对象的类型从它的视图中被“擦除”了,但可以通过 HolderBase
的虚方法在运行时访问。由于类型擦除接口(ThingWithToString
)不以被适配对象的类型为参数,我们可以在运行时选择要实例化的类型。
此时一些人可能会问,为什么我们不直接使用 HolderBase
类而不是将其包装在 ThingWithToString
中。假设 HolderBase
和 Holder<T>
不是内部类,让我们看看这样的实现可能是什么样子。
std::unique_ptr<HolderBase> getObject(int which) { switch (which) { case 0: return std::make_unique<Holder<Dog> >((Dog())); case 1: return std::make_unique<Holder<Robot> >((Robot())); default: throw std::runtime_error("Unknown object type"); } } int main() { int which = -1; std::cin >> which; std::unique_ptr<HolderBase> object = getObject(which); std::cout << object->toString() << std::endl; return 0; }
我们获得了大部分功能,但还有一个问题——由于 HolderBase
是一个抽象类,它没有值语义。而 ThingWithToString
具有值语义——复制 ThingWithToString
的实例也会复制被适配的对象。当然,如果需要,我们仍然可以通过引用或指针传递这些实例。值语义类型是可取的,因为不共享对象使得推断程序(引用透明性)更容易。除了提供值语义外,使用 HolderBase
的包装器还可以根据需要实现小对象优化。例如,Adobe 的 poly
库使用局部缓冲区来存储小对象,而不是在堆上创建它们。
既然我们已经看到了值语义类型擦除的机制,让我们来考虑 ThingWithToString
可以实例化的对象类型。对象肯定必须有一个 toString()
方法,即模拟一个 StringConvertible
的概念。它还必须是可复制构造的,因为我们想要值语义。除此之外,它拥有什么其他方法或其实际类型并不重要。这正是模板对其类型参数的要求——唯一的区别是类型擦除接口可以在运行时实例化。我们现在就可以编写运行时类型的泛型算法了!
您不必费心寻找类型擦除接口的例子。std::any
和 std::function
(及其 boost 对应项)都使用类型擦除来实现。在 Thomas Becker 的文章 On the Tension Between Object-Oriented and Generic Programming in C++ 中,他描述了类型擦除如何用于实现 any_iterator
。
乍一看,继承和类型擦除简直是风马牛不相及。那么,我们如何决定何时使用类型擦除而不是继承呢?让我们先快速回顾一下类型擦除如何融入多态的殿堂。我们将通过考察类型兼容性概念与多态的关系来实现这一点(我保证这有其道理)。
类型兼容性与多态
在我们开始之前,请考虑 Luca Cardelli 和 Peter Wegner 在《On Understanding Types, Data Abstraction and Polymorphism》中对多态的定义。
允许一个类型表达式表示多个类型,或者与许多类型兼容的类型表达式之间的相似性关系,被称为多态。
那么什么是类型兼容性?类型兼容性是指表达式的类型是否与表达式出现的上下文所期望的类型一致 [Wikipedia-TypeSystem]。两种等价(在某些等价规则下)的类型是相互兼容的。在支持子类型的语言中,子类型与其超类型兼容。广义上说,有两种类型的兼容性——命名式和结构式。
在命名类型系统中,两个变量的类型兼容,当且仅当它们的声明命名相同的类型(包括定义作用域)[Wikipedia-Nominal]。类型别名,例如 C++ 中的 typedef
,与其原始类型兼容。超类型-子类型关系通过名称显式声明。继承是命名式子类型,因为基类的名称是派生类定义的一部分。在结构类型系统中,类型等价由类型的实际结构或定义决定,而不是由其他特征(如名称或声明位置)决定 [Wikipedia-Structural]。当且仅当一种类型包含另一种类型的全部属性时,前者才是后者的子类型。在这里,我使用“属性”来指代类型 public
的方法或数据。虽然 C++ 类型系统主要是命名式的,但它在模板类型参数方面表现出结构化类型。
考虑到这一点,让我们再次看看类型擦除示例。如我们所见,ThingWithToString
可以用任何具有 toString()
方法的对象进行实例化,也就是说,任何是 ThingWithToString
结构化子类型的类型。换句话说,类型擦除允许我们在 C++ 中模拟运行时结构化子类型多态。相反,通过继承实现的多态是运行时命名式子类型多态。这是一种强大的思考类型擦除的方式,因为它使我们能够识别类型擦除和基于继承的设计之间的等价关系。例如,std::any
可以被认为是空基类的结构等价物。std::function
模板是可调用类型结构超类型的生成器——其基于继承的对应物是一个具有纯 virtual
operator()()
的抽象基类。
命名式和结构式类型兼容性之间的二分法也存在于编译时多态中。函数重载(特设多态)需要参数类型和参数类型之间的命名式兼容性或隐式可转换性。模板(参数化多态)需要类型参数和类型参数之间的结构兼容性。正如继承是重载的运行时对应物(虚函数重写本质上是对隐式 this
参数类型的重载)一样,类型擦除是模板的运行时对应物。
何时使用类型擦除?
是时候将所有内容整合起来了。理解类型兼容性与多态之间的关系,使我们能够将继承和类型擦除(以及重载和模板)之间的选择表述为命名式和结构式类型优缺点的权衡。它为我们在编译时和运行时做出这些设计选择提供了一个框架。
结构化类型显然是两者中更灵活的一种。它使我们不必预见要应用于类型的算法,也不必预见要将算法应用于哪些类型。与命名式子类型化不同,我们可以在不修改现有类型定义的情况下定义其结构化超类型。因此,结构化类型允许我们以多态的方式处理那些因继承而无关的具体类型。然而,仅仅因为两种类型结构上等价,并不意味着它们语义上等价。类型的名称传达了其结构本身无法显现的契约和不变量。例如,一个设计良好的继承层次结构传达了关于前置条件和后置条件的某些保证。另一方面,类型擦除不提供任何此类保证。命名式类型也允许程序员明确表达其设计意图,即程序各部分如何协同工作,从而提供比结构式类型更强的类型安全性。
创建类型擦除接口的库
创建类型擦除接口可能是一个繁琐的过程。幸运的是,有一些库可以减轻一些痛苦,并使这项技术变得易于使用。 Boost.TypeErasure 就是这样一个库,它提供了许多预定义的概念。 Adobe poly 是另一个用于创建类型擦除接口的出色库。
关于术语的说明
“类型擦除”一词的用法可能有点令人困惑。例如,Java 中被称为类型擦除的技术与这里描述的技术相当不同。从根本上说,C++ 中的类型擦除是一种机制,通过该机制,类型信息在编译时被“隐藏”在程序的某些部分。严格来说,继承也是一种类型擦除——客户端在编译时只知道基类,而派生类型对它们是“隐藏”的。然而,在流行用法中,“类型擦除”一词指的是本文介绍的技术。一个不那么令人困惑,也许更具描述性的名称可能是外部多态。
关注点
- 继承并非总是运行时多态的正确选择。我们需要能够编写运行时类型的泛型算法。
- 类型擦除可用于创建值语义适配器,这些适配器基于被适配对象的属性而不是其类型来实现。它允许我们编写运行时类型的泛型算法。
- 理解类型兼容性与多态之间的关系,为我们提供了一个框架来推理设计选择及其与多态的关系。