前言
本章是C++基础的最后一章,从下一章开始的内容就变得困难很多,第一次看有很多地方都比较模糊。本章的知识点相对来说都很重要,尽量搞懂,否则后面章节讲解类中更深的知识点理解起来很困难。
前言
类的基本思想是数据抽象和封装。数据抽象是一种依赖于接口和实现分离的编程技术。封装实现了类的接口和实现的分离。
一、定义抽象数据类型
1.1this
当我们调用成员函数时,实际上是在替某个对象调用它。成员函数通过一个名为this
的额外的隐式参数来访问调用它的那个对象。例如执行total.isbn()
,编译器实际上是把total
的地址传递给isbn
的隐式形参this
。
在成员函数内部,可以直接调用该对象的成员,无需通过成员访问符。任何对类成员的直接访问都被看作时对this的隐式引用。总的来说,this
会永远指向一个对象,所以它是一个常量指针。
1.2const成员函数
上面我们知道,this
的类型是指向类类型非常量版本的常量指针,所以不能把this
绑定到一个常量对象上。const
的作用就是修改隐式this
指针的类型,这样定义的函数被称为常量成员函数。
1.3构造函数
一个类含有至少一种初始化方式,也可以通过定义的方式指定类的初始化过程,满足这些需求的函数就被称为构造函数。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。注意构造函数不能被声明为const,即使定义一个const对象,该对象只有等到初始化过程完成后常量获得‘常量’属性,所以在构造过程中,const对象也能向其写入值
默认构造函数
正如前面所说,一个类含有至少一种初始化的方式,所以尽管在定义一个类时没有定义构造函数,类内部会通过一个特性的构造函数来控制默认初始化过程。编译器创建的构造函数又被称为合成的默认构造函数,它会在我们没有定义构造函数时为我们加上。但是某些类不能依赖于合成的默认构造函数。原因有三:
- 编译器只有在我们没有显示的定义构造函数才会生成一个默认的构造函数,而如果我们定义了一些其他的构造函数(没有定义默认构造函数),这时该类就不含默认构造函数。
- 合成的构造函数可能执行错误的操作。比如当含有内置类型或符合类型成员的类,如果不能在类的内部初始化这些成员,而被默认初始化就可能造成未定义的错误。
- 如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。
1.4案例
下面我们将通过一个案例来熟悉类的各种操作。我们将定义个销售数据存储类,包含交易、图书数量、图书编号等信息。
#pragma once
#include <string>
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(n * p) { }
Sales_data(std::istream &);
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() = default;
就是一个默认构造函数,定义该函数就是想在需要其他构造函数的同时,让默认构造函数与合成默认构造函数达到同一效果。
构造函数的初始值列表
初始值列表如下,其实这就是一个赋值的简写方式。可以看到使用初始值列表可以减轻代码量,并且更加的灵活。
Sales_data(const std::string &s, unsigned n, double p) :
bookNo(s), units_sold(n), revenue(n * p) { }
//相当于下面代码//
Sales_data(const std::string &s, unsigned n, double p) {
bookNo = s;
units_sold = n;
revenue = n * p;
}
// 即使只传入一个参数,也被保证每个成员变量都被初始化//
Sales_data(const std::string &s) :
bookNo(s), units_sold(0), revenue(0) { }
二、访问控制与封装
在上面的案例中,我们定义的类没有进行封装,因此用户可以访问该类所有的成员,并且控制它的具体实现细节。我们可以通过访问控制符public、private和protect
来对用户的行为进行限制。
class Sales_data {
public:
// 定义构造函数
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(n * p) { }
Sales_data(std::istream &);
std::string isbn() const { return bookNo; }
Sales_data &combine(const Sales_data &);
double avg_price() const;
private:
std::string bookNo; // 图书编号
unsigned units_sold = 0; // 图书数量
double revenue = 0.0; //图书售出价格
};
在上面代码中,我们将Sales_data
的接口定义为public
,意味着任何用户都可以使用它的方法,而把成员定义定义为private
,意味用户不能访问到它们进行修改等操作。
上面的类我们把定义的关键字换成了class
,它和struct
的区别在于如果没有显示的指定访问控制符,class
默认是private
,而struct
是public
。
2.1友元
继续上面的例子,假如Sales_data
有三个非成员函数,如下:
class 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 &);
public:
// 定义构造函数
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(n * p) { }
Sales_data(std::istream &);
std::string isbn() const { return bookNo; }
Sales_data &combine(const Sales_data &);
double avg_price() const;
private:
std::string bookNo; // 图书编号
unsigned units_sold = 0; // 图书数量
double revenue = 0.0; //图书售出价格
};
作为非成员函数,它们在类外定义的时候假如又使用了数据成员,而这时数据成员已经为私有的,就会出现访问错误。因此我们可以通过定义友元的方式,只需要在内部声明函数的前面加上friend
。友元只能在类的内部定义
class 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 &);
....
};
到这,我们已经完成封装的思想,封装可以带来两个好处:
- 确保用户的代码不会破坏封装对象的状态。
- 被封装的类的具体实现细节可以随时改变,而无需调整用户级别的代码。
三、类的其他特性
可变数据成员
如果需要修改一个类的某个数据成员,可以使用mutable
关键字,即使该数据成员是const
对象的成员也会被修改。(不是说数据成员是const,而是使用该数据成员的函数,如下。)
class E {
public:
E(int value) : a(value){}
void getNum() const;
private:
mutable int a;
}
void E:getNum() const{
a ++;
}
类数据成员的初始值
需要进行类内初始值时,必须以符号=或者花括号表示。类F
定义了一个数据成员Es
,它是一个类型为E
的vector
。
class F{
private:
std:vector<E> Es{E(2)};
}
类类型
每个类定义了唯一的类型,即使两个类除了名字完全一样,它们都是两个不同的类型。
类的声明
前面提到过函数的定义和声明可以分开写,类也如此。下面就是一个类的声明,也称为前向声明。在该类定义之前,是一个不完全类型。
class ex;
不完全类型的应用场景:
- 定义指向该类型的指针和引用。(只要一个类的名字出现,它就被认为是声明过了)
- 声明以该类型作为参数或返回类型的函数
四、类的作用域
每个类都有自己的作用域,在类的作用域外,普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符。
函数的返回类型通常出现在函数名之前,因此当成员函数定义在类的外部时,返回类型中使用的名字都位于类的作用域之外。如下代码涉及到了三个类,它们的关系可以这样表示:A>B>C
,我们在类A
中声明了一个成员函数,它接收的参数是类C
,返回类型是类B
。当我们在类外定义时,必须先处理返回类型,再处理成员函数的名字。
class A{
public:
B add(const C&);
}
A::B
A::add(const C&) {}
成员定义中的普通块作用域的名字查找
- 在成员函数内查找该名字的声明,只有在函数使用之前出现的声明才被考虑
- 如果在成员函数类没有找到,继续在类中查找
- 如果类内也没有找到,在成员函数定义之前的作用域继续查找
五、构造函数
构造函数的初始值有时必不可少,如果数据成员是引用或者const
常量,必须将其初始化。如下图,当我们定义了一个常量和一个引用没有进行初始化时,而在构造函数内进行赋值就会发生错误。
class A {
public:
A(int a);
private:
int i;
const int ci;
int &ri;
}
A::A(int a) {
i = a;
ci = a; // 错误,不能给const赋值
ri = a; // 错误,ri未初始化
}
这是因为当程序进行到构造函数的函数体时,初始化就已经完成了。因此我们可以进行显示初始化来避免这个错误。
A(int a) : i(a), ci(a), ri(a) {}
成员初始化的顺序
一般来说,如果成员之间初始化是独立的,那么这个顺序也就没什么影响,但是如下面这种情况,乍一看成员i和j
是j
先初始化,但是根据成员声明的顺序,实际上是i
先初始化,而此时的j
是未定义的,所以就会产生错误。
class A {
int i;
int j;
A(int v) : j(val), i(j) {}
}
5.1委托构造函数
C++11
新标准引入了委托构造函数,简单说该函数把自己初始化的过程交给其它构造函数。
class A {
public:
// 非委托构造函数
A(int v1, string v2, double v3) :
a(v1), s(v2), f(v3) {}
// 使用委托构造函数,委托给上面的构造函数
A() : A(1, "", 0) {} // 默认构造函数
A(string s) : A(0,s,0) {}
// 下面委托给默认构造函数,然后默认构造函数又委托给三参数构造函数
A(double f) : A() { ... }
int a;
string s;
double f;
}
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。
5.2默认构造函数
回顾一下默认初始化和值初始化发生的情况:
- 默认初始化:
- 在块作用域内不使用任何初始值定义一个非静态变量
- 当一个类本身含有类类型的成员且使用合法的默认构造函数
- 当类类型的成员没有在构造函数初始值列表中显式地初始化时
- 值初始化:
- 在数组初始化的过程中,如果提供的初始值数量少于数组的大小
- 不使用初始值定义一个局部静态变量
- 通过
T()
的形式显式地请求值初始化时,T是类型名,比如vector v(10)
5.3类的隐式类类型转换
如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制。能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型的隐式转换的规则。
class A {
A(int v1, int v2, string s1) : a(v1), b(v2), c(s1) {}
int a;
int b;
string c;
}
string str = "123"; // str 可以看做是一个A对象,此时a和b都为0
A a(str); // 正确的 相当于a(0,0,str)
上面的代码中,我们定义了一个类A
,它的构造函数可以接受三个参数,我们定义了一个字符串str
,当把str
作为a
的参数时,此时的str
就进行了隐式转换成为一个A
对象。但是只允许一步类类型转换,下面中进行了两种转换,所以出错。
A a("123"); // 错误
对于程序进行上述转换,我们可以在类内部定义构造函数时加上explicit
阻止转换发生。该关键字只对含有一个实参的构造函数有效,需要多个实参的构造函数不能用于执行隐式转换,同时只能在类内声明该关键字。
同时如果构造函数加上了explicit
,那么该函数只能用于直接初始化而不能用于拷贝初始化。
A a(str); // 直接初始化
A a = str; // 错误,不能用于拷贝初始化
5.4聚合类
聚合类满足的条件:
- 所有成员都是public
- 没有定义任何构造函数
- 没有类内初始值
- 没有基类,没有
virtual
类
struct A {
int val;
string s;
}
5.5字面值常量类
数据成员都是字面值类型的聚合类时字面值常量类。此外,满足下面要求也是一个字面值常量类。
- 数据成员都是字面值类型
- 类必须至少含有一个
constexpr
构造函数 - 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式
- 类必须使用析构函数的默认定义
前面提到过,构造函数不能是const
,但是字面值常量类的构造函数可以是constexpr
。constexpr构造函数体一般来说是空的。
六、类的静态成员
声明静态成员
可以通过在成员之前加上关键字static
使得其与类关联在一起。如下面代码,类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。所以每个Account对象包含两个数据成员:owner和amount
,只存在一个interestRate被所有Account
对象共享。对于静态成员函数不能与任何对象绑定在一起,它们不含this
指针,静态成员函数不能声明成const
,也不能在static
函数体内使用this
指针。
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();
}
void Account::rate(double newRate) { // 在类的外部也可以定义静态成员函数,不过static只需要出现在类内部就可以,不能重复static关键字
interestRate = newRate;
}
由于静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。一般来说,不在类的内部初始化静态成员,一个静态数据成员只能定义一次。
静态成员的类内初始化
前面说过一般不在类内进行初始化,但是我们可以给静态对象提供const
整数类型的类内初始值,并且要求静态成员是字面值常量类型的constexpr
。
class Account{
public:
static double rate() { return interestRate; }
static void rate(double);
private:
static constexpr int period = 30;
double daily_tbl[period];
}
小结
总的来说,静态成员独立于任何对象,在某些情况下可能只有静态成员才能应用。比如静态成员可以是不完全类型,而数据成员不行,且静态成员可以作为默认实参,这也是数据成员无法做到的。