理解多态性/多态性的要求
要理解计算机科学中所使用的多态性,从一个简单的测试和定义开始会有所帮助。请考虑以下内容:
Type1 x;
Type2 y;
f(x);
f(y);
在这里,f()
是执行一些操作,并将x
和y
作为输入值给出。
为了展示多态性,f()
必须能够操作至少两个不同类型的值(例如int
和double
),找到并执行不同的类型适当的代码。
C ++的多态机制
显式程序员指定的多态
您可以编写f()
,使其以以下任何一种方式操作多个类型:
// (note: in real code, use a longer uppercase name for a macro!)
重载:
void f(int& x) { x += 2; }
void f(double& x) { x += 2; }
模板:
template <typename T>
void f(T& x) { x += 2; }
虚拟分发:struct Base { virtual Base& operator+=(int) = 0; };
struct X : Base
{
X(int n) : n_(n) { }
X& operator+=(int n) { n_ += n; return *this; }
int n_;
};
struct Y : Base
{
Y(double n) : n_(n) { }
Y& operator+=(int n) { n_ += n; return *this; }
double n_;
};
void f(Base& x) { x += 2; }
其他相关机制
针对内置类型的编译器提供的多态性、标准转换以及强制类型转换将在后面进行讨论,以完整性为原则,因为:
- 它们通常是直观理解的(值得一个“哦,是这样啊”反应)。
- 它们影响需要、使用上述机制的无缝程度的门槛。
- 解释会分散注意力,从而使更重要的概念变得混乱不清。
术语
进一步分类
鉴于上述多态机制,我们可以以各种方式对它们进行分类:
何时选择多态类型特定代码?
- 运行时意味着编译器必须为程序可能处理的所有类型生成代码,并在运行时选择正确的代码(虚拟派遣)。
- 编译时意味着在编译期间选择特定于类型的代码。对此的一个结果是:假设程序只使用
int
参数调用f
函数 - 根据所使用的多态机制和内联选择,编译器可能避免生成任何f(double)
的代码,或者在某些编译或链接阶段抛弃生成的代码(除虚拟派遣外的所有机制)。
支持哪些类型?
- 特定于应用程序意味着您提供了明确的代码来支持每种类型(例如重载、模板专业化)。您显式地添加对“这个”(根据“特定于...”的含义)类型的支持,以及其他一些“这个”,可能还有“那个”。
参数化意味着您可以尝试使用各种参数类型的函数,而无需专门执行任何操作以启用其对它们的支持(例如模板、宏)。对象的函数/运算符的行为就像模板/宏所期望的那样1,模板/宏需要完成其工作,实际的类型无关紧要。C++20引入的“概念”表达并强制执行这些期望 - 请参见此处的cppreference页面。
参数化多态性提供了鸭子类型 - 这是一种由詹姆斯·惠特科姆·莱利(James Whitcomb Riley)所归属的概念,他显然说过:“当我看到一只走路像鸭子、游泳像鸭子、嘎嘎叫像鸭子的鸟时,我称那只鸟为鸭子。”
template <typename Duck>
void do_ducky_stuff(const Duck& x) { x.walk().swim().quack(); }
do_ducky_stuff(Vilified_Cygnet());
子类型(也称为包容)多态性允许您在不更新算法/函数的情况下使用新类型,但它们必须派生自同一基类(虚分派)
1 - 模板非常灵活。 SFINAE(参见 http://en.wikipedia.org/wiki/Substitution_failure_is_not_an_error 和 std::enable_if
)有效地允许对于参数化多态性存在几组期望值。例如,您可以编码,当您处理的数据类型具有 .size()
成员时,您将使用一个函数,否则使用不需要 .size()
的另一个函数(但可能会以某种方式变慢,例如使用较慢的 strlen()
或在日志中不打印出有用的消息)。您还可以指定模板在实例化具有特定参数时的临时行为,可以使某些参数是基于参数的(部分模板特化),也可以不是(完全特化)。
"多态性"
Alf Steinbach 指出,在 C++ 标准中,多态性 只指运行时通过虚分派实现的多态性。通用计算机科学含义更加广泛,如 C++ 创始人 Bjarne Stroustrup 的术语表所述 (http://www.stroustrup.com/glossary.html):
多态性 - 为不同类型的实体提供单一接口。虚函数通过基类提供的接口,提供动态(运行时)多态性。重载函数和模板提供静态(编译时)多态性。 TC++PL 12.2.6、13.6.1、D&E 2.9。
本答案 - 就像问题一样 - 将 C++ 特性与计算机科学术语相关联。
讨论
由于 C++ 标准使用比计算机科学社区更狭窄的 "多态性" 定义,为了确保相互理解,考虑...
- 使用清晰明确的术语(例如“我们是否可以使此代码对其他类型重用?”或“我们是否可以使用虚分派?”而不是“我们是否可以使此代码多态?”)和/或
- 清晰地定义您的术语。
仍然,成为优秀的 C++ 程序员需要理解多态性对您的工作实际上有哪些帮助...
让你编写“算法”代码一次,然后将其应用于多种类型的数据
......然后非常注意不同的多态机制如何匹配您的实际需求。
运行时多态适合于:
- 通过工厂方法处理的输入,并作为异构对象集合由
Base*
处理,
- 基于配置文件、命令行开关、UI设置等在运行时选择的实现,
- 在运行时变化的实现,例如状态机模式。
当没有明确的运行时多态驱动程序时,通常更喜欢编译时选项。考虑:
- 模板类的编译哪些被调用的方面比失败于运行时的fat接口更可取
- SFINAE
- CRTP
- 优化(包括内联和死代码消除,循环展开,静态堆栈数组 vs 堆)
__FILE__
, __LINE__
, 字符串文本连接以及宏的其他独特功能(宏仍然很邪恶 ;-))
- 模板和宏测试语义用法受支持,但不人为限制该支持是如何提供的(与虚拟分派倾向于要求完全匹配的成员函数覆盖相反)
支持多态的其他机制
如承诺的,出于完整考虑了一些外围主题:
- 编译器提供的重载
- 类型转换
- 强制转换/强制类型转换
本答案最后讨论了上述内容如何结合起来增强和简化多态代码 - 特别是参数化多态性(模板和宏)。
映射到特定类型操作的机制
> 隐式编译器提供的重载
概念上,编译器对内置类型进行了许多运算符重载。这与用户指定的重载在概念上没有区别,但因为容易被忽视而列出。例如,您可以使用相同的符号x += 2
将其添加到int
和double
中,并且编译器会生成:
然后重载无缝扩展到用户定义的类型:
std::string x;
int y = 0;
x += 'c';
y += 'c';
在高级(第三代及以上)计算机语言中,编译器通常会提供基本类型的重载函数,而显式讨论多态性通常意味着更多的内容。(汇编语言-第二代语言-通常要求程序员显式地使用不同的助记符来表示不同的类型。)
> 标准转换
C++标准的第四部分介绍了标准转换。
第一点从旧版本中很好地总结了(希望仍然基本正确):
-1- 标准转换是为内置类型定义的隐式转换。子句conv枚举了此类转换的完整集合。标准转换序列是以下顺序的标准转换序列:
来自以下集合的零个或一个转换:lvalue-to-rvalue转换、数组到指针转换和函数到指针转换。
来自以下集合的零个或一个转换:integral promotions、floating point promotion、integral conversions、floating point conversions、floating-integral conversions、pointer conversions、pointer to member conversions和boolean conversions。
零个或一个限定符转换。
[注意:标准转换序列可以为空,即它可以不包含转换。]如果需要将表达式转换为所需的目标类型,则将应用标准转换序列。
这些转换允许编写如下代码:
double a(double x) { return x + 2; }
a(3.14);
a(42);
应用之前的测试:
为了是多态的,[a()
] 必须能够使用至少两种不同类型的值进行操作(例如 int
和 double
),并且找到并执行适当类型的代码。
a()
本身只针对 double
运行代码,因此不是 多态的。
但是,在第二次调用 a()
时,编译器知道要生成 "浮点提升" 的类型适当代码(标准 §4)将 42
转换为 42.0
。那个额外的代码在 调用 函数中。我们将在结论中讨论其重要性。
> 强制转换、类型转换和隐式构造函数
这些机制允许用户定义的类指定类似于内置类型的标准转换的行为。让我们来看一下:
int a, b;
if (std::cin >> a >> b)
f(a, b);
这里,对象 std::cin
在布尔上下文中进行评估,借助于一个转换运算符。这可以从上面主题中的“标准转换”中概念上归类为“整数提升”等。
隐式构造函数实际上也是做同样的事情,但由强制转换类型控制:
f(const std::string& x)
f("hello")
编译器提供的重载、转换和强制转换的影响
考虑以下内容:
void f()
{
typedef int Amount;
Amount x = 13;
x /= 2;
std::cout << x * 1.1;
}
如果我们想要在除法时将数量 x
视为实数(即变为6.5而不是向下舍入为6),我们只需要将 typedef double Amount
进行更改。
这很好,但明确“类型正确”的代码也不会太难:
void f() void f()
{ {
typedef int Amount; typedef double Amount;
Amount x = 13; Amount x = 13.0;
x /= 2; x /= 2.0;
std::cout << double(x) * 1.1; std::cout << x * 1.1;
} }
但是,请注意我们可以将第一个版本转换为模板
:
template <typename Amount>
void f()
{
Amount x = 13;
x /= 2;
std::cout << x * 1.1;
}
由于这些小的“便利功能”,它可以轻松地为
int
或
double
实例化并正常工作。没有这些功能,我们需要显式转换、类型特征和/或策略类,一些冗长而容易出错的混乱代码。
template <typename Amount, typename Policy>
void f()
{
Amount x = Policy::thirteen;
x /= static_cast<Amount>(2);
std::cout << traits<Amount>::to_double(x) * 1.1;
}
因此,编译器提供的内建类型运算符重载、标准转换、强制类型转换/隐式构造函数——它们都为多态性提供了微妙的支持。按照本答案顶部的定义,它们通过映射解决“查找和执行适用于类型的代码”:
从参数类型中“移开”
从多态算法代码处理的许多数据类型
到编写为少量(相同或其他)类型的代码。
"向"参数化类型从常量类型的值
它们本身并不建立多态上下文,但确实有助于使这些上下文中的代码变得更加简单/强大。
你可能会感到失望……似乎不是很重要。其重要性在于,在参数化多态上下文中(即在模板或宏内部),我们试图支持任意范围的类型,但通常希望以其他函数、文字和操作的形式表达对它们的操作,而这些操作是为小型类型集设计的。当操作/值在逻辑上相同时,它减少了按每个类型创建近乎相同的函数或数据的需要。这些特性合作,添加了“尽力而为”的态度,通过使用有限的可用函数和数据来做出直观预期,只有在有真正的歧义时才停止。
这有助于限制需要支持多态代码的多态代码的需求,在使用局部化的多态不会强制使用广泛使用的情况下,将网更紧地织在多态性的使用周围,并使多态性的好处根据需要提供,而不会强加在编译时暴露实现、在目标代码中支持多个副本的逻辑函数以支持使用的类型,并进行虚拟分派而不是内联或至少编译时解析调用的成本。正如C++中典型的那样,程序员被赋予了许多控制多态性使用范围的自由。