三、精通类与对象
本文为《C++高级编程(第四版)》第八、九、十一章的部分。
- 访问控制
- 友元
- 移动语义(左值引用和右值引用)
- static和const关键字
一、访问控制
主要讲解三种访问控制权限什么时候使用。
访问说明符 | 使用场合 |
---|---|
public | 想让客户端使用的方法、访问private与protected数据成员的方法。 |
protected | 不想让客户使用的“帮助”方法。 |
private | 所有数据成员都应该是private。如果希望派生类访问,可以提供protected获取器和设置器,希望用户使用,就提供public的获取和设置器。 |
二、友元
本节主要介绍了实现友元的三种方式。
友元允许某个类、某个类的方法、某个全局函数,去访问另一个类的protected, private
属性和方法。
2.1 友元类
将B类设为A类的友元类,这样B类中,就可以调用A类中的protected
和private
方法和成员。
class A {
friend class B; // 将B类设为A的友元类
private:
void privateFun()
{
cout << "A的private方法" << endl;
}
};
class B {
public:
void bFun(A& a)
{
a.privateFun();
}
};
int main()
{
A a;
B b;
b.bFun(a);
return 0;
}
// 结果
// ======
// A的private方法
原本void privateFun()
是A的private方法,只能在类A内部使用。将B类作为A的友元类之后,B类中就可以使用A的对象方法私有方法了。
2.2 友元成员方法
有时,只想一个类的特定方法去访问另一个类的保护和私有成员,这时就可以只将一个方法友元。
2.2.1 前置声明
这里需要前置声明的知识才可继续,如果您已了解,可跳过此节。
下面这段代码是一定编译不过的,因为第一行中,还没有定义b就开始使用了。
int a = b;
int b = 10;
类也是一样。
// class B; 1. 前置声明
class A {
private:
B* b;
};
class B {
};
这样也是编译无法通过的,因为编译器找不到B这个类。不知道这个存不存在。
因为编译器不知道这个类存不存在,所以无法为这个指针分配空间。这个时候就需要一个方法告诉编译器,这个类的存在的,指针大小固定的,你大胆的分配空间即可。
告诉编译器这个类存在的方式就是前置声明,如注释1。
前置声明只能告诉编译器,这个类是存在的,并不能告诉编译器这个类需要分配多大的空间。
上个例子中,由于创建的是B的指针,所以编译器知道,4或8个字节。如果创建B的对象,前置声明将不起作用,编译器还是无法分配空间。
2.2.2 写友元成员方法
写友元成员方法的时候要注意了,其有很多细节需要注意的点。
class A; // 1. 首先前置声明A
class B { // 2. 先定义B类
public:
void bFun(A& a); // 3. 只声明不实现
};
class A {
private:
void privateFun()
{
cout << "A的私有方法" << endl;
}
};
void B::bFun(A& a) // 4. 实现B的方法
{
a.privateFun();
}
int main()
{
A a;
B b;
b.bFun(a);
return 0;
}
// 结果
// ======
// A的private方法
注释1:B的函数形参中,有A的引用。在底层汇编角度,引用类型就是封装好的指针,所以只要告诉A在这个类是存在即可,所以需要对A前向声明。
注释2:为什么不能先定义类A呢?这样就不需要前置声明类A。因为编译器需要知道B类中是否存在void bFun()
这个函数,此时前置声明类B是无法达到要求的,所以必须先实现类B。
注释3:为什么不能立刻实现,而是需要在类A实现之后才实现呢?仔细看这个函数内部,其使用了类A的对象,编译器还是无法得知类A之中的具体细节。所以还是会报错。
当编译器不知道类A的具体细节,就进行使用时,会报不允许使用不完整类型的错误。
2.3 全局函数友元
全局函数也可以做为友元去访问类的保护和私有成员。
class A {
friend void myFun();
private:
void privateFun()
{
cout << "A的private方法" << endl;
}
};
void myFun(A& a)
{
a.privateFun();
}
int main()
{
A a;
myFun(a);
return 0;
}
注意哦,全局函数的实现一定要在类实现的后面哦,否则调用对象a
会报使用不完整类型的错误哦。
友元是一种违反封装原则的方式,并且很容易被滥用,所以只在特定情况下才使用。
三、移动语义
本节着重介绍了C++中的移动语义。
移动语义主要解决对象所有权变更的问题,是C++11的新特性,也是重要的特性之一,为什么需要这个特性呢?
以往C++要想将对象a的所有权,需要先将a对象赋值一份给b对象,再将a对象删除。这其中就涉及到对象的拷贝复制和析构,这样开销就比较大,而移动语义就是解决此问题。
移动语义就是将一个对象的所有权变更给另一个对象,变更之后原来的对象会处于有效,但不确定的状态,通常会为空。
为了实现移动语义,首先要明确那些资源可以被移动,在大多数情况下,右值都是可以被安全的移动的,那么什么是右值呢?
3.1 右值和右值引用
3.1.1 左值和右值
和右值对应的叫做左值,往往在等号左边的被称为左值,而右值往往在等号的右边,一种比较广泛认同的说法为:
-
可以取地址,有名字,非临时的就是左值。
-
不能取地址,没有名字,临时的就是右值。
int a = 4 * 2;
在这个例子中,a
就是左值,而4 * 2
就是右值。
所以可以得出以下结论:
非匿名对象、变量、函数返回的引用,const对象等都是左值。立即数,函数返回的值等都是右值。
有没有办法将左值变为右值呢?有的,调用std::move()
函数。
int a = 10;
int&& b = move(a);
为什么需要将左值变为右值呢?接下来左值和右值引用中将有作用。
3.1.2 左值引用
左值引用就是我们一直经常使用的引用,必须声明时就进行初始化,并且不可重新指定。
int a = 10;
int& b = a;
在底层汇编角度,左值引用其实就是指针,其等号右边的内容必须可以取地址(即左值),像下面这种则不可以(10为右值),因为10是存放在汇编语句中,是一个立即数,根本就没有地址。
int& a = 10;
但是使用常引用则可以指向数字10。
const int& a = 10;
因为这句话编译器其实进行了额外的操作,这句话就相当于这样:
const int temp = 10;
const int& a = temp;
也就是说a
其实是这个临时变量的引用。并且这样引用之后,只能进行读操作,无法进行修改。
那有没有办法可以引用常量,而且可以修改呢?右值引用。
3.1.3 右值引用
右值引用其实就是一个指向临时对象的引用,其只能指向临时对象(即右值),比如上面例子中的10
,否则编译器会报错,例如。
int&& a = 10;
++a;
cout << a << endl; // 最后a的结果为11
这种方式指向一个右值就可以修改。
右值引用最大的优点并不是可以以引用的方式修改一个右值,其最大的优点可以移动语义,就是本文开头所提到的更改对象所有权,详细内容在下节讲解。
3.2 移动语义
移动语义由右值引用实现,若要对自定义的类使用移动语义,需要实现移动构造函数和移动运算符。
A(A&& a); // 移动构造函数
A& operator=(A&& a); // 移动运算符
下面举例说明:
#include <iostream>
#include <string>
using namespace std;
class A {
public:
A()
{
cout << "构造函数" << endl;
m_string = new string("初始");
m_num = 1;
}
~A()
{
cout << "析构函数" << endl;
// 这里要注意了,有可能string拥有权已经没了
if (m_string == nullptr)
delete m_string;
}
A(A& a)
{
cout << "拷贝构造函数" << endl;
m_string = a.m_string; // 调用string的拷贝构造函数
}
A& operator=(A& a)
{
cout << "拷贝赋值运算符" << endl;
m_string = a.m_string;
return *this;
}
A(A&& a) noexcept
{
cout << "移动构造函数" << endl;
m_string = move(a.m_string); // 这里使用move将string对象所有权变更
m_num = a.m_num; // 这种复制起来开销不大的属性,普通的复制然后清零就可以了,当然也可以用move
a.m_num = 0;
}
A& operator=(A&& a) noexcept
{
cout << "移动赋值运算符" << endl;
m_string = move(a.m_string); // 这里也是一样
m_num = a.m_num;
a.m_num = 0;
return *this;
}
string* m_string;
int m_num;
};
int main()
{
A a;
A b;
a = b; // 这里用的是拷贝赋值运算符
a = move(b); // 这里才用移动赋值运算符
return 0;
}
// 结果
// =============
// 构造函数
// 构造函数
// 拷贝赋值运算符
// 移动赋值运算符
// 析构函数
// 析构函数
四、static关键字
4.1 静态方法
静态方法不属于任何一个对象,所以其中是没有this
指针的。当一个对象调用静态方法时,静态方法不会访问这个对象的非静态数据成员。
静态方法可以访问protected, private
静态数据类型。如果向这个静态方法中传递了对象的引用或指针,则也可以访问其protected, private
非静态数据类型。
4.2 静态成员
并不是所有对象都需要包含某个属性的副本,可以将某个属性设为静态成员,供所有对象使用。
要使用静态成员首先要在类中声明静态成员。
class A
{
public:
static int m_snum;
};
然后在使用此静态成员的文件中,像定义全局变量一样再定义一次静态成员。
static int A::m_snum = 100;
inline变量 c++17
上文中使用的静态成员要在两个地方写,有些麻烦,内联变量可以不用在源文件中分配空间,即不需要在使用此静态成员的文件中,定义了。
class A
{
public:
static inline int m_snum = 100; // 可直接在此定义
};
4.3 静态链接
C++编译一个程序通常有四个步骤:预处理、汇编、编译、链接。其中前三步都是每个文件单独执行的,最后才会各个文件彼此链接。其中链接又被分为外部链接和内部链接(静态链接)。
外部链接就是该变量、函数等名称,可以被其它文件访问。
内部链接则意味着此名称在其它文件中无效。
例如:
// a.cpp
void f(); // 只有声明,定义在b.cpp中,这个函数现在就是外部链接
int main()
{
f();
return 0;
}
// b.cpp
void f()
{
cout << "f()" << endl;
}
// 结果
// ==========
// f()
如果将a.cpp
中的void f();
修改为static void f();
将会报错,因为这样就成了内部链接,其它文件将无法访问此函数。
五、const常量
5.1 const方法
常量是无法被改变的,所以当使用常量的对象时,编译器不允许调用方法,除非这些方法承诺不会修改对象的数据。这种承诺不会修改常量数据的方法就是常量方法。
void fun() const;
这个函数实际上就是给this
指针上了个const,只要修改其中任意变量,都会报错。修改函数参数是不影响的。
mutable数据成员
有时候想用常量方法,但是又想修改对象中部分属性。只需要将此属性声明为mutable
类型即可。
class A
{
public:
mutable int a; // 在常量方法中就可以修改了
};
5.2 const 方法重载
在C++中,仅返回值不同是可不重载的,必须参数类型或个数不同。但是返回值是不是const是可以作为重载条件的。通常情况下,const版本和非const版本实现是一样的,为了避免代码重复可以使用const_cast()
。
5.3 constexpr 关键字
C++很多时候需要的是常量表达式,例如在定义数组的长度的时候。
常量表达式是一种不会改变,并且在编译的过程就可以得到结果。
下面代码在C++中是无效的,因为函数返回的是常量,而不是常量表达式。
注:下列代码在g++ c++17的环境下是可以执行,并得到正确的结果。其原因是g++此编译器进行了扩展,而不是C++标准语法,故不建议使用。
const int getSize() // constexpr int getSize()
{
return 30;
}
int main()
{
int arr[getSize()];
return 0;
}
constexpr
可以使函数返回的是常量表达式。换成注释中的内容即可正确运行。
constexpr
修饰的函数也有一些限制,其确保在编译时期就可以得到值。一切需要运行时候才能确定值的语法,均不可在constexpr
中出现。