抽象数据类型
写在前面:
《C++ Primer》是C++程序员的圣经,但这并不意味着读完就精通C++了。真正的学习寓于实践,在实践中查漏补缺、锻炼能力,光看书是远远不够的。
可以刷LeetCode,可以做项目,只是抱着大部头硬啃那么进步不会很大。
成员函数
struct Sales_data { // struct和class除了public都相同
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 add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
成员函数的声明在类的内部,但是定义可以在类的内部或外部。作为接口部分的非成员函数,声明和定义在外部
定义在类内部的函数隐式内联,若声明在类内,定义在外部,则外部的定义前要加inline
inline函数
为了避免函数调用的开销,将函数在调用点“内联”地展开。
/* 在函数前加关键字`inline`即可 */
inline const string &shorterString(const string &a, const string &b) {
return a.size() < b.size() ? a : b;
}
cout << shorterString(s1, s2) << endl;
↓
cout << s1.size() < s2.size() ? s1 : s2 << endl;
内联只是向编译器发送一个请求,编译器可以选择忽略
一般来说,内联用于优化规模小、流程直接、调用频繁的函数
引入this
成员函数通过this这个额外隐式参数来访问调用它的对象,调用一个成员函数时,用该对象的地址初始化this
std::string isbn() const { return bookNo; }
total.isbn()
可以等价认为以下伪代码形式 Sales_data::isbn(&total)
因为this
就是要指向这个对象,所以this
是个常量指针,不允许改变this
保存的地址
const成员函数
承接上文的isbn
函数
在Sales_data
中,this
类型是Sales_data *const
,意味着不能绑定到常量对象上(如初始化规则中规定int *p = const对象违法)
同时常量对象也不能调用普通函数
考虑将this
声明成const Sales_data *const
类型。
做法:在参数列表后加上const
关键字,像这样使用const
的函数被称作常量成员函数
// 伪代码,下面的实际代码是违法的
std::string Sales_data::isbn(const Sales_data *const this) { return this->bookNo; }
PS:常量成员函数不能改变对象的内容
常量对象,以及常量对象的引用或指针都只能调用常量成员函数。
类作用域
类本身就是个作用域,成员函数的定义嵌套在类的作用域之内。
编译器分两步处理类:
- 编译成员的声明
- 编译成员函数体
因此成员函数体可以随意使用成员而不用在乎顺序
在类的外部定义成员函数
成员函数的定义必须与类中的声明匹配,如果被声明为常量成员函数,那么定义就得指定const
属性
/* 文章开头的Sales_data类 */
double Sales_data::avg_price() const {
if (units_sold)
return revenue / units_sold;
else
return 0;
}
编译器看到函数名,就知道代码是位于类的作用域的,所以能够隐式地使用类中的成员
返回this对象的函数
Sales_data& combine(const Sales_data &rhs) {
units_sold += rhs.units_sole;
revenue += rhs.revenue;
return *this;
}
combine
设计初衷类似于+=
当定义的函数类似某个内置运算符时,应该令该函数的行为模仿这个运算符。
int a = 3;
int b = 4;
int c = a += b; // 7
内置的赋值运算符把左侧运算对象当成左值返回
所以combine函数必须返回引用类型。把调用函数的对象当成整体 return *this
类相关的非成员函数
通常把函数的声明放在头文件,定义放在源文件。如果函数概念上属于类,但不是成员函数,应与类的声明放在同一头文件。这样用户使用接口的任何部分都只需引入一个文件。
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();
}
注意:
- I/O类不能被拷贝,所以只能通过引用传递。并且读写操作改变流的内容,所以不能是常量引用
- print不负责换行,让用户代码决定是否换行
拷贝类的对象就是拷贝对象的数据成员,由于函数已经定义好了,所以仍然可以使用成员函数
Sales_data a = b; // 将b的数据成员拷贝给a
构造函数
构造函数:初始化类对象的数据成员
构造函数不能被声明为const
,创建一个const
对象时,直到构造函数完成初始化后,对象才能真正取得const
属性
默认构造函数
初始化规则:
- 如果存在类内初始值,则用它来初始化成员
- 默认初始化该成员
默认初始化:如果定义变量时没有指定初始值,则变量被默认初始化。默认值由变量类型、变量位置决定。内置类型变量未被显式初始化,若定义于函数体外就被初始化为0,否则不被初始化,其值未定义。
有些类不能依赖合成的默认构造函数
- 编译器只有在发现类不包含任何构造函数的情况下才会生成默认构造函数
- 合成的默认构造函数可能执行错误操作
- 有时候编译器不能为某些类合成默认构造函数
default
Sales_data() = default; // 这个函数的意义是我们需要其他形式的构造函数,也需要默认的构造函数
函数初始值列表
负责为数据成员赋初值
Sales_data(donst std::string &s, unsigned n, double p):bookNo(s), units_sold(n), revenue(n * p) {}
// 函数体可为空
类的外部定义构造函数
Sales_data::Sales_data(std::istream &is) { // 构造函数没有返回类型
read(is, *this);
}
拷贝、赋值和析构
如果我们不主动定义拷贝、赋值和析构的操作,编译器会替我们合成它们。一般来说,编译器生成的版本会对每个成员执行拷贝、赋值和销毁操作。后续会讲到如何自定义上述操作
某些类不能依赖合成版本
管理动态内存的类不能依赖编译器的合成版本
不过很多需要动态内存的类可以使用vector
或string
,从而避免分配和释放内存带来的复杂性
访问控制与封装
目前,我们的类还没有封装,即用户可以直达Sales_data
内部。
我们使用访问说明符加强封装性
public
,定义类的接口private
,可以被类的成员函数访问,但是不能被使用该类的对象访问。封装(隐藏)了实现细节
一个类可以有很多个访问说明符,不过通常是俩,一public
一private
,看起来整洁一点
class,struct关键字
唯一的一点区别是:struct第一个访问说明符之前的成员是public
,class则是private
友元
类可以允许其他类或函数访问private成员,增加一条关键字friend
开始的函数声明即可。
友元出现的位置不限,但是一般在类定义开始或结束。嗯C++的编写蛮自由的
Note:
封装的两个优点:
- 确保用户代码不会破坏封装对象的状态
- 被封装的类的细节可以随时改变,而无需调整用户级别的代码
只要类的接口不变,用户代码就无需改变。但是尽管类的定义发生改变时无需更改用户代码,但是使用了该类的源文件必须重新编译
友元的声明
友元的声明只是指定某个函数或类有访问权限,而非普通意义上的函数声明。
友元有可能破坏类的封装性
类的其他特性
类成员再探
类型成员
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) {}
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;
}
自定义某种类型在类中的别名:typedef std::string::size_type pos
using pos = std::string::size_type
名字同样存在访问限制,可以是public或private
用户不应该知道Screen
使用string::size_type
对象存放数据,用pos
可以隐藏实现细节
Screen构造函数中的函数初始值列表的contents构造,意思是生成ht * wd
个c
。其实和int a{0}
string s("hello")
差不多
内联函数
定义在类中的函数默认内联,如果类中的声明没有带inline
,可以在类外部的定义前加上关键字inline
// 指定inline
inline Screen &Screen::move(pos r, pos c) { // 记得加上类的作用域
cursor = r * width + c;
return *this;
}
char Screen::get(pos ht, pos wd) const { // 在类的内部声明为inline
return contents[ht * width + wd];
}
最好只在类外部函数定义的地方说明inline
,使类更容易理解
可变数据成员
mutable
修饰,一个可变数据成员不会是const,即使是const对象的成员。即const对象内部的mutable
成员可以修改,一个const成员函数也可以改变可变成员的值。
class Screen {
public:
void func() const;
private:
mutable int access_ctr = 0;
};
void func() const {
access_ctr++; // 修改了类中的对象
}
类内初始值
class Window_mgr { // manager->mgr
private:
std::vector<Screen> screens{Screen(24, 80, ' ')};
}
想定义一个窗口管理器,开始时总有默认初始化的一个Screen
==Note:==当我们提供一个类内初始值时,必须以**=或者花括号**表示。但是初始值列表中可以使用圆括号,花括号试了下没报错(貌似也可)
当创建对象的时候,区分()和{}的使用,是《Modern Effective C++》的内容
返回this的成员函数
class Screen {
public:
Screen &set(char);
Screen &set(pos, pos, char);
...
}
inline Screen &Screen::set(char ch) {
contents[cursor] = ch;
return *this;
}
inline Screen &Screen::set(pos r, pos c, char ch) {
// 以后可以补个try-catch,防止范围超过contents表示范围
contents[r * width + c] = ch;
return *this;
}
返回调用对象自身的引用,意义是可以连着调用,如下
myScreen.move(4, 0).set('a');
从const成员函数返回*this
定义个display
的常量成员函数,则this
指向const,所以返回类型为const Screen&
。
myScreen.display().set('a')
会导致错误,因为常量引用无法set
。
Note: 一个const成员函数如果以引用的形式返回*this
,则返回类型是常量引用
基于const的重载
常量对象只能调用常量版本的函数,非常量对象可以调用常量或非常量版本,但是非常量版本是个更好的匹配。
class Screen {
public:
// 根据对象是否是const对象重载函数
Screen &display(std::ostream &os) {
do_display(os);
return *this;
}
const Screen &display(std::ostream &os) const { // 第一个const必须加,要不然就是非常量引用绑定到常量对象,导致编译错误
do_display(os);
return *this;
}
private:
void do_display(std::ostream &os) const { // 或许可以学学令人头痛的命名
os << contents;
}
}
Screen &res1 = myScreen.display(std::cout);
const Screen &res2 = constScreen.display(std::cout);
const对象里除了mutable
,类型前都要自带const
属性,所以返回值如果是引用就必须加const
对于公共代码使用私有功能函数:如上文的do_display私有函数
- 基本愿望是避免多处使用同样的代码
- display函数可能变得更复杂
- 可能在display函数中添加调试信息,在最终产品版本中去掉
- 额外的函数调用不会增加开销。在类中定义的函数被隐式声明成内联
在实践中,设计良好的C++代码常常包含大量的小函数
类类型
// 等价声明
Sales_data item1;
class Sales_data item1;
struct Sales_data item;
可以把类名跟在class
或struct
后面
类的声明
我们可以暂时声明类,可以之后定义
class Screen;
在类声明之后、定义之前,类是个不完全类型。
应用场景:
- 定义指向这个类的指针或引用
- 疑问:
Screen *p
可以定义指针,但是如何定义引用?
- 疑问:
- 声明以不完全类型作为参数或返回类型的函数
类的作用域
一旦遇到了类名,定义的参数列表和函数体就不需要再次授权了
void Window_mgr::clear(ScreenIndex i) {
Screen &s = screens[i];
s.contents = sttring(s.height * s.width, ' ');
}
名字查找与作用域
名字查找的直接方式:
- 在名字所在块中搜索声明,只考虑名字使用之前的声明。
- 没找到就继续搜索外层作用域
- 没找到匹配生命,报错
类中解析名字的方式:
- 编译成员的声明
- 编译函数体
好处是函数体直到类可见(编译完所有声明)后才会被处理,所以能使用类中出现的任何名字。
如果某个成员的声明中出现了尚未出现的名字,编译器会继续在类的作用域中找。
typedef double Money;
string bal;
class Account {
public:
Money balance() { return bal; }
private:
Money bal;
}
类型名的特殊处理(typedef/using)
如果成员使用了外层作用域的某个名字,则类不能在之后重新定义该名字
typedef double Money;
class Account {
public:
Money balance() { return bal; } // 外层作用域的Money
private:
typedef double Money; // 错误:不能重新定义Money
Money bal;
}
Tip: 类型名的定义通常出现在类的开始处,之后使用类型名就不会有问题了
成员函数作用域的名字查找
- 在成员函数内查找该名字的声明
- 查找类内所有成员
- 在成员函数定义前的作用域继续查找
tip: 成员包括成员变量和成员函数
不建议用其他成员的名字作为成员函数的参数
int height;
class Screen {
public:
typedef std::string::size_type pos;
void func(pos height) {
cursor = width * height; // height隐藏了同名的成员
}
private:
pos cursor = 0;
pos width = 0, height = 0;
}
可以用this->height
或Screen::height
指代成员height
::height
代表全局的height
,通过作用域运算符访问它
在文件中名字的出现处进行解析
int height;
class Screen {
...
}
Screen::pos verify(Screen::pos); // 之前提到的遇到类名,后面不用授予访问权限是在函数的名字上有类名,而不是返回类型,所以这里的参数列表仍然需要加Screen::
void Screen::setHeight(pos var) {
height = verify(var); // verify是没定义?
}
名字查找的第三步是成员函数定义前的作用域,所以可以使用verify
构造函数再探
Sales_data obj();
定义了一个函数而非对象,所以如果想定义一个Sales_data
对象,直接Sales_data obj
就好了
在《Modern Effective C++》里,提到了花括号和圆括号
函数初始值列表
Sales_data::Sales_data(const string &s, unsigned cnt, double price) {
// 合法但是草率
bookNo = s;
units_sold = cnt;
revenue = cnt * price;
}
上述代码和使用初始值列表效果相同,区别是这个版本对数据成员执行了赋值操作,初始值列表则是初始化了数据成员
必须使用初始值列表的情况
如果成员是const
或引用
,就必须初始化,不能使用赋值的方式;此外,当成员属于某种类类型且该类没有默认构造函数时,也必须先初始化
例:
class ConstRef {
public:
ConstRef(int ii);
private:
int i;
const int ci;
int &ri;
}
如果写成
ConstRef::ConstRef(int ii) {
i = ii; // 正确
ci = ii; // 错误:不能给const赋值,且const未初始化
ri = i; // 错误:ri没有初始化
}
构造函数体开始执行时,初始化成员就已经完成了。所以我们初始化const和引用的唯一方式就是构造函数初始值
ConstRef::ConstRef(int i)
成员初始化的顺序
成员初始化的顺序与在类中定义的顺序一致。
正常情况下,我们写函数初始值的顺序就与类中定义的顺序一致,如果不一样,在下面的情况中会导致错误
class A {
int i;
int j;
public:
X(int val): j(val), i(j) {}
// i先被初始化,但是此时j尚未初始化
// 实际上是用未定义的j的值初始化i!
}
Tip: 最好用构造函数的参数作为成员初始值
上面的构造函数可以改成 X(int val): i(val), j(val) {}
默认实参和构造函数
Sales_data(std::string s = ""): bookNo(s) {}
与默认构造函数的功能相同
Note: 如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数
不过值得注意的是,不应当写 Sales_data(std::string s="", unsigned cnt=0, double rev=0.0): bookNo(s), units_sold(cnt), revenue(rev * cnt) {}
,因为如果用户提供了cnt
,那么我们就要求用户同时提供rev
,而上述构造函数并不满足,因为用户可以提供cnt
而不提供rev
委托构造函数
一个构造函数使用另外一个构造函数,执行它自己的初始化过程,这个构造函数就叫委托构造函数
在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身。(跟在冒号后面)
class Sales_data {
Sales_data(std::string s, unsigned cnt, double rev): bookNo(s), units_sold(cnt), revenue(rev*cnt) {};
Sales_data(): Sales_data("", 0, 0.0) {}
Sales_data(std::string s): Sales_data(s, 0, 0.0) {}
Sales_data(std::istream &is): Sales_data() { read(is, *this) }
}
隐式的类类型转换
后续介绍如何定义一种将类类型转换为另一种类类型的转换规则
Note: 通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则,这个构造函数被称为转换构造函数(converting constructor)
string book("9-999-99999-9");
item.combine(book);
编译器用给定的string对象自动创建了一个临时的Sales_data
对象。combine的参数是常量引用,所以我们可以给参数传递临时量
书中提到:
我们用一个string实参调用了Sales_data的combine成员
不是很理解,难道不是Sales_data::combine(const Sales_data &, this)
吗,为什么是形参调用combine呢?
只允许一步类类型转换
以下代码隐式使用两种转换规则,所以是错误的
item.combine("9-9"); // wrong
// "9-9"->string
// string->Sales_data
item.combine(string("9-9")); // 显示转换成string,隐式转换成Sales_data
item.combine(Sales_data("9-9"));// 隐式转换成string,显示转换成Sales_data
构造函数中是std::string
,如果是正常函数const string
对象是不能被绑定到string
对象上的
蒽我什么都没说,我只能透露Sales_data
构造函数的第一个参数类型是std::string
item.combine(cin);
// 构建了对象,把它读取到的值加到item中,随后丢弃
cloudflare, heracle