Item 31:最小化文件之间的编译依赖

避免编译依赖

class Person {
public:
  Person(const std::string& name, const Date& birthday,
         const Address& addr);
  std::string name() const;
  std::string birthDate() const;
  std::string address() const;
  ...

private:
      std::string theName;        // implementation detail
      Date theBirthDate;          // implementation detail
      Address theAddress;         // implementation detail
};

为了保证 Person 能够被编译,需要包含依赖的头文件:

#include <string>
#include "date.h"
#include "address.h"

这样就建立了定义 Person 的文件和这些头文件之间的编译依赖关系。如果这些头文件中的一些发生了变化,或者这些头文件所依赖的文件发生了变化,包含 Person 类的文件和使用了 Person 的文件一样必须重新编译,这样的层叠编译依赖关系为项目带来数不清的麻烦。

前置声明可以减少依赖

namespace std {
     class string;      
}                             

class Date;                    
class Address;                 

class Person {
public:
      Person(const std::string& name, const Date& birthday,
                 const Address& addr);
      std::string name() const;
      std::string birthDate() const;
      std::string address() const;
    ...
};

如果这样可行,只有在类的接口发生变化时,Person 的客户才必须重新编译。

这种方式存在两个问题:

  • 第一个,string 不是一个类,它是一个 typedef 。造成的结果就是,string 的前向声明是不正确的。正确的前向声明要复杂得多,因为它包括另外的模板。
  • 第二个,前向声明的每一件东西必须让编译器在编译期间知道它的对象的大小。
int main()
{
 int x;               
 Person p( params );  
   ...
}

当编译器看到 p 的定义,也需要为一个 Person 分配足够的空间,但是因为这里省略了类的定义,所以无法获取对象的大小。

Pimpl

对 Person 做这件事的一种方法就是将它分开到两个类中,一个仅仅提供一个接口,另一个实现这个接口。如果那个实现类名为 PersonImpl,Person 就可以如此定义:

#include <string>                     
#include <memory>                   
class PersonImpl;                    
class Date;                  
class Address;                      
class Person {
public:
 Person(const std::string& name, const Date& birthday,
        const Address& addr);
 std::string name() const;
 std::string birthDate() const;
 std::string address() const;
 ...

private:                                  
  std::shared_ptr<PersonImpl> pImpl;  
};                                        

这样,主类(Person)除了一个指向它的实现类(PersonImpl)的指针之外不包含其它数据成员。这样一个设计经常被说成是使用了 pimpl 惯用法(指向实现的指针 "pointer to implementation")。在这样的类中,那个指针的名字经常是 pImpl,就像上面那个。

用这样的设计,使 Person 的客户脱离 dates,addresses 和 persons 的细节。这些类的实现可以随心所欲地改变,但 Person 的客户却不必重新编译。另外,因为他们看不到 Person 的实现细节,客户就不太可能写出以某种方式依赖那些细节的代码。这就是接口和实现的真正分离。

这个分离的关键就是用对声明的依赖替代对定义的依赖。这就是最小化编译依赖的精髓:只要能实现,就让你的头文件独立自足,如果不能,就依赖其它文件中的声明,而不是定义。其它每一件事都从这个简单的设计策略产生。

所以:

  • 当对象的引用和指针可以做到时就避免使用对象。仅需一个类型的声明,你就可以定义到这个类型的引用或指针。而定义一个类型的对象必须要存在这个类型的定义。
  • 只要你能做到,就用对类声明的依赖替代对类定义的依赖。注意你声明一个使用一个类的函数时绝对不需要有这个类的定义,即使这个函数通过传值方式传递或返回这个类:
class Date;                        // class declaration

Date today();                      // fine — no definition
void clearAppointments(Date d);    // of Date is needed
  • 为声明和定义分别提供头文件。为了便于坚持上面的指导方针,头文件需要成对出现:一个用于声明,另一个用于定义。当然,这些文件必须保持一致。如果一个声明在一个地方被改变了,它必须在两处都被改变。
#include "datefwd.h"            // header file declaring (but not defining) class Date

Date today();                   // as before
void clearAppointments(Date d);

Handle 类

像 Person 这样的使用 pimpl 惯用法的类经常被称为 Handle 类。为了避免你对这样的类实际上做什么事的好奇心,一种方法是将所有对他们的函数调用都转送给相应的实现类,而使用实现类来做真正的工作。例如,这就是两个 Person 的成员函数可以被如何实现的例子:

#include "Person.h"          // we're implementing the Person class,
                             // so we must #include its class definition

#include "PersonImpl.h"      // we must also #include PersonImpl's class
                             // definition, otherwise we couldn't call
                             // its member functions; note that 
                             // PersonImpl has exactly the same
                             // member functions as Person — their
                             // interfaces are identical

Person::Person(const std::string& name, const Date& birthday,
               const Address& addr)
: pImpl(new PersonImpl(name, birthday, addr))
{}

std::string Person::name() const
{
  return pImpl->name();
}

在一个继承体系的所有类中非虚拟函数的实现应该相同,因此将这样的函数实现为声明它们的 Interface 类的一部分就是有意义的。一个 Person 的 Interface 类可能就像这样:

class Person {
public:
  virtual ~Person();

  virtual std::string name() const = 0;
  virtual std::string birthDate() const = 0;
  virtual std::string address() const = 0;
  ...
};

这个类的客户必须针对 Person 的指针或引用编程,因为实例化包含纯虚函数的类是不可能的。和 Handle 类的客户一样,除非 Interface 类的接口发生变化,否则 Interface 类的客户不需要重新编译。

一个 Interface 类的客户必须有办法创建新的对象。他们一般通过调用一个为“可以真正实例化的派生类”扮演构造函数的角色的函数做到这一点的。这样的函数一般称为 factory 函数或虚拟构造函数。他们返回指向动态分配的支持 Interface 类的接口的对象的指针。这样的函数在 Interface 类内部一般声明为 static:

class Person {
public:
 ...

 static std::tr1::shared_ptr<Person>    // return a tr1::shared_ptr to a new
   create(const std::string& name,      // Person initialized with the
          const Date& birthday,         // given params; see Item 18 for
          const Address& addr);         // why a tr1::shared_ptr is returned
 ...
};

客户就像这样使用它们:

std::string name;
Date dateOfBirth;
Address address;
...

// create an object supporting the Person interface
std::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
...

std::cout << pp->name()                 // use the object via the
          << " was born on "            // Person interface
          << pp->birthDate()
          << " and now lives at "
          << pp->address();

当然,在某些地点,必须定义支持 Interface 类的接口的具体类并调用真正的构造函数。这所有的一切发生的场合,在那个文件中所包含虚拟构造函数的实现之后的地方。例如,Interface 类 Person 可以有一个提供了它继承到的虚函数的实现的具体的派生类 RealPerson:

class RealPerson: public Person {
public:
  RealPerson(const std::string& name, const Date& birthday,
             const Address& addr)
  : theName(name), theBirthDate(birthday), theAddress(addr)
  {}

  virtual ~RealPerson() {}

  std::string name() const;        // implementations of these 
  std::string birthDate() const;   // functions are not shown, but
  std::string address() const;     // they are easy to imagine

private:
  std::string theName;
  Date theBirthDate;
  Address theAddress;
};

对这个特定的 RealPerson,写 Person::create 确实没什么价值:

std::tr1::shared_ptr<Person> Person::create(const std::string& name,
                                            const Date& birthday,
                                            const Address& addr)
{
  return std::tr1::shared_ptr<Person>(new RealPerson(name, birthday,addr));
}```

总结

  • 最小化编译依赖的一般做法是依赖于声明而非定义,这个想法可以通过句柄类或接口类来实现。库的声明应当包括“完整的”和“只有声明的”两种形式。
  • 接口类和句柄类的设计隐藏了类的实现细节,减小了实现细节的改动对客户的影响。 但无论是接口类还是句柄类,都会造成一些代价:多一个pImpl指针的空间、虚函数表指针的空间、寻找虚函数的时间、间接调用的时间。
posted @ 2020-02-14 10:22  刘-皇叔  阅读(186)  评论(0编辑  收藏  举报