C++ 面向对象 | 类

根据看的书(《C++ Primer 5th》)和网上搜集的资料做一个知识沉淀。目前只是对「类」这个部分的巩固梳理。

类的基本思想

类的基本思想:

  • 数据抽象:依赖于接口实现分离的编程(及设计)技术
  • 封装:实现了类的接口和实现的分离

类的接口包括:

  • 用户所能执行的操作

类的实现包括:

  • 类的数据成员
  • 负责接口实现的函数体
  • 定义类的各种私有函数

这里我的理解是类的接口应该是 public 的,而类的实现是 private 的。

成员函数和非成员函数

类的成员函数是类的一个成员,它可以操作类的任意对象,可以访问对象中的所有成员。成员函数的声明必须在类的内部,定义既可以在类的内部也可以在类的外部。属于接口部分

非成员函数是属于类的实现部分,不属于接口部分。它们的定义和声明都在类的外部。

在类内部中定义的成员函数是隐式的inline函数。

内联函数是在编译时展开,因此没有运行时开销,可以用来优化规模小、流程直接、频繁调用的函数。

在类外部中定义的成员函数,但是定义的时候,返回类型、参数列表、函数名需要加上类的作用域,比如Sale_data::avg_price()

this 指针

成员函数通过this的额外隐式参数来访问调用它的那个对象。this是一个常量指针,不允许改变this中保存的地址。

友元函数没有this 指针,因为友元不是类的成员。只有成员函数才有this指针。

const 成员函数

有些成员函数在紧随参数列表之后有const关键字,在这里,const的作用是修改隐式this指针的类型。

默认情况下,this的类型是指向类类型非常量版本的常量指针

当我们在函数体内不会修改this所指向的对象,这时我们把this设置为指向常量类类型的常量指针。

像这样使用const的成员函数称作常量成员函数。

构造函数

构造函数是类的一种特殊的成员函数,它会在每次创建类的新对象时执行。是定义对象初始化的方式。

构造函数的名称与类的名称是完全相同的,并且不会返回任何类型,也不会返回void。构造函数可用于为某些成员变量设置初始值。

构造函数不能被声明为 const 函数,因为当我们创建类的一个 const 对象时,直到构造函数完成初始化过程,对象才真正取得“常量”属性。

构造函数可以在类的外部定义

合成的默认构造函数

默认的构造函数控制默认初始化过程,不需要任何实参。

如果类没有显式的定义构造函数,那么编译器就会为我们隐式的定义一个默认构造函数,编译器创建的默认构造函数又叫做合成的默认构造函数。这个合成的默认构造函数将按照以下规则来初始化类的数据成员:

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

一旦为类定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认的初始化函数。

1
Sale_data() = default; // 通过 = default 要求编译器生成默认构造函数

构造函数的初始值列表

1
Line::Line(double len, int p): length(len), price(p) { };

: length(len), price(p)这部分代码就是构造函数初始值列表。这个有两个参数的构造函数分别用那两个参数初始化成员。

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

拷贝、赋值和析构

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

如果我们不主动定义这些操作,则编译器会替我们合成它们。

但是当类需要分配对象之外的资源时,合成的版本会失败。比如管理动态内存的类。

访问控制与封装

说明符

封装类需要用访问说明符:

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

构造函数和部分成员函数在 public 之后;数据成员和作为实现部分的函数跟在 private 之后。

class 和 struct 关键字

class 和 struct 关键字定义的类不同在于默认的访问权限不同:

  • 使用 struct:定义在第一个说明符之前的成员都是 public 的
  • 使用 class:定义在第一个说明符之前的成员都是 private 的

友元

类可以允许其他类或者函数访问它的非公有成员,方法是令其他的类或者函数称为它的友元

如果一个类想把一个函数作为它的友元,只需要增加一条以friend关键字开始的函数声明语句即可:

1
2
3
4
5
6
7
8
9
class Sales_data {
// 友元声明
friend Sales_data add(const Sales_data&, const Sales_data&);
// ....
public:
// ...
private:
// ...
}

友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限制。

注意⚠️:友元的声明仅仅是指定了访问的权限,而非一个通常意义上的函数声明。因此如果我们希望调用这个友元函数,还需要在友元声明之外再专门对函数进行一次声明。通常放在与类在同一个头文件中的类的外部。

类的其他特性

内联函数

定义在类的内部的成员函数是自动 inline 的。同时也可以在类的内部显示的声明成员函数为 inline,也可以在外部定义时使用 inline 关键字修饰函数的定义。无需再声明和定义的地方同时说明 inline,但是这样是合法的。最好只在类外部定义的地方说明 inline,这样可使类更容易理解。

可变数据成员

有时(并不频繁)会发生这样这样的情况:希望能修改类的某个数据成员,即使在一个 const 成员函数中。可以通过在变量的声明中加入mutable关键字做到这一点。

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

类数据成员的初始值

一个类里面有一个类数据成员,这个类数据成员需要有一个默认值,通常将这个默认值声明为一个类内初始值:

1
2
3
4
class Windows {
private:
std::vector<Screen> screens{Screen(24, 80, ' ')};
}

注意:使用花括号。

友元再探

类可以将其他的类定义成友元,也可以将其他类的(已经定义过的)成员函数定义为友元。友元函数能定义在类的内部,这样的函数是隐式 inline 的。

1
2
3
4
5
6
7
8
9
10
11
// 类作为友元
class Screen {
friend class Windows;
// ...
}
// 类的函数作为友元
class Screen {
friend void Windows::clear(...);
// ...
}

友元关系不具有传递性,比如 B 是 C 的友元,而 A 是 B 的友元,但是 A 不具有能访问 C 的特权。

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

类和非成员函数的声明不是必须在它们的友元声明之前。

其他

常量对象无法调用非常量成员函数。

非常量对象可以调用常量成员函数或者非常量成员函数。

类的作用域

每个类都会定义它自己的作用域。一个类就是一个作用域,当我们在类的外部定义成员函数时,必须同时提供类名和函数名。当提供了类名后,定义的剩余部分就在类的作用域之内了。

函数返回类型通常出现在函数名之前,因此成员函数定义在外面时,返回类型中使用的名字都位于类的作用域之外,这时当返回类型是类中的成员时必须指明它是那个类的成员。

1
2
3
Windows::ScreenIndex Windows::addScreen(...) {
// ...
}

名字查找

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

  • 首先,在成员函数中查找该名字
  • 如果在成员函数中没有找到,则在类中继续查找,这时类的所有成员都可以考虑
  • 如果类中也没有找到该名字的声明,在成员函数定义以前的作用域内继续查找

构造函数再探

如果一个类的数据成员是「const」或者「引用」,必须将其初始化。类似的,当成员是某种类类型且该类没有定义默认构造函数时,也必须将其初始化。

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

默认实参

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

委托构造函数

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

一个委托构造函数有一个成员初始值的列表和一个函数体。在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身。类名之后紧跟圆括号括起来的参数列表,参数列表与类中的另一个构造函数匹配

当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行,然后控制权才会交还给委托者的函数体。

隐式的类类型转换

转换构造函数:构造函数只接受一个实参, 则它实际上定义了转换为此类类型的隐式转换机制。通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换机制。

1
2
string null_book = "9-99-999";
item.combine(null_book); // 编译器会将 null_book 自动创建了一个 Sales_data 对象
1
2
3
4
5
6
7
// 错误
item.combine("9-99-999");
// 正确:显示的转换为 string,隐式的转换为 Sales_data
item.combine(string("9-99-999"));
// 正确:隐式的转换为 string,显式的转换为 Sales_data
item.combine(Sales_data("9-99-999"));

想要抑制构造函数定义的隐式转换,可通过 explicit 声明构造函数。

关键字 explicit 只对一个实参的构造函数有效,只能在类内声明构造函数时使用 explicit 关键字,在类外部定义时不应重复。

1
2
3
4
5
6
7
8
9
class Sales_data {
public:
Sales_data() = default;
Sales_data(const string &s, unsigned n, double p): bookNo(s), units_sold(n), revenue(p*n) { }
explicit Sales_data(const string &s): bookNo(s) { }
explicit Sales_data(istream &);
private:
// ...
}

聚合类

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

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

初始化聚合类对象:

1
2
3
4
5
6
7
struct Data {
int v;
string s;
};
// 通过花括号括起来的成员初始值列表初始化数据成员
Data val1 = {0, "Anna"};

初始化顺序必须与声明的顺序一致!若初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。

字面值常量类

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

字面值类型:算数类型、引用、指针。

类是字面值类型的话,该类可能含有 constexpr 函数成员。

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

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

字面值常量类的构造函数可以是 constexpr 函数。事实上,一个字面值常量类必须至少提供一个 constexpr 构造函数。

constexpr 构造函数可以声明成=default的形式。constexpr 构造函数体一般来说应该是空的。

类的静态成员

有时候类的一些成员直接与类相关,而不是与类的各个对象相关。

在成员的声明之前加上关键字static使其与类关联在一起。静态成员可以是public或者private。静态成员的类型可以是常量、引用、指针、类类型。

类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。

类似的,类的静态成员函数也不和任何对象绑定在一起,他们不包含this指针。静态成员函数不能声明为const的,不能在 static 函数体内使用this指针。

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

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

定义静态成员

可以在类的内部或者外部定义静态成员函数。static 关键字只能出现在类内部的声明语句中

静态成员不是由类的构造函数初始化的,而且不能再类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态成员只能定义一次。

静态成员的类内初始化

通常情况下,类的静态成员不应该在类的内部初始化。然而我们可以为静态成员提供 const 整数类型的类内初始化,不过要求静态成员必须是字面值常量类型的 constexpr。初始值必须是常量表达式。

1
2
3
4
5
6
class Accout {
public:
// ...
private:
static constexpr int period = 30
}

即使一个一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。

静态成员和普通成员的一个区别:静态成员可以作为默认实参,非静态成员不能作为默认实参。