7 类

目录

  • 类的基本思想是 数据抽象封装
  • 数据抽象 是一种依赖于 接口实现 分离的编程及设计技术。类的接口包括用户所能执行的操作类的实现包括类的数据成员、负责接口实现的函数体以及其他私有函数
  • 封装 实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分
  • 注意:程序员们常把运行其程序的人称作 用户类的用户是程序员,而非应用程序的最终使用者。

1. 定义抽象数据类型

  • Sales_item 类有一个名为 isbn 的 成员函数(member function), 并且支持 + 、= 、+= 、<< 和 >> 运算符。
  • Sales_data 的接口应该包含以下操作:
    • 一个 isbn 成员函数,用于返回对象的 ISBN 编号
    • 一个 combine 成员函数,用于将一个 Sales_data 对象加到另一个对象上
    • 一个 add 的函数,执行两个 Sales_data 对象的加法
    • 一个 read 函数,将数据从 istream 读入到 Sales_data 对象中
    • 一个 print 函数, 将 Sales_data 对象的值输出到 ostream

1.1 定义成员函数:声明必须在类的内部,定义可以在类的内外部

  • 成员函数 的声明必须在类的内部,定义则既可以在类的内部也可以在类的外部
  • 定义在类内部的函数是隐式的内联(inline)函数
  • 对象使用点运算符 . 调用成员函数。
  • 作为接口组成部分的非成员函数,它们的定义和声明都在类的外部
struct Sales_data
{
    // 新成员:关于Sales_data对象的操作
    std::string isbn() const { return bookNo; }
    Sales_data& combine(const Sales_data&);
    double avg_price() const;
    // 数据成员
    std::string bookNo;
    unsigned units_sold = 0;
    double 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&);

this指针:类比python的self

  • 实例化对象——>隐式通过this指针调用——>成员函数
  • 成员函数通过一个名为 this 的隐式额外参数来访问调用它的对象
  • 注意:每个成员函数都有一个隐式形参即this指针
  • this总是指向当前对象,因此this是一个常量指针,被初始化为调用该函数的对象地址,不允许改变 this 中保存的地址
// 当我们调用一个成员函数时,用请求该函数的对象地址初始化this
// 如前所述: std::string isbn() const { return bookNo; }  // 隐式使用this
total.isbn();  
// 编译器负责把total的地址传递给成员函数isbn的隐式形参this
// 可以等价地认为编译器将调用重写成了如下的形式:
// 伪代码,用于说明调用成员函数的实际执行过程
Sales_data::isbn(&total)  // 调用Sales_data的isbn成员时传入了total的地址
std::string isbn() const { return this->bookNo; }  // 显式使用this,等价于total.bookNo
  • 任何对类成员的直接访问都被看做this的隐式引用,因此在成员函数内部,我们可以直接使用 调用该函数的对象 的成员,而无须通过成员访问运算符来做到这一点。比如isbn直接使用bookNo时,它隐式地使用this指向的成员,就像我们书写了this->bookNo一样。
std::string isbn( ) { return bookNo; }
std::string isbn( ) { return this->bookNo; }
 //二者是等价的
  • 我们可以在成员函数内部使用this,尽管没有必要
  • 可以定义返回 this 对象的成员函数
Sales_data& Sales_data::combine(const Sales_data &rhs)
{
    units_sold += rhs.units_sold;   // 把rhs的成员加到this对象的成员上
    revenue += rhs.revenue;
    return *this;       			// 返回调用该函数的对象
}
// 其中,return 语句解引用 this 指针以获得执行该函数的对象。
// return *this;可以让成员函数连续调用。

const成员函数:参数列表后添加关键字const,以修改隐式this指针的类型

  • 回顾:指向常量的指针——>常量或非常量;常量指针——>非常量
  • 普通的非const成员函数:默认情况下,this 的类型是指向类类型非常量版本的常量指针
    • this 也遵循初始化规则,所以默认不能把 this 绑定到一个常量对象上,意味着我们不能在常量对象上调用普通的成员函数。(例如在Sales_data成员函数中,this的类型是Sales_data *const)
  • const成员函数:this是指向const类类型(类类型常量版本)的const指针(既不能改变this所指向的值,也不能改变this保存的地址。如此时this类型为const Sales_data *const)
    • C++允许在成员函数的参数列表后面添加关键字 const(因为this是隐式且不出现在参数列表中),表示 this 是一个指向常量的指针
    • 使用关键字 const 的成员函数被称作 常量成员函数
// 伪代码,说明隐式的this指针是如何使用的
// 下面的代码是非法的:因为我们不能显式地定义自己的this指针
// 谨记此处的this是一个指向常量的指针,因为isbn是一个常量成员
std::string Sales_data::isbn(const Sales_data *const this)
{ return this->isbn; }

  • 常量对象和指向常量对象的引用或指针都只能调用常量成员函数。
  • 总结
    • const对象只能初始化指向常量的指针this,所以const对象只能调用const成员函数,不能调用非const成员函数
    • 非const对象可以指向常量/非常量的指针this,所以非const对象可以调用const/非const成员函数

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

  • 类本身就是一个作用域,成员函数的定义嵌套在类的作用域之内
  • 编译器处理类时分两步处理类:
    • 首先会是编译成员声明
    • 然后才轮到编译成员函数体(如果有的话)
  • 因此,成员函数可以随意使用类的其他成员而无须在意这些成员的出现顺序
  • 在类的外部定义成员函数时,注意
    • 成员函数的定义必须与它的声明相匹配。也就是说,返回类型、参数列表和函数名都得与类内部的声明保持一致
    • 如果成员函数被声明为常量成员函数,那么它的定义也必须在参数列表后面指定 const 属性
    • 同时,类外部定义的成员名字必须包含它所属的类名
// 在类Sales_data中的声明:double avg_price() const;
// 声明在类Sales_data的作用域内定义了一个名为avg_price的函数
double Sales_data::avg_price() const 
{
    if (units_sold)
        return revenue / units_sold;
    else
        return 0;
}
  • 定义在类内部的函数是隐式的inline函数

1.2 定义类相关的非成员函数:定义和声明都应该在类的外部

  • 和类相关的非成员函数,定义和声明都应该在类的外部
  • 类的作者通常会定义一些辅助函数,尽管这些函数从概念上来说属于类接口的组成部分,但实际上它们并不属于类本身
  • 如果非成员函数是类接口的组成部分,则这些函数的声明应该与类放在同一个头文件中
// 输入的交易信息包括ISBN、售出总数和售出价格
// IO类属于不能拷贝的类型,只能通过引用来传递它们,且读取和写入通常会改变流的内容,所以是普通引用
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;
}
// print函数不负责换行
// 一般来说,执行输出任务的函数应该尽量减少对格式的控制,
// 这样可以确保由用户代码来决定是否换行。

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

1.3 构造函数:特殊的成员函数

  • 类通过一个或几个特殊的成员函数来控制其对象的初始化操作,这些函数被称作 构造函数(constructor)。
  • 构造函数一般放在类的public部分。
  • 构造函数的任务是初始化类对象的数据成员无论何时只要类的对象被创建,就会执行构造函数
  • 构造函数的名字和类名相同,和其他函数不一样的是,构造函数没有返回类型,且不能被声明为 const 函数
  • 构造函数在 const 对象的构造过程中可以向其写值。
  • 构造函数格式
    • Sales_item(): units_sold(0), revenue(0.0)
    • 构造函数名即类名(传参列表): 初始化列表 {构造函数的函数体}
    • 传参列表:实例化对象时传进来的参数
    • 构造函数初始值列表(constructor initializer list):冒号和花括号之间的代码,负责为新创建对象的一个或几个数据成员赋初始值,形式是每个成员名字后面紧跟括号括起来的(或者在花括号内的)成员初始值,不同成员的初始值通过逗号分隔。
struct 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 &);
    // 之前已有的其他成员
};
// 根据实例化对象时传进来的不同参数列表执行不同的默认构造函数。
// 如果实例化对象时没有传进任何参数,则执行默认构造函数

  • 数据成员初始化方式有三种(1)用构造函数传进来的参数对数据成员进行初始化,如果没有则执行(2)如果存在类内初始值,则用它来初始化成员。如果没有则执行(3)默认初始化该成员

  • 当某个数据成员被构造函数初始值列表忽略时,它会以与合成默认构造函数相同的方式隐式初始化。

// 上面只接受一个string参数的构造函数等价于
Sales_data(const std::string &s):
    bookNo(s), units_sold(0), units_sold(0) { }  
// units_sold和units_sold执行默认初始化
  • 构造函数不应该轻易覆盖掉类内初始值,除非新值与原值不同。如果编译器不支持类内初始值,则所有构造函数都应该显式初始化每个内置类型的成员。

  • 没有出现在构造函数初始值列表中的成员将通过相应的类内初始值(如果存在的话)初始化,或者执行默认初始化

  • 数据成员可以在构造函数初始值列表中初始化,也可以在构造函数体内初始化

  • 在类的外部定义构造函数
    // 使用 this 来把对象当成一个整体访问,而非直接访问对象的某个成员。

Sales_data::Sales_data(std::istream &is)
{
    read(is, *this); 
    // read函数的作用是从is中读取一条交易信息然后存入this对象中
}
####默认构造函数:无须提供实参
- 默认构造函数是一种特殊的构造函数。
- 类通过 **默认构造函数** 来控制默认初始化过程,**默认构造函数无须任何实参**。
- 默认构造函数是无须任何实参进行调用的构造函数,它包括了以下两种形式:
    - **没有带明显形参的构造函数**
    - **提供了默认实参的构造函数**
- 强调“没有带明显形参”的原因是,编译器总是会为我们的构造函数形参表插入一个隐含的this指针,所以”本质上”是没有不带形参的构造函数的,只有不带明显形参的构造函数,它就是默认构造函数
- 注意:一个类只能有一个默认构造函数!也就是说上述两种形式不能同时出现,一般选择 testClass(); 这种没有形参的默认构造函数 
```C++
// 参考:cnblogs.com/gklovexixi/p/5814626.html
class testClass
{
public:
    testClass();                    /* 默认构造函数 */
    testClass(int a, char b);        /* 构造函数 */
    testClass(int a=10,char b='c');    /* 默认构造函数 */

private:
    int  m_a;
    char m_b;
};
  • 默认构造函数什么时候被调用?
    • 如果定义一个对象时没有提供显式初始值,就使用默认构造函数
testClass classA;
// 或者  testClass *classA = new testClass;
// 在这种情况下,如果没有提供默认构造函数,编译器会报错;
  • 非默认构造函数在调用时接受参数,如以下形式:
testClass classA(12,'H');
 //或者  testClass *classA = new testClass(12,'H');

合成的默认构造函数

  • 如果类没有显式地定义构造函数,则编译器会为类隐式地定义一个默认构造函数,该构造函数也被称为 合成的默认构造函数(synthesized default constructor),即编译器构建的构造函数
    • 只要程序员定义了构造函数,编译器就不会再提供默认构造函数。程序员最好手动定义一个默认构造函数
  • 对于大多数类来说,合成的默认构造函数初始化数据成员的规则如下:
    • 如果存在类内初始值,则用它来初始化成员。
    • 否则默认初始化该成员。
  • 某些类不能依赖于合成的默认构造函数。合成的默认构造函数只适合非常简单的类,对于一个普通的类来说,必须定义它自己的默认构造函数,原因有三:
    • 只有当类没有声明任何构造函数时,编译器才会自动生成默认构造函数。一旦类定义了其他构造函数,那么除非再显式地定义一个默认构造函数,否则类将没有默认构造函数。
    • 对于某些类来说,合成的默认构造函数可能执行错误的操作。如果类包含内置类型或者复合类型的成员,则只有当这些成员全部存在类内初始值时,这个类才适合使用合成的默认构造函数。否则用户在创建类的对象时就可能得到未定义的值
    • 有时候编译器不能为某些类合成默认构造函数。例如类中包含一个其他类类型的成员,且该类型没有默认构造函数,那么编译器将无法初始化该成员。
  • 注意:只有在编译器需要默认构造函数来完成编译任务的时候,编译器才会为没有任何构造函数的类合成一个默认构造函数
  • 总结:
    • 合成默认构造函数总是不会初始化类的内置类型及复合类型的数据成员。
    • 分清楚默认构造函数被程序需要与被编译器需要,只有被编译器需要的默认构造函数,编译器才会合成它
  • 以下四种情况的类,编译器总是需要默认构造函数完成某些工作:

=default

  • 如果用户没有定义默认构造函数,在需要的时候编译器会生成一个默认的构造函数。但是,如果此时用户有定义其他构造函数(比如有参数的,或者参数不同的),那么编译器无论如何都不会合成默认的构造函数。
  • 在这种情况下,如果类需要默认的构造函数,可以通过在参数列表后面添加 =default 来要求编译器生成构造函数。
  • =default 可以和函数声明一起出现在类的内部,也可以作为定义出现在类的外部(不是内联的)。
  • 和其他函数一样,如果 =default 在类的内部,则默认构造函数是内联的。
Sales_data() = default;

1.4 拷贝、赋值和析构

  • 编译器能合成拷贝、赋值和析构函数,但是对于某些类来说,合成的版本无法正常工作。特别是,当类需要分配类对象之外的资源时,合成的版本通常会失效。
  • 使用 vector 或者 string 的类能避免分配和释放内存带来的复杂性

2. 访问控制与封装

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

访问说明符:public、protected、private

  • 使用 访问说明符 可以加强类的封装性:
    • 定义在 public 说明符之后的成员在整个程序内都可以被访问public 成员定义类的接口
    • 定义在 private 说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问。private 部分封装了类的实现细节
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.0;
};
  • 作为接口的一部分
    • 构造函数部分成员函数 紧跟在 public 说明符之后;
    • 数据成员作为实现部分的函数 则跟在 private 说明符后面。
  • 一个类可以包含零或多个访问说明符,而且对于某个访问说明符能出现多少次也没有严格限定。
  • 每个访问说明符指定了接下来的成员的访问级别,其有效范围到出现下一个访问说明符或类的结尾处为止

class和struct:都可以用来定义一个类。唯一的区别在于访问权限

  • 使用关键字 struct 定义类时,定义在第一个访问说明符之前的成员是 public 的;
  • 而使用关键字 class 时,这些成员是 private 的。
  • 二者唯一的区别就是默认访问权限不同。struct的默认访问权限是public,class的默认访问权限是private

2.1 友元(friend)

  • 类可以允许其他类或函数访问它的非public成员,方法是使用关键字 friend其他类或函数声明为它的 友元(friend)。
  • 友元允许特定的非成员函数访问一个类的私有成员.
  • 友元的声明以关键字 friend开始
    • friend Sales_data add(const Sales_data&, const Sales_data&);
    • 表示非成员函数add可以访问类的非公有成员。
  • 通常将友元声明集中放在类定义的开始或者结尾。
  • 友元声明只能出现在类定义的内部,具体位置不限。
  • 友元不是类的成员,也不受它所在区域访问级别的约束。
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:
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};
// Sales_data接口的非成员组成部分的声明
Sales_data add(const Sales_data&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
  • 通常情况下,最好在类定义开始或结束前的位置集中声明友元

  • 友元类:如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。

  • 友元声明仅仅指定了访问权限,而并非一个通常意义上的函数声明。如果希望类的用户能调用某个友元函数,就必须在友元声明之外再专门对函数进行一次声明(部分编译器没有该限制)。

  • 为了使友元对类的用户可见,通常会把友元的声明(类的外部)与类本身放在同一个头文件中。

  • 许多编译器并未强制限定友元函数必须在使用之前在类的外部声明。

  • 一些编译器允许在尚无友元函数的初始声明的情况下就调用它。不过即使你的编译器支持这种行为,最好还是提供一个独立的函数声明。这样即使你更换了一个有这种强制要求的编译器,也不必改变代码。

3. 类的其他特性

3.1 类成员再探

由类定义的类型名字

  • 由类定义的类型名字和其他成员一样存在访问限制,可以是 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 成员函数也该与类定义在同一个头文件中

关键字mutable:声明可变数据成员,const成员函数可修改可变成员的值

  • 有时我们希望能修改类的某个数据成员,即使是在一个const成员函数内
  • 使用关键字 mutable 可以声明 可变数据成员(mutable data member)。
  • 可变数据成员永远不会是 const 的,即使它在 const 对象内。因此 const成员函数可以修改可变成员的值
class Screen 
{
public:
    void some_member() const;
private:
    mutable size_t access_ctr;  // 即使在一个const对象内也能被修改
    // 其他成员与之前的版本一致
};
void Screen::some_member() const
{
    ++access_ctr;   // 保存一个计数值,用于记录成员函数被调用的次数
    // 该成员需要完成的其他工作
}
  • 为类数据成员提供类内初始值时,必须使用 =(赋值初始化) 或花括号形式(直接初始化)

3.2 返回*this的成员函数:返回对象本身,实现对象自身修改,无需赋值操作

  • 返回*this的成员函数的作用:返回对象本身,实现实例化对象自身修改,无需赋值操作。如直接A.t()来实现自身A的修改,而无须B = A.t()
class Screen {
public:
    Screen &set(char);
    Screen &set(pos, pos, char);
};

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对象作为左值返回
}
//这些函数返回的是对象本身而非对象的副本
// 把光标移动到一个指定的位置,然后设置该位置的字符值
myScreen.move(4,0).set('#');
// 等效于
myScreen.move(4,0);
myScreen.set('#');
	
// 如果令move返回Screen而非Screen&:
Screen temp = myScreen.move(4,0);  //对返回值进行拷贝
temp..set('#');  //不会改变myScreen的contents

从常量成员函数中返回*this

  • this指向const指针而*this是const对象
  • const 成员函数如果以引用形式返回 *this,则返回类型是常量引用。此时实例对象变成常量,非常量版本的函数对于常量对象是不可用的,即该实例对象就不能再调用非常量版本的函数了。为了避免这种问题,进行改进:基于const的重载
    • 通过区分成员函数是否为 const 的,可以对其进行重载。因为非常量版本的函数对于常量对象是不可用的,在常量对象上只能调用 const 版本的函数;在非常量对象上,尽管两个版本都能调用,但显然会选择非常量版本,因为是一个更好的匹配。
    • 常量对象只能调用常量成员函数
    • 非常量对象可以调用常量成员函数和非常量成员函数,但会更倾向于非常量版本,毕竟更匹配
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; }
    // 其他成员与之前的版本一致
};

Screen myScreen(5,3);
const Screen blank(5, 3);
myScreen.set('#').display(cout);    // 调用非常量版本
blank.display(cout);    	// 调用常量版本
/*
根据之前所学,当一个成员调用另外一个成员时,this指针在其中隐式地传递。
因此当display调用do_display时,它的this指针隐式地传递给do_display,
而当display的非常量版本调用do_display时,它的this指针将隐式地从指向非常量的指针转换成指向常量的指针。
而当display的常量版本调用do_display时,它的this指针不用转换,一直是常量的指针。
---------
当do_display完成后,display函数各自返回解引用this所得的对象。
在非常量版本中,this指针指向一个非常量对象,因此display返回一个普通的(非常量)引用,
在常量版本中,display则返回一个常量引用。
*/

3.3 类类型

  • 每个类定义了唯一的类型。即使两个类的成员列表完全一致,它们也是不同的类型。

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

Sales_data iteml;	// 默认初始化Sales_data类型的对象
class Sales_data iteml;	// 一条等价的声明
  • 可以仅仅声明一个类而暂时不定义它。这种声明被称作 前向声明,用于引入类的名字。在类声明之后定义之前都是一个 不完全类型。
class Screen;   // Screen类的声明
  • 不完全类型只能在非常有限的情景下使用:

    • 可以定义指向不完全类型的指针或引用,
    • 也可以声明(不能定义)以不完全类型作为参数或返回类型的函数。
  • 在创建一个类对象之前该类必须被定义过,而不能仅仅被声明,否则编译器就无法了解这样的对象需要多少存储空间。同样,类也必须先被定义才能用引用或指针访问其成员,不然编译器也不清楚该类到底有哪些成员。

  • 直到类被定义之后数据成员才能被声明成这种类类型。必须首先完成类的定义,然后编译器才能知道存储该数据成员需要多少空间。因为只有当类全部完成后才算被定义,所以一个类的成员类型不能是该类本身

  • 但是一旦类的名字出现,就可以被认为是声明过了,因此类可以包含指向它自身类型的引用或指针

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

3.4 友元再探

  • 除了普通函数,类还可以把其他类或其他类的成员函数声明为友元
  • 此外,友元函数能定义在类的内部,这样的函数是隐式内联的。但是必须在类外部提供相应声明令函数可见。
  • 友元类的成员函数可以访问此类包括非公有成员在内的所有成员。

将另一个类B作为友元:友元类

  • 除了普通函数,类还可以把其他类或其他类的成员函数声明为友元。
  • 友元类的成员函数可以访问此类包括非公有成员在内的所有成员
  • 注意:友元关系不存在传递性,每个类负责控制自己的友元类或友元函数。
class Screen 
{
    // Window_mgr的成员可以访问Screen类的私有部分
    friend class Window_mgr;
    // Screen类的剩余部分
};

将另一个类B的成员函数作为友元

  • 把其他类的成员函数声明为友元时,必须明确指定该函数所属的类名,即类名::函数名
  • 要想令类B的成员函数C作为类A的友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。必须按如下方式设计程序:
    • 先定义类B,其中声明成员函数C,但是还不能定义它(因为它要使用类A的成员,但类A还没有声明)。在成员函数C使用类A的成员之前必须先声明类A。
    • 接下来定义类A,包括对于成员函数C的友元声明
    • 最后类外定义成员函数C,此时它才可以使用类A的成员。
  • 如果类想把一组重载函数声明为友元,需要对这组函数中的每一个分别声明。
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的声明在作用域中了
  • 友元声明的作用是影响访问权限,它本身并非普通意义上的声明。请注意,有的编译器并不强制执行上述关于友元的限定规则

4. 类的作用域

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

4.1 定义在类外部的成员

  • 一个类就是一个作用域。
  • 当我们在类的外部定义成员函数时,必须同时提供类名和函数名即类名::函数名一旦遇到了类名,定义的剩余部分(包括参数列表和函数体)就在类的作用域之内了,即可直接使用类的其他成员。
  • 函数的返回类型通常在函数名前面,因此当成员函数定义在类外时,返回类型中使用的名字位于类的作用域之外,此时返回类型必须指明它是哪个类的成员。
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;
}

4.2 名字查找与作用域

  • 编译器处理完类中的全部声明后才会处理成员函数的定义

  • 成员函数体直到整个类可见后才会被处理,因此它能使用类中定义的任何名字。

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

  • 声明中使用的名字,包括返回类型或参数列表,都必须确保使用前可见。
  • 如果某个成员的声明使用了类中尚未出现的名字,贝IJ编译器将会在定义该类的作用域中继续查找。
  • 如果类的成员使用了外层作用域的某个名字,而该名字表示一种类型,则类不能在之后重新定义该名字。
  • 一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果类的成员使用了外层作用域的某个名字,而该名字表示一种类型,则类不能在之后重新定义该名字
typedef double Money;
class Account
{
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;
};
  • 尽管类的成员(外层的对象)被隐藏了,但我们仍然可以通过加上类的名字+作用域运算符:: 或 显式地使用 this 指针来强制访问被隐藏的类成员
// 不建议的写法:成员函数中的名字不应该隐藏同名的成员
void Screen::dummy_fcn(pos height) 
{
    cursor = width * this->height;  	// 成员height
    // 另外一种表示该成员的方式
    cursor = width * Screen::height;  	// 成员height
}

// 建议的写法:不要把成员名字作为参数或其他局部变量使用variable
void Screen::dummy_fcn(pos ht) 
{
    cursor = width * height;  			// 成员height
}

5. 构造函数再探

5.1 构造函数初始值列表

  • 如果没有在构造函数初始值列表中显式初始化成员,该成员会在构造函数体之前执行默认初始化
  • 如果成员是 const、引用,或者是某种未定义默认构造函数的类类型,必须在构造函数初始值列表中对这些成员进行初始化。
  • 当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。
class ConstRef
{
public:
    ConstRef(int ii);
private:
    int i;
    const int ci;
    int &ri;
};

// 正确:显式地初始化引用和const成员
ConstRef::ConstRef(int ii): i(ii), ci(ii), ri(i) { }
  • 最好令构造函数初始值的顺序与成员声明的顺序一致,并且尽量避免使用某些成员初始化其他成员。
  • 如果可能的话,最好用构造函数的参数作为成员的初始值,而尽量避免使用同一个对象的其他成员,好处是我们可以不必考虑成员的初始化顺序。
    • X(int val): i(val), j(val){}
  • 如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数
    • Sales_data(std::string s = ""): bookNo(s) {}
  • 建议读者养成使用构造函数初始值的习惯

5.2 委托构造函数

  • 委托构造函数(delegating constructor, c++11):将自己的职责委托给了其他构造函数
  • C++11扩展了构造函数初始值功能,可以定义 委托构造函数
  • 委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数
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); } //先委托给默认构造函数,,默认构造函数又接着委托给三参数版本
    // 其他成员与之前的版本一致
}

5.3 默认构造函数的作用

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

  • 默认初始化的发生情况

    • 在块作用域内不使用初始值定义非静态变量或数组。
    • 类本身含有类类型的成员且使用合成默认构造函数。
    • 类类型的成员没有在构造函数初始值列表中显式初始化。
  • 值初始化的发生情况

    • 数组初始化时提供的初始值数量少于数组大小。
    • 不使用初始值定义局部静态变量。
    • 通过 T() 形式(T为类型)的表达式显式地请求值初始化。
  • 类必须包含一个默认构造函数

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

  • 如果想定义一个使用默认构造函数进行初始化的对象,应该去掉对象名后的空括号对

// 正确:obj是个默认初始化的对象
Sales_data obj;
  • 对于C++的新手程序员来说有一种常犯的错误,它们试图以如下的形式声明一个用默认构造函数初始化的对象:
Sales_data obj();   // 错误:声明了一个函数而非对象
Sales_data obj2;    // 正确:obj2是一个对象而非函数

5.4 隐式的类类型转换

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

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

string null_book = "9-999-99999-9";
// 构造一个临时的Sales_data对象
// 该对象的units_sold和revenue等于0,bookNo等于null_book
item.combine(null_book);
/*
在Sales_data类中,只接受一个string的构造函数和只接受一个istream的构造函数分别定义了从这两种类型向Sales_data 隐式转换的的规则。
如上例中,编译器用给定的string自动创建了一个Sales_data对象,新生成的这个临时Sales_data对象被传递给combine。
*/
  • 编译器只会自动执行一步类型转换。
// 错误:需要用户定义的两种转换。
// (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"));

explicit关键字:只对构造函数起作用,用来抑制隐式转换

  • 抑制构造函数定义的隐式转换(因为类类型转换不是总有效)
参考:https://www.cnblogs.com/cutepig/archive/2009/01/14/1375917.html
class A{  
    A(int a);  
};  
int Function(A a);   
// 当调用Function(2)的时候,2 会隐式转换为 A 类型。
// 这种情况常常不是程序员想要的结果,所以,要避免之,就可以这样写:  
   
class A{  
    explicit A(int a);  
};  
int Function(A a);  
// 这样,当调用Function(2) 的时候,编译器会给出错误信息(除非Function有个以int为参数的重载形式),这就避免了在程序员毫不知情的情况下出现错误。
  • 什么时候抑制

    • 如果这种转换是自然而然的,则不应该把它定义成 explicit 的;
    • 如果二者的语义距离较远,则为了避免不必要的转换,应该指定对应的构造函数是 explicit 的。
  • 在要求隐式转换的程序上下文中,可以通过将构造函数声明为 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&);
    // 其他成员与之前的版本一致
};
  • explicit 关键字只对接受一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,所以无须将这些构造函数指定为 explicit 的。

  • 只能在类内声明构造函数时使用 explicit 关键字,在类外定义时不能重复

  • 执行拷贝初始化时(使用 =)会发生隐式转换,所以 explicit 构造函数只能用于直接初始化(使用圆括号)不能用于拷贝初始化。意思是说我们创建新的实例对象时,初始化只能用直接初始化,不能拷贝初始化

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

  • 尽管编译器不会将 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的

5.5 聚合类

  • 聚合类满足如下条件:
    • 所有成员都是 public 的。
    • 没有定义任何构造函数。
    • 没有类内初始值。
    • 没有基类。
    • 没有虚函数。
  • 下面的类是一个聚合类:
struct Data
{
    int ival;
    string s;
};
  • 可以使用一个用花括号包围的成员初始值列表初始化聚合类的数据成员初始值顺序必须与声明顺序一致。如果初始值列表中的元素个数少于类的成员个数,则靠后的成员被值初始化。
// val1.ival = 0; val1.s = string("Anna")
Data val1 = { 0, "Anna" };

// 错误:不能使用"Anna"初始化ival,也不能使用1024初始化s
Data va12 = { "Anna", 1024 };

5.6 字面值常量类

  • constexpr函数的参数和返回值必须是字面值。

  • 除了算术类型、引用和指针外,某些类也是字面值类型。

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

  • 如果一个类不是聚合类,但符合下列所有条件,则也是字面值常量类:

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

  • constexpr 构造函数必须初始化所有数据成员,初始值使用 constexpr 构造函数或常量表达式。

6. 类的静态成员:用static关键字声明,所有实例化对象共享

  • 使用关键字 static 可以声明类的静态成员,可以是 public 的或 private 的。
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数据成员存在于类类型的每个对象中
  • 静态成员存在于任何对象之外,对象中不包含与静态成员相关的数据。类似的, 静态成员函数也不与任何对象绑定在一起。
  • 由于静态成员不与任何对象绑定,因此静态成员函数不能声明为 const 的,也不能在静态成员函数内使用 this 指针
  • 每个static数据成员是与类关联的对象,并不与该类的对象相关联。

访问静态成员的方式

  • (1)外部访问类的静态成员能直接通过类名来访问,即使用作用域运算符::直接访问静态成员:r = Account::rate();
  • (2)虽然静态成员不属于类的某个对象,也仍然可以通过类对象、引用或指针访问:r = ac.rate();
  • (3)类的成员函数可以直接访问静态成员,不用作用域运算符
double r;
r = Account::rate(); // 使用作用城运算符访问静态成员

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

class Account
{
public:
    void calculate() { amount += amount * interestRate; }
private:
    static double interestRate;
    // 其他成员与之前的版本一致
};

定义静态成员

  • 和其他的成员函数一样,我们既可以在类的内部也可以在类的外部定义静态成员函数。
  • 在类外部定义静态成员时不用加static,即不能重复 static 关键字,该关键字只出现在类内部的声明语句
// 类外部
void Account::rate(double newRate)
{
    interestRate = newRate;
}
  • 和类的所有成员一样,当我们指向类外部的静态成员时,必须指明成员所属的类名。

初始化静态成员

  • 和类的所有成员一样,当我们指向类外部的静态成员时,必须指明成员所属的类名。static 关键字则只出现在类内部的声明语句中。

  • 由于静态数据成员不属于类的任何一个对象,因此它们并不是在创建类对象时被定义的,这意味着它们不是由类的构造函数初始化的。

  • 通常情况下,不应该在类内部初始化静态成员。而必须在类外部定义并初始化每个静态成员。

    • double Account::interestRate = initRate();
  • 一个静态成员只能被定义一次。一旦它被定义,就会一直存在于程序的整个生命周期中。

// 定义并初始化一个静态成员
double Account::interestRate = initRate();
  • 建议把静态数据成员的定义与其他非内联函数的定义放在同一个源文件中,这样可以确保对象只被定义一次。

  • 通常情况下,不应该在类内部初始化静态成员。如果一定要在类内部定义,则要求静态成员必须是字面值常量类型的constexpr,此时可以为静态成员提供 const 整数类型的类内初始值。

  • 初始值必须是常量表达式,因为这些成员本身就是常量表达式, 所以它们能用在所有适合于常量表达式的地方。

class Account
{
public:
    static double rate() { return interestRate; }
    static void rate(double);
private:
    static constexpr int period = 30;  // period是常量表达式
    double daily_tbl[period];
};
  • 即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。

  • 静态成员独立于任何对象。

    • 特别的,静态数据成员的类型可以就是它所属的类类型。而非静态数据成员则受到限制,只能声明成它所属类的指针或引用:
class Bar
{
public:
	// ...
private:
    static Bar mem1;   	// 正确:静态成员可以是不完全类型
    Bar *mem2;    	// 正确:指针成员可以是不完全类型
    Bar mem3;   	// 错误:数据成员必须是完全类型
};
  • 静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参。
class Screen
{
public:
    // bkground表示一个在类中稍后定义的静态成员
    Screen& clear(char = bkground);
private:
    static const char bkground;
};
  • 非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分, 这么做的结果是无法真正提供一个对象以便从中获取成员的值, 最终将引发错误。
posted @ 2021-05-25 00:55  夏目的猫咪老师  阅读(337)  评论(0编辑  收藏  举报