C++面向对象 - 封装
回顾:类与对象的基本概念
访问控制
此部分对应教材第5章
-
掌握public, private, protected关键字的含义及使用
-
友元:指定可以访问当前类的private成员
-
所有友元函数不管来自哪里,都必须在当前类内部以friend关键字声明
1
2
3
4
5
6
7
8class X {
int i; //default to be private,但指定为友元的函数可以访问
public:
void init();
friend void g(); //全局函数可以作友元(同时作为声明)
friend class Z; //一个类内的所有成员函数全部为友元(同时声明类)
friend void Y::f(); //其他类的成员函数可以指定为友元
} -
类内嵌套结构的成员不会自动获得访问private成员的权限,需要遵循声明->指定friend->定义的三步策略
-
引用与指针
-
C++中关于指针的四点注意:
- 避免野指针(拿到指针先判空)
- 避免内存泄漏(用完及时free)
- Avoid Return address of local var
- Avoid Multi-pointer for one object
-
引用 reference 可以看作一个比较安全的指针
1
2int x = 0;
int& a = x; //相当于a是x的一个“别名”,二者指向同一块内存空间 -
独立使用引用时的几点注意:
- 引用被创建时必须被初始化;
- 一旦被初始化,引用就不允许链接到其它对象
- 引用必须与合法的存储单元关联,不允许出现NULL
-
引用作为函数参数传递:比指针更加简洁
1
2
3
4
5
6void passref(int& a, int& b) {
...
}
int x = 1;
int y = 2;
passref(x, y); //看起来就像普通的传值一样,但实际上传的是地址- 当形参是引用时不能传递常量。如实在需要,应当使用const int&作为形参;
- 类对象或结构作为引用传参时,在函数内访问它们的成员要使用
.
运算符,与传值的行为一致;只有指针才能使用->
!
-
引用作为函数返回值:注意不能返回局部变量的引用(需要全局或static),可以作为赋值表达式的左值
-
传值还是传地址(指针或引用)?
传值 Pass by value 传地址 Pass by address 权限 Read Only Read & Write 空间占用 Sizeof (object) Sizeof (int) = 4 调用 拷贝构造函数 Nothing
结论:Never Pass by Value (基本类型除外)
构造与析构
- 二者区别于其它成员函数的一个特征:没有返回值
- 调用时机:
- 构造函数:与实例化对象同时
- 析构函数:超出当前对象所在作用域时,简而言之,在包括当前对象的右大括号 “}” 处调用;先执行析构函数体中的内容,然后释放空间(需要显式调用free吗)
- 存在多个对象时,构造函数与析构函数的调用先后顺序是相反的
默认构造
- 一个类必须存在构造函数,如果用户在类中没有定义,那么编译器将会自动生成一个无参数且什么都不做的默认构造函数;一旦用户在类中定义了,那么编译器将不会再生成;
- 构造函数可以重载,不过一旦定义就必须被调用,意味着不管是编译器生成的还是用户定义的,在实例化对象时传的参数必须符合一种构造函数的参数列表形式
- 应该尽可能的自行定义构造函数
拷贝构造
-
为了解决大规模数据类型有时不得不传值的问题,使用已存在对象的数据创建出一个新的对象
-
基本形式
1
2
3
4
5
6
7
8
9
10class Test {
int x;
public:
Test(int x); //一般构造
Test(const Test &t); //拷贝构造,一般是const引用,使const对象亦可作为参数,无需调用构造
~Test(); //析构函数
};
Test t1(1); //调用一般构造
Test t2 = t1; //会调用拷贝构造
Test t3(t1); //也会调用拷贝构造 -
默认拷贝构造
- 当未显式定义时编译器自动定义拷贝构造
- 默认拷贝构造的作用是使用原对象的成员变量一一赋值给新对象的成员变量
- 大多数情况下够用,但无法覆盖所有的需求(如动态内存、指针等)
-
浅拷贝(Bitwise copy):按二进制位复制,默认拷贝构造的实现
-
深拷贝(Logical copy):逻辑上也复制原对象的功能,需要自行定义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class Deepcp {
int *p; //指向一片内存空间的指针
public:
Deepcp();
Deepcp(Deepcp& d);
~Deepcp();
};
Deepcp::Deepcp() {
p = (int*)calloc(10, sizeof(int)) //分配10个整数的空间,指针的值赋给p
}
Deepcp::Deepcp(Deepcp &d) {
this->p = (int*)calloc(10, sizeof(int)) //参数和自身有重名情况,所以不可省略this
memcpy(this->p, d->p, 10*sizeof(int))
//如果使用默认的浅拷贝,将只会把p的值赋给新对象,这样一来新老对象就共用了一片内存空间;拷贝构造函数不仅新申请了10个整数的空间,还把原对象空间里的值复制到新申请的空间中,实现了逻辑上的复制
} -
多数情况下,如果类内存在指针成员,一般需要自行定义深拷贝构造函数。
析构函数
- 析构函数没有参数,不可以被重载
- 可以自定义析构函数,如果没有定义,编译器会自动定义一个仅释放对象所占空间的默认析构;
自定义的析构函数中是否需要显式地free对象所占内存?或者说默认析构中是否包含这样的操作?
这要从C++的内存管理机制来解释。简而言之,无论析构函数是默认还是自定义,编译器可以在上文所述的“时机”自动地释放普通数据成员所占的内存空间(它们存放在栈内),而不需要我们显式地指定。实验中发现,如果这么做了,会导致程序在执行析构函数时非正常退出。
另外,如果在程序中显式调用析构函数,那么并不会执行回收栈空间和销毁对象的操作,对象仍然存在,只会执行析构函数中定义的语句。
但是,如果类定义中包含由malloc
, calloc
或new
等手动分配的内存空间,这些空间是开辟在堆区域,此时默认析构不会管理堆的内存,只会释放掉栈内占用的空间。此时需要我们自己定义析构函数将它们释放掉。需要注意的时,在析构函数中使用free时应当先判断指针参数是否为空,以免出现问题。
一些关键字
this
指向当前对象的常量指针,只能在类内部使用,由编译器自动赋值,用户不可更改。struct
const
此部分对应教材第8章
- 一般来讲,使用const定义常量相比于使用常量宏更加安全
- const默认只能在定义的文件内可见,必须在声明时完成初始化,除非使用extern修饰;初始化后无法修改
- 本课主要讨论const用于函数参数传递和在类内的应用
- 在函数参数传递方面,总的原则是:形参声明为const时,调用者可以传给const对象,也可以传给非const对象;但如果形参没有声明为const,那么只能传给非const的对象。
- 在C++中,当传递一个参数时,首先选择按引用传递,而且是const引用
- 对于函数调用者,这样传参实际上的效果就跟传值一样,只需简单地使用标识符即可,也不用加const;同时const引用意味着函数内部在使用参数时不会改变该地址所指向的内容,也有一层安全的保证
- 对于函数创建者,使用引用而非传值,可以提高参数传递的效率;
- const引用作为参数还允许传递临时对象,因为这些临时量具有const属性;
- const在类中有两个用法
- 修饰数据成员:与一般变量中const的含义相同,但是需要使用构造函数的“初始化列表”赋给初值
- 修饰函数成员:必须在声明和定义时两次在参数列表后指明const标识符,向编译器保证其执行过程不修改对象中的数据,也不调用非const的成员函数;
- 声明为const的对象只能调用const成员函数;
static
一般从存储位置和可见域两个方面来考虑static产生的影响。
此部分对应教材第10章
面向过程的static
-
static修饰局部变量
- 将会存储在静态区,生命周期由一般局部变量的一次调用延长至整个程序运行期间
- 作用域仍然仅限定义的函数内
- 只会在首次访问时初始化一次,如未显式初始化则一般为0(应用:调用次数的计数器)
-
static修饰全局变量
- 一般的全局变量默认外部可见,但其它文件中使用需以extern关键字声明
- 或者在源文件对应头文件中以extern声明,其它文件include该头文件
- 使用static修饰后可见域缩小至本文件内,但是不管有无static都在静态区存储
- 一般的全局变量默认外部可见,但其它文件中使用需以extern关键字声明
-
static修饰普通函数
- 一般的普通函数默认外部可见,其它文件中使用只需以一般形式在文件头部声明即可,不需要extern
- 或者把声明语句放到头文件中,其它文件include该头文件
- 使用static修饰后可见域缩小至本文件内
- 一般的普通函数默认外部可见,其它文件中使用只需以一般形式在文件头部声明即可,不需要extern
面向对象中的static
-
static修饰类成员变量:这个变量将被所有对象共有,存储在专门的静态区域
-
需要在类定义外部以下文所述的方式初始化,未初始化无法使用,不能在声明时初始化
类定义时可以直接初始化非静态的成员变量吗?
一般而言是不允许的,需要在构造函数中初始化;但是从C++11开始可以这么做了,只是构造函数中的初始化会覆盖掉类定义时的初始化。
-
静态成员可以通过
classname::x
objectname.x
访问 -
静态成员也可设置public, private, protected访问控制
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using namespace std;
int x = 100;
int y = 20;
class Test {
static int x;
static int y;
public:
void print() {
cout << y << endl; //这里的y不是全局变量的y,而是静态成员的y
}
};
int Test::x = 1; //初始化静态成员变量
int Test::y = x + 1; //这里的x也不是全局变量的x,而已经被Test::限定为静态成员
//存在同名情况时,使用全局变量需要用“::x”的形式
int main(void) {
Test t;
t.print();
}
-
-
static修饰成员函数:函数为类的全体对象服务,同样是专门存储
- 一般成员函数需要实例化之后方可调用,但静态成员可以直接使用
classname::func()
调用,也可通过实例化之后的对象调用 - 不管哪种方式调用,静态成员函数无法使用this指针,只能访问类中的静态成员变量或调用其它的静态成员函数(存储区域的问题)
- 一般成员函数需要实例化之后方可调用,但静态成员可以直接使用
-
综合应用静态成员变量和静态成员函数,可以实现 “单件模式” ,即一个类只有一个对象
extern
见预备知识部分“extern关键字与混合编程”
new / delete
-
new
= malloc + constructor1
Test *p = new Test(1, 2);
-
delete
= destructor + free1
delete p; //delete需要一个对象的地址,且该对象必须是由new创建的
-
new将在堆区为对象开辟空间,因此被称为“动态对象创建”。
-
new和delete是一种运算符,可以被重载。它们也十分智能,主要在于可以自动计算对象在堆中所占的空间,并将调用构造/析构、分配/释放内存、类型转化、安全检查等操作组合在一起,无需程序员手动操作。
-
对象数组上的new / delete
- 如果要使用
Test *p = new Test[100]
的形式定义对象数组,则Test类必须存在无参数的默认构造函数,否则无法用这种方式定义; - 关于其它方式:C++对象数组中使用构造函数 (biancheng.net)
- 释放整个数组时使用
delete []p
的形式,告知编译器是对数组的每个对象进行销毁
- 如果要使用
运算符重载
-
运算符重载实际上是一种特殊的函数形式,其基本格式:
1
2
3返回值类型 operator 运算符名称 (形参列表) {
//TODO:
} -
运算符重载时需要注意以下几点:
- 不改变原运算符的优先级、结合性、用法以及参数的数量和位置,语义上应当尽量保持运算符本身含义;
- 重载函数可以是普通函数或类的成员函数,但是需要注意不能有默认参数;普通函数必须至少存在一个参数是用户自定义的对象类型,以防重载妨碍运算符的基本使用,且往往需要在对象类内声明为友元函数。
=
,->
,[]
,()
这几个运算符只能以成员函数的形式重载.
,.*
,->*
,::
,sizeof
,...?...:...
,#
这几个运算符不能重载
-
关于++和–的重载
1
2
3
4
5
6
7
8
9
10
11//前置形式:++x
stopwatch stopwatch::operator++(){
return run();
}
//后置形式:x++,标志是有无占位参数
stopwatch stopwatch::operator++(int n){
stopwatch s = *this; //因为需要返回递增之前的对象,所以不得不传递拷贝
run();
return s;
}
内联函数
- 内联函数作为类的成员,有两种表现方式:
- 像普通成员一样在类定义外定义函数体,但是在函数签名前添加
inline
关键字。 - 在类定义内包括函数体,而不是像普通成员那样只有函数声明,此时可以省略
inline
关键字;
- 像普通成员一样在类定义外定义函数体,但是在函数签名前添加
- 内联函数与普通类成员函数的区别是:普通成员函数的声明语句将被编译成跳转至函数代码的指令;而对于内联函数,编译器会直接将整个函数体的代码插入对应语句处。
- 内联函数的实现机理与预处理指令十分相似,但是存在两点关键不同:
- 内联函数的展开是在编译阶段,而宏定义的展开是在预处理阶段;
- 宏在展开时只是简单的字符替换,而内联函数展开后,编译器还是会像普通函数一样进行一系列检查操作,保证函数调用的安全。