提问人:OB OB 提问时间:6/25/2009 最后编辑:DeduplicatorOB OB 更新时间:10/8/2022 访问量:33548
为什么 STL 如此严重地基于模板而不是继承?
Why is the STL so heavily based on templates instead of inheritance?
问:
我的意思是,除了它的名字,标准模板库(演变成C++标准库)。
C++ 最初将 OOP 概念引入 C。也就是说:你可以根据一个特定的实体的类和类层次结构来判断它能做什么和不能做什么(不管它是如何做的)。由于多重继承的复杂性,以及 C++ 以某种笨拙的方式支持仅接口继承(与 java 等相比),一些能力组合更难以这种方式描述,但它就在那里(并且可以改进)。
然后模板和 STL 一起发挥作用。STL 似乎采用了经典的 OOP 概念,并将它们冲入下水道,而是使用模板。
当使用模板来概化类型时,类型本身与模板的操作无关(例如容器),应该区分两种情况。有一个是完全有道理的。vector<int>
然而,在许多其他情况下(迭代器和算法),模板化类型应该遵循一个“概念”(输入迭代器、前向迭代器等),其中概念的实际细节完全由模板函数/类的实现定义,而不是由模板使用的类型的类定义,这在某种程度上是 OOP 的反用法。
例如,您可以告诉函数:
void MyFunc(ForwardIterator<...> *I);
更新:由于在原始问题中不清楚,因此可以将 ForwardIterator 本身模板化以允许任何 ForwardIterator 类型。相反的是,将 ForwardIterator 作为一个概念。
仅通过查看其定义来期望前向迭代器,其中您需要查看实现或文档:
template <typename Type> void MyFunc(Type *I);
我可以提出两个支持使用模板的主张:1.通过为每个使用的类型重新编译模板,而不是使用动态调度(主要通过 vtables),可以提高编译代码的效率。2. 模板可以与原生类型一起使用。
但是,我正在寻找一个更深刻的理由来放弃经典的 OOP 而支持 STL 的模板?
答:
我认为你在问/抱怨的问题最直接的答案是:C++是一种OOP语言的假设是一个错误的假设。
C++ 是一种多范式语言。它可以使用OOP原理进行编程,可以进行程序化编程,可以进行通用编程(模板),并且使用C++11(以前称为C++0x)甚至可以对某些东西进行功能编程。
C++的设计者认为这是一个优势,所以他们会争辩说,当泛型编程更好地解决问题时,限制C++像纯粹的OOP语言一样工作,嗯,更通用,将是一种倒退。
评论
基本问题
void MyFunc(ForwardIterator *I);
如何安全地获得迭代器返回的事物类型?使用模板,这是在编译时为您完成的。
评论
简短的回答是“因为C++已经前进了”。是的,早在 70 年代后期,Stroustrup 就打算创建一个具有 OOP 功能的升级 C,但那是很久以前的事了。到 1998 年该语言标准化时,它不再是 OOP 语言。它是一种多范式语言。它当然对OOP代码有一些支持,但它也覆盖了一个图灵完备的模板语言,它允许编译时元编程,人们已经发现了通用编程。突然间,OOP似乎并不那么重要了。当我们可以使用模板和泛型编程提供的技术来编写更简单、更简洁和更高效的代码时,情况就不一样了。
OOP不是圣杯。这是一个可爱的想法,它比 70 年代发明的过程语言有了很大的改进。但老实说,这并不是它的全部。在许多情况下,它是笨拙和冗长的,它并没有真正促进可重用的代码或模块化。
这就是为什么今天的C++社区对泛型编程更感兴趣,以及为什么每个人都终于开始意识到函数式编程也是相当聪明的。OOP本身并不是一个漂亮的景象。
尝试绘制假设的“OOP-ified”STL 的依赖关系图。有多少班级必须相互了解?会有很多依赖关系。您是否能够只包含标题,而不同时获得甚至拉入?STL 使这一切变得简单。向量知道它定义的迭代器类型,仅此而已。STL 算法一无所知。它们甚至不需要包含迭代器标头,即使它们都接受迭代器作为参数。那么哪个更模块化呢?vector
iterator
iostream
STL 可能没有遵循 Java 定义的 OOP 规则,但它没有实现 OOP 的目标吗?难道它不能实现可重用性、低耦合性、模块化和封装性吗?
难道它不比OOP版本更好地实现这些目标吗?
至于为什么 STL 被采用到语言中,发生了几件事导致了 STL。
首先,将模板添加到 C++ 中。添加它们的原因与将泛型添加到 .NET 的原因大致相同。能够在不抛弃类型安全性的情况下编写诸如“T 型容器”之类的东西似乎是个好主意。当然,他们确定的实现要复杂得多,功能强大得多。
然后人们发现他们添加的模板机制比预期的还要强大。有人开始尝试使用模板来编写一个更通用的库。一个受函数式编程的启发,一个使用了C++的所有新功能。
他把它提交给了C++语言委员会,他们花了相当长的时间来适应它,因为它看起来很奇怪和不同,但最终意识到它比传统的OOP等价物更好,否则他们必须包括。因此,他们对它进行了一些调整,并将其采用到标准库中。
这不是一个意识形态的选择,也不是“我们是否想成为OOP”的政治选择,而是一个非常务实的选择。他们评估了该库,发现它运行良好。
无论如何,您提到的支持 STL 的两个原因都是绝对必要的。
C++ 标准库必须高效。如果它的效率低于等效的手卷 C 代码,那么人们就不会使用它。这会降低生产力,增加出现错误的可能性,总的来说是一个坏主意。
STL 必须使用原始类型,因为原始类型是你在 C 语言中的全部,它们是两种语言的主要部分。如果 STL 不能与本机数组一起使用,它将毫无用处。
您的问题有一个强烈的假设,即 OOP 是“最好的”。我很想知道为什么。你问他们为什么“放弃经典的OOP”。我想知道他们为什么要坚持下去。它有什么优势?
评论
std::set
std::set
std::set
您如何与 ForwardIterator* 进行比较?也就是说,你如何检查你拥有的物品是否是你要找的,或者你已经错过了它?
大多数时候,我会使用这样的东西:
void MyFunc(ForwardIterator<MyType>& i)
这意味着我知道我指向的是 MyType,并且我知道如何比较它们。虽然它看起来像一个模板,但它并不是真的(没有“模板”关键字)。
评论
为什么对数据结构和算法库进行纯OOP设计会更好?! OOP 并不是所有事情的解决方案。
恕我直言,STL 是我见过的最优雅的图书馆:)
对于您的问题,
你不需要运行时多态性,对于STL来说,使用静态多态性实现库是一个优势,这意味着效率。 尝试编写一个通用的 Sort 或 Distance 或适用于所有容器的任何算法! 您在 Java 中的排序将调用通过 n 级动态的函数来执行!
你需要像 Boxing 和 Unboxing 这样愚蠢的东西来隐藏所谓的纯 OOP 语言的令人讨厌的假设。
我在 STL 和一般模板中看到的唯一问题是可怕的错误消息。 这将使用 C++0X 中的概念来解决。
将 STL 与 Java 中的集合进行比较就像将泰姬陵与我家的:)进行比较
评论
static_assert
“对我而言,OOP 只意味着消息传递、本地保留、保护和隐藏状态进程,以及所有事物的极端后期绑定。它可以在 Smalltalk 和 LISP 中完成。可能还有其他系统可以做到这一点,但我不知道它们。
C++、Java 和大多数其他语言都与经典的 OOP 相去甚远。也就是说,为意识形态争论并不是很有成效。C++在任何意义上都不是纯粹的,因此它实现了当时似乎具有实用意义的功能。
我的理解是,Stroustrup 最初更喜欢“OOP 风格”的容器设计,实际上没有看到任何其他方法可以做到这一点。亚历山大·斯捷潘诺夫(Alexander Stepanov)是STL的负责人,他的目标不包括“使其面向对象”:
这是最基本的一点:算法是在代数结构上定义的。我又花了几年时间才意识到,你必须通过向正则公理添加复杂性要求来扩展结构的概念。...我相信迭代器理论对计算机科学至关重要,就像环或巴拿赫空间理论对数学至关重要一样。每次我看一个算法时,我都会试图找到一个定义它的结构。所以我想做的是通用地描述算法。这就是我喜欢做的事情。我可以花一个月的时间研究一个众所周知的算法,试图找到它的泛型表示。...
至少对我而言,STL代表了编程的唯一途径。事实上,它与C++编程完全不同,因为它在大多数教科书中都有介绍,并且仍然在介绍。但是,你看,我不是试图用C++编程,而是试图找到处理软件的正确方法。...
我有很多错误的开始。例如,我花了数年时间试图找到继承和虚拟的一些用途,然后我才明白为什么这种机制从根本上是有缺陷的,不应该使用。我很高兴没有人能看到所有的中间步骤——他们中的大多数都非常愚蠢。
(他确实解释了为什么继承和虚拟——也就是面向对象的设计“从根本上是有缺陷的,不应该在采访的其余部分使用”)。
当斯捷潘诺夫向斯特鲁斯特鲁普展示他的库时,斯特鲁斯特鲁普和其他人费尽心思才将其纳入ISO C++标准(同一次采访):
比亚恩·斯特鲁斯特鲁普(Bjarne Stroustrup)的支持至关重要。Bjarne 真的希望 STL 成为标准,如果 Bjarne 想要什么,他就会得到。...他甚至强迫我在 STL 中做出我永远不会为其他人做出的改变......他是我认识的最专一的人。他把事情做好了。他花了一段时间才明白 STL 是怎么回事,但当他明白时,他已经准备好推动它了。他还为STL做出了贡献,他坚持了不止一种编程方式是有效的观点--反对十多年来无休止的抨击和炒作,并在模板中追求灵活性、效率、重载和类型安全的结合,使STL成为可能。我想非常明确地说,Bjarne是我这一代人中最杰出的语言设计师。
评论
答案可以在对 STL 作者 Stepanov 的采访中找到:
是的。STL 不是面向对象的。我 认为面向对象是 几乎和人工一样多的骗局 情报。我还没有看到 有趣的代码 来自这些 OO 人。
评论
模板化类型应该遵循 一个“概念”(输入迭代器,前向 迭代器等...其中实际的 定义了概念的细节 完全通过实施 模板函数/类,而不是 与 模板,这有点 反使用OOP。
我认为您误解了模板对概念的预期用途。例如,前向迭代器是一个非常明确定义的概念。要找到类成为前向迭代器必须有效的表达式,以及它们的语义(包括计算复杂性),请查看标准或 http://www.sgi.com/tech/stl/ForwardIterator.html(您必须按照输入、输出和琐碎迭代器的链接查看全部内容)。
该文档是一个非常好的界面,“概念的实际细节”就在那里定义。它们不是由前向迭代器的实现定义的,也不是由使用前向迭代器的算法定义的。
STL 和 Java 在处理接口的方式上存在三点差异:
1) STL 使用对象定义有效的表达式,而 Java 定义必须在对象上可调用的方法。当然,有效的表达式可能是方法(成员函数)调用,但不一定是。
2) Java 接口是运行时对象,而 STL 概念即使在运行时也不可见,即使使用 RTTI。
3) 如果无法使 STL 概念所需的有效表达式有效,则在实例化具有该类型的某个模板时,会出现未指定的编译错误。如果无法实现 Java 接口所需的方法,则会出现特定的编译错误。
如果你喜欢一种(编译时)“鸭子类型”,那么第三部分是:接口可以是隐式的。在 Java 中,接口在某种程度上是显式的:当且仅当一个类说它实现了 Iterable 时,它才“是”Iterable。编译器可以检查其方法的签名是否全部存在且正确,但语义仍然是隐式的(即它们要么被记录在案,要么没有被记录,但只有更多的代码(单元测试)才能告诉你实现是否正确)。
在C++中,就像在Python中一样,语义和语法都是隐式的,尽管在C++中(在Python中,如果你得到强类型预处理器),你确实可以从编译器那里得到一些帮助。如果程序员要求实现类对接口进行类似 Java 的显式声明,那么标准方法是使用类型特征(多重继承可以防止这太冗长)。与 Java 相比,缺少的是一个单一的模板,我可以用我的类型实例化它,并且当且仅当所有必需的表达式对我的类型都有效时,它才会编译。这将告诉我“在我使用它之前”我是否已经实现了所有必需的位。这很方便,但它不是 OOP 的核心(它仍然不测试语义,测试语义的代码自然也会测试所讨论的表达式的有效性)。
STL 可能足以满足您的口味,也可能不足以满足您的口味,但它肯定会将接口与实现完全分开。它确实缺乏 Java 对接口进行反射的能力,并且它以不同的方式报告违反接口要求的情况。
你可以告诉功能...仅期望前向迭代器 查看其定义,您需要查看 实施或文档...
我个人认为,如果使用得当,隐式类型是一种优势。该算法说明了它对模板参数的作用,而实现者则确保这些东西有效:这正是“接口”应该做什么的共同点。此外,对于 STL,您不太可能使用,例如,基于在头文件中查找其正向声明。程序员应该根据函数的文档来计算函数需要什么,而不仅仅是函数签名。这在 C++、Python 或 Java 中是正确的。在任何语言中打字都存在局限性,尝试使用打字来做一些它不做的事情(检查语义)将是一个错误。std::copy
也就是说,STL 算法通常以一种明确需要什么概念的方式命名其模板参数。但是,这是为了在文档的第一行提供有用的额外信息,而不是使转发声明更具信息性。您需要了解的内容比参数类型中可以封装的内容要多,因此您必须阅读文档。(例如,在采用输入范围和输出迭代器的算法中,输出迭代器可能需要足够的“空间”来容纳一定数量的输出,具体取决于输入范围的大小以及其中的值。尝试用力键入该内容。
以下是 Bjarne 关于显式声明接口的内容: http://www.artima.com/cppsource/cpp0xP.html
在泛型中,参数必须是 派生自接口的类( C++ 等同于接口是 abstract class) 中指定的 泛型的定义。这意味着 所有泛型参数类型都必须 适合层次结构。这强加 对设计的不必要约束 需要不合理的远见 部分开发人员。例如,如果 你写一个泛型,我定义一个 类,人们不能将我的类用作 除非我知道,否则对你的通用参数 关于您指定的接口和 从中衍生出我的类。那是 刚性。
反过来看,使用 duck 类型,您可以在不知道接口存在的情况下实现接口。或者有人可以故意编写一个接口,以便您的类实现它,在查阅您的文档后,看看他们不会要求您做任何您还没有做的事情。 这很灵活。
评论
std
将接口与接口分离并能够交换实现的概念并不是面向对象编程所固有的。我相信这是一个在基于组件的开发(如Microsoft COM)中孵化的想法。 (请参阅我的答案 什么是组件驱动开发?)在成长和学习C++的过程中,人们被大肆宣传遗传和多态性。直到 90 年代,人们才开始说“编程到'接口',而不是'实现'”和“偏爱'对象组合'而不是'类继承'”。(顺便说一句,两者都引用了 GoF)。
然后 Java 出现了内置的垃圾收集器和关键字,突然之间,将接口和实现分开变得实用。在不知不觉中,这个想法成为了 OO 的一部分。C++、模板和 STL 早于所有这些。interface
评论
STL 最初的目的是提供一个涵盖最常用算法的大型库,目标是一致性行为和性能。模板是使该实施和目标可行的关键因素。
只是为了提供另一个参考:
1995 年 3 月,艾尔·史蒂文斯 (Al Stevens) 采访了 DDJ 的亚历克斯·斯捷潘诺夫 (Alex Stepanov):
斯捷潘诺夫解释了他的工作经验和对大型算法库的选择,该库最终演变为STL。
告诉我们一些关于你对泛型编程的长期兴趣
.....然后我得到了贝尔实验室的工作,在C++小组从事C++库的工作。他们问我是否可以用C++来做。当然,我不懂C++,当然,我说我可以。但是我不能用C++做到这一点,因为在1987年,C++没有模板,而模板对于实现这种编程风格至关重要。遗传是获得通用性的唯一机制,这还不够。
即使是现在,C++继承对于泛型编程也没有多大用处。让我们讨论一下原因。许多人尝试使用继承来实现数据结构和容器类。正如我们现在所知道的,几乎没有成功的尝试。C++ 继承以及与之相关的编程风格受到极大的限制。不可能实现一个设计,包括像使用它一样平等的微不足道的东西。如果从层次结构根的基类 X 开始,并在此类上定义一个虚拟相等运算符,该运算符采用 X 类型的参数,则从类 X 派生类 Y。相等的界面是什么?它具有将 Y 与 X 进行比较的相等性。 以动物为例(OO 人喜欢动物),定义哺乳动物并从哺乳动物派生出长颈鹿。然后定义一个成员函数 mate,其中 animal 与 animal 交配并返回 animal。然后你从动物那里得到长颈鹿,当然,它有一个功能伴侣,长颈鹿与动物交配并返回动物。这绝对不是你想要的。虽然对接对 C++ 程序员来说可能不是很重要,但相等是。我不知道没有一种算法不使用某种相等性。
这个问题有很多很好的答案。还应该提到的是,模板支持开放式设计。以面向对象编程语言的现状,在处理此类问题时必须使用访问者模式,真正的 OOP 应该支持多个动态绑定。参见 Open Multi-Methods for C++, P. Pirkelbauer, et.al. 阅读。
模板的另一个有趣之处是,它们也可以用于运行时多态性。例如
template<class Value,class T>
Value euler_fwd(size_t N,double t_0,double t_end,Value y_0,const T& func)
{
auto dt=(t_end-t_0)/N;
for(size_t k=0;k<N;++k)
{y_0+=func(t_0 + k*dt,y_0)*dt;}
return y_0;
}
请注意,如果是某种向量(不是 std::vector,应调用以避免混淆),此函数也将起作用Value
std::dynamic_array
如果很小,则此函数将从内联中获得很多收益。用法示例func
auto result=euler_fwd(10000,0.0,1.0,1.0,[](double x,double y)
{return y;});
在这种情况下,你应该知道确切的答案(2.718...),但是很容易在没有基本解的情况下构造一个简单的常微分方程(提示:在y中使用多项式)。
现在,您在 中有一个很大的表达式,并且在许多地方都使用了 ODE 求解器,因此您的可执行文件在任何地方都受到模板实例化的污染。该怎么办?首先要注意的是常规函数指针可以工作。然后,你要添加currying,以便编写一个接口和一个显式实例化func
class OdeFunction
{
public:
virtual double operator()(double t,double y) const=0;
};
template
double euler_fwd(size_t N,double t_0,double t_end,double y_0,const OdeFunction& func);
但是上面的实例化只适用于,为什么不把接口写成模板呢:double
template<class Value=double>
class OdeFunction
{
public:
virtual Value operator()(double t,const Value& y) const=0;
};
并专门针对一些常见的值类型:
template double euler_fwd(size_t N,double t_0,double t_end,double y_0,const OdeFunction<double>& func);
template vec4_t<double> euler_fwd(size_t N,double t_0,double t_end,vec4_t<double> y_0,const OdeFunction< vec4_t<double> >& func); // (Native AVX vector with four components)
template vec8_t<float> euler_fwd(size_t N,double t_0,double t_end,vec8_t<float> y_0,const OdeFunction< vec8_t<float> >& func); // (Native AVX vector with 8 components)
template Vector<double> euler_fwd(size_t N,double t_0,double t_end,Vector<double> y_0,const OdeFunction< Vector<double> >& func); // (A N-dimensional real vector, *not* `std::vector`, see above)
如果函数是首先围绕接口设计的,那么您将被迫从该 ABC 继承。现在您有此选项,以及函数指针、lambda 或任何其他函数对象。这里的关键是我们必须有 ,并且我们必须能够在其返回类型上使用一些算术运算符。因此,在这种情况下,如果 C++ 没有运算符重载,模板机制就会中断。operator()()
现在,让我们把标准库看作是集合和算法的数据库。
如果你研究过数据库的历史,你无疑知道,在一开始,数据库大多是“分层”的。分层数据库与经典的 OOP 非常接近,特别是单继承类型,例如 Smalltalk 使用的。
随着时间的流逝,很明显,分层数据库几乎可以用于对任何东西进行建模,但在某些情况下,单继承模型具有相当大的局限性。如果你有一扇木门,可以把它看作是一扇门,或者是一块原材料(钢、木头等),这很方便。
因此,他们发明了网络模型数据库。网络模型数据库与多重继承非常接近。C++完全支持多重继承,而Java支持有限形式(你只能从一个类继承,但也可以实现任意数量的接口)。
分层模型和网络模型数据库大多已经从通用用途中消失了(尽管少数数据库仍处于相当特定的领域)。在大多数情况下,它们已被关系数据库所取代。
关系数据库接管的主要原因是多功能性。关系模型在功能上是网络模型的超集(而网络模型又是分层模型的超集)。
C++在很大程度上遵循了相同的路径。单继承与分层模型、多重继承与网络模型的对应关系相当明显。C++ 模板和分层模型之间的对应关系可能不太明显,但无论如何它都非常接近。
我还没有看到正式的证明,但我相信模板的功能是多重继承提供的超集(这显然是单一惯性的超集)。一个棘手的部分是模板大多是静态绑定的,也就是说,所有绑定都发生在编译时,而不是运行时。因此,继承提供了继承能力的超集的形式证明可能有些困难和复杂(甚至可能是不可能的)。
无论如何,我认为这是 C++ 不对其容器使用继承的大部分真正原因——没有真正的理由这样做,因为继承只提供了模板提供的功能的子集。由于模板在某些情况下基本上是必需的,因此它们几乎可以在任何地方使用。
上一个:Cout 不是 STD 的成员
下一个:如何反转 C++ 向量?
评论
vector<int>
vector<char>
vector<int>
vector<char>