什么是类不变式

我请教一下,什么是类不变式
最新回答
吃酸奶舔瓶盖

2024-10-30 14:15:49

The C++ Style Sweet Spot

罗翼 译 蒋贤哲 校

从C往上

Bill Venners:在一次采访中,您曾说过:“C++社群正在逐渐消化C++标准所提供的基础设施。通过重新思考C++使用风格,在代码的编写、正确性、可维护性以及效率上都可以得到很大改进”。请问C++程序员该如何重新思考C++的使用风格呢?

Bjarne Stroustrup:通常情况下,指出不要做什么比指出要做什么容易得多,所以我也将采用这种方式来回答你的问题。很多人认为C++只不过是对C作了一些细小的扩充而已。在他们的代码中,充斥着原生指针和原生数组,他们将原先在C语言中使用malloc的地方换为使用new。总而言之,他们的代码的抽象层次很低。使用C形式的编码风格是一种使用C++的方式,可是这种方式并不能有效地利用C++。

我认为一种较好的使用C++的方法就是采用标准库提供的一些基础设施,例如使用vector代替原生数组。vector知道自己的大小,而原生数组就做不到。你可以方便地隐式或显示地改变一个vector的大小。如果需要改变一个原生数组的大小,你必须利用realloc、malloc以及memcpy等函数显示处理内存相关的问题。再例如利用内联函数代替宏,你就可以避开一些与宏相关的问题。还有,使用C++中的string类而不是显示地去手工操纵一个C字符串。如果在你的代码中出现了大量的转型操作符,那么几乎可以肯定代码中存在某些问题,因为你已经从一种较高的、基于类型的抽象层次下降到了一种低级的、直接与位和字节打交道的层次了。你不应该纵容这种情况经常发生。

脱离低抽象层次的风格并不意味着你需要从头手工打造一些基础的类来开始你的工作,作为替代,你可以使用由类库提供的设施。标准库是最容易想到同时也最显而易见的一个类库,当然,同时也还存在着许多其它致力于不同专业领域的库,例如数学或系统程序设计。作为一个示例,你并不需要在C层次编写你的线程代码,你可以使用一个C++线程库,例如Boost.Threads,而且在C++世界,线程库的数量是相当多的。再例如如果你需要使用回调函数机制的话,你最好不要直接 采用原生的C函数风格,而可以使用一个名为libsigc++的库来替代,它将为你正确 地处理很多与回调机制相关的事务,包括回调类、槽位和信号等。采用这种类库是非常值得的,因为它可以使你的代码更接近你的理想,并能使你从纷繁易错的细节中抽出身来,集中精力解决主要问题。

很多这样的技术都曾受到过“效率低下”的不公指责。发出这种指责的基本假设就是“优雅和高阶就意味慢”。是的,我承认在某些情况下会慢,那么我们应该在较低的层面处理这些情况,但可以从较高的层面着手。在有些场合下,你不会有任何负担,例如vector就和原生数组一样快。

面向对象的泛滥

另一种使人们陷入困境的情况则恰恰相反:这些人认为C++应该是一种极其高阶的语言,应该一切面向对象。他们坚信每当他们需要增加一个新功能时,就需要在一个拥有很多虚函数的庞大的类继承体系中插入一个新 类。这种思想已反映在诸如Java这样的语言中,但很多问题并不适合用类层次结构来解决。例如一个整数就不应该成为类继承体系的一部分 ,这根本不需要,如果你强制性地将它加进去,不仅会付出高昂的代价,而且很难做到优雅。

你可以只使用一些独立的类来进行程序设计。假如我需要一个复数,我只需要一个代表复数的类就可以了,它没有虚函数,与继承体系也没有任何关系。继承体系只有当程序需要时才是必需的。以我的书中最古老的那个从Simula借鉴而来的shape例子为例,拥有一个shapes继承体系或一个windows继承体系之类的东西是有意义的 ,但在其他很多情况下,你并不需要设计一个继承体系,因为你根本不需要。

所以,你可以从一种简单得多的抽象开始。再一次,标准库为我们提供了一些例子:vector、string和复数类。除非你确实需要,否则不要动用继承体系。再例如,如果在你的代码中出现了很多从基类到派生类的转型 运算符,这很可能就是一个危险的信号,提醒你已经在继承体系中走得太远了。在“远古”C++的年代,这种转型一般 是通过C风格的转型运算符实现的,这是不安全的。而在更为“现代”的C++中,你已经可以使用动态转型符来完成工作了,无论如何,至少这种转型是安全的。在一个好的设计中,转型应该只出现在你从程序外部收集信息来产生对象时,因为 那时对象类型是不确定的,你只有在稍后收集到完整信息后才能确定对象的正确类型并转型之。

Bill Venners:在太过低层和太着迷于面向对象这两条路上走下去会有什么样的代价?问题何在?

Bjarne Stroustrup:如果你用写C代码的思想来写代码,那你就会遇到C形式的问题:缓冲区溢出、指针问题、难以维护的代码等等。因为你的抽象层次太低,所以代价就是开发时间和维护时间的延长。

再来看看庞大的继承体系带来的问题。你需要写更多的原本不需要的代码,维护更多的各部分之间的联系。我特别不喜欢有很多get-set函数的类,它给人的第一感觉更像是一个数据集合而不是一个类。如果它真的是一个数据集合,那就让它回归到数据集合罢。

类应该强制执行不变式

Bjarne Stroustrup:根据我的经验,当且仅当你确定你的类中有一个不变式时,你应该设计一个具有接口的类以及一个隐藏的表示。

Bill Venners:您所说的不变式是什么意思?

Bjarne Stroustrup:是什么让一个对象成为一个有效的对象?不变式允许你说出一个对象的表示何时良好何时不好。以vector作为一个非常简单的例子,一个vector知道它容纳了n个元素,它有一个指向这n个元素的指针。这儿不变式就是指指针指向一块内存区域,而这块内存区域可以容纳n个元素。如果它容纳了n-1或者n+1个元素,那就出现了bug。如果指针为0,那也是bug,因为该指针并未指向任何东西,这就表示了它违背了一个不变式。所以你必须分辨出哪些对象有意义,哪些是好的,哪些是坏的。这样你就可以提炼出维护不变式的接口。这是检查成员函数合理性的一种途径,同时也是判断一个操作是否应该成为成员函数的一种方式。那些不需要与内部表示混在一起的操作最好被安排到类外。这样一来,你就可以得到一个整洁、小巧而容易理解和维护的接口。

Bill Venners:这就是说,不变式表示一个类存在的正当性,因为类本身承担起了维护该不变式的责任?

Bjarne Stroustrup:没错。

Bill Venners:这样说来,不变式就是类中各个不同的数据成员之间的一种关系?

Bjarne Stroustrup:是这样的。如果每个数据成员都可以被赋予任何值,那就没什么太大必要做成一个类。以一个简单的“名字—地址”数据结构为例,如果任何字符串都是合法的名字,任何字符串都是合法的地址,那么它本质上就是一个结构,而不是一个类,请使用struct关键字来声明它。千万别把名字和地址作为私有数据成员隐藏起来,然后再提供类似于get_address、set_address、get_name以及set_name这样的成员函数来存取它们。或者更糟糕地,提供一个拥有get_name和set_name之类的虚函数的抽象基类,然后在一个派生类中 重写它们,这种做法纯粹是挖空心思而已,绝无必要。

Bill Venners:您的意思是因为那些类有且仅有一种具体的实现,所以把它们声明为class是不必要的 。可是有一种辩解认为:如果你将数据成员存取操作封装为函数,那么你就可以灵活地改变这个类的具体实现方式了。

Bjarne Stroustrup:大部分情况下是这样的,但有些实现是你不会去改变的:你并不会经常去改变一个整数、或者一个点、或者一个复数等类的实现。如果真的 需要改变它们的话,你应该在某个地方做好设计决策。

下一层次,当你要从原始的数据结构转移到真正的类的时候,让我们再次以名字-地址为例:你应该不会将这个类命名为name_and_address吧?也许你可以把它称为personnel_record或者mailing_address。在这个层次上,你认为名字和地址都不仅仅只是字符串而已。也许你想把名字拆开为first name、middle name和last name来分别存储,或者你决定采用一种由你自己确定语义的字符串来存储这三个部分,你也可以决定是否判断地址的有效性,并根据这些性质将字符串分为first address、second address、城市、洲、国家以及邮编 之类的东西。

当开始进行这样的分解工作时,你就应该开始考虑更改这个类的具体实现的可能性了。这时,你要开始做出决定:是真的往类中加入一个私有数据成员,并使用继承 ,还是仅仅只使用一个平凡的类,并固定其表现形式,或者你希望为数据提供一个抽象接口,这样它们就可以拥有不同的表现形式。这里的重点不是“如何决定”,而是“做出决定”。你不能毫无章法可言地将一些类和函数堆砌在一起 ,如果你决定采用私有数据成员的话,你需要先定义一些确切的语义。

整个事情的思路是:在构造函数中,建立好成员函数进行操作时所必需的环境。换句话来说,构造函数建立了不变式。而为了建立不变式,你通常需要分配一些资源。在析构函数中,你可以清理环境并切释放资源 ,这些资源可以是内存、文件、同步锁以及socket连接等,凡是你能想到的,都可以在析构函数中被释放。

设计简单的接口

Bill Venners:您刚才提到不变式可以帮助我们确定什么东西是应该放到接口 中的,可以解释得更详细一点吗?我现在试着复述一遍您刚才所说的概念,看看我是否已经理解了它。第一点,所有和不变式相关或能够操作不变式的功能都应该放到类中。

Bjarne Stroustrup:对!

Bill Venners:任何仅仅使用数据 而不会对不变式产生影响的功能就不需要放到类中。

Bjarne Stroustrup:我来给个例子具体说明一下 ,有些操作是你一定要与具体实现进行直接的交互才能完成的。如果一个操作会改变一个vector的大小,那你最好也让 其同时改变vector内容纳的元素的数量。如果你仅仅需要读出这个大小变量的话,那么肯定应该存在这样的一个成员函数。但除 了这些需要直接与不变式打交道的基本函数外,还存在许多以这些函数为基础的其他函数,比如对vector的高效存取、查找和搜索操作等 ,这些函数就最好不要被设计为成员函数。

作为另一个例子,让我们再来看看日期类。所有能够改变年、月、日的操作应该被作为该类的成员函数,而那些诸如搜索下一个周末、周日的函数则可以建构在基本成员函数之上。我曾经 看到过一个拥有60到70个成员函数的日期类,那个类的设计者把所有操作都放到类里面去了,甚至包括find_next_Sunday这样的函数也不例外,实际上这些函数在逻辑上与这个类没有任何关系,如果你将这个函数作为该类的成员变量,那么这些函数就可以接触到类中的具体数据成员,这就意味着如果你想改变日期的表现形式的话,你需要 修改近60个函数,在近60个地方修改 (译注:可能还不止:))。

作为一种替代,如果你为这个日期类建立一个具有相互联系的简单接口的话,那么出于逻辑相关或者性能等方面的考虑,这个类中应该只会有5到10个左右的成员函数 — 虽然我想不出日期类有什么性能问题可言,不过它的确是思考问题时一个重要的焦点。然后以这5到10个基本操作为基础,你再把另外的50个操作放到一个支撑库中。 最近这种思维方式被越来越多的人所接受。甚至在Java中,你也可以在拥有一个容器的同时拥有一个由静态方法所构成的支撑库。

可惜的是,尽管我已经为这种思想作了近20年的宣传工作,人们仍然倾向于把所有东西都放到类和继承体系中去。关于上面提到的日期问题,我还见过这样的解决方案:提供一个基类,该基类有一些基本的操作和被声明为protected的数据成员。日后当你需要 添加一些新的工具函数时,你需要从这个基类派生出一个新类,然后再在新类中加上新的工具函数。相信我,你的系统就是被这些东西弄得一团糟的,把这些工具函数放到派生类中毫无道理可言。将这些工具函数分别独立实现可以让我们自由 地组合使用这些工具函数。由于你写的函数和我写的函数是完全独立的,所以可以自由组合它们。如果我和你都从那个日期基类派生出新类型,然后通过往新类型中添加新函数的方法来实现各自的工具函数,那么第三人将很难同时使用我们的函数库,在这里,类继承体系 即被滥用了。