《C++ Primer》笔记 第7章 类

《C++ Primer》笔记 第7章 类

定义抽象数据类型

定义成员函数

  • 成员函数的声明必须在类的内部,它的定义则既可以在类的内部也可以在类的外部。作为接口组成部分的非成员函数,它们的定义和声明都在类的外部。
  • 定义在类内部的函数是隐式的inline函数。

引入this

  • 成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this(相当于Python中的self形参?)。伪代码示意:Sales_data::isbn(&total),任何对类成员的直接访问都被看做this的隐式引用,也就是说,当isbn使用bookNo时,它隐式地使用this指向的成员,就像我们书写了this->bookNo一样。
  • 对于我们来说,this形参是隐式定义的。实际上,任何自定义名为this的参数或变量的行为都是非法的。我们可以在成员函数体内部使用this。例:std::string isbn() const { return this->bookNo; }
  • 因为this的目的总是指向“这个”对象,所以this是一个常量指针,我们不允许改变this中保存的地址。

引入const成员函数

  • 默认情况下,this的类型是指向类类型非常量版本的常量指针。尽管this是隐式的,但它仍然需要遵循初始化规则,意味着(在默认情况下)我们不能把this绑定到一个常量对象上。这一情况也就使得我们不能在一个常量对象上调用普通的成员函数。
  • 紧跟在参数列表后面的const表示this是一个指向常量的指针。像这样使用const的成员函数被称作常量成员函数
  • 在常量成员函数中,因为this是指向常量的指针,所以常量成员函数不能改变调用它的对象的内容。
  • 常量对象,以及常量对象的引用或指针都只能调用常量成员函数。

类作用域和成员函数

  • 编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体(如果有的话)。因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。

在类的外部定义成员函数

  • 当我们在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配。例:double Sales_data::avg_price() const {...}。使用作用域运算符(::),告知编译器剩余的代码是位于类的作用域内的。(参数列表和函数体内不用再加作用域运算符)

定义一个返回this对象的函数

  • 内置的赋值运算符把它的左侧运算对象当成左值返回。下面的示例模拟了加法行为,并返回对象的引用:
      Sales_data& Sales_data::combine(const Sales_data &rhs)
      {
            units_sold += rhs.units_sold; // 把rhs的成员加到this对象的成员上
            revenue += rhs.revenue;
            return *this; // 返回调用该函数的对象
      }
    

定义类相关的非成员函数

  • 一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类在同一个头文件中。
  • 默认情况下,拷贝类的对象其实拷贝的是对象的数据成员。

构造函数

  • 构造函数的名字和类名相同。
  • 构造函数没有返回类型。
  • 类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。
  • 构造函数不能被声明成const的。当我们创建类的一个const对象时,直到构造函数完成初始化过程后,对象才能真正取得其“常量”属性。因此,构造函数在const对象的构造过程中可以向其写值。
  • 类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数,默认构造函数无须任何实参。如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。
  • 编译器创建的构造函数又被称为合成的默认构造函数。对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:
    • 如果存在类内的初始值,用它来初始化成员。
    • 否则,默认初始化该成员。

不能依赖合成的默认构造函数

  • 只有当类没有声明任何构造函数时,编译器才会自动地生成默认构造函数。
  • 如果类包含有内置类型或者复合类型(比如数组和指针)的成员,则只有当这些成员全都被赋予了类内的初始值时(否则它们的值将是未定义的),这个类才适合于使用合成的默认构造函数。
  • 有的时候编译器不能为某些类合成默认的构造函数。例如:如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。对于这样的类来说,我们必须自定义默认构造函数,否则该类将没有可用的默认构造函数。

= default的含义

  • 如果我们需要默认的行为,那么可以通过在参数列表后面写上= default来要求编译器生成构造函数。
  • 其中,= default既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。
  • 和其他函数一样,如果= default在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的。例:Sales_data() = default;

构造函数初始值列表

  • 构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的(或者在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来。例:Sales_data(const std::string &s): bookNo(s) {}
  • 当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。如果你的编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。
  • 没有出现在构造函数初始值列表中的成员将通过相应的类内初始值(如果存在的话)初始化,或者执行默认初始化。

拷贝、赋值和析构

  • 对象在几种情况下会被拷贝,如我们初始化变量以及以值的方式传递或返回一个对象等。当我们使用了赋值运算符时会发生对象的赋值操作。当对象不再存在时执行销毁的操作。如果我们不主动定义这些操作,则编译器将替我们合成它们。一般来说,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作。
      total = trans; // 处理下一本书的信息
      
      // 它的行为与下面的代码相同
      total.bookNo = trans.bookNo;
      total.units_sold = trans.units_sold;
      total.revenue = trans.revenue;
    

访问控制与封装

使用class和struct的区别

  • 使用class和struct定义类唯一的区别就是默认的访问权限。如果我们使用struct关键字,则定义在第一个访问说明符之前的成员是public的;相反,如果过我们使用class关键字,则这些成员是private的。

友元

友元的声明

  • 友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。一般来说,最好在类定义开始或结束前的位置集中声明友元。
  • 友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。为了使友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)。
  • 许多编译器并未强制限定友元函数必须在使用之前在类的外部声明。

类的其他特性

类成员再探

定义一个类型成员

  • 除了定义数据和函数成员之外,类还可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存在访问权限,可以是public或者private中的一种。用来定义类型的成员必须先定义后使用,这一点与普通成员有所区别。因此,类型成员通常出现在类开始的地方。
      class Screen
      {
      public:
            typedef std::string::size_type pos; // using pos = std::string::size_type;
      private:
            pos cursor = 0;
            pos height = 0, width = 0;
            std::string contents;
      };
    

令成员作为内联函数

  • 定义在类内部的成员函数是自动inline的。我们可以在类的内部把inline作为声明的一部分显式地声明成员函数,同样的,也能在类的外部用inline关键字修饰函数的定义。
  • 虽然我们无须在声明和定义的地方同时说明inline,但这么做其实是合法的。不过,最好只在类外部定义的地方说明inline,这样可以使类更容易理解。和我们在头文件中定义inline函数的原因一样(函数的展开不仅需要函数的声明,还需要函数的定义),inline成员函数也应该与相应的类定义在同一个头文件中。

可变数据成员

  • 一个可变数据成员永远不会是const,即使它是const对象的成员。因此,一个const成员函数可以改变一个可变成员的值。

      class Screen
      {
      public:
            void some_member() const;
      private:
            // 该成员是个可变成员,因此任何成员函数,包括const函数在内都能改变它的值
            mutable size_t access_ctr; // 即使在一个const对象内也能被修改
            // 其他成员与之前的版本一致
      };
      void Screen::some_member() const
      {
            ++access_ctr; // 保存一个计数值,用于记录成员函数被调用的次数
            // 该成员需要完成的其他工作
      }
    

类数据成员的初始值

  • 当我们为数据成员提供一个类内初始值时,必须以符号=或者花括号表示(不能用圆括号,会被当成函数)。

返回*this的成员函数

  • 一个const成员函数如果以引用的形式返回*this,那么它的返回类型将是常量引用。

类类型

  • 即使两个类的成员列表完全一致,它们也是不同的类型。对于一个类来说,它的成员和其他任何类(或者任何其他作用域)的成员都不是一回事儿。

  • 我们可以把类名作为类型的名字使用,从而直接指向类类型。或者,我们也可以把类名跟在关键字class或struct后面。

      Sales_data item1; // 默认初始化Sales_data类型的对象
      class Sales_data item1; // 一条等价的声明
    

类的声明

  • 就像可以把函数的声明和定义分离开来一样,我们也能仅仅声明类而暂时不定义它:class Screen;这种声明有时被称为前向声明,它向程序中引入了名字Screen并且指明Screen是一种类类型。在它声明之后定义之前是一个不完全类型(不清楚它到底包含哪些成员)。

  • 对于不完全类型:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。

  • 对于一个类来说,在我们创建它的对象之前该类必须被定义过,而不能仅仅被声明。否则编译器就无法了解这样的对象需要多少存储空间。类似的,类也必须首先被定义,然后才能用引用或指针访问其成员。毕竟,如果类尚未定义,编译器也就不清楚该类到底有哪些成员。

  • 因为只有当类全部完成后类才算被定义,所以一个类的成员类型不能是该类自己。然而,一旦一个类的名字出现后,它就被认为是声明过了(但尚未定义),因此类允许包括指向它自身类型的引用或指针:

      class Link_screen
      {
            Screen window;
            Link_screen *next;
            Link_screen *prev;
      };
    

友元再探

  • 在类中,可以把普通的非成员函数是定义成友元,也可以把其他类定义成友元,也可以把其他类(之前已定义过的)的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的。

      class Screen
      {
            // Window_mgr::clear必须在Screen类之前被声明
            friend void Window_mgr::clear(ScreenIndex);
            // Screen类的剩余部分
      };
    
  • 如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。

      class Screen
      {
            // Window_mgr的成员可以访问Screen类的私有部分
            friend class Window_mgr;
            // Screen类的剩余部分
      };
    
  • 友元关系不存在传递性。每个类负责控制自己的友元或友元函数。

令成员函数作为友元

  • 要想令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。

函数重载和友元

  • 如果一个类型想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明。

友元声明和作用域

  • 类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的。然而,友元本身不一定真的声明在当前作用域中。甚至就算在类的内部定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。换句话说,即使我们仅仅是用声明友元的类的成员调用该友元函数,它也必须是被声明过的。

      struct X
      {
            friend void f() { /*友元函数可以定义在类的内部*/ }
            X() { f(); } // 错误:f还没有被声明
            void g();
            void h();
      };
      void X::g() { return f(); } // 错误:f还没有被声明
      void f(); // 声明那个定义在X中的函数
      void X::h() { return f(); } // 正确:现在f的声明在作用域中了
    
  • 上面这段代码最重要的是理解友元声明的作用是影响访问权限,它本身并非普通意义上的声明

  • 注意,有的编译器并不强制执行上述关于友元的限定规则。

类的作用域

  • 每个类都会定义它自己的作用域。在类的作用域之外,普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符来访问。对于类类型成员则使用作用域运算符访问。不论哪种情况,跟在运算符之后的名字都必须是对应类的成员。

作用域和定义在类外部的成员

  • 一旦遇到了类名,定义的剩余部分就在类的作用域之内了,这里的剩余部分包括参数列表和函数体。

      void Window_mgr::clear(ScreenIndex i)
      {
            Screen &s = screens[i];
            s.contents = string(s.height * s.width, " ");
      }
    
  • 函数的返回类型通常出现在函数名之前,因此当成员函数定义在类的外部时,返回类型中使用的名字都位于类的作用域之外。这时,返回类型必须指明它是哪个类的成员(尾置返回类型在当前类的定义域中)。

    class Window_mgr{
    public:
        // 向窗口添加一个Screen,返回它的编号
        ScreenIndex addScreen(const Screen&);
        // 其他成员与之前的版本一致
    };
    // 首先处理返回类型,之后我们才进入Window_mgr的作用域
    Window_mgr::ScreenIndex
    Window_mgr::addScreen(const Screen &s)
    {
    	Screens.push_back(s);
        return screens.size() - 1;
    }
    

名字查找与类的作用域

  • 一般的名字查找(寻找与所用名字最匹配的声明的过程):
    • 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明。
    • 如果没找到,继续查找外层作用域。
    • 如果最终没有找到匹配的声明,则程序报错。
  • 对于定义在类内部的成员函数来说,解析其中名字的方式与上述的查找规则有所区别:
    • 首先,编译成员的声明
    • 直到类全部可见后才编译函数体
  • 编译器处理完类中的全部声明后才会处理成员函数的定义。
  • 按照这种两段的方式处理类可以简化类代码的组织方式。因为成员函数体直到整个类可见后才会被处理,所以它能使用类中定义的任何名字。相反,如果函数的定义和成员的声明被同时处理,那么我们将不得不在成员函数中只使用那些已经存在的名字。

用于类成员声明的名字查找

  • 这种两段的处理方式只适用于成员函数体中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。如果某个成员的声明使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找(即进行一般的名字查找过程)。

      // 理解下面这段程序代码
      typedef double Money;
      string bal;
      class Account
      {
      public:
            Money balance() { return bal; } // 返回的是成员bal,而非外层作用域的string对象
      private:
            Money bal; // balance函数体在整个类可见后才被处理
            // ...
      };
    
    /*
    只有成员函数体内部使用的名字使用的是二段式的处理方式,其余均遵从一般的名字查找。比如上面的两个Money都会向前查找声明,为typedef处的double类型。而函数体中的bal会在类体内查找,返回的是成员bal,而非外层作用域的string对象。
    */
    

类型名要特殊处理

  • 一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字(因为两段式的名字查找)。

      typedef double Money;
      string bal;
      class Account
      {
      public:
            Money balance() { return bal; } // 使用外层作用域的Money
      private:
            typedef double Money; // 错误:不能重新定义Money,即使与外层作用域中的定义完全一致
            Money bal;
            // ...
      };
    
  • 类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。

成员定义中的普通块作用域的名字查找

  • 成员函数中使用的名字按照如下方式解析:

    • 首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑。(成员函数作用域)
    • 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。(类作用域)
    • 如果类内也没找到该名字的声明,在成员函数定义之前(成员函数可以定义在类外)的作用域内继续查找。(类的外层作用域)
  • 尽管类的成员被隐藏了,但我们仍然可以通过加上类的名字或显式地使用this指针来强制访问成员。

      void Screen::dummy_fcn(pos height) // 不建议隐藏类中同名的成员
      {
            cursor = width * this->height;
            // 另一种表示该成员的方式
            cursor = width * Screen::height;
      }
    

类作用域之后,在外围的作用域中查找

  • 尽管外层的对象被隐藏掉了,但我们仍然可以用作用域运算符访问它。

      void Screen::dummy_fcn(pos height) // 不建议隐藏外层作用域中可能被用到的名字
      {
            cursor = width * ::height; // 全局的那个height
      }
    

在文件中名字的出现处对其进行解析

  • 当成员定义在类的外部时,名字查找的第三步不仅要考虑类定义之前的全局作用域中的声明,还需要考虑在成员函数定义之前的全局作用域中的声明。

      // 代码可以被正常使用
      int height; // 定义了一个名字,稍后将在Screen中使用
      class Screen
      {
      public:
            typedef std::string::size_type pos;
            void setHeight(pos);
            pos height = 0; // 隐藏了外层作用域中的height
      };
      Screen::pos verify(Screen::pos);
      void Screen::setHeight(pos var)
      {
            // var:参数
            // height:类的成员
            // verify:全局函数
            height = verify(var);
      }
    

构造函数再探

构造函数初始值列表

  • 如果没有在构造函数的初始值列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化。

构造函数的初始值有时必不可少

  • 如果成员是const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。

  • 随着构造函数体一开始执行,初始化就完成了。我们初始化const或者引用类型的数据成员的唯一机会就是通过构造函数初始化。

      class ConstRef
      {
      public:
            ConstRef(int ii);
      private:
            int i;
            const int ci;
            int &ri;
      };
    
      // 错误:ci和ri必须被初始化
      ConstRef::ConstRef(int ii)
      {
            // 赋值
            i = ii; // 正确
            ci = ii; // 错误:不能给const赋值
            ri = i; // 错误:不能给const赋值
      }
    
      // 正确形式:显式地初始化引用和const成员
      ConstRef::ConstRef(int ii): i(ii), ci(ii), ri(i) { }
    

成员初始化的顺序

  • 成员的初始化顺序与它们在类定义中的出现顺序一致。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。

      class X
      {
            int i;
            int j;
      public:
            // 未定义的:i在j之前被初始化
            X(int val): j(val), i(j) { } // 错误:试图使用未定义的值j初始化i
      };
    
  • 最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。

  • 如果可能的话,最好用构造函数的参数作为成员的初始值,而尽量避免使用同一个对象的其他成员。这样的好处是我们可以不必考虑成员的初始化顺序。

默认实参和构造函数

  • 如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。

委托构造函数

  • 一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。

      class Sales_data
      {
      public:
            // 非委托构造函数使用对应的实参初始化成员
            Sales_data(std::string s, unsigned cnt, double price):
                  bookNo(s), units_sold(cnt), revenue(cnt*price) { }
            // 其余构造函数全都委托给另一个构造函数
            Sales_data(): Sales_data("", 0, 0) { }
            Sales_data(std::string s): Sales_data(s, 0, 0,) { }
            Sales_data(std::istream &is): Sales_data() { read(is, *this); }
            // 其他成员与之前的版本一致
      }
    
  • 当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行(然后才会执行委托者的函数体)。

默认构造函数的作用

  • 当对象被默认初始化或值初始化时自动执行默认构造函数。

  • 默认初始化在以下情况下发生:

    • 当我们在块作用域内不使用任何初始值定义一个非静态变量或者数组时。
    • 当一个类本身含有类类型的成员且使用合成的默认构造函数时(那么这个成员执行默认构造函数)。
    • 当类类型的成员没有在构造函数初始值列表中显式地初始化时(那么这个成员执行默认构造函数)。
  • 值初始化在以下情况下发生:

    • 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时。
    • 当我们不使用初始值定义一个局部静态变量时。
    • 当我们通过书写形如T()的表达式显式地请求值初始化时,其中T是类型名(例如vector)。
  • 类必须包含一个默认构造函数以便在上述情况下使用,其中的大多数情况非常容易判断。不那么明显的一种情况是类的某些数据成员缺少默认构造函数:

      class NoDefault
      {
      public:
            NoDefault(const std::string&);
            // 还有其他成员,但是没有其他构造函数了
      };
      struct A
      { 
            // 默认情况下my_mem是public的
            NoDefault my_mem;
      };
      A a; // 错误:不能为A合成构造函数
      struct B
      {
            B() { } // 错误:b_member没有初始值
            NoDefault b_member;
      }
    
  • 在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数。

使用默认构造函数

  • 对于C++新手来说有一种常犯的错误:

      Sales_data obj(); // 错误:声明了一个函数而非对象
      Sales_data obj2; // 正确:obj2是一个对象而非函数
    

隐式的类类型转换

  • 如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数

  • 能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则。

  • 编译器只会自动地执行一步类型转换。例如,因为下面的代码隐式地使用了两种转换规则,所以它是错误的:

      // 错误:需要用户定义的两种转换:
      // (1)把"9-999-99999-9"转换成string
      // (2)再把这个(临时的)string转换成Sales_data
      item.combine("9-999-99999-9");
    
      // 正确:显式地转换成string,隐式地转换成Sales_data
      item.combine(string("9-999-99999-9"));
      // 正确:隐式地转换成string,显式地转换成Sales_data
      item.combine(Sales_data("9-999-99999-9"));
    
      // 通过读取标准输入创建了一个(临时的)Sales_data对象,随后将得到的对象传递给combine。
      item.combine(cin);
    
      // Sales_data对象是个临时量,一旦combine完成我们就不能再访问它了。实际上,我们构建了一个对象,先将它的值加到item中,随后将其丢弃。
    

抑制构造函数定义的隐式转换

  • 在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为explicit加以阻止。此时,没有任何构造函数能用于隐式地创建Sales_data对象,之前的两种用法都无法通过编译:

      item.combine(null_book); // 错误:string构造函数是explicit的
      item.combine(cin); // 错误:istream构造函数时explicit的
    
  • 关键字explicit只对一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,所以无需将这些构造函数指定为explicit的。只能在类内声明构造函数时使用explicit关键字,在类外部定义时不应重复。

    • inline是用于实现的关键字(放在定义处)
    • static是用于声明的关键字(放在声明处)
    • explicit是用于声明的关键字(放在声明处)
    • friend是用于声明的关键字(放在声明处)

explicit构造函数只能用于直接初始化

  • 发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=)。此时,我们只能使用直接初始化而不能使用explicit构造函数。

      Sales_data item1(null_book); // 正确:直接初始化
      // 错误:不能将explicit构造函数用于拷贝形式的初始化过程
      Sales_data item2 = null_book;
    
  • 当我们用explicit关键字声明构造函数时,它将只能以直接初始化的形式使用。而且,编译器将不会在自动转换过程中使用该构造函数。

为转换显式地使用构造函数

  • 尽管编译器不会将explicit的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换。
      // 正确:实参是一个显式构造的Sales_data对象
      item.combine(Sales_data(null_book));
      // 正确:static_cast可以使用explicit的构造函数
      item.combine(static_cast<Sales_data>(cin));
    

标准库中含有显式构造函数的类

  • 我们用过的一些标准库中的类含有单参数的构造函数:
    • 接受一个单参数的const char*的string构造函数不是explicit的。
    • 接受一个容量参数的vector构造函数是explicit的。

聚合类

  • 聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:

    • 所有成员都是public的
    • 没有定义任何构造函数
    • 没有类内初始值
    • 没有基类,也没有virtual函数
      struct Data
      {
            int ival;
            string s;
      };
    
  • 我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员:Data val1 = {0, "Anna"};。初始值的顺序必须与声明的顺序一致Data val2 = {"Anna", 1024};错误。如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。初始值列表的元素个数绝对不能超过类的成员数量。

  • 显式地初始化类的对象存在三个明显的缺点:

    • 要求类的所有成员都是public的
    • 将正确初始化每个对象的每个成员的重任交给了类的用户(而非类的作者)。因为用户很容易忘掉某个初始值,或者提供一个不恰当的初始值,所以这样的初始化过程冗长乏味且容易出错。
    • 添加或删除一个成员之后,所有的初始化语句都需要更新。

字面值常量类

  • constexpr函数的参数和返回值必须是字面值类型。除了算术类型、引用和指针外,某些类也是字面值类型。和其它类不同,字面值类型的类可能含有constexpr函数成员。这样的成员必须符合constexpr函数的所有要求,它们是隐式const的。
  • 数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:
    • 数据成员都必须是字面值类型
    • 类必须至少含有一个constexpr构造函数
    • 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
    • 类必须使用析构函数的默认定义,该成员负责销毁类的对象。

constexpr构造函数

  • constexpr构造函数必须既符合构造函数的要求(意味着不能包含返回语句),又符合constexpr函数的要求(意味着它能拥有的唯一可执行语句就是返回语句)。综合这两点可知,constexpr构造函数体一般来说应该是空的。

      class Debug
      {
      public:
            constexpr Debug(bool b = true): hw(b), io(b), other(b) { }
            constexpr Debug(bool h, bool i, bool o): hw(h), io(i), other(o) { }
            constexpr bool any() { return hw || io || other; }
            void set_io(bool b) { io = b; }
            void set_hw(bool b) { hw = b; }
            void set_other(bool b) { other = b; }
      private:
            bool hw; // 硬件错误,而非IO错误
            bool io; // IO错误
            bool other; // 其他错误
      };
    
      constexpr Debug io_sub(false, true, false); // 调试IO
      if (io_sub.any()) // 等价于if(true)
            cerr << "print appropriate error messages" << endl;
      constexpr Debug prod(false); // 无调试
      if (prod.any()) // 等价于if(false)
            cerr << "print an error message" << endl;
    
  • constexpr构造函数必须初始化所有数据成员。初始值(或者)使用constexpr构造函数或者是一条常量表达式。

  • constexpr构造函数用于生成constexpr对象以及constexpr函数的参数或返回类型。

类的静态成员

声明静态成员

  • 我们通过在成员的声明之前加上关键字static使得其与类关联在一起。和其他成员一样,静态成员可以是public的或private的。静态数据成员的类型可以是常量、引用、指针、类类型等。
  • 类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。
  • 类似的,静态成员函数也不与任何对象绑定在一起,它们不包含this指针。作为结果,静态成员函数不能声明成const的,而且我们也不能在static函数体内使用this指针。这一限制既适用于this的显式使用,也对调用非静态成员的隐式使用有效。

使用类的静态成员

  • 使用作用域运算符直接访问静态成员:r = Account::rate();
  • 虽然静态成员不属于类的某个对象,但是我们仍然可以使用类的对象、引用或者指针来访问静态成员:r = ac1.rate()r = ac2->rate()
  • 成员函数可以不用通过作用域运算符就能直接使用静态成员:
  class Account
  {
  public:
        void calculate() { amount += amount * interestRate; }
        static double rate() { return interestRate; }
        static void rate(double);
  private:
        std::string owner;
        double amount;
        static double interestRate;
        static double initRate();
  };

定义静态成员

  • 我们既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部的声明语句:

      void Account::rate(double newRate)
      {
            interestRate = newRate;
      }
    
  • 和类的所有成员一样,当我们指向类外部的静态成员时,必须指明成员所属的类名。static关键字只出现在类内部的声明语句中。

  • 因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。和其它对象一样,一个静态数据成员只能定义一次。

  • 类似于全局变量,静态数据成员定义在任何函数之外。因此一旦它被定义,就将一直存在于程序的整个生命周期中。

  • 定义静态数据成员的方式和在类的外部定义成员函数差不多。我们需要指定对象的类型名,然后是类名、作用域运算符以及成员自己的名字。

      // 定义并初始化一个静态成员
      double Account::interestRate = initRate();
      // 从类名开始,这条定义语句的剩余部分就都位于类的作用域之内了。因此,可以直接使用initRate函数
      // 注意,虽然initRate是私有的,我们也能用它初始化interestRate
      // 和其他成员的定义一样,interestRate的定义也可以访问类的私有成员
    
  • 要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。

静态成员的类内初始化

  • 通常情况下,类的静态成员不应该在类的内部初始化。然而,我们可以为静态成员提供const整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr。(除了静态常量成员之外,其他静态成员不能在类的内部初始化。)初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所有适合于常量表达式的地方。例如:

    class Account
    {
    public:
          static double rate() { return interestRate; }
          static void rate(double);
    private:
          static constexpr int period = 30; // period是常量表达式
          double daily_tbl[period];
    };
    
  • 如果在类的内部提供了一个初始值,则成员的定义不能再指定一个初始值了。

    // 一个不带初始值的静态成员的定义
    constexpr int Account::period; // 初始值在类的定义内提供
    
  • 即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。

静态成员能用于某些场景,而普通成员不能

  • 静态成员的优点包括:作用域位于类的范围之内,避免与其他类的成员或者全局作用域的名字冲突;可以是私有成员,而全局对象不可以;通过阅读程序可以非常容易地看出静态成员与特定类关联,使得程序的含义清晰明了。

  • 静态数据成员可以是不完全类型。特别的,静态数据成员的类型可以就是它所属的类类型,非静态数据成员只能声明成它所属类的指针或引用。

    class Bar {
    public:
    	// ...
    private:
    	static Bar mem1; // 正确:静态成员可以是不完全类型
    	Bar *mem2; // 正确:指针成员可以是不完全类型
    	Bar mem3; // 错误:数据成员必须是完全类型
    };
    
  • 我们可以使用静态成员作为默认实参。非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分,这么做的结果是无法真正提供一个对象以便从中获取成员的值,最终将引发错误。(普通成员函数包含this形参,但因为函数参数解析顺序是未定的,所以该默认值也是未定的,如int function(class_type *this, int n = this->a)

    class Screen {
    public:
    	// bkground表示一个在类中稍后定义的静态成员
    	Screen& clear(char = bkground);
    private:
    	static const char bkground;
    };
    
hmoban主题是根据ripro二开的主题,极致后台体验,无插件,集成会员系统
自学咖网 » 《C++ Primer》笔记 第7章 类