Skip to content

Latest commit

 

History

History
1897 lines (1376 loc) · 73.9 KB

File metadata and controls

1897 lines (1376 loc) · 73.9 KB

类的思想是数据抽象(data abstraction)和封装(encapsulation):

  • 数据抽象:一种依赖于接口(interface)和实现(implementation)分离的编程以及设计技术。
    • 类的接口表示用户所能执行的操作。
    • 类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需要的各种私有函数
  • 封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。

类要想实现数据抽象和封装,需要首先定义一个抽象数据类型(abstract data type)。在抽象数据类型中,有类的设计者负责考虑类的实现过程;使用该类的程序员则只需要抽象地思考类型做了什么,而不需要了解类型的工作细节。

定义抽象数据类型

例如早前使用的Sales_item类是一个抽象数据类型,我们通过它的接口来使用一个Sales_item对象。我们不能访问Sales_item对象的数据成员,甚至也不知道这个类有哪些数据成员。

我们定义一个自己的类,名为Sales_dataSales_data类不是一个抽象数据类型。它允许类的用户直接访问它的数据成员,并且要求由用户来编写操作。要想把Sales_data变成抽象数据类型,我们要定义一些操作以供类的用户使用。一旦Sales_data定义了它自己的操作,我们就可以封装它的数据成员了。

设计Sales_data类

最终目的是让Sales_dataSales_item类有完全一样的操作集合。

Sales_item有个名为isbn成员函数(member function),且支持+、=、+=、<<、>>运算符。

为这些运算定义普通的函数形式。Sales_data应包含操作:

  • 一个isbn成员函数,用于返回对象的ISBN编号
  • 一个combine成员函数,用于将一个Sales_data对象加到另一个对象上
  • 一个名为add的函数,执行两个Sales_data对象的加法
  • 一个read函数,将数据从istream读入到Sales_data对象中
  • 一个print函数,将Sales_data对象的值输出到ostream

类的设计者也是为其用户设计并实现一个类的人,但类的用户是程序员。

当我们设计类的时候应该使类易于使用,使用类的时候不应该顾及类的实现机理。

使用示例

// 看看就行 具体类内容还没定义
#include <iostream>
#include "Sales_data.h"
using namespace std;

int main(){
    Sales_data total;                           // 保存当前求和结果的变量
    if(read(cin, total)){                       // 读入第一笔交易
        Sales_data trans;                       // 保存下一笔交易数据的变量
        while(read(cin, trans)){                // 读入剩余交易
            if (total.isbn() == trans.isbn())   // 检查isbn
                total.combine(trans);           // 更新total当前值
            else{
                print(cout, total) << endl;     // 输出结果
                total = trans;                  // 更新变量total当前值
            }
        }
        print(cout, total) << endl;             // 输出最后一条交易
    }else{
        cerr << "No data?!" << endl;            // 通知用户
    }

    system("pause");
    return 0;
}

定义改进的Sales_data类

Sales_data.h

#ifndef SALES_DATA_H        // 当预处理变量SALES_DATA_H没有定义时,就执行下面内容直到#endif
#define SALES_DATA_H        // 把SALES_DATA_H定义为预处理变量
#include <string>           // 告诉预处理器我们要使用string库


struct Sales_data{          // 定义Sales_data类
    // 新成员:关于Sales_dat对象的操作
    std::string isbn() const { return bookNo; }
    Sales_data& combine(const Sales_data&);
    double avg_price() const;

    // 以下的是数据成员
    std::string bookNo;     // 生成数据成员bookNo string对象bookNo
    unsigned units_sold = 0;// 生成数据成员units_sold 初始化无符号int类型units_sold变量为0
    double revenue = 0.0;   // 生成数据成员revenue 初始化双精度浮点型变量revenue变量为0.0
};

// Sales_data的非成员接口函数
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);

#endif

我们的类包含两个成员函数:combineisbn。我们还赋予类一个成员函数用于返回售出书籍的平均价格,该函数被命名为avg_priceavg_price的目的并非什么时候都能用,所以它是属于类的实现的一部分,而非接口。

成员函数的声明必须要在类的内部,它的定义可以在类内部也可以在类外部。作为接口组成部分的非成员函数,例如addreadprint等都定义和声明在类的外部。

定义在类内部的函数是隐式的inline(内联)函数。

定义成员函数

所有成员都要在类的内部声明,成员函数体可以定义在类内部或外部。如上我们就在类内定义isbn,类外定义combineavg_price

isbn函数的参数列表为空,返回string对象:

std::string isbn() const {return bookNo;}		// 成员函数体只有一个return

返回Sales_data对象的bookNo数据成员。

引入this

total.isbn()

使用点运算符访问调用total对象的isbn成员。

在上述所示调用中,当isbn返回bookNo时候,实际上它隐式地返回total.bookNo

成员函数通过名为this的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数的时候,用请求该函数的对象地址初始化this。编译器会把total的地址传给isbn函数的隐式形参this。任何自定义名为this的参数都是非法的。

this是个常量指针,指向调用当前成员函数的对象地址。

// isbn利用this的另一种定义方式
std::string isbn() const { return this->bookNo; }

引入const成员函数

isbn函数的另一个关键之处是紧随参数列表之后的const关键字,const的作用是修改隐式this指针的类型。

默认this是指向类类型变量版本的常量指针(一对一绑定)。也就是默认我们不能把常量指针指向一个常量对象(如果要指向常量对象,那应该是指向常量的指针)。这一情况导致我们不能在一个常量对象上调用普通的成员函数。

isbn是个普通函数且this是个普通指针,那就该把this声明称const Sales_data *const。毕竟,在isbn的函数体里不会改变this所指的对象,所以把this设置成指向常量的指针有助于提高函数的灵活性。

但是this是个隐式参数。

C++语法允许把const关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的const表示this是一个指向常量的指针。类似这样使用const成员函数被称作常量成员函数(const member function)。

std::string isbn() const {return bookNo;}

this是指向常量的常量指针,所以不能改变调用它的对象的内容。

常量对象,以及常量对象的引用或者指针都只能调用常量成员函数。

伪代码帮助理解:

std::string Sales_data::isbn(const Sales_data *const this){
	return this->isbn;
}

/*
定义一个返回类型是string类型的Sales_data命名空间里的isbn函数
该函数接收一个 指向一个常量Sales_data类型的常量指针this
返回 解引用指针this后传入isbn()函数的值
*/

类作用域和成员函数

编译器分两步处理类:

  1. 编译成员的声明
  2. 编译成员函数体(如果有)

所以成员函数体可以随便用类里的其他成员而无须在意成员出现的顺序

在类的外部定义成员函数

成员函数的定义必须与声明相匹配。若成员被声明成常量成员函数,那么它的定义也要在参数列表后明确指定cosnt属性。且类外部定义的成员名也必须包含其所属类名:

double Sales_data::avg_price() const{
    if (units_sold)
        return revenue/units_sold;
    else
        return 0;
}
  • Sales_data::avg_price使用::作用域运算符说明
    • 定义了一个名为avg_price函数
    • 该函数被声明在类Sales_data的作用域内

所以当该函数使用revenueunits_sold时候,就相当于隐式使用类中的成员。

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

combine的函数:

Sales_data& Sales_data::combine(const Sales_data &rhs)
{
    units_sold += rhs.units_sold;       // 把rhs的成员加到this对象的成员上
    revenue += rhs.revenue;
    return *this;                       // 返回调用该函数的对象解引用之后的对象 就是返回调用该常量成员函数的对象
}

调用:

Sales_data total=书籍信息, trans=书籍信息;
total.combine(trans);		// 使用trans对象更新total对象

定义类相关的非成员函数

类通常需要定义一些辅助函数,比如此处的addreadprint。这些函数定义的操作从概念上看属于类的接口组成部分,但其实不属于类本身。

若函数在概念上属于类但是不定义在类中,则它应与类声明在同一个头文件。之后用户想要用接口的哪个部分都只要引入一个文件。

也就是说:如果成员函数是类接口的组成部分,那这些函数的声明应该和类在同一个头文件里。

定义read和print函数

// 输入的交易信息包括ISBN、售出总数和售出价格
std::istream &read(std::istream &is, Sales_data &item)
{
    double price = 0;
    is >> item.bookNo >> item.units_sold >> price;
    item.revenue = price * item.units_sold;
    return is;
}

std::ostream &print(std::ostream &os, const Sales_data &item)
{
    os << item.isbn() << " " << item.units_sold << " " << item.revenue << " " << item.avg_price();
    return os;
}
  1. readprint分别接受一个各自IO类型的引用作为其参数,这是因为IO类属于不能拷贝的类型,因此只能用引用来传递他们。且,因为读入的操作会改变流的内容,所以俩函数接受的都是普通引用,而非对常量的引用。
  2. print函数不负责换行。执行输出的函数应该减少对格式的控制。

定义add函数

add函数接收两个Sales_data对象作为参数,返回值是个新的Sales_data,用于表示前两个对象的和:

Sales_data add(const Sales_data &lhs, const Sales_data &rhs)
{
    Sales_data sum = lhs;       // 把lhs的数据成员拷贝给sum
    sum.combine(rhs);           // 把rhs的内容添加到sum当中
    return sum;
}

构造函数

每个类都分别定义了它的对象被初始化的方式,类通过一个或者几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数(constructor)。构造函数要做的是初始化类对象的数据成员,不管什么时候只要类的对象创建了,就会执行构造函数。

构造函数的名字和类名相同。构造函数没有返回类型。类可以有多个构造函数,和其他重载函数类似,不同的构造函数之间必须要在参数数量或参数类型上有区别。

构造函数不可以被声明成const。当我们创建类的一个const对象的时候,直到构造函数完成对象初始化的过程,对象才能真正拿到"常量"属性。也就是说构造函数可以在const对象构造地过程当中向其写值。

合成的默认构造函数

Sales_data total;		// 如何初始化?

类通过特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数(default constructor)。默认构造函数不需要任何实参。编译器会帮我们隐式定义一个默认构造函数。

编译器创建的构造函数称为合成的默认构造函数(synthesized default constructor)。大多数类的合成的默认构造函数按如下规则初始化类的数据成员:

  • 若存在类内的初始值,就用它来初始化成员
  • 否则,默认初始化该成员

Sales_dataunits_soldrevenue提供了初始值,所以合成的默认构造函数会用这些值来初始化对应成员,同时由于bookNostring类型,所以会默认初始化成空字符串。

某些类不能依赖于合成的默认构造函数

合成的默认构造函数只适用于简单的类。

于一个普通类而言,必须定义自己的默认构造函数,原因:

  1. 只有类没有声明任何构造函数,编译器才会自动生成默认构造函数。若我们定义了其他的构造函数,除非我们再定义一个默认的构造函数,否则类就没有默认构造函数。
  2. 于部分类而言,合成的默认构造函数可能执行错误操作。要是定义在块中的内置类型或者复合类型(如指针和数组)的对象被默认初始化,那么它们的值是未定义的。
    • 要是类里面有内置类型或者复合类型的成员,那么只有这些成员全被赋予了类内的初始值的时候,这个类使用合成的默认构造函数才合适。
  3. 有时编译器不能给某些类合成默认的构造函数。
    • 比如类中包含一个其他类类型的成员,而且该成员的类型没有默认构造函数,那么编译器没法初始化这个成员。所以就需要我们自定义默认构造函数。

定义Sales_data的构造函数

  • 一个istream&,从中读取一条交易信息
  • 一个const string&,表示ISBN编号
    • 一个unsigned,表示售出的图书数量
  • 一个const string&,表示ISBN编号,编译器将赋予其他成员默认值
  • 一个空参数列表,也就是默认构造函数,因为已经定义了其他构造函数,就还需要一个默认构造函数
struct Sales_data{          // 定义Sales_data类
    
    // 新增的默认构造函数
    Sales_data() = default;
    Sales_data(const std::string &s) : bookNo(s) { }
    Sales_data(const std::string &s, unsigned n, double p) : bookNo(s), units_sold(n), revenue(p*n) { }
    Sales_data(std::istream &);

    // 已有的成员
    std::string isbn() const { return bookNo; }
    Sales_data& combine(const Sales_data&);
    double avg_price() const;
    std::string bookNo;     // 生成数据成员bookNo string对象bookNo
    unsigned units_sold = 0;// 生成数据成员units_sold 初始化无符号int类型units_sold变量为0
    double revenue = 0.0;   // 生成数据成员revenue 初始化双精度浮点型变量revenue变量为0.0
};

=default的含义

Sales_data() = default;

因为该构造函数不接受任何实参,所以是一个默认构造函数。定义该构造函数的的目的只是我们既需要其他形式的构造函数,也需要默认的构造函数。我们希望该函数的作用与先前的合成默认构造函数相同。

C++11中,若我们需要默认的行为,可以在参数列表后写上= default来要求编译器生成构造函数。

= default可以和声明一起出现在类内部,也可以作为定义出现在类外部。

若出现在内部,则默认构造函数是内联的,若在外部,则默认不是内联的。

上述的默认构造函数之所以对Sales_data有效,因为我们给内置类型的数据成员提供了初始值。若编译器不支持类内初始值,那么默认构造函数就应该用构造函数初始值列表来初始化类的每个成员。

构造函数初始值列表

Sales_data(const std::string &s) : bookNo(s) { }

{}定义了空的函数体。把:{}间的代码称之为构造函数初始值列表(constructor initialize list),它负责为新创建的对象的一个或几个数据成员赋初始值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的(或者在花括号里的)成员初始值。

上述示例中的构造函数仅初始化bookNo,而unis_soldrevenue没有显式初始化。当有数据成员被构造函数初始值列表忽略的时候,它将以合成默认构造函数相同的方式隐式初始化。此例中,这样的成员使用类内初始值初始化,也就等价于如下构造函数:

Sales_data(const std::string &s) : 
		   bookNo(s), units_sold(0), revenue(0)		// 构造函数初始值列表
           { }

使用构造函数初始化所有数据成员示例

Sales_data(const std::string &s, unsigned n, double p) : 
		   bookNo(s), units_sold(n), revenue(p*n)		// 构造函数初始值列表
           { }

构造函数不应该轻易覆盖掉类内的初始值,除非新赋的值和原来值不一样。

若编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。

上述示例中的花括号都是空的,因为这些构造函数的唯一目的就是为数据成员赋初值,一旦没有其他任务需要执行,函数体也就空了。

在类的外部定义构造函数

istream为参数的构造函数需要执行一些实际的操作。在其函数体中调用了read函数以给数据成员赋以初值:

// 构造函数
Sales_data::Sales_data(std::istream &is)
{
    read(is, *this);        // read函数的作用是从is中读取一条交易信息然后存入this对象中
}

与成员函数一样,在类外部定义构造函数时候要指明该构造函数是哪个类的成员。

Sales_data::Sales_data含义是我们定义Sales_data类的成员,其名字是Sales_data。因为该成员的名字与类名相同,所以它是个构造函数。

没有出现在构造函数初始值列表里的成员会通过相应的类内初始值(如果有)初始化,或者执行默认初始化。对Sales_data来说,就是只要函数开始执行,那么bookNo会被初始化成空string对象,而units_soldrevenue将是0

完整代码:

#ifndef SALES_DATA_H        // 当预处理变量SALES_DATA_H没有定义时,就执行下面内容直到#endif
#define SALES_DATA_H        // 把SALES_DATA_H定义为预处理变量
#include <string>           // 告诉预处理器我们要使用string库
#include <istream>

struct Sales_data;


// Sales_data的非成员接口函数
Sales_data add(const Sales_data&, const Sales_data&);		// 声明接收俩常量Sales_data类型地引用
std::ostream &print(std::ostream&, const Sales_data&);		// 声明接收一个引用输出流对象和一个常量Sales_data类型地引用
std::istream &read(std::istream&, Sales_data&);				// 声明接收一个引用输入流对象和一个常量Sales_data类型地引用

struct Sales_data{          // 定义Sales_data类
    // 新增的默认构造函数
    Sales_data() = default;					// 使用编译器提供的构造函数
    Sales_data(const std::string &s, unsigned n, double p) : bookNo(s), units_sold(n), revenue(p*n) { }				// 接收一个常量引用字符串类型的值、一个无符号数字、一个双精度浮点数 会覆盖掉编译器提供的构造函数

    // 构造函数
    Sales_data(std::istream &is, std::ostream &os)		// 接受一个输入流引用对象和一个输出流引用对象
    {
        read(is, *this);								// 构造read,需要接收一个输入流和一个调用对象
        print(os, *this);								// 构造print的,接受一个输出流和一个调用对象
    }
    
    // 已有的成员
    std::string isbn() const { return bookNo; }			// 创建isbn方法 返回一个返回调用该函数的对象的bookNo属性
    Sales_data& combine(const Sales_data&);		// 声明combine方法需要接受一个常量引用Sales_data的值
    double avg_price() const;					// 声明avg_price方法
    std::string bookNo;     // 生成数据成员bookNo string对象bookNo
    unsigned units_sold = 0;// 生成数据成员units_sold 初始化无符号int类型units_sold变量为0
    double revenue = 0.0;   // 生成数据成员revenue 初始化双精度浮点型变量revenue变量为0.0
};

double Sales_data::avg_price() const{		// 创建Sales_data类的avg_price方法 将调用该方法的对象转成常量
    if (units_sold)							// 如果该调用该方法的(常量)对象的units_sold有值
        return revenue/units_sold;			// 返回平均值
    else
        return 0;
}

Sales_data& Sales_data::combine(const Sales_data &rhs)	// 引用调用该方法的Sales_data类型对象 该对象接收一个常量Sales_data类型对象的引用rhs
{
    units_sold += rhs.units_sold;       // 把rhs的成员units_sold加到调用该方法的对象的成员units_sold上
    revenue += rhs.revenue;				// 意义如上
    return *this;                       // 返回调用该函数的对象
}

// 输入的交易信息包括ISBN、售出总数和售出价格
std::istream &read(std::istream &is, Sales_data &item)
{
    double price = 0;
    is >> item.bookNo >> item.units_sold >> price;
    item.revenue = price * item.units_sold;
    return is;
}

std::ostream &print(std::ostream &os, const Sales_data &item)
{
    os << item.isbn() << " " << item.units_sold << " " << item.revenue << " " << item.avg_price();
    return os;
}

Sales_data add(const Sales_data &lhs, const Sales_data &rhs)		// 由于只是临时求和 所以不需要引用
{
    Sales_data sum = lhs;       // 把lhs的数据成员拷贝给sum
    sum.combine(rhs);           // 把rhs的内容添加到sum当中
    return sum;
}

#endif

拷贝、赋值和析构

除定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为。

某些类不能依赖于合成的版本

编译器能替我们合成拷贝、赋值、销毁的操作,但是需要知道的是,对于某些类来说合成的版本无法正常工作。

特别是类需要分配类对象之外的资源时候,合成的版本常常失效。

访问控制与封装

在C++中,使用访问说明符(access specifiers)加强类的封装性。

  • 定义在public说明符之后的成员在整个程序里面可以被访问public成员定义类的接口。
  • 定义在private说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的==代码==访问private封装了类的实现细节。

重新定义Sales_data类:

class Sales_data {

    public:         // 访问说明符 构造函数和部分成员函数 
        Sales_data() = default;	
        Sales_data(const std::string &s, unsigned n, double p) :
                bookNo(s), units_sold(n), revenue(p*n) { }
        Sales_data(const std::string &s) : bookNo(s) { }
        Sales_data(std::istream&);
        std::string isbn() const { return bookNo; }
        Sales_data &combine(const Sales_data&);
    
    private:        // 访问说明符 具体实现部分的函数
        double avg_price() const 
            { return units_sold ? revenue/units_sold : 0; }
        std::string bookNo;
        unsigned units_sold = 0;
        double revenue = 0;

};		// 注意分号

每个类都可以有访问说明符,每个访问说明符出现几次也没有限定。每个访问说明符制定了接下来的成员的访问级别,有效范围直到出现下一个访问说明符或者到达类的结尾处为止。

使用class或者struct关键字

structclass的唯一区别是默认访问权限不一样。

类可以在它的第一个访问说明符前定义成员,对这种成员的访问权限依赖于类定义的方式。如果使用struct,则定义在第一个说明符前的成员是public的;如果用class,那么这些成员是private的。

为了统一编程风格,当我们希望定义的类的所有成员是public时,使用struct;如果希望成员是private的,就用class

友元

因为Sales_data的数据成员是被封装(private)过的,导致readprintadd函数无法访问它们,所以就无法正常编译。

类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元(friend)。只需增加一条以friend关键字开始的函数声明语句就行:

class Sales_data {

    // 为Sales_data的非成员函数所做的友元声明
    friend Sales_data add(const Sales_data&, const Sales_data&);
    friend std::istream &read(std::istream&, Sales_data&);
    friend std::ostream &print(std::ostream&, const Sales_data&);

    public:
        Sales_data() = default;
        Sales_data(const std::string &s, unsigned n, double p) :
                bookNo(s), units_sold(n), revenue(p*n) { }
        Sales_data(const std::string &s) : bookNo(s) { }
        Sales_data(std::istream&);
        std::string isbn() const { return bookNo; }
        Sales_data &combine(const Sales_data&);
    
    private:
        double avg_price() const 
            { return units_sold ? revenue/units_sold : 0; }
        std::string bookNo;
        unsigned units_sold = 0;
        double revenue = 0;

};

// Sales_data接口的非成员组成部分的声明
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);

友元声明只能出现在类定义的内部,但是类内出现的具体位置不限。友元不是类的成员也不受所在的区域访问控制级别的约束。但是最好在类定义开始或者结束前的位置集中声明友元。

封装的益处

  • 确保用户代码不会无意间破坏封装对象的状态。
  • 被封装的类的具体实现细节可以随时改变,而不需要调整用户级别的代码。

友元的声明

友元的声明只指定了访问的权限,而不是一个通常意义上的函数声明。要是我们想让类的用户可以调用某个友元函数,那我们就得在友元声明之外再专门对函数进行一次声明。

通常把友元的声明和类本身放在同一个头文件里面,类的外面。所以Sales_data头文件应该给readprintadd提供独立的声明(除了类里面的友元声明之外)。

许多编译器没有强制友元函数必须在使用之前在类的外部声明。

类的其他特性

类成员再探

定义一堆相互关联的类:ScreenWindow_mgr

定义一个类型成员

Screen表示显示器的一个窗口。

class Screen {
    public:
        typedef std::string::size_type pos;
    private:
        pos cursor = 0;
        pos height = 0, width = 0;
        std::string contents;
};

用来定义类型的成员必须先定义之后才能用。

Screen类的成员函数

添加构造函数使用户能够定义屏幕的尺寸和内容,以及其他俩成员负责移动光标和读取给定位置的字符:

class Screen {
    public:
        typedef std::string::size_type pos;
        Screen() = default;     // 因为Screen有一个构造函数 所以该函数是必须的

        // cursor被其类内初始值初始化为0
        Screen(pos ht, pos wd, char c) : height(ht), width(wd), contents(ht * wd, c) { }
        char get() const        // 将调用该函数的对象(*this)转成常量 读取该对象光标处的字符
            { return contents[cursor]; }            // 隐式内联
        inline char get(pos ht, pos wd) const;      // 显式内联 属于函数重载 用于接收参数时候的获取 构造
        Screen &move(pos r, pos c);                 // 能在之后被设为内联 构造
        
    private:
        pos cursor = 0;
        pos height = 0, width = 0;
        std::string contents;
};

令成员作为内联函数

在类中,常有一些规模较小的函数适合被声明成内联函数。定义在类内部的成员函数是自动内联的。

可以在类的内部把inline作为声明的一部分显式地声明成员函数,也能在类的外部用inline关键字修饰函数的定义

inline          // 可以在函数的定义处指定inline 对应类里的Screen &move
Screen &Screen::move(pos r, pos c)		// 引用调用对象
{
    pos row = r * width;                // 计算行的位置
    cursor = row + c;                   // 在行内将光标移动到指定的位置
    return *this;                       // 以左值的形式返回对象 由于是引用 也就相当于返回当前对象了
}

char Screen::get(pos r, pos c) const    // 在类的内部声明成inline 由于获取不用对象本身 但是对象不能被修改 所以虽然没有引用 但是有const将对象转成常量
{
    pos row = r * width;                // 计算行的位置
    return contents[row + c];           // 返回给定列的字符
}

最好只在类外部定义的地方说明inline,可以使类更容易理解。

重载成员函数

只要函数间在参数的数量或类型上有区别,成员函数也可以被重载。

Screen myscreen;
char ch = myscreen.get();			// 调用Screen::get()
ch = myscreen.get(0,0);				// 调用Screen::get(pos, pos)

可变数据成员

可变数据成员(mutable data member)永远不会是const,即便它是const对象的成员。因此,一个const成员函数可以改变成一个可变成员的值。只需在数据成员的最前面加上一个mutable就能使得该成员变成一个可变数据成员。

Screen添加一个名为access_ctr的可变成员,用它来追踪每个Screen的成员函数被调用了多少次:

class Screen {
    public:
		...
        void some_member() const;

    private:
        ...
        mutable size_t access_ctr;		// 即使在一个const对象内也能被修改

};

void Screen::some_member() const	// 指向调用该方法的对象会被转成常量
{
    ++access_ctr;					// 保存一个计数值,用于记录成员函数被调用的次数
}

虽然some_member是个const成员函数,它还是可以改变access_ctr的值。该成员是个可变成员,因此任何成员函数,包括const函数在内都可以改变它的值。

类数据成员的初始值

定义一个Screen类型的vector,该vector命名为Window_mgr类,该类开始时会带有一个默认初始化的Screen。

class Window_mgr{
    private:
        // 这个window_mgr追踪的Screen
        // 这个默认情况下,一个window_mgr包含一个标准尺寸空白的Screen
        std::vector<Screen> screens{Screen(24, 80, ' ')};	// 使用花括号初始化
};

类内初始值必须用=或者{}初始化。

返回*this的成员函数

class Screen {
    public:
		...
    	Screen &set(char);
        Screen &set(pos, pos, char);

    private:
        ...

};

inline Screen &Screen::set(char c)			// 引用调用该方法的对象
{
    contents[cursor] = c;       // 设置当前光标所在位置的新值
    return *this;               // 将调用该方法的对象返回
}

inline Screen &Screen::set(pos r, pos col, char ch)			// 引用调用该方法的对象
{
    contents[r*width + col] = ch;   // 设置给定位置的新值
    return *this;                   // 将this对象作为左值返回
}

示例使用:

移动myScreen内的光标,设置myScreen的contents成员

// 将光标移动到一个指定的位置 然后设置该位置的字符值
myScreen.move(4, 0).set('#');		// move和set的返回值类型需要是引用,否则将会导致赋值操作 而不会改变myScreen的contents

从const成员函数返回*this

再添加一个display操作负责打印Screen的内容。

从逻辑上而言,显示一个Screen并不需要改变它的内容,因此我们让display为一个const成员,此时,this是个指向const的指针,而*this是const对象。所以可以推断display的返回类型是const Screen&。但是如果真把display返回一个const引用,那就不能把display嵌入到一组动作的序列里:

Screen myScreen;
// 若display返回常量引用 则调用set会引发错误
myScreen.display(cout).set('*');

即使myScreen是个非常量对象,对set的调用也无法通过编译。问题在于display的const版本返回的是常量引用,而我们无权设置一个常量对象。

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

基于const的重载

通过区分成员函数是否是const的,可以对其进行重载。

定义一个名为do_display的私有成员,由其负责打印Screen的实际工作。所有的display操作都会调用这个函数,然后返回执行操作的对象:

class Screen {
    public:
        ...
        // 根据对象是否是const重载了display函数
        Screen &display(std::ostream &os){
            do_display(os);
            return *this;
        }
        const Screen &display(std::ostream &os) const{
            do_display(os);
            return *this;
        }

    private:
        ...
        // 该函数负责显式Screen的内容 调用该方法的对象会被转成常量
        void do_display(std::ostream &os) const{
            os << contents;		// 输出该对象的contents属性
        }
};

当某个对象调用display时候,这个对象是不是const决定了应该调用display的哪个版本:

#include <iostream>
#include "Screen.h"
#include <string>
using namespace std;

int main(){
    // Screen(24,80,' ');
    Screen myscreen(5,5,' ');
    const Screen blank(5,3,' ');
    
    myscreen.set('#').display(cout);        // 调用非常量版本
    blank.display(cout);                    // 调用常量版本
    
    system("pause");
    return 0;
}
对于公共代码使用私有功能函数

有人可能会疑惑为啥要整个单独的do_display函数?毕竟对do_display的调用不比do_display函数内部所做的操作简单多少。原因如下:

  • 一个基本的愿望是避免在多处使用同样的代码
  • 预期随着类的规模发展,display可能变得更加负责,此时,把相应的操作写在一起而不是俩个地方的作用就比较明显了。
  • 很可能在开发过程中给do_display函数加些调试信息,而这些信息会在代码的最终产品版本去掉。这时候在一个地方一次增加或者删除要更容易些。
  • 这个额外的函数调用不会增加任何开销。因为类的内部定义了do_display,所以它隐式地被声明成内联函数。这样的话,调用do_display就不会带来任何额外的运行时开销。

类类型

两个类的内容即使一样,他们也是不同的类型。不能将一个类的值赋给另一个类。

可以把类名作为类型的名字使用,从而直接指向类类型。也可以把类名跟在关键字classstruct后面:

Sales_data item1;				// 声明Sales_data类型对象item1
class Sales_data item1;			// 与上面一样 该方式从C语言继承而来
struct Sales_data item1;		// 与第一条一样

类的声明

亦可以只声明类而不定义。

class Screen;

这种声明有时叫前向声明(forward declaration),其向程序引入名字Screen并指明Screen是种类型。

Screen而言,声明后到定义前的过程是不完全类型(incomplete type)。就是知道这是个类型,但是不知道会包含哪些成员的意思。

不完全类型可以用的场景:

  • 可以定义指向这种类型的指针或引用
  • 可以声明但是不定义以不完全类型作为参数或者返回类型的函数

类在创建其对象前必须被定义,不然编译器不能知道这样的对象需要多少存储空间,用户才能引用或者指针访问其成员。所以类的成员的类型不能是该类自己。但是,只要出现了类的名字,那这个类就是被声明了。所以类允许包含指向它自身类型的引用或指针:

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

友元再探

类还可以把其他类定义成友元,也可以把其他类(已经定义过的)的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的。

类之间的友元关系

例如Window_mgr类添加一个clear成员,负责把一个指定的Screen的内容都设成空白。这个clear需要访问Screen的私有成员;要想让这种访问合法,Screen需要把Window_mgr指定成它的友元:

class Screen{
	// Window_mgr的成员可以访问Screen类的私有部分
	friend class Window_mgr;
	...
}

若一个类指定了友元类,那么友元类的成员函数就可以访问这个类的全部成员。也就是说通过上述代码,因此我们可以把Window_mgrclear成员写成如下形式:

class Window_mgr{
    public:
        // 窗口中每个屏幕的编号
        using ScreenIndex = std::vector<Screen>::size_type;
        // 按照编号将指定的Screen重置为空白
        void clear(ScreenIndex);
    private:
        // 这个window_mgr追踪的Screen
        // 这个默认情况下,一个window_mgr包含一个标准尺寸空白的Screen
        std::vector<Screen> screens{Screen(24, 80, ' ')};
}; 

void Window_mgr::clear(ScreenIndex i){
    // s是个Screen的引用,指向我们想清空的哪个屏幕
    Screen &s = screens[i];
    // 将那个选定的Screen重置为空白
    s.contents = std::string(s.height * s.width, ' ');
};

如果clear不是Screen的友元,那么上面的代码就无法通过编译。因为如果不是友元,那么s.heights.width这种调用Screen类的非公有成员。

注意:友元关系不存在传递性。要是Window_mgr有它自己的友元,但这些友元并不能具有访问Screen非公有成员的权限。也就是每个类负责控制自己的友元类或友元函数。

源码示例:

#ifndef SCREEN_H
#define SCREEN_H

#include<iostream>
#include<string>
#include<vector>

class Screen {
    friend class Window_mgr;
    public:
        typedef std::string::size_type pos;
        
        Screen() = default;     // 因为Screen有一个构造函数 所以该函数是必须的
        // cursor被其类内初始值初始化为0
        Screen(pos ht, pos wd, char c) : height(ht), width(wd), contents(ht * wd, c) { }

        char get() const { return contents[cursor];}        // 隐式内联读取光标处的字符
        inline char get(pos ht, pos wd) const;              // 显式内联
        Screen &move(pos r, pos c);                         // 能在之后被设为内联
        void some_member() const;
        Screen &set(char);
        Screen &set(pos, pos, char);
        // 根据对象是否是const重载了display函数
        Screen &display(std::ostream &os){
            do_display(os);
            return *this;
        }
        const Screen &display(std::ostream &os) const{
            do_display(os);
            return *this;
        }

    private:
        pos cursor = 0;
        pos height = 0, width = 0;
        std::string contents;
        mutable size_t access_ctr;
        // 该函数负责显式Screen的内容
        void do_display(std::ostream &os) const{
            os << contents;
        }
};

inline Screen &Screen::set(char c)
{
    contents[cursor] = c;       // 设置当前光标所在位置的新值
    return *this;               // 将this对象作为左值返回
}

inline Screen &Screen::set(pos r, pos col, char ch)
{
    contents[r*width + col] = ch;   // 设置给定位置的新值
    return *this;                   // 将this对象作为左值返回
}

inline          // 可以在函数的定义处指定inline
Screen &Screen::move(pos r, pos c)
{
    pos row = r * width;                // 计算行的位置
    cursor = row + c;                   // 在行内将光标移动到指定的列
    return *this;                       // 以左值的形式返回对象
}
char Screen::get(pos r, pos c) const    // 在类的内部声明成inline
{
    pos row = r * width;                // 计算行的位置
    return contents[row + c];           // 返回给定列的字符
}

void Screen::some_member() const
{
    ++access_ctr;
}

class Window_mgr{
    public:
        // 窗口中每个屏幕的编号
        using ScreenIndex = std::vector<Screen>::size_type;
        // 按照编号将指定的Screen重置为空白
        void clear(ScreenIndex);
    private:
        // 这个window_mgr追踪的Screen
        // 这个默认情况下,一个window_mgr包含一个标准尺寸空白的Screen
        std::vector<Screen> screens{Screen(24, 80, ' ')};
}; 

void Window_mgr::clear(ScreenIndex i){
    // s是个Screen的引用,指向我们想清空的哪个屏幕
    Screen &s = screens[i];
    // 将那个选定的Screen重置为空白
    s.contents = std::string(s.height * s.width, ' ');
};

#endif

令成员函数作为友元

除了让整个Window_mgr作为友元,其实Screen也可以只为clear提供访问权限,但是就需要明确指出该成员函数属于哪个类。

class Screen{
    // Window_mgr::clear必须在Screen类之前被声明
    friend void Window_mgr::clear(ScreenIndex);
    ...
}

由于程序的结构中Window_mgrScreen晚声明定义,且未来可能不止clear需要访问Screen非公有成员,所以不用改源码。

要想让某个成员函数作为友元,必须组织好程序结构以满足声明和定义地彼此依赖关系:

  • 首先定义Window_mgr类,其中声明clear函数,但是不能定义它。在clear使用Screen的成员之前必须先声明Screen
  • 定义Screen,包括对clear的友元声明
  • 最后定义clear,此时它才可以使用Screen的成员

函数重载和友元

如果想把一组重载函数声明成它的友元,他需要对这组函数里的每个重载函数分别声明:

// 声明重载的storeOn函数
extern std::ostream& storeOn(std::ostream &, Screen &);
extern BitMap& storeOn(BitMap &, Screen &);

class Screen{
    // storeOn的ostream版本可以访问Screen对象的私有部分
    friend std::ostream& storeOn(std::ostream &, Screen &);
    ...
}

Screen允许ostream&storeOn函数声明成它的友元,但是接收BitMap&作为参数的版本还是不能访问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的声明在作用域里面

当一个名字第一次出现在一个友元声明里面,我们隐式地假定这个名字在当前作用域是可见的。但是,友元本身不一定真的声明在当前作用域里。

就算在类里面定义该函数,我们也必须要在类外面提供相应地声明从而使得函数可见。

就是如果我们只用声明友元的类的成员调用该友元函数,它也必须是被声明过的。

如上示例,第8行的g函数调用友元函数f(),但是此时f友元函数还没有被声明过,所以第8行是错误的。而第9行声明了友元函数f()。所以到了第10行的h函数调用友元f(),就正确了。

此处需要理解的是友元声明的作用是影响访问权限,它本身并不是普通意义上的声明。

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

练习

定义自己的ScreenWindow_mgr,其中clearWindow_mgr的成员,是Screen的友元。

#ifndef SCREEN_H
#define SCREEN_H

#include<iostream>
#include<string>
#include<vector>

class Screen;           // 由于要先有个能容纳Screen类型的容器对象 所以先声明Screen类型

class Window_mgr{
    public:
        using ScreenIndex = std::vector<Screen>::size_type;
        void clear(ScreenIndex);        // 声明clear成员函数
    private:
        std::vector<Screen> screens;    // 创建一个容器screens 容器内装Screen类型的对象
}; 

class Screen {
    friend void Window_mgr::clear(Window_mgr::ScreenIndex);     // 声明友元函数 Window_mgr里已经事先声明了clear
    public:
        typedef std::string::size_type pos;
        
        Screen() = default;
        Screen(pos ht, pos wd, char c) : height(ht), width(wd), contents(ht * wd, c) { }

        char get() const { return contents[cursor];}
        inline char get(pos ht, pos wd) const;
        Screen &move(pos r, pos c);
        void some_member() const;
        Screen &set(char);
        Screen &set(pos, pos, char);
        Screen &display(std::ostream &os){
            do_display(os);
            return *this;
        }
        const Screen &display(std::ostream &os) const{
            do_display(os);
            return *this;
        }

    private:
        pos cursor = 0;
        pos height = 0, width = 0;
        std::string contents;
        mutable size_t access_ctr;
        void do_display(std::ostream &os) const{
            os << contents;
        }
};

void Window_mgr::clear(ScreenIndex i){      // Window_mgr成员函数clear接收一个ScreenIndex参数
    // s是个Screen的引用,指向我们想清空的哪个屏幕
    Screen &s = screens[i];
    // 将那个选定的Screen重置为空白
    s.contents = std::string(s.height * s.width, ' ');
};

inline Screen &Screen::set(char c)
{
    contents[cursor] = c;       // 设置当前光标所在位置的新值
    return *this;               // 将this对象作为左值返回
}

inline Screen &Screen::set(pos r, pos col, char ch)
{
    contents[r*width + col] = ch;   // 设置给定位置的新值
    return *this;                   // 将this对象作为左值返回
}

inline          // 可以在函数的定义处指定inline
Screen &Screen::move(pos r, pos c)
{
    pos row = r * width;                // 计算行的位置
    cursor = row + c;                   // 在行内将光标移动到指定的列
    return *this;                       // 以左值的形式返回对象
}
char Screen::get(pos r, pos c) const    // 在类的内部声明成inline
{
    pos row = r * width;                // 计算行的位置
    return contents[row + c];           // 返回给定列的字符
}

void Screen::some_member() const
{
    ++access_ctr;
}

#endif

类的作用域

每个类都有自己的作用域。作用域外普通的数据和函数成员只能由对象、引用、指针使用成员访问运算符->来访问。

对于类类型成员就要用作用域运算符::来访问。

Screen::pos ht = 24, wd = 80;		// 使用 Screen定义的pos类型
Screen scr(ht, wd , ' ');
Screen* p = &scr;
char c = scr.get();					// 访问scr对象的get成员
c = p->get();						// 访问p所指对象的get成员

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

void Window_mgr::clear(ScreenIndex i){      
    Screen &s = screens[i];
    s.contents = std::string(s.height * s.width, ' ');
};

因为编译器在处理参数列表之前已经明确了正处在Window_mgr类的作用域中,所以不必再专门说明ScreenIndexWindow_mgr类定义的。编译器也知道函数体中的screens是在Window_mgr类中定义的。

当成员函数定义在类的外部时,返回类型里用的名字都属于类的作用域外。这时的返回类型就必须要指明它是哪个类的成员。

示例向Window_mgr类添加一个新的名为addScreen函数,负责向显示器新增一个新的屏幕。该成员返回类型是ScreenIndex,用户可以通过它定位到指定的Screen

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;
}

因为该类型出现在类名之前,所示其实它是位于Window_mgr类的作用域之外的。这种情况下,要想用ScreenIndex作为返回类型,必须明确指定哪个类定义了它。

名字查找与类的作用域

名字查找(name lookup):

  1. 在名字所在块中寻找其声明语句,只考虑在名字的使用之前出现的声明
  2. 如果没找到,继续查找外层作用域
  3. 若最终没有找到匹配的声明,则程序报错

类的定义:

  1. 编译成员的声明
  2. 直到类全部可见之后编译函数体

编译器处理完类的全部声明之后才会处理成员函数的含义。

类型名要特殊处理

类的成员不能重新定义外层作用域的名字

typedef double Money;
class Acoount{
public:
	Money balance(){ return bal; }	// 使用外层作用域的Money
private:
    typedef double Money;		// 错误 不能重定义Money 即使重定义后与外层一致
    Money bal;
}

一些编译器会允许通过。但这是错误的行为。

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

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

  • 在成员函数里查找该名字的声明。只有在函数使用前出现的声明才被考虑。
  • 如果在成员函数里没找到,那就在类里继续找,这时类的所有成员都可以被考虑。
  • 要是类里也没找到这个名字的声明,那么在成员函数定义前的作用域里继续找。
// 此段代码仅作说明 实则是段乐色代码
int height;		// 定义了一个名字 后面会在Screen里用
class Screen{
public:
    typedef std::string::size_type pos;
    void dummy_fcn(pos height){
        cursor = width * height;		// 到底是哪个height?
    }
private:
    pos cursor = 0;
    pos height = 0, width = 0;
}

第7行的height实际上是其用于接收的形参,与类本身和外层作用域的height都么的关系。

如果想绕开上面的查找规则,应该将其代码变为:

// 不建议的写法:成员函数里的名字不应该隐藏同名的成员
void Screen::dummy_fcn(pos height)
{
    cursor = width * this->height;		// 对象的height属性
    // 与上面效果相同
    cursor = width * Screen::height;
}

虽然类的成员被封装了,但我们还是可以通过加上类名或者显式地使用this指针来强制访问成员。

最好别用其他成员的名字作为某个成员函数的参数。

最好的写法是给我们要用的height成员起个其他名字:

void Screen::dummy_fcn(pos ht){
	cursor = width * height;		// 调用成员height
}

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

上例中的外层height被成员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);
}

全局函数verify的声明在Screen类的定义前是不可见的。但是名字查找的第三步包括了成员函数出现之前的全局作用域。在此例中,verify的声明位于setHeight的定义之前,所以可以被正常使用。

构造函数再探

构造函数初始值列表

如果没有在构造函数的初始值列表里显式地初始化成员,那么这个成员在构造函数体之前执行默认初始化。

// Sales_data 构造函数的另一种写法 虽然合法但比较草率 没有使用构造函数初始值
Sales_data::Sales_data(const string &s, unsigned cnt, double price)
{
    bookNo = s;
    units_sold = cnt;
    revenue = cnt * price;
}

之前的写法:

struct Sales_data{          // 定义Sales_data类
    
    // 新增的默认构造函数
    Sales_data() = default;
    Sales_data(const std::string &s) : bookNo(s) { }
    Sales_data(const std::string &s, unsigned n, double p) : bookNo(s), units_sold(n), revenue(p*n) { }
    Sales_data(std::istream &);

    // 已有的成员
    std::string isbn() const { return bookNo; }
    Sales_data& combine(const Sales_data&);
    double avg_price() const;
    std::string bookNo;     // 生成数据成员bookNo string对象bookNo
    unsigned units_sold = 0;// 生成数据成员units_sold 初始化无符号int类型units_sold变量为0
    double revenue = 0.0;   // 生成数据成员revenue 初始化双精度浮点型变量revenue变量为0.0
};

上述两种方式的效果相同。区别只有下面的版本初始化了它的数据成员,而上面的版本是对数据成员执行了赋值操作。该区别所带来的影响依赖于数据成员的类型。

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

若成员是const或引用的化,就必须要将其初始化。

当成员属于某种类类型且该类型没有定义默认构造函数的时候,也必须将这个成员初始化:

class ConstRef{
public:
    ConstRef(int ii);
private:
    int i;
    const int ci;
    int &ri;
}

和其他常量对象或者引用一样,成员ciri都必须要初始化,如果没有为它们提供构造函数初始值就会引发错误:

// 错误 ci和ri必须初始化
ConstRef::ConstRef(int ii)
{
    // 赋值
    i = ii;		// 正确
    ci = ii;	// 错误 不能给const赋值
    ri = i;		// 错误 ri没被初始化
}

随着构造函数已开始执行,初始化就完成了。初始化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) { }
};

从构造函数初始值的形式上看是先用val初始化了j,然后再用j初始化i。实际上i先被初始化,因此初始值的效果是试图用未定义的值j初始化i。原因是类里的ij先定义。

有的编译器会弹出警告。

最好是让构造函数初始值的顺序和成员声明的顺序保持一致。而且也应该尽量避免用某些成员初始化其它成员。

如果可以,最好是用构造函数的参数作为成员的初始值,而避免用同一个对象的其他成员。好处是可以不考虑成员的初始化顺序。比如,X的构造函数如果写成如下的形式效果会更好:

X(int val): i(val), j(val) { }
// 此版本中i和j的初始化顺序就没影响了

默认实参和构造函数

Sales_data的默认构造函数bookNo指定一个默认实参

class Sales_data{
public:
    // 定义默认构造函数 让其和 只接受一个string实参的构造函数功能相同
    Sales_data(std::string s = ""): bookNo(s){ }
    ...
   	
    Sales_data(std::string s, unsigned cnt, double rev):
    	bookNo(s), units_sold(cnt), revenue(rev*cnt){ }
 
    Sales_data(std::istream &is) { read(is, *this); }
    ...
}

原先版本:

Sales_data(const std::string &s, unsigned n, double p) : 
		   bookNo(s), units_sold(n), revenue(p*n)		// 构造函数初始值列表
           { }

如果没有给定实参,或者给定了一个string实参时候,两个版本的类创建了相同的对象。

如果一个构造函数给所有参数都提供了默认实参,那么也就是定义了默认构造函数。

但我们其实不该给Sales_data接收三个实参的构造函数提供默认值。因为大多的书价格都不相同,书名也和书的价格和书的数量没有关系,更和默认值没有关系。所以其实要根据具体的类的含义,在合适的时候给定默认实参。

委托构造函数

C++11扩展了构造函数初始值的功能,使得可以定义委托构造函数(delegating constructor)。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的职责委托给了其他构造函数。

使用委托构造函数重写Sales_data类:

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); }
    // 余下的跟原版本一样
};

委托构造函数也有一个成员初始值列表和一个函数体。在委托构造函数中,成员初始值列表的唯一的入口就是类名本身。类名后跟圆括号,圆括号里是参数列表,参数列表必须和类里另一个构造函数匹配。

Sales_data(): Sales_data("", 0, 0) { }定义默认构造函数令其使用三参数的构造函数完成初始化过程。

接受一个string对象的构造函数Sales_data(std::string s): Sales_data(s, 0, 0) { }同样委托给了三参数版本。

接受istream&的构造函数也是委托构造函数,委托给了默认构造函数,默认构造函数又接着委托给三参数构造函数。当这些受到委托的构造函数执行完成后,接着执行istream&构造函数体的内容:调用read函数读取给定的istream

当一个构造函数委托给了另一个构造函数的时候,受委托的构造函数的初始值列表和函数体被依次执行。在Sales_data里,受委托的构造函数体是空的,如果有代码的话,就会先执行那些代码,然后控制权交还给委托者的函数体。

默认构造函数的作用

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

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

  • 在块作用域里不使用任何初始值定义一个非静态变量或者数组时。
  • 当一个类本身含有类类型的成员且使用合成的默认构造函数时。
  • 当类类型的成员没有在构造函数初始值列表中显式地初始化时。

值初始化在以下情况下发生:

  • 在数组初始化的过程里如果我们提供的初始值数量少于数组的大小时。
  • 不使用初始值定义一个局部静态变量时。
    • 如果局部静态变量没有显式的初始值,会执行值初始化,内置类型的局部静态变量初始化为0。
    • 局部静态变量指在程序执行路径第一次经过对象定义语句时初始化,直到程序终止才被销毁。参考 函数——局部静态对象
  • 通过书写形如T()的表达式显式地请求值初始化时,其中T是类型名。(vector的一个构造函数只接受一个实参用于说明vector大小,它就是使用一个这种形式的实参来对它的元素初始化进行值初始化。)

类必须要有一个默认构造函数以便在上述情况下使用。

不那么明显的一种情况是类的某些数据成员缺少默认构造函数:

class NoDefault{
public:
    NoDefault(const std:string&);
    // 还有其他成员,但是没有其他构造函数了
}

struct A{
    NoDefault my_mem;		// 默认my_mem是public的
}

A a;	// 错误 不能为A合成构造函数

struct B{
    B() { }		// 错误 b_member没有初始值
    NoDefault b_member;
}

在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数。

使用默认构造函数

下面的obj的声明可以正常编译通过:

Sales_data obj();						// 正确 定义了一个函数而非对象
if(obj.isbn() == Primer_5th_ed.isbn())	// 错误 obj是个函数

但是想用obj时候会编译器报错,提示我们不能对函数使用成员访问运算符。

问题在于,尽管我们想声明一个默认初始化的对象,obj实际的含义却是个不接受任何参数的函数并且其返回值是Sales_data类型的对象。

要是想定义一个使用默认构造函数进行初始化的对象,正确的方法是去掉对象名之后的空的括号:

Sales_data obj;

隐式的类类型转换

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

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

string null_book = "9-999-99999-9";
// 会构造一个临时的Sales_data对象, 它的参数是null_book的值
// 该对象的units_sold和revenue等于0,bookNo等于null_book
item.combine(null_book);

只允许一步类类型转换

编译器只会自动执行一步类型转换。

// 错误示例 转了两步
// 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"));
item.combine(Sales_data("9-999-99999-9"));

类类型转换不是总有效

// 使用istream构造函数创建一个函数传递给combine
item.combine(cin);

上述隐式地将cin转成Sales_data,该转换执行了接受一个istreamSales_data构造函数。该构造函数通过读取标准输入创建了一个临时的Sales_data对象,随后把得到的对象传递给combine

Sales_data对象是个临时量,一旦得到combine完成,我们就不能再访问它了。

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

在要求隐式转换的程序上下文中,可以通过构造函数声明为explicit加以阻止:

class Sales_data{
public:
    Sales_data() = default;
    Sales_data(const std::string &s, unsigned n, double p):
    	bookNo(s), units_sold(n), revenue(p*n) { }
    explicit Sales_data(const std::string &s): bookNo(s) { }
    explicit Sales_data(std::istream&);
    ...
}

现在就没有构造函数能用于隐式地创建Sales_data对象:

// 下面两种写法都无法通过编译
item.combine(null_book);		// 错误 string的构造函数是explicit
item.combine(cin);				// 错误 istream的构造函数是explicit

explicit只对一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换。

只能在类里声明构造函数时使用explicit,在类外部定义时不该重复:

// 错误 explicit只允许在出现在类内的构造函数声明处
explicit Sales_data::Sales_data(istream& is){
    read(is, *this);
}

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

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

Sales_data item1(null_book);		// 正确 直接初始化

// 错误 不能将explicit构造函数用于拷贝形式的初始化过程
Sales_data item2 = null_book;

当使用explicit关键字声明构造函数时,它将只能以直接初始化的形式使用。而且,编译器不会在直接转换过程里使用该构造函数。

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

尽管编译器不会把explicit的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换:

// 正确 实参是个显式构造的Salse_data对象
item.combine(Sales_data(null_book));

// 正确 static_cast可以使用explicit的构造函数
item.combine(static_cast<Sales_data>(cin));

在第一个调用里,直接使用Sales_data的构造函数,该调用通过接受string的构造函数创建了一个临时的Sales_data对象。在第二个调用里,我们使用static_cast执行了显式的而非隐式的转换。其中,static_cast使用istream构造函数创建了一个临时的Sales_data对象。

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

之前也有用过一些标准库里的类含有单参数的构造函数:

  • 接受一个单参数的const char*string构造函数不是explicit的。
  • 接受一个容量参数的vector构造函数是explicit

聚合类

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

  • 所有成员都是public
  • 没有定义任何构造函数
  • 没有类内初始值
  • 没有基类,也没有virtual函数

聚合类示例:

struct Data{
    int ival;
    string s;
}

可以提供一个花括号括起来的成员初始化列表,并用它初始化聚合类成员:

// val1.ival = 0; val1.s = string("Anna");
Data val1 = {0, "Anna"};

初始化的顺序必须和声明的顺序一样:

// 错误示例
Data val2 = {"Anna", 1024};

与初始化数组元素的规则一样,若初始值列表元素个数少于类的成员数量,则靠后的成员被值初始化。但如超过类的成员数量也不行。

显式地初始化类的对象的成员的缺点:

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

字面值常量类

前面提过constexpr函数的参数和返回值必须是字面值类型。除算术类型、引用和指针外,某些类也是字面值类型。和其他类不同,字面值类型的类可能含有constexpr函数成员。这样的成员必须符合constexpr函数的所有要求,它们是隐式const的。

  • 数据成员都是字面值类型。
  • 聚合类是字面值常量类。

如果一个类不是聚合类,但它只要符合以下要求,就也能算是字面值常量类:

  • 数据成员都必须是字面值类型
  • 类必须至少有一个constexpr构造函数
  • 如果一个数据成员含有类内初始值,那么内置类型成员的初始值必须是一条常量表达式;或者从属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
  • 类必须使用析构函数的默认定义,该成员负责销毁类的对象

关于析构函数的内容会在第十三章学到

constexpr构造函数

constexpr构造函数可以声明成=default的形式。否则,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) { hw = b; }
private:
    bool hw;			// 硬件错误 而非IO错误
    bool io;			// IO错误
    bool other;			// 其他错误
}

constexpr构造函数必须执行以下其中一项:

  1. 初始化所有数据成员或初始值
  2. 使用constexpr构造函数
  3. 一条常量表达式

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

constexpr Debug io_sub(false, true, false);		// 调试IO
if (io_sub.any)									// if(true)
    cerr << "print appropriate errormessages" << endl;

constexpr Debug prod(false);					// 无调试
if (prod.any())									// if(false)
    cerr << "print an error message" << endl;

类的静态成员

有时类需要它的一些成员和类本身直接相关,而不是与类的各个对象保持关联。

下面示例是个银行账户类可能需要一个数据成员来表示当前的基准利率。此例中,我们希望利率与类关联,而非与类的每个对象关联。从实现效率来看,没必要每个对象都存储利率信息。且一旦利率浮动,我们希望所有的对象都能使用新值。

声明静态成员

在成员的声明前加上关键字static使得其与类关联在一起。

和其他成员一样,静态成员可以是publicprivate。静态数据成员的类型可以是常量、引用、指针、类类型等。

定义一个类,用来表示银行的账户记录:

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();
};

类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。因此,每个Account对象将包含两个数据成员:owneramount。只存在一个interestRate对象且它被所有Account对象共享。

类似的,静态成员函数也不和任何对象绑定在一起,它们不包含this指针。作为结果,静态成员函数不能被声明成const,我们也不能在static函数体里使用this指针。这一限制既适用于this的显式使用,也对调用非静态成员的隐式使用有关。

使用类的静态成员

使用作用域运算符直接访问静态成员:

double r;
r = Account::rate();

虽然静态成员不属于类的某个对象,但也可以使用类的对象、引用或者指针来访问静态成员

Account ac1;
Account *ac2 = &ac1;
// 调用静态成员函数rate的等价方式
r = ac.rate();				// 通过Account的对象或引用
r = ac2->rate();			// 通过指向Account对象的指针

成员函数不需要通过作用域运算符就能直接使用静态成员:

class Account {
public:
    void calculate() { amount += amount * interestRate; }
private:
    static double interestRate;
    ...
};

定义静态成员

当在类的外部定义静态成员的时候,不能重复static关键字:

void Account::rate(double newRate)
{
    interestRate = newRate;
}

因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。

也就是它们不是由类的构造函数初始化的。

必须在类的外部定义和初始化每个静态成员。一个静态数据成员只能定义一次。

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

要想确保对象只定义一次,最好是把静态数据成员的定义与其他非内联函数的定义放在同一个文件里。

静态成员的类内初始化

通常类的静态成员不该在类的内部初始化。但是我们可以给静态成员提供const整数类型的类内初始值,但是这要求静态成员必须是字面值常量类下的cosntexpr。初始值必须是常量表达式。

示例用一个初始化了的静态数据成员指定数组成员的维度:

class Account{
public:
    static double rate() { return interestRate; }
    static void rate(double);
    static constexpr int period = 30;	// period是常量表达式
    double daily_tb1[period];
};

若某静态成员的应用场景仅限于编译器可以替换它的值的情况,则一个初始化的conststatic constexpr不需要分别定义。但若我们将其用于值不能替换的场景中,则该成员必须有一条定义语句。

例如,要是period的唯一用途就是定义daily_tb1的维度,就不需要在Account外面专门定义period。这时候如果我们忽略了这个定义,那么对程序非常微小的改动也可能造成编译错误,因为程序找不到这个成员的定义语句。

例如当需要把Account::period传递给一个接受const int&的函数时,必须定义period

若类的内部提供了一个初始值,那么成员的定义就不能再指定一个初始值了:

// 不带初始值的静态成员的定义
constexpr int Account::period;		// 初始值在类的定义内提供

就算一个常量静态数据成员在类里被初始化了,但是在类外也该定义一下该成员。

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

静态成员独立于任何对象。所以在某些非静态数据成员可能非法的场合,静态成员却可以正常使用。比如静态数据成员可以是不完全类型。

静态数据成员的类型可以是其所属的类类型。

而非静态数据成员则受到限制,只能声明它所属类的指针或引用:

class Bar{
public:
	...
private:
    static Bar mem1;		// 正确 mem1是个不完全类型的静态成员
    Bar *mem2;				// 正确 指针成员可以是不完全类型
    Bar mem3;				// 错误 数据成员必须是完全类型
};

静态成员和普通成员的另一个区别是我们可以使用静态成员作为默认实参:

class Screen{
public:
    // bkground 表示一个在类中稍后定义的静态成员
    Screen& clear(char = bkground);
private:
    static const char bkground;
};

非静态数据成员不能作为默认实参,因其值本身属于对象的一部分,这么做的结果是无法真正提供一个对象以便从中获取成员的值,最终会引发错误。