The C++ Style Sweet Spot

The C++ Style Sweet Spot
A Conversation with Bjarne Stroustrup, Part I
by Bill Venners
October 13, 2003

 

 

Summary
Bjarne Stroustrup talks with Bill Venners about the perils of staying too low level and venturing too object-oriented in C++ programming style.

Bjarne Stroustrup is the designer and original implementer of C++. He is the author of numerous papers and several books, including The C++ Programming Language (Addison-Wesley, 1985-2000) and The Design and Evolution of C++ (Addison-Wesley, 1994). He took an active role in the creation of the ANSI/ISO standard for C++ and continues to work on the maintenance and revision of that standard. He is currently the College of Engineering Chair in Computer Science Professor at Texas A&M University.

On September 22, 2003, Bill Venners met with Bjarne Stroustrup at the JAOO conference in Aarhus, Denmark. In this interview, which will be published in multiple installments on Artima.com, Stroustrup gives insights into C++ best practice. In this first installment, Stroustrup describes how C++ programmers can reconsider their style of C++ use to gain maximum benefit from the language.

Climbing Above C-Level

Bill Venners: In an interview, you said, "The C++ community has yet to internalize the facilities offered by standard C++. By reconsidering the style of C++ use, major improvements in ease of writing, correctness, maintainability, and efficiency can be obtained." How should C++ programmers reconsider their style of C++ use?

Bjarne Stroustrup: It's always easier to say what not to do, rather than what to do, so I'll start that way. A lot of people see C++ as C with a few bits and pieces added. They write code with a lot of arrays and pointers. They tend to use new the way they used malloc. Basically, the abstraction level is low. Writing C-style code is one way to get into C++, but it's not using C++ really well.

I think a better way of approaching C++ is to use some of the standard library facilities. For example, use a vector rather than an array. A vector knows its size. An array does not. You can extend a vector's size implicitly or explicitly. To get an array of a different size, you must explicity deal with memory using realloc, malloc, memcpy, etc. Also, use inline functions rather than macros, so you don't get into the macro problems. Use a C++ string class rather than manipulating C strings directly. And if you've got a lot of casts in the code, there's something wrong. You have dropped from the level of types, a high level of abstraction, down to a level of bits and bytes. You shouldn't do that very often.

To get out of writing low level code, you needn't start writing a lot of classes. Instead, start using facilities provided in libraries. The standard library is the first and most obvious source, but there are also good libraries for things like math or systems programming. You don't have to do threading at the C level. You can use a C++ threading library, for example, Boost.Threads. There are quite a few threading libraries. If you want callbacks, don't use just plain C functions. Get libsigc++, and you'll have a proper library that deals with callbacks—callback classes, slots and signals, that kind of stuff. It's available. It's conceptually closer to what you're thinking about anyway. And you don't have to mess with error prone details.

Most of these techniques are criticized unfairly for being inefficient. The assumption is that if it is elegant, if it is higher level, it must be slow. It could be slow in a few cases, so deal with those few cases at the lower level, but start at a higher level. In some cases, you simply don't have the overhead. For example, vectors really are as fast as arrays.

Object-Orientaphilia

The other way people get into trouble is exactly the opposite. They believe that C++ should be an extremely high level language, and everything should be object-oriented. They believe that you should do everything by creating a class as part of a class hierarchy with lots of virtual functions. This is the kind of thinking that's reflected in a language like Java for instance, but a lot of things don't fit into class hierarchies. An integer shouldn't be part of a class hierarchy. It doesn't need to. It costs you to put it there. And it's very hard to do elegantly.

You can program with a lot of free-standing classes. If I want a complex number, I write a complex number. It doesn't have any virtual functions. It's not meant for derivation. You should use inheritance only when a class hierarchy makes sense from the point of view of your application, from your requirements. For a lot of graphics classes it makes perfect sense. The oldest example in the book is the shape example, which I borrowed from Simula. It makes sense to have a hierarchy of shapes or a hierarchy of windows, things like that. But for many other things you shouldn't plan for a hierarchy, because you're not going to need one.

So you can start with much simpler abstractions. Again the standard library can provide some examples: vector, string, complex number. Don't go to hierarchies until you need them. Again, one indication that you've gone too far with class hierarchies is you have to write casts all the time, casting from base classes to derived classes. In really old C++, you would do it with a C style cast, which is unsafe. In more modern C++, you use a dynamic cast, which at least is safe. But still better design usually leads you to use casting only when you get objects in from outside your program. If you get an object through input, you may not know what it is until a bit later, and then you have to cast it to the right type.

Bill Venners: What is the cost of going down either of those two paths, being too low-level or too enamored with object-orientation? What's the problem?

Bjarne Stroustrup: The problem with the C way is that if you write code C-style, you get C-style problems. You will get buffer overflows. You will get pointer problems. And you will get hard to maintain code, because you're working at a very low level. So the cost is in development time and maintenance time.

Going to the big class hierarchy is again, you write more code than you need to, and you get too much connection between different parts. I particularly dislike classes with a lot of get and set functions. That is often an indication that it shouldn't have been a class in the first place. It's just a data structure. And if it really is a data structure, make it a data structure.

Classes Should Enforce Invariants

Bjarne Stroustrup: My rule of thumb is that you should have a real class with an interface and a hidden representation if and only if you can consider an invariant for the class.

Bill Venners: What do you mean by invariant?

Bjarne Stroustrup: What is it that makes the object a valid object? An invariant allows you to say when the object's representation is good and when it isn't. Take a vector as a very simple example. A vector knows that it has n elements. It has a pointer to n elements. The invariant is exactly that: the pointer points to something, and that something can hold n elements. If it holds n+1 or n-1 elements, that's a bug. If that pointer is zero, it's a bug, because it doesn't point to anything. That means it's a violation of an invariant. So you have to be able to state which objects make sense. Which are good and which are bad. And you can write the interfaces so that they maintain that invariant. That's one way of keeping track that your member functions are reasonable. It's also a way of keeping track of which operations need to be member functions. Operations that don't need to mess with the representation are better done outside the class. So that you get a clean, small interface that you can understand and maintain.

Bill Venners: So the invariant justifies the existence of a class, because the class takes the responsibility for maintaining the invariant.

Bjarne Stroustrup: That's right.

Bill Venners: The invariant is a relationship between different pieces of data in the class.

Bjarne Stroustrup: Yes. If every data can have any value, then it doesn't make much sense to have a class. Take a single data structure that has a name and an address. Any string is a good name, and any string is a good address. If that's what it is, it's a structure. Just call it a struct. Don't have anything private. Don't do anything silly like having a hidden name and address field with get_name and set_address and get_name and set_name functions. Or even worse, make a virtual base class with virtual get_name and set_name functions and so on, and override it with the one and only representation. That's just elaboration. It's not necessary.

Bill Venners: It's not necessary because there's one and only representation. The justification is usually that if you make it a function, then you can change the representation.

Bjarne Stroustrup: Exactly, but some representations you don't change. You don't change the representation of an integer very often, or a point, of a complex number. You have to make design decisions somewhere.

And the next stage, where you go from the plain data structure to a real class with real class objects, could be that name and address again. You probably wouldn't call it name_and_address. You'll maybe call it personnel_record or mailing_address. At that stage you believe name and address are not just strings. Maybe you break the name down into first, middle, and last name strings. Or you decide the semantics should be that the one string you store really has first, middle, and last name as parts of it. You can also decide that the address really has to be a valid address. Either you validate the string, or you break the string up into first address field, second address field, city, state, country, zip code, that kind of stuff.

When you start breaking it down like that, you get into the possibilities of different representations. You can start deciding, does it really add to have private data, to have a hierarchy? Do you want a plain class with one representation to deal with, or do you want to provide an abstract interface so you can represent things in different ways? But you have to make those design decisions. You don't just randomly spew classes and functions around. And you have to have some semantics that you are defending before you start having private data.

The way the whole thing is conceived is that the constructor establishes the environment for the member functions to operate in, in other words, the constructor establishes the invariant. And since to establish the invariant you often have to acquire resources, you have the destructor to pull down the operating environment and release any resources required. Those resources can be memory, files, locks, sockets, you name it—anything that you have to get and put back afterwards.

Designing Simple Interfaces

Bill Venners: You said that the invariant helps you decide what goes into the interface. Could you elaborate on how? Let me attempt to restate what you said, and see if I understand it. The functions that are taking any responsibility for maintaining the invariant should be in the class.

Bjarne Stroustrup: Yes.

Bill Venners: Anything that's just using the data, but not defending the invariant, doesn't need to be in the class.

Bjarne Stroustrup: Let me give an example. There are some operations you really can't do without having direct access to the representation. If you have an operation that changes the size of a vector, then you'd better be able to make changes to the number of elements stored. You move the elements and change the size variable. If you've just got to read the size variable, well, there must be a member function for that. But there are other functions that can be built on top of existing functions. For example, given efficient element access, a find function for searching in a vector is best provided as a non-member.

Another example would be a Date class, where the operations that actually change the day, month, and year have to be members. But the function that finds the next weekday, or the next Sunday, can be put on top of it. I have seen Date classes with 60 or 70 operations, because they built everything in. Things like find_next_Sunday. Functions like that don't logically belong in the class. If you build them in, they can touch the data. That means if you want to change the data layout, you have to review 60 functions, and make changes in 60 places.

Instead, if you build a relatively simple interface to a Date class, you might have five or ten member functions that are there because they are logically necessary, or for performance reasons. It's hard for me to imagine a performance reason for a Date, but in general that's an important concern. Then you get these five or ten operations, and you can build the other 50 in a supporting library. That way of thinking is fairly well accepted these days. Even in Java, you have the containers and then the supporting library of static methods.

I've been preaching this song for the better part of 20 years. But people got very keen on putting everything in classes and hierarchies. I've seen the Date problem solved by having a base class Date with some operations on it and the data protected, with utility functions provided by deriving a new class and adding the utility functions. You get really messy systems like that, and there's no reason for having the utility functions in derived classes. You want the utility functions to the side so you can combine them freely. How else do I get your utility functions and my utility functions also? The utility functions you wrote are independent from the ones I wrote, and so they should be independent in the code. If I derive from class Date, and you derive from class Date, a third person won't be able to easily use both of our utility functions, because we have built dependencies in that didn't need to be there. So you can overdo this class hierarchy stuff.

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的大小。如果需要改变一个原生数组的大小,你必须利用reallocmalloc以及memcpy等函数显示处理内存相关的问题。再例如利用内联函数代替宏,你就可以避开一些与宏相关的问题。还有,使用C++中的string类而不是显示地去手工操纵一个C字符串。如果在你的代码中出现了大量的转型操作符,那么几乎可以肯定代码中存在某些问题,因为你已经从一种较高的、基于类型的抽象层次下降到了一种低级的、直接与位和字节打交道的层次了。你不应该纵容这种情况经常发生。

 

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

 

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

 

面向对象的泛滥

 

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

 

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

 

所以,你可以从一种简单得多的抽象开始。再一次,标准库为我们提供了一些例子:vectorstring和复数类。除非你确实需要,否则不要动用继承体系。再例如,如果在你的代码中出现了很多从基类到派生类的转型 运算符,这很可能就是一个危险的信号,提醒你已经在继承体系中走得太远了。在“远古”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_addressset_addressget_name以及set_name这样的成员函数来存取它们。或者更糟糕地,提供一个拥有get_nameset_name之类的虚函数的抽象基类,然后在一个派生类中 重写它们,这种做法纯粹是挖空心思而已,绝无必要。

 

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

 

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

 

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

 

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

 

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

 

设计简单的接口

 

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

 

Bjarne Stroustrup对!

 

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

 

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

 

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

 

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

 

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

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页