C++ Primer 学习笔记 第七章 类

类的基本思想是数据抽象和封装。数据抽象是一种依赖于接口和实现分离的编程技术,类的接口包括用户所能执行的操作,类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。封装实现了类的接口和实现的分离,封装后的类隐藏了它的实现细节,即类的用户只能使用接口而不能访问实现部分。

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

我们可以通过抽象数据类型提供的接口来完成某些操作,但如果一个类没有任何接口供用户使用,只有一些数据成员,那这个类就不是抽象数据类型,一旦定义了一些成员函数供类的用户使用,它就变成了抽象数据类型。

定义和声明成员函数的方式与普通函数差不多,成员函数的声明必须在类的内部,但它的定义既可以在类的内部,也可以在类的外部。定义在类内部的函数是隐式的inline函数。

Sales_data类:

struct Sales_data {
    string isbn() const {
        return bookNo;
    }
    
    Sales_data &combine(const Sales_data&);
    double avg_price() const; 
    
    string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};

以上例子中,isbn函数定义在了类内而conbine和avg_price函数定义在了类外。

上例的isbn函数中,只有一条return语句,用于返回Sales_data对象的bookNo数据成员,但它是如何获取bookNo成员所依赖的对象的呢?

Sales_data total;
total.isbn();

在上例中,使用了点运算符访问total对象的isbn成员,然后再调用它。成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象,当我们调用一个成员函数时,用请求该函数的对象地址初始化this,可以等价地认为编译器将以上调用重写成了:

Sales_data::isbn(&total)    //伪代码,用于说明成员函数的实际执行过程

在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符来做到这一点。因为this所指的正是调用该函数的那个对象,在函数中任何对类成员的直接访问都被看做this的隐式引用,即当isbn使用bookNo时,它隐式地使用this指向的成员,就像写了this->bookNo一样。

但对我们来说,this形参是隐式定义的,实际上,任何自定义名为this的参数或变量都是非法的(任何位置都是这样)。

我们可以在成员函数体内部使用this,尽管没有必要:

string isbn() const {   
    return this->bookNo;
}

因为this的目的总指向调用函数的对象,所以this是一个常量指针,我们不允许改变this中保存在地址。

isbn的参数列表之后紧跟着一个const关键字,这个const的作用是修改隐式this指针的类型,默认情况下,this的类型是指向类类型非常量版本的常量指针,例如Sales_data的成员函数中,this的类型是Sales_data *const。尽管this是隐式的,它仍然要遵循初始化规则,意味着默认情况下,我们不能把this绑定到一个常量对象上,即我们不能在一个常量对象上调用普通成员函数。在isbn函数体内不会改变this所指的对象,因此把this设置为指向常量的指针有助于提高函数的灵活性(此时this的类型为const Sales_data *const)。像这样形参列表后有const的成员函数称为常量成员函数,可把isbn的函数体想象成如下形式:

string Sales_data::isbn(const Sales_data *const this) const {
    return this->bookNo;
}    //伪代码

常量成员函数中不能改变对象的值,只能读取该对象。

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

类本身就是一个作用域,但即使上例中bookNo定义在函数isbn之后,isbn也还是能访问到bookNo。编译器首先编译成员的声明,然后才轮到成员函数体(如果有的话),因此成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。

当我们在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配,即返回类型、参数列表、函数名都得与类内部的声明保持一致,如果成员被声明成常量成员函数,那么它的定义也必须在参数列表后明确指定const属性,同时,类外部定义的成员的名字必须包含它所属的类名:

//必须定义在类体后
double Sales_data::avg_price() const {
    if (units_sold) {
        return revenue / units_sold;
    }
    else {
        return 0;
    }
}

上例中函数名Sales_data::avg_price使用作用域运算符说明我们定义的这个函数被声明在类Sales_data作用域内。

一个返回this对象的函数:

Sales_data &Sales_data::combine(const Sales_data &rhs) {
    units_sold += rhs.units_sold;
    revenue += rhs.revenue;
    return *this;    //对this解引用获得执行该函数的对象
}

当我们如下调用函数时:

total.combine(trans);    

total的地址被绑定到隐式的this参数上,而rhs绑定到了trans上。当我们定义的函数类似于某个内置运算符时,应该令这个函数的行为模仿这个运算符,内置的赋值运算符把它的左侧运算对象当成左值返回,因此为了与它保持一致,combine函数必须返回引用类型。

类的作者通常需要定义一些辅助函数,尽管这些函数定义的操作从概念上说属于类的接口的组成部分,但它们实际上不属于类,比如Sales_data的read、print等辅助函数。我们定义非成员函数的方式与定义其他函数一样,通常把函数的声明和定义分离开来,如果函数在概念上属于类但不定义在类中,则它一般应与类声明(而非定义)在同一个头文件中,这样用户使用接口的任何部分都只需要引入一个文件:

//输入交易信息
istream &read(istream is, Sales_data &item) {
    double price = 0;
    is >> item.bookNo >> item.units_sold >> price;
    item.revenue = price * item.units_sold;
    return is;
}
//输出商品信息
ostream &print(ostream &os, const Sales_data &item) {
    os << item.isbn() << " " << item.units_sold << " " <<item.revenue << " " << item.avg_price();
    return os;
}

以上函数各自接收了IO类型的引用作为参数,这是因为IO类属于不能被拷贝的类型,因此我们只能用引用来传递它们,而且因为读取和写入操作会改变流的内容,所以两个函数接受的都是普通引用而非对常量的引用。还有print函数不负责换行,执行输出任务的函数应尽量减少对格式的控制,确保由用户代码来决定是否换行。

接下来定义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);
    return sum;
}

默认情况下,拷贝类的对象其实拷贝的是对象的数据成员。

类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数。其任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。

构造函数的名字与类名相同,但没有返回类型,它也有一个可能为空的参数列表和可能为空的函数体。一个类可以包含多个构造函数,和重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。但构造函数不能被声明成const的,当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正获得其“常量属性”,因此,构造函数在const对象的构造过程中可以向其写值。

我们创建对象时如果没有提供初始值,则它执行了默认初始化(块内的内置类型的默认初始化值未定义),类通过一个特殊的构造函数控制默认初始化过程,这个函数叫默认构造函数。默认构造函数无需任何实参。

如果我们的类没有显式地定义构造函数,那么编译器会为我们隐式地定义一个默认构造函数,编译器创建的构造函数又被称为合成的默认构造函数,对大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:
1.如果存在类内初始值,用它来初始化成员。
2.否则,默认初始化该成员。(有些编译器执行值初始化,因此最好设置类内初始值,若有些编译器不支持类内初始值,则在构造函数中初始化)

这个合成的默认构造函数只适合非常简单的类,对一个普通的类来说,必须定义它自己的默认构造函数,原因有三:
1.编译器只有在发现类不包含任何构造函数的情况下才会替我们生成一个默认的构造函数,一旦我们定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认构造函数。若一个类在某种情况下需要控制对象初始化,那么该类很可能在所有情况下都需要控制。
2.对某些类来说,合成的默认构造函数可能执行错误的操作。块内的内置类型或复合类型(数组、指针)对象默认初始化的值是未定义的。只有当类中的内置类型或复合类型的成员全部被赋予了类内的初始值时,这个类才适合用合成的默认构造函数。
3.编译器有时无法为某些类合成默认的构造函数,如当类中包含着一个其他类类型成员且这个成员的类型没有默认构造函数。

给类新增构造函数:

struct Sales_data {
    //新增的构造函数
    Sales_data() = default;
    Sales_data(const string &s) : bookNo(s) { }
    Sales_data(const string &s, unsigned n, double p) : bookNo(s), units_sold(n), revenue(p * n) { }
    Sales_data(istream &);
    //之前的成员
    string isbn() const {
        return bookNo;
    }
    
    Sales_data &combine(const Sales_data&);
    double avg_price() const; 
    
    string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};

以上代码中,Sales_data() = default的含义,首先明确一点,该构造函数不接受任何实参,所以它是一个默认构造函数,定义这个构造函数的目的是我们既需要其他形式的构造函数,也需要默认的构造函数,我们希望这个函数作用完全等同于之前使用的合成默认构造函数。这是C++11新标准。=default既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部,和其他函数一样,如果=default在类的内部,则默认构造函数是内联的,如果在类的外部,则该成员默认情况下不是内联的。上面的默认构造函数之所以对Sales_data有效,是因为我们为内置类型的数据成员提供了初始值,如果你的编译器不支持类内初始值,那么你的默认构造函数应该使用构造函数初始值列表初始化类的每个成员。

构造函数初始化列表:以上代码中默认构造函数后的两个构造函数中的冒号以及冒号和花括号之间的部分被称为构造函数初始化列表。它负责为新创建的对象的一个或几个数据成员赋初值。当某个数据成员被构造函数初始化列表忽略时,他将以与合成默认构造函数相同的方式隐式初始化。

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

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

在上面两个构造函数中函数体都是空的,这是因为这些构造函数的唯一目的就是为数据成员赋初值,没有别的任务需要执行。

最后一个构造函数是在类外定义的构造函数:

Sales_data::Sales_data(istream &is) {
    read(is, *this);
}

上例中,构造函数没有返回类型,所以上述定义从函数名开始,它的构造函数初始值列表是空的,但是由于它执行了构造函数体,所以对象成员仍能被初始化。没有出现在构造函数初始值列表中的成员将通过相应地类内初始值(如果有),或执行默认初始化,之后再调用read函数改变成员的值。

对象在以下几种情况下会被拷贝:我们初始化变量以及以值的方式传递或返回一个对象。我们使用赋值运算符时会发生对象的赋值操作。对象不再存在时执行了销毁操作(局部对象在创建它的块结束时被销毁,vector对象销毁时存储在其中的对象也会被销毁)。如果我们不定义这些操作,编译器将替我们合成它们,如赋值:

total = trans;
//等价于以下代码
total.bookNo = trans.bookNo;
total.units_sold = trans.units_sold;
total.revenue = trans.revenue;    //赋值默认相当于将trans的成员值赋值给total

但对于某些类的拷贝、赋值和销毁操作的合成版本无法正常工作,特别是当类需要分配类对象之外的资源时,如C++分配和管理动态内存时。

很多使用动态内存的类应该使用vector对象或string对象管理必要的存储空间,使用vector和string的类能避免分配和释放内存带来的复杂性。如果类包含vector或string成员,则其拷贝、赋值和销毁的合成版本能够正常工作,当我们对含有vector成员的对象进行拷贝或赋值操作时,vector会很好地拷贝或者赋值每个成员,当这样的对象被销毁时,将销毁vector对象,也就依次销毁了vector中的每一个元素。

目前为止,我们已经为类定义了接口,但并没有任何机制强制用户使用这些借口(如获取isbn可以通过函数获得值,也可以直接访问成员获取值)。我们可以使用访问说明符加强类的封装性:

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

定义在public说明符后面的成员在整个程序内可被访问,public成员定义类的接口。
定义在private说明符后面的成员可以被类的成员函数访问,但不能被使用该类的代码访问,private部分封装(隐藏)了类的实现细节。

作为接口的一部分,构造函数和部分成员函数紧跟在public说明符之后(但构造函数也可以定义在private说明符后,而能通过编译),而数据成员和作为实现部分的函数则在private说明符之后。

一个类可以包含0个或多个访问说明符,而且对于某个说明符能出现多少次也没有严格规定,每个访问说明符指定了接下来的成员访问级别,有效范围直到出现下一个访问说明符或者到达类的结尾处为止。

在上面的类定义中,使用了class而非struct,这唯一的区别就是默认访问权限不一样。类可以在它的第一个访问说明符之前定义成员,这种成员的访问方式取决于类的定义方式,struct关键字定义的类的默认访问权限是public,class是private。

既然Sales_data的数据成员是private的,那么read、print。add函数就无法正常编译了,因为这几个函数是类的接口的一部分,但不是类的成员,类可以允许其他类或函数访问它的非公有成员,方法是令其他函数或类成为它的友元:

class Sales_data {
friend Sales_data add(const Sales_data&, const Sales_data&);
friend istream &read(istream &, Sales_data &);
friend ostream &print(ostream &, const Sales_data &);
public:
    Sales_data() = default;
    Sales_data(const string& s) : bookNo(s) { }
    Sales_data(const string& s, unsigned n, double p) : bookNo(s), units_sold(n), revenue(p* n) { }
    Sales_data(istream&);
    string isbn() const {
        return bookNo;
    }
    Sales_data& combine(const Sales_data&);
private:
    double avg_price() const;
    string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};

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

封装有两个优点:
1.确保用户代码不会无意间破坏封装对象的状态。
2.被封装的类的具体实现可以随时改变,而无须调整用户级别的代码。

尽管当类的定义发生改变时无需更改用户代码,但使用了该类的源文件必须重新编译。

友元的声明仅仅指定了访问权限,而非一个通常意义上的函数声明,如果我们希望类的用户能够调用某个友元函数,我们必须再在友元声明之外专门对函数进行一次声明。

为了使友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部),因此,我们的Sales_data头文件应该为read、print和add提供独立的声明(除了类内部的友元声明之外)。

许多编译器并未强制限定友元函数必须在使用之前再在类的外部声明。但最好在类外再声明一次友元的函数,这样即使更换了编译器也能通过编译。

新的类型:

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

我们发现,类还可以自定义某种类型在类中的别名,由类定义的类型名字和其他成员一样存在访问限制,用户使用方法:

Screen::pos p = 8;

用来定义类型的成员必须先定义后使用,因此,类型成员通常出现在类开始的地方。

继续完善Screen类:

class Screen {
public:
    typedef std::string::size_type pos;
    Screen() = default;

    Screen(pos ht, pos wd, char c) : height(ht), width(wd), contents(ht* wd, c) { }    //将窗口设为指定大小并且用字符c填充满

    char get() const {    //获取光标处的字符,隐式内联
        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 Screen &Screen::move(pos r, pos c) { 
    pos row = r * width;    //计算行位置
    cursor = row + c;
    return *this;
}

char Screen::get(pos ht, pos wd) const {
    pos row = r * width;
    return contents[row + c];
}

上例中第二个构造函数隐式地使用了类内初始值初始化cursor,如果类中不存在cursor的类内初始值,我们需要像初始化其他成员一样初始化cursor。

Screen的构造函数和返回光标所指字符的get函数默认是inline函数。get和move函数也都是inline函数,虽然我们无需在声明和定义的地方同时说明inline,但这么做是合法的,但最好只在类外部定义的地方说明inline,这样可以使类更容易理解。

和我们在头文件中定义inline函数原因一样,inline成员函数也应该与相应的类定义在同一个头文件中。

和非成员函数一样,成员函数也可以被重载,与普通函数的重载相同。

有时我们希望修改类的某个数据成员,即使是在一个const成员函数内,我们可以通过在变量的声明中加入mutable关键字来实现,这个变量被称为可变数据成员,它永远不会是const,即使它是const对象的成员。如给Screen类添加一个名为access_ctr的可变成员,通过它追踪每个Screen的成员函数被调用了多少次:

class Screen {
public:
    void some_member() const;
private:
    mutable unsigned access_ctr;
};

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

上例的some_member尽管是一个const成员函数,但它仍然能改变access_ctr的值。

在定义好Screen类之后,我们想继续定义一个管理窗口的类Window_mgr并用它表示显示器上的一组Screen,这个类将包含一个Screen类型的vector,每个元素表示一个Screen,默认情况下,我们希望Window_mgr类开始时总是拥有一个默认初始化的Screen。在C++11新标准中,最好的方式是使用列表初始化把这个默认值声明成一个类内初始值:

class Window_mgr {
private:
    vector<Screen> screens{Screen(10, 20, ' ')};    //列表初始化 
};

上例中使用一个单独的值对vector成员进行了列表初始化,这个Screen值被传递给vector<Screen>的构造函数。如我们之前所知,类内初始值只能使用=或列表初始化的方式。

Screen类能安全地依赖于拷贝和赋值操作的默认版本,因为它的数据成员都能够调用默认构造函数或是内置类型。

返回this的成员函数,继续完善Screen类:

class Screen {
public:
    Screen &set(char);
    Screen &set(pos, pos, char);
    //其他成员和之前版本一致
};

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

和move操作一样,set成员返回的是调用set的对象的引用,返回引用的函数是左值的,意味着这些函数返回的是对象本身而非对象的副本:

myScreen.move(4, 0).set('#');    //把光标移到指定位置并设置该位置的字符
//上句代码等价于:
myScreen.move(4, 0);
myScreen.set('#');

但如果我们令move和set的返回类型由Screen &改为Screen,上述语句的行为将大不相同:

Screen temp = myScreen.move(4, 0);
temp.set('#');    //不会改变myScreen的contents

即返回类型不是引用而是*this的副本,因此调用set只能改变临时副本的值,而不能改变myScreen的值。

从const成员返回*this:继续完善Screen类,添加一个名为display的操作,负责打印Screen的内容,我们希望这个函数能和move和set出现在同一序列中,因此display函数也应该返回执行它的对象的引用,但显示Screen并不需要更改它的内容,因而我们令display为一个const函数,此时,this指针指向const对象,*this为const对象,因此,display的返回类型应声明为const Screen &。然而如返回的是const Screen &类型对象,则不能将display嵌入到一组动作中去:

Screen myScreen;
myScreen.display(cout).set('*');    //即使myScreen是非常量对象,对set的调用也无法通过编译

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

通过区分成员函数是否是const的,可以对其进行重载,因为非常量版本的函数不能被常量对象调用,所以我们只能在一个常量对象上调用const成员函数。另一方面,虽然可以在非常量对象上调用常量版本和非常量版本,但此时非常量版本是一个更好的匹配。

下面例子由私有函数成员完成display核心功能:

class Screen {
public:
    Screen &display(ostream &os) {
        do_display(os);
        return *this;
    }
    
    const Screen &display(ostream &os) const {
        do_display(os);
        return *this;
    }
private:
    void do_display(ostream &os) const {
        os << contents;
    }
};

当display的非常量版本调用do_display时,它的this指针隐式地传递给do_display,并隐式地从指向非常量的指针转换成指向常量的指针。而当do_display完成后,display函数各自返回解引用this所得的对象。我们在某对象上调用display时,该对象是否是const决定了应该调用display的哪个版本:

Screen myScreen(5, 3);
const Screen blank(5, 3);
myScreen.set('#').display(cout);    //调用非常量版本
blank.display(cout);    //调用常量版本

定义do_display原因:
1.避免多处使用相同代码。
2.预期着display可能变得更复杂,此时只需更改一处即可。
3.开发过程可能在函数中添加调试信息,开发完成后会删掉这些信息,明显在一处增删这些信息比较方便。
4.这个额外函数不会增加任何开销,因为它是隐式内联函数。

每个类定义了唯一的类型,对于两个类来说,即使它俩的成员完全一样,这两个类也是不同的类型。

Sales_data item1;
class Sales_data item2;    //与上句等价的声明
struct Sales_data item3;    //与上句等价的声明(后两种由C语言继承而来)

我们也能仅仅声明类而不定义它:

class Screen;

这种声明有时被称为前向声明,它向程序中引入了名字Screen并且指明Screen是一种类类型。在Screen声明之后定义之前是一个不完全类型,即仅知道Screen是一个类类型但不清楚它到底包含哪些成员。

不完全类型只能在有限情境下使用:可以定义指向这种类型的指针或引用,也可以声明(但不能定义)以不完全类型作为参数或者返回类型的函数。

对于一个类来说,我们在创建它的对象之前,该类必须被定义过,而不能仅仅只是声明过,否则编译器就不知道这样的对象需要多少存储空间。类似地,类也必须先被定义过才能用引用或指针访问其成员。

类被定义之后,它的数据成员才能被声明成这种类类型。然而,一旦一个类的名字出现后,它就被认为是声明过了,因此类允许包含指向它自身类型的引用或指针。

类可以把其他类当做友元,也可以把之前已经定义过的其他类的成员函数定义为友元。友元函数还能定义在类的内部,这样的函数是隐式内联的。

Window_mgr类的某些成员可能需要访问它管理的Screen类的内部数据,要想令这种访问合法,Screen需要把Window_mgr指定成它的友元:

class Screen {
    friend class Window_mgr;
    //Screen的剩余部分
};

友元类Window_mgr的成员函数可以访问此类包括非公有成员在内的所有成员。

友元不存在传递性。每个类负责控制自己的友元类或友元函数。

令成员函数成为友元:

class Screen {
    friend void Window_mgr::clear(ScreenIndex);    //需要明确指出成员函数所属的类
};

以上这种程序的组织结构必须仔细组织以满足声明和定义的彼此依赖关系:

class Screen;    //由于Window_mgr中使用到了Screen类,要先声明Screen类

class Window_mgr {
public:
    typedef std::vector<Screen>::size_type ScreenIndex;
    void clear(ScreenIndex);    //此处不能定义,因为其中要用到Screen类的私有成员,现在还不知道Screen类的具体结构
private:
    vector<Screen> screens;
};

class Screen {
    friend void Window_mgr::clear(ScreenIndex);    //声明Window_mgr的clear成员函数为友元,以便clear接下来定义时使用Screen的私有成员
public:
    Screen(int, int, char);
private:
    string contents;
    int height;
    int width;
};

void Window_mgr::clear(ScreenIndex i) {    //最后在定义函数,因为函数中用到了Screen中的私有成员,定义了Screen后且该函数在Screen中被声明为友元才能用
    Screen& s = screens[i];
    s.contents = string(s.height * s.width, ' ');
}

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

类和非成员函数的声明不一定要在它们的友元声明之前,但另一个类的成员函数一定要声明在它的友元函数声明之前:

class Y {
    friend class X;    //正确
    friend int func1();    //正确
    friend void X::func();    //错误
};

class X {
public:
    void func();
};

int func1() {
    return 0;
}

当一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的,然而,友元本身不是真的声明在当前作用域中(友元本身不属于类,不算是真的声明)。就算在类的内部定义友元函数,只有在类外声明后才能访问到:

struct X {
    friend void f() { }
    X() { 
        f();    //错误,f还没有声明
    }
    void g();
    void h();
};
void X::g() {
    return f();    //错误,f还没有声明
}
void f();    //声明
void X::h() {
    return f();    //正确
}

以上代码说明友元声明作用是影响访问权限,并非普通意义上的声明。但有的编译器并不强制执行上述关于友元的限定。

我们再添加一个addScreen函数给类Window_mgr:

class Window_mgr {
public:
    ScreenIndex addScreen(const Screen &);
    //其它成员与之前相同
};

Window_mgr::ScreenIndex Window_mgr::addScreen(const Screen &s) {
    screens.push_back(s);
    return screens.size() - 1;
}    //此函数的返回类型需要该类型标识所在类名,函数名也需要,但函数名之后的内容不再需要

名字查找(寻找与所用的名字最匹配的声明)的过程:
1.在名字所在的块中寻找其声明语句,只考虑在名字的使用之前的声明。
2.如没找到,继续查找外层作用域。
3.如没找到,报错。

定义在类内部的成员函数来说,解析名字的方式:
1.编译成员的声明。
2.直到类全部可见后(类中所有声明都编译后)才编译函数体。

即编译器处理完类中的全部声明后才会处理成员函数的定义。这种方式可以使成员函数体使用类中定义的任何名字(包括定义在函数体之前的和之后的)。

但以上这种两阶段的处理方式只适用于成员函数中使用的名字,但声明中使用的名字(包括返回类型或参数列表中使用的)都必须在使用前确保可见。

typedef double Money;
string bal;

class Account {
public:
    Money balance() {  
        return bal;
    }
private:
    Money bal;
};

以上代码中,在balance函数的声明前使用了Money,编译器会现在类内找Money的类型别名语句,但没找到,才会在类外找。而函数中return的bal也会先在类内找,因此此处的bal是类内私有成员而非类外string成员。

而在类中重新定义类型别名时,则可能不能重新定义:

typedef double Money;

class Account {
public;
    Money balance() {    //使用类外定义的Money
        return bal;
    }
private:
    typedef int Money;    //错误,不能重新定义Money
    typedef double Money;    //错误,不能重新定义Money,即使与外层定义相同
    Money bal;
};

以上代码尽管是错误的,但编译器并不为此负责,一些编译器仍能通过重复定义类型别名的代码而忽略代码有错的事实。还有类型名的定义通常出现在类的开始处,从而确保所有使用该类型的成员都出现在类名定义之后。

成员函数名字解析方式:
1.首先在成员函数内查找,只在使用前出现的声明才考虑。
2.如在成员函数中没找到,在类内找,类内所有位置声明的成员都能被考虑。
3.类外之前找。

int height;

class Screen {
public:
    typedef std::string::size_type pos;
    void dummy_func(pos height) {    //不要将形参与类内成员重名
        cursor = width * height;    //此处height是形参,可以使用this->height、Screen::height来使用类内成员height,可以使用::height使用类外的height
    }
private:
    pos cursor = 0;
    pos height = 0, width = 0;
};

当类的成员定义在类外部:

class Screen {
public:
    typedef std::string::size_type pos;
    void setHeight(pos var);    //不能定义在此处,因为函数中用到了verify函数,还未定义
private:
    pos height = 0;
};

Screen::pos verify(Screen::pos p) {    //只能声明在类后,因为该函数需要用到Screen类中的类型
    return p;
}   

void Screen::setHeight(pos var) {    //此时才能定义函数
    height = verify(var);
};

构造函数的初始值列表有时与以下写法相同:

Sales_data::Sales_data(const string &s, unsigned cnt, double prices) {
    bookNo = s;
    units_sold = cnt;
    revenue = cnt * price;
}

以上构造函数没有初始值列表,因此在执行函数体之前所有成员执行默认初始化,之后再在函数体中重新赋值。初始化数据成员和对成员赋值有什么深层次影响取决于数据成员类型。

有时构造函数的初始值必不可少,如成员是const或引用时,以及当成员属于某种类类型且该类没有定义默认构造函数时:

class ConstRef {
public:
    ConstRef(int ii) {
        i = ii;    //正确
        ci = ii;    //错误
        ri = ii;    //错误
    }
private:
    int i;
    const int ci;
    int &ri;
};

很多类中,初始化和赋值的区别事关底层效率问题,前者直接初始化数据成员,后者先初始化再赋值。并且有些类型必须使用构造函数初始化列表。因此,建议使用构造函数初始值初始化。

构造函数初始值列表中的每个成员只能出现一次,因为多次初始化为不同的值没意义。但是,构造函数初始值列表只说明用于初始化成员的值,但并不对初始化的顺序做出规定。成员的初始化顺序与它们在类中定义的顺序一致。

一般来说,初始化顺序没什么特别要求,但如果一个成员是用另一个成员来初始化时,顺序就关键了:

class X {
    int i;
    int j;
public:
    X(int val) : j(val), i(j) { }
    X(int val) : j(val), i(val) { }    //优化版本
};

此例中,从构造函数初始值列表中看是先用val初始化j,再用j初始化i,但实际顺序是先用未初始化的j初始化i,再用val初始化j,其结果是未定义的。

最好令构造函数初始值列表的顺序与成员声明的顺序一致,而且可能的话要避免用某些成员初始化其他成员。有的编译器当构造函数初始值列表中数据成员的顺序与成员声明顺序不符时会生成一条警告信息。

将cin作为接受istream&参数的构造函数的默认实参:

class ConstRef {
public:
    ConstRef(istream &is = cin) { }
};

上例中,如果接受string的构造函数也有默认实参,这种行为不合法,这样就不能分清调用的是哪个构造函数。

C++11新标准中我们可以使用委托构造函数(使用它所属的类的其他构造函数执行它自己的初始化过程,即它把它自己的一些或全部职责委托给了其他构造函数):

class Sales_data {
public:
    Sales_data(string s, unsigned cnt, double price) : bookNo(s), units_sold(cnt), revenue(cnt* price) { }
    //委托构造函数,其所使用的构造函数必须与类中另一个一个构造函数匹配
    Sales_data(string s) : Sales_data(s, 0, 0) { }
    Sales_data() : Sales_data("", 0, 0) { }
    //委托构造函数委托给另一个委托构造函数
    Sales_data(std::istream& is) : Sales_data() {
        read(is, *this);
    }
};

当一个构造函数委托另一个构造函数时,受委托的构造函数的初始值列表和函数体依次被执行。

委托构造函数的冒号后只能有另一个构造函数,而不能有其他的初始化列表内容。

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

默认初始化在以下情况下发生:
1.在块作用域内不使用任何初始值定义一个非静态变量或数组时。
2.一个类中含有类类型成员且这个含有类类型成员的类使用合成的默认构造函数时,这个类中的类类型成员会默认初始化。
3.类类型成员没有在构造函数初始值列表中显式初始化时。

值初始化发生在:
1.数组初始化过程中我们提供的初始值数量少于数组的大小时,剩下的部分会值初始化。
2.不通过初始值定义一个局部静态变量时。
3.当我们通过书写形如T()的表达式显式地请求值初始化时。(T是类型名)

类必须包含一个默认构造函数以便在上述情况下使用。因此最好在定义了其他构造函数后也提供一个默认构造函数。

例子:

class NoDefault {
public:
    NoDefault(const std::string &);    //没有其他构造函数了
};

struct A {
    NoDefault my_mem;
};

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

struct B {
    B() { }    //错误,没有在构造函数初始化列表中给b_member一个初始值
               //类B不能自动合成构造函数,因为有一个没有默认构造函数的类类型成员,但此处我们可以主动定义一个,虽然也是错的
    NoDefault b_member;
};

使用默认构造函数:

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

如想使用默认构造函数初始化对象:

Sales_data obj;    //去掉上例中的括号即可

NoDefault类没有默认构造函数时,在别的类A中使用NoDefault的成员:

class NoDefault {
public:
    NoDefault(const std::string&) { }  
};

struct A {
    A() : my_mem("sss") {

    }
    NoDefault my_mem;    //虽然my_mem没有默认构造函数,但A的默认构造函数中使用了别的版本的构造函数初始化了my_mem
    NoDefault my_mem("sss");    //错误,这是在声明函数,会报错要求输入类型说明符(形参列表中没写声明符)
};

转换构造函数:构造函数只接受一个实参,它实际上定义了转换为此类类型的隐式转换机制。如Sales_data类型有接收string和istream的构造函数,实际上定义了这两种类型向Sales_data隐式转换的规则,即使用到Sales_data的地方可以使用以上两种类型代替:

//类成员函数Sales_data &combine(const Sales_data&);
string null_book = "9-999-99999-9";
item.combine(null_book);    //因为combine的参数是一个常量引用,因此可以给该参数传递一个临时量

这里我们用一个string实参调用了Sales_data的combine成员,这是合法的,编译器用给定的string自动创建了一个Sales_data对象。新生成的这个临时Sales_data对象被传递给combine。

只允许一步类类型转换:

//类成员函数Sales_data &combine(const Sales_data&);
item.combine("9-999-99999-9");    //失败,需要两步转换,首先是将const char[]转换成string类型,然后才能转换成Sales_data类型
item.combine(string("9-999-99999-9"));    //正确
item.combine(Sales_data("9-999-99999-9"));    //正确

类似地:

item.combine(cin); 

上例把cin转换成Sales_data,转换时执行了一个接受了一个istream的构造函数,创建了一个临时量,执行完combine后被丢弃。

我们可以阻止类类型的转换,方法是在构造函数前加explicit关键字:

class Sales_data {
public:
    explicit Sales_data(const std::string &s) : bookNo(s) { }    //不能再将string类型转化成Sales_data类型
private:
    string bookNo;
};

explicit关键字只对一个实参的构造函数有效,且该关键字只允许出现在类内的构造函数声明处,定义在类外的构造函数不能加。

Sales_data item1(null_book);    //正确,直接初始化
Sales_data item2 = nullbook;    //错误,但当构造函数不是explicit时可以这样

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

item.combine(Sales_data(null_book));
item.combine(static_cast<Sales_data>(cin));    //static_cast使用以istream为参数的构造函数创建了一个临时Sales_data对象

标准库中的例子:
1.接受一个参数的const char *的string构造函数,不是explicit的,可以用=赋值。
2.接收一个容量的vector构造函数是explicit的:

    vector<int> vi = static_cast<vector<int>>(2);
    for (int i : vi) {
        cout << i << endl;    //输出两个0
    }

聚合类:用户可以直接访问其成员,并且有特殊的初始化语法。其满足:
1.所有成员都是public的。
2.没定义任何构造函数。(除非只有一个构造函数,接收0个参数且=default)
3.没有类内初始值。
4.没有基类、virtual函数。

如:

struct Data {
    int ival;
    string s;
};

初始化聚合类:

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

初始值的顺序必须与声明的顺序一致:

Data val1 = {"Anna", 0};    //错误

如果初始值列表中的元素个数少于类的成员的数量,则靠后的成员被值初始化。初始值列表中元素数不能超过类的成员数。

显式地初始化类的对象缺点:
1.要求类的所有成员都是public的。
2.将正确初始化每个对象的重任交给了用户。用户容易忘掉某个初始值或提供一个不恰当的初始值 。
3.类中添加或删除一个成员之后,所有初始化语句都需要更新。

某些类也是字面值类型(字面值类型指算术类型、引用、指针或有些类),其可能含有constexpr函数成员,这样的成员必须符合constexpr函数的所有要求,它们是隐式const的成员函数。

数据成员都是字面值类型的聚合类是字面值常量类(如上例的Data类),如果一个类不是聚合类,但它符合以下要求,则它也是一个字面值常量类:
1.数据成员必须是字面值类型。
2.类至少含有一个constexpr构造函数。
3.如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
4.类必须使用析构函数的默认定义。

尽管构造函数不能是const的,但是字面值常量类的构造函数可以是constexpr函数。事实上,一个字面值常量类必须提供一个constexpr构造函数。

C++11标准中,constexpr构造函数可以声明成=default的形式或者是删除函数的形式。否则,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;
    bool io;
    bool other;
};

constexpr构造函数必须初始化所有数据成员,成员的初始值或者使用constexpr构造函数赋值或者它本身就是常量表达式(类中的数据成员不能是constexpr的,但可以是static constexpr的)。

constexpr构造函数用于生成constexpr对象,这个对象可以用作constexpr函数的参数或返回类型:

constexpr Debug io_sub(false, true, false);
if (io_sub.any()) {
    cerr << "print appropriate error messages" << endl;
}

constexpr Debug prod(false);
if (prod.any()) {
    cerr << "print an error message" << endl;
}

Debug类的set开头的成员函数不应该声明为constexpr函数,因为它还执行了除返回语句的其他可执行语句。

有时候类需要一些与类本身直接相关,而不是与类的各个对象相关联的对象:

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对象将包含两个数据成员:owner和amount。只存在一个interestRate对象而且它被所有Account对象共享。

静态成员的类型可以是const的、引用、指针、类类型等。

类似地,静态成员函数也不与任何对象绑定在一起,它们不包含this指针,作为结果,静态成员函数不能声明成const的,在static函数体内也不能使用this指针,这个限制包括显式使用this和static函数调用普通成员函数时的隐式使用。

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

double r = Account::rate();

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

Account ac1;
Account *ac2 = &ac1;
r = ac1.rate();
r = ac2->rate();

在类外定义static成员函数时,不能重复使用static关键字,该关键字只能出现在类的内部:

void Account::rate(double newRate) {    //不能加static关键字
    interestRate = newRate;
}

静态数据成员不是在创建类的对象时被定义的,这意味着它们不是由类的构造函数初始化的。一般,我们不能在类的内部初始化静态成员,必须在类的外部定义和初始化每个静态成员,和其他变量一样,只能定义一次。

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

double Account::interestRate = initRate();    
//interestRate和initRate都是私有成员,但也能使用
//interestRate可用是因为它是静态数据成员
//initRate可用是因为interestRate声明了类的作用域,它已经在类作用域内了
double Account::interestRate = 1.02;

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

通常,类的静态成员不应该在类内初始化,但我们可以为const整数(即不带小数的数)类型静态成员提供类内初始值,但对于constexpr字面值类型则必须提供一个类内初始值:

class Account {
private:
	static constexpr string s = "sss";    //错误,string不是字面值类型
	static const double d = 2.6;    //错误,const修饰时double不是整数类型
	static constexpr double d2 = 2.6;    //正确,double是constexpr修饰的静态字面值类型
    static constexpr int period = 30;    //正确
    double daily_tbl[period];
};

上例的period是常量表达式,可用于初始化数组大小等处。

如果在类内提供了一个初始值,则在类外的成员定义就不需要再指定一个初始值了:

class Account {
public:
    static const int i = 1;    //正确,static const整数类型可以在类内或类外定义
};

const int Account::i = 2;    //错误,重定义i,如在类内i没有赋初始值,就像普通的static成员一样,在类外定义是正确的

静态成员可以是不完全类型:

Class A {
private:
    static A val1;    //正确静态成员可以是不完全类型
    A *pVal2;    //正确,指针或引用成员可以是不完全类型
    A val3;    //错误,数据成员必须是完全类型
}

我们还可以使用静态成员作为默认实参:

class A {
public:
    A func(char = sc);
private:
    static char sc;
};

找错误:

class A {
public:
	static double rate = 6.5;    //非const整数类型或constexpr的字面值类型的类内静态成员不能有类内初始值
	static const int i = 20;    //正确
    static vector<double> vec(i);    //错误,首先类内初始值不能用圆括号初始化,其次,就算用{i}初始化,也因为这是非constexpr的字面值类型或const整数类型的类内静态成员而失败
};

double A::rate;    //需提供初始值
vector<double> A::vec;    //需提供初始值
发布了193 篇原创文章 · 获赞 11 · 访问量 6万+

猜你喜欢

转载自blog.csdn.net/tus00000/article/details/104534982