简介

  • C++ 惯用写法

以良好的方式编写 C++ class

  • 假设现在我们要实现一个复数类complex,在类的实现过程中探索良好的编程习惯

  • Header(头文件)中的防卫式声明 ```cpp complex.h:

    ifndef COMPLEX

    define COMPLEX

    class complex {

}

endif

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
+ 防止头文件的内容被多次包含

+ 把数据放在 private 声明下,提供接口访问数据
```cpp
# ifndef  __COMPLEX__
# define __COMPLEX__
class complex
{
    public:
        double real() const {return re;}
        double imag() const {return im;}
    private:
        doubel re,im;
}
# endif
  • 不会改变类属性(数据成员)的成员函数,全部加上 const 声明
    1
    2
    
    double real () `const` {return re;}
    double imag() `const` {return im;}
    
  • 既然函数不会改变对象,那么就如实说明,编译器能帮你确保函数的const属性,阅读代码的人也明确你的意图
  • 而且,const 对象才可以调用这些函数 – const 对象不能调用非const成员函数

  • 使用构造函数初始值列表
    1
    2
    3
    4
    5
    6
    7
    8
    
    class complex
    {
      public:
          complex(double r = 0, double i =0)
              : re(r), im(i)  { }
      private:
          doubel re,im;
    }
    
  • 在初始值列表中,才是初始化。在构造函数体内的,叫做赋值

  • 如果可以,参数尽量使用 reference to const
  • 为complex类添加一个+=操作符
    1
    2
    3
    4
    5
    
    class complex
    {
      public:
          complex& operator += (const complex &)
    }
    
  • 使用引用避免类对象构造与析构的开销,使用const确保参数不会被改变。内置类型的值传递与引用传递效率没有多大差别,甚至值传递效率会更高
  • 例如,传递char类型时,值传递只需要传递一个字节;引用实际上是指针实现,需要四个字节(32位机)的传递开销。但是为了一致,不妨统一使用引用。

  • 如果可以,函数返回值也尽量使用引用
  • 以引用方式返回函数局部变量会引发程序为定义行为,离开函数作用域的局部变量被销毁,引用该变量没有意义。但是我要说的是,如果可以,函数应该返回引用。
  • 当然,要放回的变量要有一定限制:改变量必须自进入函数之前,已经被分配了内存。以此条件来考量,很容易决定是否要放回引用。而在函数被调用时才创建出来的对象,一定不能返回引用。
  • 说回 operator+=,其返回值就是引用,原因在于,执行a += b时,a已经在内存上存在了。
  • 而 operator+,其返回值不能时引用,因为 a+b 的值,在调用 operator+ 的时候才产生
  • 下面是 operator+=与operator+的实现:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    inline complex & complex :: operator += (const complex & r)
    {
          this -> re+= r->re;
          this -> im+= r->im;
          return * this;
    }
    inline complex operator + (const complex & x , const complex & y)
    {
          return complex ( real (x)+ real (y), //新创建的对象,不能返回引用
                           imag(x)+ imag(y));
    }
    
  • 在operator+= 中返回引用还是必要的,这样可以使用连续的操作
  • c3 += c2 += c1;

  • 如果重载了操作符,就考虑是否需要多个重载
  • 就我们的复数来说,+可以有多种使用方式
    1
    2
    3
    4
    5
    
    complex c1(2,1);
    complex c2;
    c2 = c1+ c2;
    c2 = c1 + 5;
    c2 = 7 + c1;
    
  • 为了应付多种加法,+需要有如下三种重载: ```cpp inline complex operator+ (const complex & x ,const complex & y) { return complex (real(x)+real(y),imag(x+imag(y))); } inline complex operator + (const complex & x, double y) { return complex (real(x)+y,imag(x)); }

inline complex operator + (double x,const complex &y) { return complex (x+real(y),imag(y)); }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

+ 提供给外界使用的接口,放在类声明的最前面
  + 这是某次面试中,面试官大哥告诉我的。想想确实是有道理,类的用户用起来也舒服,一眼就能看见接口

## Class with pointer member(s): 记得写Big Tree

+ C++的类可以分为带指针数据成员与不带指针数据成员两类,complex 就属于不带指针成员的。而这里要说的字符串类String,一般的实现会带有一个 char * 指针。带指针数据成员的类需要自己实现class三大件: 拷贝构造函数,拷贝赋值函数,析构函数
```cpp
class String
{
    public:
        String (const char * cstr = 0);
        String (const String & str);
        String & operator = (const String & str);
        ~String();
        char * get_c_str() const {return m_data};
    private:
        char * m_data;
}
  • 如果没有写拷贝构造函数,赋值构造函数,析构函数,编译器默认会给我们写一套。然而带指针的类不能依赖编译器的默认实现–这涉及到资源的释放,深拷贝与浅拷贝的问题。在实现String类的过程中我们来阐述这些问题。

  • 析构函数释放动态分配的内存资源
  • 如果class里有指针,多半是需要进行内存动态分配(例如String),析构函数必须负责在对象生命结束时释放掉动态申请来的内存,否则就造成了内存泄漏。
  • 局部对象在离开函数作用域时,对象析构函数被自动调用,而使用new动态分配的对象,也需要显式的使用delete来删除对象。而delete实际上会调用对象的析构函数,我们必须在析构函数中完成释放指针m_data所申请的内存。下面是一个构造函数,体现了m_data的动态内存申请:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    /*String的构造函数*/
    inline
    String ::String (const char *cstr = 0)
    {
      if(cstr)
      {
          m_data = new char[strlen(cstr)+1];   // 这里,m_data申请了内存
          strcpy(m_data,cstr);
      }
      else
      {
          m_data= new char[1];
          *m_data = '\0';
      }
    }
    
  • 这个构造函数以C风格字符串为参数,当执行
    1
    
    String *p = new String ("hello");
    
  • m_data 向系统申请了一块内存存放字符串hello
  • 析构函数必须负责把这段动态申请来的内存释放掉:
    1
    2
    3
    4
    5
    
    inline
    String ::~String()
    {
      delete[]m_data;
    }
    
  • 赋值构造函数与复制构造函数负责进行深拷贝
  • 来看看如果使用编译器为String默认生成的拷贝构造函数与赋值操作符会发生什么事情。默认的复制构造函数或赋值操作符所做的事情是对类的内存进行按位的拷贝,也成为浅拷贝。他们只是把对象内存上的每一个bit复制到另一个对象上去,在String中就只是复制了指针,而不复制指针所指内容。
  • 来看看我们自己实现的构造函数是如何解决这个问题的,它复制的是指针所指的内存内容,这称为深拷贝
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    /*拷贝赋值函数*/
    inline String &String ::operator= (const String & str)
    {
      if(this == &str)           //①
          return *this;
      delete[] m_data;        //②
      m_data = new char[strlen(str.m_data)+1];        //③
      strcpy(m_data,str.m_data);            //④
      return *this
    }
    
  • 这是拷贝赋值函数的经典实现,要点在于:
    • 处理自我赋值,如果不存在自我赋值问题,继续下列步骤
    • 释放自身已经申请的内存
    • 申请一块大小与目标字符串一样大的内存
    • 进行字符串的拷贝
  • 同样的,复制构造函数也是一个深拷贝的过程:
    1
    2
    3
    4
    5
    
    inline String ::String(const String & str )
    {
      m_data = new char[ strlen (str) +1];
      strcpy(m_data,str.m_data);
    }
    
  • 另外,一定要在 operator= 中检查是否 self assignment 。假设这时候确实执行了对象的自我赋值,左右pointers指向同一个内存块,前面的步骤2 delete 该内存块造成的结果是:当企图对内存进行访问时,结果是未定义的

static 与类

  • 不和对象直接相关的数据,声明为 static
    • 想象有一个银行账户的类,每个人都可以开银行账户。存在银行利率这个成员变量,它不应该属于对象,而应该属于银行这个类,由所有的用户来共享。
    • static 修饰成员变量时,该成员变量放在程序的全局区中,整个程序运行过程中只有该成员变量的一个副本。而普通的成员变量存在每个对象的内存中,如果把银行利率放在每个对象中,是浪费了内存。
  • static 成员函数没有this指针
    • static 成员函数与普通函数一样,都是只有一份函数的副本,存储在进程的代码段上。不一样的是staic 成员函数没有this指针,所以它不能够调用普通的成员变量,只能调用static成员变量。普通成员函数的调用需要通过对象来调用,编译器会把对象取地址,作为this指针的实参传递给成员函数
      1
      
      obj.func() ---> Class :: fun(&obj);
      
  • 而 static 成员函数即可以通过对象来调用,也可以通过类名称来调用。

  • 在类的外部定义 static 成员变量
    • 另一个问题是 static 成员变量的定义。static 成员变量必须在类外部进行定义:
      1
      2
      3
      4
      5
      6
      
      class A
      {
      private:
          static int a; //①
      }
      int A::a = 10;  //②
      
  • 注意①是声明,②才是定义,定义为变量分配了内存。

  • static 与类的一些小应用
    • 这些可以用来应付一下面试,在实现单例模式的时候,static 成员函数与 static 成员变量得到了使用,下面是一种称为 饿汉式 的单例模式的实现:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      
      class A
      {
          public:
              static A& getInstance();
              setup(){...};
          private:
              A();
              A(const A & rhs);
              static A a;
      }
      
    • 这里把class A的构造函数都设置为私有,不允许用户代码创建对象。要获取对象实例需要通过接口getInstance。饿汉式 缺点在于无论有没有代码需要a,a都被创建出来。下面是改进的单例模式,称为懒汉式:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      
      class A
      {
      public:
          static  A& getInstance();
          setup(){....};
      private:
          A();
          A(const A& rsh);
          ...
      };
      A& A::getInstance()
      {
          static A a;
          return a;
      }
      
    • “懒汉式”只有在真正需要a时,调用getInstance才创建出唯一实例。这可以看成一个具有拖延症的单例模式,不到最后关头不干活。很多设计都体现了这种拖延的思想,比如string的写时复制,真正需要的时候才分配内存给string对象管理的字符串。

RAII

  • RAII可能是C++中最常用的编程技法.他的思想是把资源映射到对象,根据这些对象的作用域自动管理它们的生命周期
  • 例如,如果在堆上打开一个文件句柄,那么一旦我们从函数返回(或循环,或任何它在内部声明的作用域)时,它都应该被隐式关闭。
  • 如果类的成员中有动态内存分配,那么当该类实例被销毁时,相关内存应该被隐式释放。

  • 每一种资源-内存分配,文件句柄,数据库连接,套接字和任何其他需要获取和释放的资源 都应该包装在这样一个RAII类中,其声明周期由它的对象所在的作用域决定,和RAII类对象绑定.
  • C++语言会保证,当对象超出作用域时,析构函数被调用,不管对象如何离开该作用域.即使抛出异常,所有相关对象也将超出作用域,所以他们的相关资源都会被释放.

  • C++很多地方都用到了RAII特性,例如lock_guard, 智能指针等.

使用enum class 而非 enum

  • enum class 最重要的好处是: 防止隐式转换

Copy-and-swap

  • copy-and-swap 技法保证了强异常安全的保证,它可以非常方便的实现operator=
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    class String
    {
      char * str;
    public:
      String & operator = (String s) // the pass-by-value parameter serves as a temporary
      {
         s.swap (*this); // Non-throwing swap
         return *this;
      }// Old resources released when destructor of s is called.
    
      void swap(String & s) noexcept // Also see non-throwing swap idiom
    {
          std::swap(this->str, s.str);
      }
    };
    
  • 补充知识点,异常安全的四种保证
    • Nothrow(或者nofail)异常保证: 该函数永远不会抛出异常.对于可能在堆栈展开期间被调用的析构函数和其他函数,期望它们不会抛出异常(错误通过其他方式报告或隐藏).析构函数默认情况下是noexcept的(自C++11起).Nofail(函数总是成功执行)的要求适用于交换(swaps),移动构造函数和其他被那些提供强异常保证的函数所使用的函数.
    • 强异常保证: 如果该函数抛出异常,程序的状态将回滚到函数调用之前的状态(例如 std::vector::push_back).即使抛出异常,程序仍然处于有效状态.
    • 基本异常保证: 如果该函数抛出异常,程序仍然处于有效状态,没有资源泄漏,所有对象的不变性仍然保持完整.
    • 无异常保证: 如果该函数抛出异常,程序可能处于无效状态,可能会发生资源泄漏,内存损坏或其他破坏不变性的错误.

CRTP: Curiously Recurring Template Pattern

  • CRTP 是指将一个类作为模板参数传递给其基类的情况 ```cpp

template struct BaseCRTP {};

struct Example : BaseCRTP {};

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

+ 在基类中,通过进行类型转换(可以使用static_cast或dynamic_cast)可以获取派生类实例,包括派生类型
```cpp

template<class Derived>
struct BaseCRTP {
  void call_foo() {
    Derived& self = *static_cast<Derived*>(this);
    self.foo();
  }
};

struct Example : BaseCRTP<Example> {
  void foo() { cout << "foo()\\n"; }
};

PIMPL 模式

  • 简介
    • 很实用的一种基础模式
  • PIMPL 解释:
    • PIMPL(Private Implementation 或 Pointer to Implementation)是通过一个私有的成员指针,将指针所指向的类的内部实现数据进行隐藏。
  • 示例: ```cpp //x.h class X { public: void Fun(); private: int i; //add int i; };

//c.h #include class C { public: void Fun(); private: X x; //与X的强耦合 };

PIMPL做法: //c.h class X; //代替#include class C { public: void Fun(); private: X *pImpl; //pimpl };

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
+ 降低模块的耦合。因为隐藏了类的实现,被隐藏的类相当于原类不可见,对隐藏的类进行修改,不需要重新编译原类。
+ 降低编译依赖,提高编译速度。指针的大小为(32位)或8(64位),X发生变化,指针大小却不会改变,文件c.h也不需要重编+ 
+ 接口与实现分离,提高接口的稳定性。
  + 通过指针封装,当定义“new C”或"C c1"时 ,编译器生成的代码中不会掺杂X的任何信息。
  + 当使用C时,使用的是C的接口(C接口里面操作的类其实是pImpl成员指向的X对象),与X无关,X被通过指针封装彻底的与实现分离。
```cpp
//c.cpp
C::C()pImpl(new X())
{
}

C::~C()
{
     delete pImpl;
     pImpl = NULL;
}

void C::Fun()
{
    pImpl->Fun();
}

//main
#include <c.h>
int main()
{
    C c1;
    c1.Fun();
    return 0;
}