面向对象程序设计笔记
面向对象程序设计——陈伟
一、概述
编程泛型:编写代码的风格;
- 过程式编程,函数调用
- 结构化编程,栈
- 函数式编程
- 面向对象OOP
- 面向方法的编程AOP
- 消息驱动的编程,windows下面的消息驱动
一种编程泛型可以被多种语言支持,一种语言也可以支持多种变成泛型
语言的发展历史:汇编语言–>ALGOL–>FORTRAN–>ANSIC C语言,pascal语言–> SmallTalk(面向对象)–>c++语言–>java, c#
c++98, c++03, c++11, c++14, c++17, c++20
二、cpp概述
- 可执行的文件主要有exe和dll
- 每一个cpp文件编译的时候都是垂直编译的,cpp文件也就是每一个编译的最小单元
- 预编译(预处理)的过程就是将头文件展开,
- 编译的过程要求编译器知道每一个标识符,知道各自的类型,含义,但是不要求知道存放位置,linux下面的编制生成汇编语言
- 汇编过程指的是将汇编语言转化为机器码
- 链接过程要求编译器知道每一个标识符的所对应的含义和地址
- 总体的总结为
- .c文件到.i文件,这个过程叫预处理。
- .i文件到.s文件,这个过程叫编译。
- .s文件到.o文件,这个过程叫汇编。
- .o文件到可执行文件,这个过程叫链接。
- 为什么程序的main要有返回值?因为你写的程序可能是操作系统调用的,所以,你需要有返回值告知,你的程序是不是正常的结束
- 使用”<>”来包含头文件的时候,在系统目录中查找头文件,使用” “” “双引号的时候先在当前工程目录中查找,再在系统目录中查找,一般建议不出错的系统头文件放在前面,自己写的放后面
- 使用#include
是cpp风格的头文件, #include<xx.h>是c语言风格的头文件, - 为什么需要前置声明,因为有一些声明可能是互相依赖的,通过把定义提前的方式,没有办法解决
- 为什么要头文件?从各自的cpp文件出发,将可以向外公开的变量,函数,结构等,放入对应的头文件,这样做为了
- 自用代码和可以公开的代码的分离
- 通过使用自己的头文件,不使用公共的头文件,可以做到权责分明
- 为什么在头文件中需要使用包含警戒,为了防止头文件的重复包含,展开,通常情况下,一个文件不会包含两次头文件,但是多个文件互相包含的时候,就有可能头文件的重复包含,在预处理之后,所有的头文件只会出现一次,包含警戒的格式为:也可以是下面的形式,下面的形式有的不支持
1
2
3
4
5#ifndef xxx
#define xxx
...
#endif
1 | #pragma once |
- 怎么使得包含警戒的名唯一?
- 微软使用全球唯一的标识符
- 我们可以直接使用和头文件名一样,根据系统保证的同一个文件夹下文件不能一样来保证唯一
三、抽象数据类型
- 抽象数据类型:一个数学模型+可以施加的操作
- 与具体表示无关
- 与现实世界无关
- 任意性和无穷性
- 泛型:以类型为参数的类型(参数化的类型)
- 元类型及元对象,类型的类型,c++不支持
- c++的类型
- 内置类型, char, wchar_t, int, signed, bool, ……, auto, decltype,……
- 自定义类型,typedef本质上没有增加新类型,class, struct, union
- 导出类型,数组,指针,引用
- 声明:解释说明一个编译单元中的一个名字的含义和属性,一个声明同时也是一个定义,定义要有初值,位置等,也就是要有具体的东西
- 变量使用原则
- 就近原则(现使用,现定义)
- 先声明后使用原则
- 单一定义原则,声明可以多次,定义只能一次。头文件中尽量不要有定义,当多个文件都包含这个头文件连接的时候就会出现,重复的定义。这种重复定义是使用包含警戒不能避免的,包含警戒只能保证在编译的时候不重复定义。可以在头文件只是使用声明,而将定义写在cpp实现中
- 逗号表达式,先计算逗号之前的表达式,然后计算逗号后面的表达式,并把逗号后面的表达式作为返回值
四、指针和数组
- 以后尽可能将指针的初值设置为nullptr,这是C11之后的新标准
- 引用:
- 是一个别名
- 对应的变量/对象,必须存在
- 必须初始化
- 如果没有引用,调用的时候需要知道原来变量的地址,这是不合理的,主要的使用就是函数的参数调用
- 常量的类型
- 文字常量 少用
- 宏定义 #define xxx
- 可以用于条件编译, 在预处理之后,就可以展开
- 可以使用#, ##, @#
- 可以使用_LINE_(表示当前文件的行数), FILE(文件名), FUNCTION(函数)等
- 命名常量,类似于这种const int CARD_COUNT = 54 可以放在头文件中,因为,它会有常量折叠,就是把这种常量替换为数,也就不存在重复包含的问题。命名常量如果取地址,编译器会临时开辟一个地址,分配给它
- 指向常量的指针,const int *p = &a 与int const *p = &a的意思是一样的,这两句话都是我指向的东西,不能通过我来更改,也就是我的指针不能给别人,因为给了别人,别人就可以通过我来进行修改了。
- 常指针(指针常量),T* const pt = exp, 这表示pt的指向关系不允许改变,不允许指向别人,但是指向的东西可以改变,必须初始化
- 指向常量的常指针,const T * const pt = exp; (必须初始化)
- 字符串的使用
- char *str = “this is a string” 相当于 const char *str = “this is a string”, 存放在常量数据区,也就是通过str没有办法改变字符串,如果想改变,可以使用char str[] = “this is a string”,这句话放在了变量数据区
- 另外一种使用#include <string>
- const和引用,const int &a = b, 就是给b起一个外号,但是不能通过a来修改
五、函数
- 返回类型和缺省值不能作为函数的区分标志
- 清栈的操作是直接把栈顶的指针移动就可以
- 函数调用的时候函数参数为什么要从右往左压?因为历史原因,保证左边的地址在低地址
- 函数名怎么表示?底层都是地址
- 调用约定, 两个不同的程序之间的函数调用,跨语言等,可能需要使用不同的函数名来进行进行调用识别,有的是加下划线,有的是全变大写字母等,当然现在的标准调用约定,函数名就不变了。有时,清栈者也有可能不一样。
- 函数重载:多个重名的函数,但是可以具有不同的参数类型,参数个数,const修饰,异常说明数等,这称为函数重载。函数重载调用的时候为什么可以区分呢,可以将函数的输入的参数变成函数名的一部分,将函数的名字进行重整,不同的编译器重整之后名字可能不一样。下面这段代码的意思就是以c的风格进行处理,不允许函数重载
1
2
3
4extern "C"{
int func();
int func(int); // 非法
} - c++中的函数传递方式
- 值传递 需要在栈里面创建,有损耗
- 指针传递
- 引用传递
- 函数的返回类型
- 按值返回 int f() 等价于const f()
- 默认返回int类型
- void
- 内置类型
- 自定义类型
- 指针 T*f()等价于T *const f(), 但是不等价于const T * f()
- 引用 T &f() 不等价与const T &f(), 必须返回一个有效对象的引用
- 按值返回 int f() 等价于const f()
六、类和对象
抽象数据类型,分为数据和行为,行为才是区分的根本
不同的语言对于抽象数据类型有不同的表示方法,c++中使用的class类来表示
行为:指的是一个或者多个操作共同完成的,每一个操作使用的成员函数来进行表示
数据:使用数据成员表示使用前置声明可以避免循环定义,也就是两个类中互相引用包含。也可以降低文件之间的依赖性
对象:是类的一个实例化结果
对象的实例化:
- ClassName ObjectName; 直接分配在当前的函数的栈帧中,超过作用域的时候自动销毁
- ClassName *pObj = new ClassName; 在堆内存中分配对象,并且返回对象的指针
函数成员对象对象在访问的时候,要理解为是发送消息,不理解为函数调用,区别在于不去考虑怎么实现,谁接受的也不管
对象访问的时候只有三种情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class My{
public:
void f(int n);
};
int main(){
My obj;
obj.f(20);
My& o2 = obj;
o2.f(99);
My \* p1 = &obj;
p1->f(5);
}对象所占用的存储空间
- 与非静态数据成员的个数、类型有关
- 与成员函数的个数无关
- 与静态数据成员的多少,类型无关
- 与访问控制无关
- 一定非0 如何看两个对象是不是一个对象,看名字不行,看的是对象的地址
- 与是否含有虚函数有关
- 与编译的时候字节的对齐方式的设置有关
- 与非静态数据成员的个数、类型有关
对象中的数据的对齐方式,是2个字节还是4个字节可以在编译选项中进行设置
七、成员函数
- 行为函数的表示:
- 一般的成员函数
- 常成员函数
- 重载的成员函数
- 构造、析构、拷贝构造、赋值函数
- 自动转换函数
- 类方法
- 数据的表示
- 实例变量
- 类变量
- this指针是一个关键字,也是一个保留字,指向当前对象,作用域只是在当前的{}内,是非静态成员函数的第一个形参,其类型相当于T * const this 另外s1.study(4) 的内部实现是 Student::study(&s1, 4), 其中的s1就是当前的对象
- 成员函数如果返回的是指向this的引用,那么我们就可以连续的函数调用
- 外联实现:成员函数的声明在头文件中,而实现在某一个cpp文件中,这样的实现就是外联实现,对于频繁使用的函数,效率不高
内联实现:在定义成员函数的时候就直接给出实现,或者在.h头文件中使用关键字inline - 内联实现注意
- inline关键字只有在{}才有意义,
- 建议编译器在调用处直接展开函数代码,永远只是建议,可以提高效率,但是代码量可能增加
- 使用内联的时候,直接展开可能会导致文件之间的相互依赖,这个时候就需要使用外联实现或者前置声明
- 封装:将事物的特征和相关信息,通过打包的过程,包装成一个整体。只通过公开的特征进行沟通,不必了解事物内部的细节。c++中封装的实现手段—-class
- 封装和信息隐蔽是面向对象的基石
- 分离使用和实现(函数实现、数据组织、数据表示)
- 分离接口与实现
- 软件复用
- 类变量:形如static int a;是类的变量,在类加载的时候就已经给它分配内存空间。
实例变量:形如int a;在该类进行创建对象的时候进行分配内存
类方法:也称为静态方法,形如 static void show();是不依赖于任何特定的对象的方法。类加载的时候,就为该类方法分配了入口地址
实例方法:只有在对象被创建的时候才分配入口地址
实例变量和实例方法都是在对象消亡的时候才释放内存空间,类变量和类方法直到程序运行结束才释放所有内存的空间 - 构造函数:创建对象的同时,进行初始化的工作
- 名字与类型相同
- 无返回值
- explicit关键字可选
八、构造函数与析构函数
- 自定义构造函数
- 可以重载
- 可以设置不同的访问设置
- 可选explicit关键字
- 可带缺省参数
- 二义性问题
- 只声明,无实现
- 缺省的构造函数
- 无参数的、public的
- 只有用户没有提供自定义的构造函数的时候,才由编译器提供
- 对象的初始化
- 可以通过赋值的形式,进行初始化
- 析构函数:负责对象销毁之前最后需要执行的清理工作
- 名字:~类名
- 无参数
- 无返回值
- 访问控制:一般为public
- 析构函数也隐含着this指针,
- 整数直接就释放就可以了
- 引用需要释放指针
- 对于对象,需要调用对象的析构函数
- 对象的创建与销毁
- 创建对象:访问构造函数
- 销毁对象:访问析构函数
其中:构造函数的访问 - 显式调用(显式创建对象)
- 隐式调用(自动转换)
- 对象成员的创建
析构函数的访问 - 程序区、栈区:生存期结束后,自动执行
- 堆区:需显示调用
九、对象的拷贝
- 为什么需要拷贝构造?按照传值的方式传递对象,按照传值的方式返回对象
- 拷贝构造函数,从无到有的构建一个新对象。
- 赋值和拷贝有什么区别?主要就是看被创建的对象原来是不是已经存在,如果已经存在那就是赋值,否则就是拷贝
- 缺省的拷贝构造函数
- 没有显式的提供拷贝构造函数的时候,由编译器提供
- 它的访问控制是public的
- 拷贝方式是“浅”拷贝,是按照byte位来进行拷贝
- “浅”拷贝有时不能满足要求
- “浅”拷贝:对于对象数据成员,自动调用对象所属类的拷贝构造函数,而对于其他的数据成员直接按照byte进行拷贝
- “浅”拷贝因为是完全按照位进行的赋值,那么函数执行结束的时候,会执行析构函数,有指针的时候两个析构函数可能会释放同一个区域,导致错误。那么就需要用户自定义拷贝构造函数
- 深拷贝(深复制),按照程序员的目的进行拷贝,深拷贝只能通过自定义拷贝构造函数实现
- 需要自定义拷贝构造函数的情况:
- 深拷贝时
- 禁止拷贝
- 防止按值传递对象
- 程序员的其他目的(计数、所有权转移、单件等 )
- 缺省赋值函数:没有显式的给出赋值函数的时候,由编译器提供
- 访问控制是public
- 采用浅赋值 含义类似于浅拷贝
- 对象成员的赋值
- 引用成员不能赋值
- 有引用成员函数的时候,不允许赋值
- 使用编译器提供的浅赋值与浅拷贝一样,可能不会满足我们的需要
- 自定义赋值函数为了保证连续赋值,赋值函数应该返回My的引用。因为赋值的时候等号右边通常是不需要改变的,所以使用const
1
2
3
4
5
6
7
8
9class My{
public:
My & operator = (const My & rhs){
......
return *this;
}
}
十、运算符的重载
- 运算符分为:
- 一元运算符:只有一个操作数
- 二元运算符: 只有一个操作数
- 三元运算符: 只有一个操作数
注意: - 只能重载一元和二元运算符,不能重载多元运算符
- 操作数至少有一个是自定义类型,即不可以改变内置类型的运算符的运算符语义
- 无法改变运算符的结合律,优先级等固有性质
- 不可以使用新的运算符
- 有一些不可以重载的操作或者运算符,:: . sizeof new delete typeid
- 二元运算符的重载
自由函数的形式成员函数的形式1
2
3
4TVector operator+(const Tvector& v1, const Tvector& v2){
return TVector(v1.x+v2.x, v1.y+v2.y);
}
a+b 编译的时候,转换为operator+(a+b)1
2
3
4
5
6class TVector{
public:
TVector operator+(const TVector&rhs) const
{ return Tvector(x+rhs.x, y+rhs.y)}
};
a+b编译的时候转换为a.operator+(b) - 运算符函数重载的返回类型
- 返回引用\值,参考操作数为内置类型时的语义
- 带const吗?引用型参考内置类型,值类型一般不带const
- 用 return A(lhs.x+rhs.x, lhs.y+rhs.y) 编译器会自动的认为这是一个创建一个东西扔出去,然后,编译器会产生返回值优化,尽可能使用这种
- 成对重载:适用return A(lhs)+=lhs也可以适用返回优化
- 适用用户的不同的使用习惯
- 可以去掉friend的声明, 一般来说友元能不使用就不使用
- 便于更改数据成员
- 一元运算符的重载,例++
1
2A& A::operator++(){...} //前置++, 有返回类型,为A的引用
A A::operator++(int){...} //后置++,没有返回类型,直接返回值 - []运算符的重载上面的两个运算符重载都要有,不能缺少
1
2
3
4
5
6
7
8
9
10
11
12class A{
public:
int operator[](int index) const
{ return numbs[index]; }
int& operator[](int index)
{ return numbs[index]; }
private:
int nums[100];
};
1 | int main(){ |
运算符重载的时候,一般会挑选最匹配的重载进行,所以实现的时候,如果只有第一个实现,a1[i] = i会报错,因为不允许赋值改变,如果只有第二个实现,cout << a2[i]会报错,因为实现可能会更改更改。
- ()运算符的重载可以把一个类看做一个函数,使用A类的对象就像调用函数一样,这被称为仿函数,主要应用于模板库,c11之后可以使用lambda函数取代仿函数。
- c++中规定,重载->时候,必须满足下面一个
- 函数operator->() 返回指针类型
- 函数operator->() 返回自定义类型,且该类型中重载了operator->
- 永远不要重载&&,||和,三种操作符,其中逗号表达式,一定是先计算左边的,再计算右边的然后把右边的作为返回值
保持内置类型的该运算符的使用习惯
十一、动态内存管理
- 对象的存储内存
- 静态内存管理(代码区,数据区,栈区),这里考虑数据区和栈区
- 全局数据区、常量数据区,程序结束自动释放
- 全局对象(常量、变量)
- 静态对象(变量)
- 栈区,很小,一般遇到}自动释放,
- 局部自动对象(变量)
- 全局数据区、常量数据区,程序结束自动释放
- 动态内存管理
- 全局堆区,比较大
- 静态内存管理(代码区,数据区,栈区),这里考虑数据区和栈区
- 静态存储的不足
- 栈区容量有限
- 对象的生存期和作用域不够灵活,程序结束马上清栈
- 对象数组:MyClass obj[50];
- 数组的大小必须是编译期常量
- 数组中的各对象是相同类型,相同的大小
- 通常需要无参构造函数构建各分量对象
- 增大类间的编译器依赖和耦合性
- 静态存储的优点
- 不用担心程序的释放问题
- 动态内存管理
- 按照程序员的时间、地点的需要、创建和释放对象
- 创建:关键字 new 释放: 关键字: delete
- 单个对象(变量)的动态分配和释放
- 数组对象(变量)的动态分配与释放
- 动态分配单个对象,基本格式:new T(参数列表)
1
2
3
4
5
6int * p1 = new int(5);
const int* const p2 = new int(*p1);
A* p3 = new A;
const A* p4 = new A(100, 200);
const A** p5 = new A*(p4); - T *p = new T(3, 2);的过程
- 调用 void* operator new(std::size_t size)函数尝试分配空间,若失败则转到异常的处理函数new_error_handle();成功则继续
- 执行类T的相应的构造函数
- 将void*指针转换为T*指针并且返回
- delete pobj的过程
- 若pobj为nullptr,则退出
- 否则执行析构函数 T::~T()
- void operator delete(void*, std::size_t);
- 用户可自定义重载operator new和operator delete
- 一定是静态(static)的
- 若没有显示提供,则使用全局的::operator new 和::operator delete
- 什么时候重载operator new和operator delete?比如希望统计是否造成了内存泄漏
- 数组的动态分配和释放
- 分配:new T[size];
- 调用void * T::operator new[](size_t); 尝试分配空间,若失败则转到异常处理函数new_error_handle();成功则继续
- 执行size次类T的无参构造函数
- 将void* 指针转换为T*指针,并返回
- 释放:delete[] p;
- 若pointer为nullptr,则退出
- 否则执行多次T::~T()
- void * operator delete[](void*, size_t);
- 智能指针c11已经废弃,现在开始使用共享指针
- 通过拷贝构造的时候,我们希望不要直接构造了,直接两个指向同一个指针就好了,在被共享的类A里面增加一个引用计数就好了。但是通常情况下,被共享的类中是拿不到源代码的,这样怎么共享呢?我们可以考虑在希望共享A类的B类中建立一个指针,所有B类都指向一个共同的指针
- 动态内存管理的补充说明
- void *p = …; delete p; 少了一步调用析构函数的过程,会出问题
- 悬浮指针(无效指针、野指针):T* p = new T; delete p; p -> f(); delete之后不要再指向调用了,会出问题。一般最后再delete,然后将p = nullptr;这样执行的时候会明确的报错
- 内存泄漏:T *p = new T; return;
- 写时复制:两个B对象同时指向A对象的时候,当有一个B对象需要修改的时候,就拷贝一个A对象,这就是叫写时复制
- 定位分配:有时需要在指定的位置创建对象,需要显式的调用析构函数
- 根据指定的内存起始位置,构建对象
- 不新分配空间,只是在分配空间上面构建
- 已经分配的空间可以在栈区,也可以在堆区
- 格式:new (void*) T(…);上面的代码中,new后面的()可以作为new的参数用来指定创建的位置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17struct A{
A(int v):n(v){};
int n;
}
int main(){
char data[sizeof(A)*3];
A * pA1 = new (data) A(1);
A * pA2 = new (pA1+1) A(2);
A * pA1 = new (pA1+2) A(3);
cout << pA2->n << endl;
pA1 -> ~A();
pA2 -> ~A();
pA3 -> ~A();
return 0;
}
新分配new的时候,需要向操作系统要空间,这样的情况很慢,我们可以先分配之后,再使用定位分配,这样快很多
十二、转换函数、名字空间、友元、流等概念
- 类型之间的转换
- 内置类型<->内置类型
- 自定义类型<->内置类型
- 自定义类型<->自定义类型
- 内置类型->自定义类型:使用函数的构造函数,通过自动转换函数
- A->B,可以使用在B类中的构造函数,以A为参数实现,也可以使用在A类中定义的自动转换函数。但是只能有一个
- 必须有return,可以多个不同转换目标类型的转换函数,class和class之间的相互转换不要有二义性
- 名字空间
- std名字空间
- 当前名字空间
- 全局名字空间
- 自定义名字空间
- 嵌套名字空间
- 名字空间的别名 namespace me = my;
- 匿名名字空间 namespace {int abc = 100;}
类不能拆开,但是名字空间可以拆开
类名可以看做是名字空间的一种退化
- 例如
1
2::myFunc(); //表示全局名字空间内的myFunc(); 前面的两个::代表作用域解析符
myFunc(); //带边当前名字空间的myFunc() - 名字的汇入 using namespace std; 将所有的标准的名字空间汇入当前的名字空间,在{}内, 如果是汇入自定义的名字空间需要慎用。
using first::x 汇入first下所有的带有x的名字 - 友元:在类A中有一个私有的成员属性,在类A的外边一般是不允许访问的,我们可以在类A中设置一个公共的接口get();但是这样类A外边的所有的函数就都可以调用了,我们如果只想要一个类A外边的函数可以访问,就可以在A类中使用friend进行控制。
- 友元函数:自由函数和类的成员函数
友元类:类的全部成员函数 - 友元的几点说明
- 友元的本质都是友元函数,没有友元数据
- 友元函数不是类的成员
- 友元关系是单向的
- 友元关系没有传递性
- 友元对封装和信息隐蔽的影响:
- 局部来看,破坏封装及信息隐蔽
- 全局来看,保护信息
- 嵌套类:在一个类里面又定义了另外一个类
- 将一个或多个嵌套类封装到一个类中
- 在使用多个嵌套类的同时
- 一定程度上隐藏嵌套类
- 流是从管道这个概念出来的,就是管道当中每一个最小的单位是字节的时候,就是流。
- 流整体上就是以字节为单位的,但是至于几个字节构成一个有效的信息单元,那就不一定了,根据这个就可以分为字节流和字符流。所以流在传送的时候就需要指定流的传输格式了,有UTF-8,UTF-16,GBK等
- 文件流
- 二进制方式 字节流
- 文本方式 字符流
- 输入流类 istream cin, 输出流类 ostream cout
1
2
3cout << i << "\n"; //这条语句会输出i,但是会等到管道满了之后才会输出
cout << i << endl; //这条语句的endl,会实时的将管道内的内容输出,不管是不是满了
十三、编译依赖
下面的4条都是编译期依赖性
定义类的时候,尽可能不使用对象成员,而是使用指针或者引用
直接使用内联的方式定义成员函数,可能增强文件之间的依赖性,可使用前置声明和外联实现避免
使用指针或引用数据成员,函数参数以指针或引用的形式进行传递,函数的返回值不要使用传值的方式
类之间的联系依次减弱
- 继承:类之间的垂直关系
- 硬联系(硬关联)
- 强联系(强关联)
- 弱联系(弱关联)
- 软联系(软关联)
垂直:泛化和实现 水平:关联和依赖
类间水平关系的形式
- 数据成员形式 —关联
- 函数参数 —依赖
- 函数返回值 —依赖
- 函数实现 —依赖
上面怎么记呢?如果B类中有A类的数据成员,那么创建B的时候一定要知道A,而其他的不一定,所以第一种的联系最紧密,也就是叫做关联
关联:类B中包含类A的数据成员,可单向,可双向,在B类的生存周期内“一直知道”A类对象
关联分类
- 一般关联,强调非偶然性的知道
- 一般关联
- 自关联,自己的类型关联自己的类型,比如学生类关联自己的同学
- 关联类,将关联关系抽象成一个/多个独立的关联类,使得关联类既表示关联关系,也表示关联对象。比如,把学生和教师之间的关系抽象为课程类,课程类是学生类和老师类之间的纽带。再比如,如果丈夫和妻子之间的结婚日期,应该记录在哪呢,记录在丈夫类里面不合理,记录在妻子类里也不合理,这个时候,我们就可以抽象出来一个婚姻类或者夫妻类,用来专门记录结婚日期等日子。
- 聚集关联:强调整体-部分关系
- 聚合:整体包含部分,但是整体不负责部分的生存和消亡,比如计算机和网卡的关系,有没有网卡都叫计算机
- 组合:整体包含部分,整体负责部分的生存和消亡,比如汽车和轮子,没有轮子就不叫汽车
- 一般关联,强调非偶然性的知道
依赖 核心功能最好是放在主动多变的类中,逻辑关系是在程序员的头脑中的
十四、面向对象的三大特征
- 封装和信息隐蔽、继承、多态
- 封装:通过对客观对象的抽象,分析事物的本质特征,总结和提炼事物的行为和属性,并利用类和对象表示的过程
- 信息隐蔽:在利用类和对象表示事物的时候,只将行为和属性公开给可信的外部事物,相应的,隐藏自身的内部特征信息
- 对于面向对象的抽象,需要先用自然语言进行描述,不要直接一头杀入代码实现中
- 抽象,类型的抽象,行为的抽象,数据的抽象
- 表示,类型的表示,行为的表示,数据的表示
- 信息隐蔽的意义
- 减少外部可见行为和数据
- 对象间只通过可见行为交互
- 软件的更安全、可靠
- 维护方便
- 便于复用
- 分离使用和实现
建议- 尽量减少使用和实现的耦合度
- 前置声明好于include
- 定义类的时候尽量减少与运行环境、平台、硬件系统等关联
- 多个类的设计
- 类的拆分
- 类的合并
- 数据关系
- 行为关系
- 当所创建的对象只能有一个的时候,可以使用单件模式,以下面的代码为例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24class Ball{
public:
static Ball\* GetInstance(){
if(pBall == nullptr) pBall = new Ball;
return pBall;
}
static void ReleaseInstance(){
delete pBall;
pBall = nullptr;
}
private:
static Ball* pBall; //不要忘记类外分配存储空间
Ball(){}
Ball(const Ball&); //只声明,不实现
Ball& operator=(const Ball&); //只声明,不实现
//在c++1z下面,也可以
Ball (const Ball&) = delete;
Ball (const Ball&&) = delete;
Ball& operator=(const Ball&) = delete;
public:
void Func();
}
十五、复用
- 复用方式
- 拷贝、粘贴、修改
- 水平关系-普通关系、聚合、组合和依赖—黑盒复用,不需要知道内部实现,只知道接口
- 垂直关系-继承—-白盒复用,内部的实现都知道
- 黑盒复用
- 是一种功能复用
- 改变被复用类A的具体实现,不会影响复用类B,C的实现
- 但是要求被复用类具有良好的设计(行为的设计合理,独立)
- 复用类只要求被复用类A的定义即可,不需要A的完整源代码实现,二进制代码就行
- 白盒复用是一种实现复用或代码复用
- 为了实现白盒复用,我们在c++中通过继承的方式来进行实现
- 格式 class 派生类名:继承方式 基类名称
例如:class Child:public Parent1, private Parent2{} - 继承方式有三种,public、protected、private三种,默认private继承
- 基类
- 单继承:只有一个基类
- 多重继承:有两个或两个以上的基类
- 派生类
- 派生类可以是其他继承的基类,即派生类可继续派生,最多7~9层,一般3~5层
- 多层继承和继承树,
- 父类和子类
- 在public继承下面,称基类为父类,派生类为子类
- 派生类中的成员
- 派生类的构造、析构拷贝、赋值函数
- 派生类中定义的成员函数、数据函数
- 基类中的所有成员函数(除基类中的构造、析构、拷贝、赋值函数、自动转换函数)
- 派生类对象的大小为基类大小加派生类的大小
- 派生类的访问控制
- 基类中的私有private成员,派生类不管是什么继承方式,派生类都是不可访问,那有什么用呢?因为你可访问的那些成员需要访问它
- 基类中的公有public成员,以什么方式继承就以什么方式作为访问控制
- 基类中的保护protected成员,以public和protected继承的时候,仍然是保护protected控制,以private继承的时候是private控制
- 派生类中的构造函数
- 构造顺序:先基类,再派生类
- 初始化列表中可以指定基类的构造函数或拷贝构造函数
- 多重继承时,基类按照先后顺序构造
- 派生类的析构函数
- 先执行派生类的析构,再自动执行基类的析构
- 派生类的拷贝构造函数和赋值函数
- 派生类一般需要在构造函数的初始化列表中调用基类的构造函数以初始化继承的成员
- 赋值函数也是一样的道理
- 派生类中的几个函数,下面的redefine和overwrite是一个类似的概念
- newdefine:派生类中新定义的函数(基类中无同名得函数)
- redifine:派生类中新定义的函数(基类中有同名、同原型的函数)
- overload:派生类中多个同名得重载函数
- overwrite:派生类中定义了某个函数,且基类中有同名得函数,则派生类的函数会将基类的同名函数隐藏掉(hide),如何避免呢?使用命名空间using
- override:若基类中的某个函数是虚的(带virtual关键字),并在派生类中定义了相同原型的函数,称为override
- 继承
- public继承方式,有点类似于is a 或like a, 或者is a kind of ,有点类似于类型的细化的概念,比如父类是哺乳动物,子类是猫,或狗。那么当我们需要一个哺乳动物的时候,我们传入哺乳动物,猫,狗都可以,这种方便性,别的水平关系没有办法取代。
- private继承方式,类似于基类中的有一些代码,我的派生类的成员函数等需要用到
- protested继承方式,基类的public和protested可以沿着继承树一直保持下去,直到叶子
- 组合与继承的选择
- 组合
- 具有 has a 或 contain-a的关系
- 子对象所属类的源代码可有可无
- 类间是水平关系,相比继承可以减少类的层次
- 继承:继承方式不同,目的不同
- 基类的源代码必须有
- public继承,private继承(可以使用组合进行替代),protected继承(也可以用组合替代)
在选择的时候,应该遵循多造低矮的继承树,不要太高。一般公有继承可以多用
十六、继承和类型转换
- 从基类向派生类进行转换:向下类型转换
- 在private/protected继承下没有实际意义,若确实需要转换,可以在derived中定义构造函数 Derived::Derived(const Base&);
- public继承,见后
- 从派生类向基类转换:向上的类型转换,将派生类的指针、引用或对象转换为基类的指针、引用或对象
- protected/private继承方式,向上转换没有意义,非要转换可以使用强制类型转换或自动转换函数进行转换
- public继承方式
- public继承方式下的向上继承转换
- 这个时候转换是有一定意义的
- 逻辑上,父类是类型的泛化或一般化
- 语言上,public行为集被窄化
- 子类的指针转换为父类的指针
1
2
3MTcar c;
Car \* c1 = &c; //安全的
Car \* c2 = new MTcar; //安全的 - 子类的对象转换为父类的引用
1
2
3MTcar c;
Car & c1 = c; //安全的
const Car & c2 = c; //安全的 - 子类转换为父类对象将c转换为父类对象是安全的,但是转换后的对象是一个新对象,它与c不是同一个对象,这个新对象是经过剪裁的,但是它与前面两个不一样,子类对象向父类对象是经过了裁剪,所以实际使用的时候很多使用指针。
1
2MTcar c;
(Car) c; - c++中的类型转换方式
- 内置类型的自动转换,如int->float
- 构造函数转换
- 定义自动转换函数
- public继承下的向上类型自动转换
- 使用类型转换操作符
- static_cast转换操作符
- const_case转换操作符
- reinterpret_cast转换操作符
- dynamic_cast转换操作符
- static_cast转换操作符
- 格式:static_cast
(exp) - T表示指针、引用、内置类型、枚举类型,但不能是对象
- 比如:
1
2A \* pA = new A;
char *pByte = static_cast<char\*>(pA);
- 格式:static_cast
- const_cast转换操作符
- 用于添加或移除表达式中的const或volatile约束
- volatile修饰一个变量,就是让编译器不要做任何优化,直接按照写的方式去执行,一般在多线程中使用的较多
- reinterpret_cast转换类型操作符
- 重新解释,对表达式的类型做出重新解释,常用于重新解释函数
- dynamic_cast类型转换操作符
- 动态类型转换,一般应用于从父类向子类的转换
十七、多重继承
- 通过多重继承可以很方便的创建新的类型
- 构造的时候,严格按照继承的顺序构建
- 析构的时候,正好与构造的顺序相反
- 多重继承下面的名字冲突问题
- 在派生类调用的时候,指明从哪个基类来的 cout << B::f() << endl;
- 使用using 关键字,using A::f, 本质上没有解决名字的冲突
- 多重继承中的菱形结构,简单的要求名字变量不一样,不能解决命名冲突
- 使用虚基类解决了上面的问题,也就创建成员的时候,不直接创建,直到最后再创建
缺点- 要求B类及C类的作者,预知未来将来会被多种集成
- 类型的向上转换困难
- 其他的解决方案
- 限定只能单继承,比如vb语言
- 限定多个基类中,最多只能有一个基类有实例变量
- 避免了多重继承下数据成员名字冲突的问题
- 保留了多重继承的方便性
- 常见的无实例类型变量的类
- 用类型做区分标志
- 工具类:类中只放置多个工具函数或类变量,有点类似于c语言中头文件
- 接口类:指明其后裔类的公共行为集(也称为接口),通常接口类不能实例化,但其子孙类可实例化。接口类无实例变量,一般只给出public行为,实例方法或类方法均可。
十八、虚机制
- 静态编联(早绑定,静态绑定):
- 编译期间就决定了程序运行时将具体调用哪个函数体,即使没有主程序,也能知道程序中各个函数体之间的调用关系
- 动态编联(晚绑定,动态绑定)
- 在运行期间,决定具体调用哪个函数体
- 如何实现动态编联呢?
- 多种方式
- 虚机制(使用虚拟函数或虚拟函数表)
- 动态编联可以使用虚函数来进行实现
- 在普通的函数前面加上virtual关键字,这样使用的就是动态编联
- 虚函数的几点说明
- 必须是成员函数
- 静态成员函数和构造函数、拷贝构造函数不能是虚的
- 析构函数可以是虚的
- 若类中有其他虚函数,那么析构函数也应该是虚的
- 赋值函数通常不定义常虚的
- 虚函数可以带const修饰,也可以不带
- 访问控制可以任意(public、protected、private )
- 派生类中虚函数
- 通常采用public方式继承
- 若基类的析构函数是虚的,那么派生类中的析构函数也是虚的
- 派生类override基类中的虚函数
- 函数名字同基类中虚函数的名字
- virtual关键字可以省略
- 返回类型必须与基类中虚函数的返回类型相同或相容
- 可能会隐藏基类中重载的虚函数
- 虚函数表(虚拟表、虚表、VTable): 一个指针数组,各元素存放对应虚函数的入口地址
- 基类中的顺序一般是自定义的
- 派生类中定义一般是前面和基类顺序一样,后面是自己的
- 几点说明
- 要求对应的类中至少有一个虚函数
- 一个类至多有一个虚拟表,同一个类的不同对象共享该虚拟表
- 首次创建该类实例对象时,在内存中同时创建该类的虚拟表
- 按照函数顺序的序号依次存放入口地址
静态类型:在编译期间就可以确定的变量类型
- 指针型:Parent *pObj = &child;
- 引用型:Parent& obj = child;
- 对象型:Parent obj = child;// 对象型中obj的静态、动态一致
动态类型:在运行的时候才可以确定的、对应于变量的真实类型
函数调用的编译(以p->Func()为例)
- 确定p的静态类型,如A*
- 在A类中,寻找名字为Func,且参数可以匹配的函数
- 若找不到,则编译错误
- 若找到,该函数是virtual吗
- 若不是,编译成p->A::Func()
- 若是虚函数,采用动态编译,从而编译成(* ptr->vptr)[index]((void *)p, …);,即在运行时,根据vptr中的函数入口地址,选择执行函数
若希望pObj->Func();或obj.Func()合法,必须有: - pObj/obj的静态类型中必须有匹配的函数Func;
- 即使匹配的Func,永远不被调用,也要有
- 虚函数的访问
- 虚函数中访问非虚函数
- 静态编联,使用本地版本
- 非虚函数中访问的虚函数
- 动态编联
- 虚函数中访问虚函数
- 动态编联
- 构造函数和虚函数
- 构造函数不能是虚函数
- 调用的虚函数采用静态编联,使用本地版本
- 析构函数和虚函数
- 析构函数可以是虚函数
- 若类中含有虚函数,那么析构函数也应为虚函数
- 调用的虚函数采用静态编联,使用本地版本
- 具体类和抽象类
- 具体类:可以实例化
- 抽象类:为子类提供更高层次的抽象,本身不能被实例化,但是后裔类可以实例化
- 抽象类的定义
- 含有一个或多个纯虚函数
- 纯虚函数的格式(一定是成员函数)virtual Return Type Func(…)[const] = 0;
- 纯虚函数的访问控制可任意
- 具体类的子类可以是具体类或抽象类
- 抽象类的子类可以是具体类或抽象类
- 纯抽象类:除静态、构造、析构等函数均为纯虚函数
- 纯虚定义:对纯虚函数给出缺省实现(定义),只能放在类外,比如cpp文件中
- 运行时类型识别RTTI(Run Time Type Identify),要有虚函数,不然没有意义
- Typeid(exp);
- exp是指针,则返回静态类型
- exp是对象/引用类型,且含有虚函数,则返回动态类型对应的
- dynamic_cast
(exp) - T只能是指针或引用型
- 若T是指针,成功则返回T类型指针;失败返回nullptr
- 若T是引用,成功则返回T类型引用,失败则产生bad_cast异常
十九、静态多态与动态多态
- 多态性:相同的消息请求,执行不同的代码体,从而有不同的行为后果。
- 静态多态:根据目标对象的静态类型和参数表中参数的静态类型确定目标的代码体
- 模板:不同的模板参数
- 函数重载
- 动态多态
- 根据目标对象的动态类型和参数表中的参数的静态类型确定目标代码体(虚机制)
- 根据目标对象的动态类型和参数表中的参数的动态类型确定目标代码体(c++不支持)
- 静态多态:根据目标对象的静态类型和参数表中参数的静态类型确定目标的代码体
- 父类提供框架,子类提供细节
- 拷贝构造函数不能为虚的,那么我们可以新增加一个虚拟的同名函数,如clone,这个新函数就可以是虚的了。这就是虚拟的拷贝构造函数了
- 使用虚机制,使得当变更子类的具体实现的时候,不用改客户端的定义及实现
二十、总结
- 面向对象程序设计的过程
- 建立模型
- 用关联、依赖关系建立较高层的关系模型
- 根据问题域、领域知识、经验等抽象出类型
- 只使用水平关系
- 主要考察类的公有行为
- 细化模型
- 用关联、依赖关系降低模型的抽象层次
- 更体现领域知识、经验等作用
- 只使用水平关系
- 主要考察类的行为
- 类型的抽象与表示
- 用类表示抽象出的类型
- 涉及类的拆分、类的合并、行为的表示、数据的表示和组织
- 封装变化,有可能的权衡
- 将可能变化的部分、用类单独封装
- 涉及类的行为接口变化、行为实现的变化、数据表示变化、数据组织变化等
- 需要领域知识、经验等
- 使用水平关系
- 用子类型化适应变化
- 针对一个维度的变化,用子类型化适应未来变化
- 对于多个维度的变化,先将各维度的变化独立出来(用水平关系)
- 上诉过程的迭代
- 建立模型