EffectiveC++学习笔记-条款30|31

xiaoxiao2021-02-28  119

条款30 透彻了解inlining的里里外外 条款31 将文件间的编译依存关系降到最低

透彻了解inlining的里里外外

在大多数情况下,inline并不是程序的瓶颈,真正的精力应该放在改善一些算法的修缮,以及反复调用的代码研究上,它们往往才是耗时的瓶颈所在

使用inline可能会增加目标码的大小。 有些时候编译器优化代码,声明称inline形式。例如一个成员函数,很简短的那种。

但注意inline只是对编译器的一个建议,编译器并不表示一定会采纳,比如当一个函数内部包含对自身的递归调用时,inline就会被编译器所忽略。对于虚函数的inline,编译器也会将之忽略掉,因为内联(代码展开)发生在编译期,而虚函数的行为是在运行期决定的,所以编译器忽略掉对虚函数的inline。对于函数指针,当一个函数指针指向一个inline函数的时候,通过函数指针的调用也有可能不会被编译器处理成内联。

看一个例子

inline void f(){...} void (*pf)() = f;//pf指向f ... f();//这个调用将会inlined,因为它是一个正常调用 pf();//函数指针 获取不会调用inline

将文件间的编译依存关系降到最低

主要体现的是接口与实现分离! From:http:// www.cnblogs.com/jerry19880126/p/3551836.html 在说这一条款之前,先要了解一下C/C++的编译知识,假设有三个类ComplexClass, SimpleClass1和SimpleClass2,采用头文件将类的声明与类的实现分开,这样共对应于6个文件,分别是ComplexClass.h,ComplexClass.cpp,SimpleClass1.h,SimpleClass1.cpp,SimpleClass2.h,SimpleClass2.cpp。

ComplexClass复合两个BaseClass,SimpleClass1与SimpleClass2之间是独立的,ComplexClass的.h是这样写的:

#include “SimpleClass1.h” #include “SimpleClass2.h” class ComplexClass { SimpleClass1 xx; SimpleClass2 xxx; };

考虑以下几种情况:

Case 1:

现在SimpleClass1.h发生了变化,比如添加了一个新的成员变量,那么没有疑问,SimpleClass1.cpp要重编,SimpleClass2因为与SimpleClass1是独立的,所以SimpleClass2是不需要重编的。

那么现在的问题是,ComplexClass需要重编吗?

答案是“是”,因为ComplexClass的头文件里面包含了SimpleClass1.h(使用了SimpleClass1作为成员对象的类),而且所有使用ComplexClass类的对象的文件,都需要重新编译!

Case 2:

如果把ComplexClass里面的#include “SimpleClass1.h”给去掉,当然就不会重编ComplexClass了,但问题是也不能通过编译了,因为ComplexClass里面声明了SimpleClass1的对象xx。那如果把#include “SimpleClass1.h”换成类的声明class SimpleClass1,会怎么样呢?能通过编译吗?

答案是“否”,因为编译器需要知道ComplexClass成员变量SimpleClass1对象的大小,而这些信息仅由class SimpleClass1是不够的,但如果SimpleClass1作为一个函数的形参,或者是函数返回值,用class SimpleClass1声明就够了。如:

// ComplexClass.h class SimpleClass1; … SimpleClass1 GetSimpleClass1() const; …
Case 3:

但如果换成指针呢?像这样:

// ComplexClass.h #include “SimpleClass2.h” class SimpleClass1; class ComplexClass: { SimpleClass1* xx; SimpleClass2 xxx; };

这样能通过编译吗?

答案是“是”,因为编译器视所有指针为一个字长(在32位机器上是4字节),因此class SimpleClass1的声明是够用了。但如果要想使用SimpleClass1的方法,还是要包含SimpleClass1.h,但那是ComplexClass.cpp做的,因为ComplexClass.h只负责类变量和方法的声明。

Case 3:
// ComplexClass.h #include “SimpleClass2.h” class SimpleClass1; class ComplexClass { SimpleClass1* xx; SimpleClass2 xxx; }; // ComplexClass.cpp void ComplexClass::Fun() { SimpleClass1->FunMethod(); }

请问上面的ComplexClass.cpp能通过编译吗?

答案是“否”,因为这里用到了SimpleClass1的具体的方法,所以需要包含SimpleClass1的头文件,但这个包含的行为已经从ComplexClass里面拿掉了(换成了class SimpleClass1),所以不能通过编译。

如果解决这个问题呢?其实很简单,只要在ComplexClass.cpp里面加上#include “SimpleClass1.h”就可以了。换言之,我们其实做的就是将ComplexClass.h的#include “SimpleClass1.h”移至了ComplexClass1.cpp里面,而在原位置放置class SimpleClass1。

这样做是为了什么?假设这时候SimpleClass1.h发生了变化,会有怎样的结果呢?

SimpleClass1自身一定会重编,SimpleClass2当然还是不用重编的,ComplexClass.cpp因为包含了SimpleClass1.h,所以需要重编,但换来的好处就是所有用到ComplexClass的其他地方,它们所在的文件不用重编了!因为ComplexClass的头文件没有变化,接口没有改变!

以上总结

总结一下,对于C++类而言,如果它的头文件变了,那么所有这个类的对象所在的文件都要重编,但如果它的实现文件(cpp文件)变了,而头文件没有变(对外的接口不变),那么所有这个类的对象所在的文件都不会因之而重编。

因此,避免大量依赖性编译的解决方案就是:在头文件中用class声明外来类,用指针或引用代替变量的声明;在cpp文件中包含外来类的头文件。

简而言之,接口不变,不需要重新编译。

Handler和Interface

假设我们要写一个Person类,如下:

class Person { private: string name; MyDate birthday; MyAddress address; public: // fallows functions // ... };

这个Person类里面包含有人的名字,人的生日以及地址,还有一些没有列出来的方法。注意到这里用到了string(它不是一个class,只是一个typedef,大多数情况下,我们认为它不会有任何改变),以及自定义的类MyDate与MyAddress,一种常见的做法是采用include头文件,像这样:

#include <string> #include "MyDate.h" #include "MyAddress.h"

在MyDate.h里面写好日期类相关的成员变量与方法,而在MyAddress.h里面写好地址类相关的成员变量与方法。但如果此后要往MyDate类或者MyAddresss类添加成员变量,那么不仅仅所有用到MyDate或者MyAddress对象的文件需要重新编译,而且所有用到Person对象的文件也需要重编译,一个小改动竟然会牵涉到这么多的地方!

可以把问题归结为“C++并没有把将接口从实现中分离这件事做好”,因为包含头文件的做法很直观很方便,用的人也很普遍,而C++并没有对这种做法加以限制。

如果要把编译的依赖性降低,就要换一种思路来处理,不能出现定义式,只能出现声明式,代价是增加代码的复杂度以及性能上的一些损失。

书上提到了两种方法,第一种是采用Handler Classes(用指针指向真正实现的方法),第二种是Interface Classes(抽象基类)。

Handler Class

就是.h里面不包含类的自定义头文件,用“class 类名”的声明方式进行代替(也要把相应的成员变量替换成指针或引用的形式),在.cpp文件里面包含类的自定义头文件去实现具体的方法。

// Person.h #include <string> using namespace std; class PersonImp; class Person { private: //string Name; //MyDate Birthday; //MyAddress Address; PersonImp* MemberImp; public: string GetName() const; string GetBirthday() const; string GetAddress() const; // follows functions // ... }; // Person.cpp #include "PersonImp.h" #include "Person.h" string Person::GetName() const { return MemberImp->GetName(); } string Person::GetBirthday() const { return MemberImp->GetName(); } string Person::GetAddress() const { return MemberImp->GetAddress(); } //但是有很多会把PersonImp这个类的声明以及实现都放在Person.cpp下,不在单独一个文件 // PersonImp.h #include <string> #include "MyAddress.h" #include "MyDate.h" using namespace std; class PersonImp { private: string Name; MyAddress Address; MyDate Birthday; public: string GetName() const { return Name; } string GetAddress() const { return Address.ToString(); } string GetBirthday() const { return Birthday.ToString(); } }; //MyDate.h 和 MyAddress.h略

这里有一点要说一下,在Person.h里面并没有使用MyDate*和MyAddress*,而是用了PersonImp*,由PersonImp里面包含MyDate与MyAddress,这样做的好处就是方便统一化管理,它要求PersonImp里面的方法与Person的方法是一致的。以后Person添加成员变量,可以直接在PersonImp中进行添加了,从而起到了隔离和隐藏的作用,因为客户端代码大量使用的将是Person,而不必关心PersonImp,用于幕后实现的PersonImp只面向于软件开发者而不是使用者。

书上是用shared_ptr来管理PersonImp的,使资源管理上更加科学与合理。

另外,书上也提倡把class x; class xx; class xxx;的声明放至一个名为”xxxfwd.h”的头文件里,比如”datefwd.h”,这个头文件里面只有声明式,而没有具体的类细节。也就是说,对于某个类,比如MyDate,应该分出三个文件,一个是datefwd.h,里面是一些用到的外来的class声明式;一个是MyDate.h里面是MyDate类的结构声明;一个是MyDate.cpp,它与MyDate.h配对,给出具体的实现细节。

Interface Class

从上面也可以看出,避免重编的诀窍就是保持头文件(接口)不变化,而保持接口不变化的诀窍就是不在里面声明编译器需要知道大小的变量,Handler Classes的处理就是把变量换成变量的地址(指针),头文件只有class xxx的声明,而在cpp里面才包含xxx的头文件。Interface Classes则是利用继承关系和多态的特性,在父类里面只包含成员方法(成员函数),而没有成员变量,像这样:

// Person.h #include <string> using namespace std; class MyAddress; class MyDate; class RealPerson; class Person { public: virtual string GetName() const = 0; virtual string GetBirthday() const = 0; virtual string GetAddress() const = 0; virtual ~Person(){} };

而这些方法的实现放在其子类中,像这样:

// RealPerson.h #include "Person.h" #include "MyAddress.h" #include "MyDate.h" class RealPerson: public Person { private: string Name; MyAddress Address; MyDate Birthday; public: RealPerson(string name, const MyAddress& addr, const MyDate& date):Name(name), Address(addr), Birthday(date){} virtual string GetName() const; virtual string GetAddress() const; virtual string GetBirthday() const; };

在RealPerson.cpp里面去实现GetName()等方法。从这里我们可以看到,只有子类里面才有成员变量,也就是说,如果Address的头文件变化了,那么子类一定会重编,所有用到子类头文件的文件也要重编,所以为了防止重编,应该尽量少用子类的对象。利用多态特性,我们可以使用父类的指针,像这样Person* p = new RealPerson(xxx),然后p->GetName()实际上是调用了子类的GetName()方法。

但这样还有一个问题,就是new RealPerson()这句话一写,就需要RealPerson的构造函数,那么RealPerson的头文件就要暴露了,这样可不行。还是只能用Person的方法,所以我们在Person.h里面加上这个方法:

// Person.h static Person* CreatePerson(string name, const MyAddress& addr, const MyDate& date);

注意这个方法是静态的(没有虚特性),它被父类和所有子类共有,可以在子类中去实现它:

// RealPerson.cpp #include “Person.h” Person* Person::CreatePerson(string name, const MyAddress& addr, const MyDate& date) { return new RealPerson(name, addr, date); }

这样在客户端代码里面,可以这样写:

// Main.h class MyAddress; class MyDate; void ProcessPerson(const string& name, const MyAddress& addr, const MyDate& date); 复制代码 // Main.cpp #include "Person.h" #include “MyAddress.h”; #include “MyDate.h”; void ProcessPerson(const string& name, const MyAddress& addr, const MyDate& date) { Person* p = Person::CreatePerson(name, addr, date); … }

就可以减少编译依赖了。

总结

总结一下,Handler classes与Interface classes解除了接口和实现之间的耦合关系,从而降低文件间的编译依存性。减少编译依存性的关键在于保持.h文件不变化,具体地说,是保持被大量使用的类的.h文件不变化,这里谈到了两个方法:Handler classes与Interface classes。

Handler classes化类的成员变量为指针,在.h文件里面只包含class xxx的外来类声明,而不包含其头文件,在.cpp涉及到具体外来类的使用时,才包含xxx.h的头文件,这样最多只影响本身类的cpp重编,但因为.h文件没有变化,所以此类的对象存在的文件不必重编。

当然,书上说的Handler classes更想让我们在类A的基础上另造一个中间类AImp(成员函数完全与类A一致),这个中间类的成员中里面放置了所有类A需要的外来类的对象,然后类的逻辑细节完全在Almp.cpp中实现,而在A.cpp里面只是去调用Almp.cpp的同名方法。A.h的成员变量只有Almp的指针,这看上去好像一个Handler,因此而得名。

Interface classes则是将细节放在子类中,父类只是包含虚方法和一个静态的Create函数声明,子类将虚方法实现,并实现Create接口。利用多态特性,在客户端只需要使用到Person的引用或者指针,就可以访问到子类的方法。由于父类的头文件里面不包含任何成员变量,所以不会导致重编(其实由于父类是虚基类,不能构造其对象,所以也不用担心由于父类头文件变化导致的重编问题)。

如果object reference 或者 object pointers可以完成的任务就不要使用objects。如果可以尽量使用class声明式替换class定义式
转载请注明原文地址: https://www.6miu.com/read-61750.html

最新回复(0)