C++OOP
类和对象
类是概念模型,是对象的原型,对象是实际实体
对象是对客观事物的抽象,类是对对象的抽象
对象是类的实例,类是对象的模板。对象是通过类的构造方法来产生的
三大特性
封装
隐藏属性、方法或实现细节的过程称为封装,仅对外提供公共访问方式,将变化隔离,便于使用,提高复用性和安全性。
抽象和封装
- 抽象是指只关注事物的重要细节,而忽略事物的次要细节。
- 处理事物复杂性的方法
- 抽象包括过程抽象和数据抽象
- 封装是屏蔽细节,抽象是提取共性
继承
- 继承是面向对象中类之间的一种关系
- 继承是面向对象中代码复用的重要手段
- 子类拥有父类的所有属性和行为
- 在子类定义时,父类的构造函数先被调用,子类的构造函数再被调用,析构顺序相反(前提是析构函数为虚函数)。
- 定义子类时,先父后子;父类构造函数调用父子共用的虚函数,此时子类尚未构造,只会调用父类虚函数
- 析构子类时,先子后父;父类析构函数调用父子共用的虚函数,此时子类已被析构,只会调用父类虚函数
- 子类对象可以作为父类对象使用
- 子类中可以添加父类没有的方法和属性
重写的虚函数将覆盖基类虚函数
多态
- 「派生类的指针」可以赋给「基类指针」;
- 通过基类指针调用基类和派生类中的同名「虚函数」时
- 调用哪个虚函数,取决于指针对象指向哪种类型的对象。
- 派生类的对象可以赋给基类「引用」
- 通过基类引用调用基类和派生类中的同名「虚函数」时
- 调用哪个虚函数,取决于引用的对象是哪种类型的对象。
这两种机制就叫做多态
1.当强制类型转换派生类为父类后,调用的虚函数则为父类的虚函数
Derived d; Base* pBase = &(Base)d; pBase->fun1();//调用Base的fun1
2.当某成员函数不为虚函数,则即是是子类赋值给父类指针,调用时也为父类成员函数
在使用基类指针调用虚函数的时候,它能够根据所指的类对象的不同来正确调用虚函数。而这些能够正常工作,得益于虚指针和虚函数表的引入,使得在程序运行期间能够动态调用函数。
动态绑定有以下三项条件要符合:
- 使用指针进行调用
- 指针属于up-cast后的
- 调用的是虚函数
与动态绑定相对应的是静态绑定,它属于编译的时候就确定下来的,如上文的非虚函数,他们是类对象直接可调用的,而不需要任何查表操作,因此调用的速度也快于虚函数。
静态类型和动态类型:
- 静态类型:变量在声明时的类型,是在编译阶段确定的。静态类型不能更改
- 动态类型:目前所指对象的类型,是在运行阶段确定的。动态类型可以更改
静态绑定和动态绑定:
- 静态绑定是指程序在 编译阶段 确定对象的类型(静态类型)
- 动态绑定是指程序在 运行阶段 确定对象的类型(动态类型)
在非构造函数,非析构函数的成员函数中调用「虚函数」,是多态!!!
// 基类
class A
{
public:
virtual void Func() // 虚函数
{
cout << "A::Func" << endl;
}
};
// 派生类
class B : public A
{
public:
virtual void Func() // 虚函数
{
cout << "B::Func" << endl;
}
};
int main()
{
A a;
A * pa = new B();
pa->Func(); // 多态
//a存放A类虚函数表,pa存放b类虚函数表
// 64位程序指针为8字节
//使得p1指向a类虚函数表,p2指向b类虚函数表
int * p1 = (int *) & a;
int * p2 = (int *) pa;
//将pa存放的b类虚函数表替换成a类的
* p2 = * p1;
pa->Func();//最终调用a类里的Func
return 0;
}
将派生类对象赋值给基类指针,当delete基类指针时,若析构函数不是虚函数,则只调用基类的析构函数,这就会存在派生类对象的析构函数没有调用到,存在资源泄露的情况。
分类
- 静态多态:重载函数,模板
- 动态多态:虚函数,虚继承
编译时多态和运行时多态的区别:
时期不同:编译时多态发生在程序编译过程中,运行时多态发生在程序的运行过程中;
实现方式不同:编译时多态运用泛型编程来实现,运行时多态借助虚函数来实现。
函数重载
函数重载的概念:C++允许在同一作用域中声明几个功能类似的同名函数,这些同名函数的形参列表必须不同
- 参数的个数不同
- 参数的类型相同
- 参数的次序不同
通过重载函数可以防止隐式转换
// 禁止外部复制构造
Single(const Single &signal);
// 禁止外部赋值操作
const Single &operator=(const Single &signal);
C++函数重载通过函数签名实现,C语言没有函数重载是因为其函数签名没有保存参数列表信息
函数签名
C++中的函数签名(function signature):包含了一个函数的信息,包括函数名、参数类型、参数个数、顺序以及它所在的类和命名空间,函数签名用于识别不同的函数
自增自减
为了区分所重载的是前置运算符还是后置运算符,C++规定:
- 前置运算符作为一元运算符重载
- 后置运算符作为二元运算符重载,多写一个没用的参数,重载为成员函数的个数如下:
T & operator++(); // 前置自增运算符的重载函数,函数参数是空
const T operator++(int); // 后置自增运算符的重载函数,多一个没用的参数,一般传入0
- 前置运算符
- 执行步骤: 1. 成员变量自增或自减; 2. 返回对象引用;
- 返回值可以作为左值
- 后置运算符
- 重载函数的执行步骤: 1. 先要产生一个临时对象来保存未自增或自减前的对象; 2. 接着成员变量自增或自减; 3. 最后返回修改前的对象;
- 后置运算符,是不能作为左值,因为返回的是临时变量
模板
模板是C++支持参数化多态的工具,目的就是能够让程序员编写与类型无关的代码;通常有两种形式:函数模板和类模板;
template=class,<>括号中的参数叫模板形参,模板形参不能为空。
template <class T>
void swap(T& a, T& b)
{
//...
}
template<class 形参名,class 形参名,…>
class 类名
{
//...
};
- 类模板里的静态成员初始化的时候,最前面要加
template<>
。 - 注意模板编程不支持分离式编译,即模板类/模板函数的声明与定义应该放在头文件里,否则会在链接时报错;
- 类模板和函数模板都可以被全特化;
- 重载与特化
- 重载是实现一个独立的新的函数
- 特化是不引入新模板实例,只是对原来的主(或者非特化)模板中已经隐式声明的实例提供另一种定义
模板形参
- 类模板的“<类型参数表>”中可以出现非类型参数:template <class T, int size>
- 可以为类模板的类型形参提供默认值,但不能为函数模板的类型形参提供默认值。
- 函数模板和类模板都可以为模板的非类型形参提供默认值。
- 类模板类型形参默认值和函数的默认参数一样,如果有多个类型形参则从第一个形参设定了默认值之后的所有模板形参都要设定默认值
函数查找
在有多个函数和函数模板名字相同的情况下,编译器如下规则处理一条函数调用语句:
- 先找参数完全匹配的普通函数(非由模板实例化而得的函数);
- 再找参数完全匹配的模板函数;
- 再找实参数经过自动类型转换后能够匹配的普通函数;
- 上面的都找不到,则报错。
全特化与偏特化
对主版本模板类、全特化类、偏特化类的调用优先级从高到低进行排序是:全特化类>偏特化类>主版本模板类。这样的优先级顺序对性能也是最好的。
全特化
- 有时为了需要,针对特定的类型,需要对模板进行特化,也就是所谓的特殊处理
- 已经不具有template的意思了,明确遇到特定类型会如何处理
//原
template <class T>
class Compare
{
public:
bool IsEqual(const T& arg, const T& arg1);
};
//全特化
template <>
class Compare<float>
{
public:
bool IsEqual(const float& arg, const float& arg1);
};
偏特化
- 偏特化是指提供另一份template定义式,而其本身仍为templatized
- 偏特化后仍然带有templatized
// 一般化设计
template <class T, class T1>
class TestClass
{
public:
TestClass()
{
cout<<"T, T1"<<endl;
}
};
// 针对普通指针的偏特化设计
template <class T, class T1>
class TestClass<T*, T1*>
{
public:
TestClass()
{
cout<<"T*, T1*"<<endl;
}
};
函数模板与类模板
- 实例化方式不同:函数模板实例化由编译程序在处理函数调用时自动完成,类模板实例化需要在程序中显示指定。
- 实例化的结果不同:函数模板实例化后是一个函数,类模板实例化后是一个类。
- 默认参数:类模板在模板参数列表中可以有默认参数。
- 特化:函数模板只能全特化;而类模板可以全特化,也可以偏特化。
- 调用方式不同:函数模板可以隐式调用,也可以显示调用;类模板只能显示调用。
可变参数模板
用省略号表示接受可变数目参数的模板函数或模板类。将可变数目的参数被称为参数包,包括模板参数包和函数参数包。
#include <iostream>
using namespace std;
template <typename T>
void print_fun(const T &t)
{
cout << t << endl; // 最后一个元素
}
template <typename T, typename... Args>
void print_fun(const T &t, const Args &...args)
{
cout << t << " ";
print_fun(args...);
}
int main()
{
print_fun("Hello", "wolrd", "!");
return 0;
}
/*运行结果:
Hello wolrd !
*/
虚表指针与虚函数表
C++ 类中有虚函数的时候,会有一个存放该类所有虚函数的表,不包括普通函数的函数指针,称为虚函数表;指向该表的指针,称为虚表指针
父类子类共享一个虚表指针,所以不管父子类各有多少虚函数,都只是算成一个虚表指针的占用内存,指向共享的虚函数表,但多继承的话会有多张虚函数表
每个类都会维护一张虚表,编译时,编译器根据类的声明创建出虚表
- 确定时间:虚函数表在编译的时候就确定了,而类对象的虚函数指针vptr是在运行阶段确定的,这是实现多态的关键!
- 数据结构:虚函数表实质上是一个指针数组,存放指向虚函数的指针
- 内存位置:虚函数表存放于常量区(只读),虚函数存放在代码区
- 虚函数表和类绑定,虚表指针和对象绑定。同一个类的所有对象都使用同一个虚表。
class Base
{
public:
virtual void a() { cout << "Base::a" << endl; }
virtual void b() { cout << "Base::b" << endl; }
virtual void c() { cout << "Base::c" << endl; }
virtual void d() { cout << "Base::d" << endl; }
virtual void e() { cout << "Base::e" << endl; }
};
int main()
{
Base b;
//(long*)(&b)=&b
cout << "虚表指针地址:" << (long*)(&b) << endl;//= &b
//虚表指针值=虚表地址
cout << "虚表指针值:" << *(long*)(&b) << endl;//10进制
cout << "虚表地址:" << (long*)*(long*)(&b) << endl;//16进制
//虚表地址再解引用是指向虚表内容,再强转long*地址,最后=虚表第一个虚函数地址
cout << "虚函数表 — 第一个函数地址:" << (long*)*(long*)*(long*)(&b) << endl;
// 1.第一种函数指针
typedef void(*pFun)(void);
//等价于using pFun=void(*)(void);
//三种取值
pFun pa = reinterpret_cast<pFun>(*(long*)(((long*)(&b))[0]));
auto pb = (pFun)((long*)*(long*)(&b))[1]; //虚表地址下标取值
pFun pc = (pFun) * ((long*)*(long*)(&b) + 2);
//第二种函数指针
typedef void(Base::* pFun1)();
pFun1 pd = *(pFun1*)(*(long*)(&b) + sizeof(pFun1) * 3);
//第三种函数指针
typedef void(*pFun2)(Base*);
pFun2 pe = *(pFun2*)(*(long*)(&b) + sizeof(pFun2) * 4);
pa();
pb();
pc();
(b.*pd)();
pe(&b);
}
虚函数重写要求,建议在派生类中的函数头后加入关键字override。如果不一致则会报错
- 函数的const属性必须一致。
- 函数的返回类型必须相同或协变。
- 函数名称与参数列表一致。
- 虚函数不能是模板函数。
notice
- 父类的函数定义时有virtual.子类的函数没有virtual但是其他都相同,也同样构成重写!且子类的子类仍然可以继续重写
- 父类析构函数是虚函数时,编译器在编译时,析构函数名字统一为destucter,所以只要父类的析构函数是虚函数,那么子类的析构函数钱是否加virtual,都构成重写
- 析构函数重写后,当需要调用父类析构函数时,通过作用域运算符访问
- 构造函数中最好不要调用虚函数,因为此时对象很有可能还没有构造出来;析构函数中也一样,因为有可能对象已经释放。
内存布局
如何查看内存布局
在VS的cpp文件右键打开属性,选择C/C++,选择命令行,在其他选项那输入
/d1 reportSingleClassLayoutchildchild
//childchild 就是你想看内存布局的类名
无虚继承时内存布局,例:C继承于A,B;A,B有虚函数
C中内存布局如下
- AC虚表指针
- 基类A成员变量(按顺序)
- BC虚表指针
- 基类B成员变量
- C成员变量
有虚继承时内存布局,例:B,C虚继承于A;D继承于B,C;
D中内存布局如下
- 自己的虚表指针
- 虚继承偏移量表指针
- 自己成员变量
- 继承来的B虚表指针
多重继承
B,C继承A,D继承B,C
又称为菱形继承/钻石继承
引发问题:二义性
- 解决方法:虚继承
- 让BC虚继承于A
- 作用域限定
- 函数覆盖虚基类/虚继承
虚函数与虚继承
- 虚函数:virtual void xx
- 纯虚函数:virtual void xx()=0
- 在基类中没有定义,但要求任何派生类都要定义自己的实现方法
- 虚继承:class B: virtual public A
- 又名共享继承,各派生类的对象共享基类的的一个拷贝
- 为了解决多继承时的命名冲突和冗余数据问题,C++提出了虚继承,使得在派生类中只保留一份间接基类的成员。
- 虚继承会多一个指向虚继承偏移量表指针,先存放自己,再按虚继承顺序存放父类成员域偏移量
抽象类与接口
抽象类
- C++:带有纯虚函数的类
- C#:被abstract修饰的类
抽象类不能生成对象,只能用作父类被继承,子类必须实现纯虚函数的具体功能
接口是一种特殊的抽象类,所以也不能生成对象
接口
如果一个类里面只有纯虚函数,没有其他成员函数和数据成员,就是接口类
为何有抽象类,还需接口?
接口用于抽象事物的特性,抽象类用于代码复用。接口带来的最大好处就是避免了多继承带来的复杂性和低效性,并且同时可以提供多重继承的好处。
类的设计
对于一个空类,编译器默认生成四个成员函数:
- 默认构造函数
- 构造函数不能用const修饰(因为const修饰类的成员函数时,该函数不能修改成员变量,但是构造函数要修改类的成员变量,因此不可以由const修饰)
- 将对象放入数组或vector等,会自动调用构造函数(逐个)
- 析构函数
- 默认情况下析构函数为非虚函数
- 对象生命周期结束时,C++编译系统系统自动调用析构函数。
- void*指针指向的对象,即使delete也不会调用析构函数
- 当将指向对象的指针存入数组中,
- 拷贝构造函数
- 使用已存在的对象创建新的对象
- 传值方式作为函数的参数
- 传值方式作为函数的返回值
- 拷贝赋值函数
class String
{
public:
String(const char* str = NULL);//普通构造函数
String(const String& other);//拷贝构造函数
~String(void);//析构函数
String& operator=(const String& other);//赋值函数
private:
char* m_data;//用于保存字符串
};
//构造函数
String::String(const char* str)
{
if (str == NULL)
{
m_data = new char[1];
*m_data = '\0';
}
else
{
int length = strlen(str);
m_data = new char[length + 1];
strcpy(m_data, str);
}
}
//拷贝构造函数
String::String(const String& other)
{
int length = strlen(other.m_data);
m_data = new char[length + 1];
strcpy(m_data, other.m_data);
}
//析构函数
String::~String(void)
{
delete[] m_data;
//由于m_data是内部成员,也可以写成delete m_data
}
//赋值运算符重载
String& String::operator=(const String& other)
{
//检查自赋值
if (this == &other)
return* this;
//释放原有的内存资源
delete[] m_data;
//分配新的内存资源,并复制内容
int length = strlen(other.m_data);
m_data = new char[length + 1];
strcpy(m_data, other.m_data);
//返回本对象的引用
return* this;
}
构造函数
负责初始化类
Student s1; //默认构造函数,等价于Student s1{} or Student s1={}
Student s2=s1; //拷贝构造函数
s1=s2; //拷贝赋值函数
- 默认构造函数,又称合成的默认构造函数
- 只有当类没有声明任何构造函数时,编译器才会自动地生成默认构造函数
- =default可以告诉编译器,仍然需要一个默认构造函数
- 复制/拷贝构造函数
- 一个对象作为函数参数(且为值传递),以值传递的方式传入函数体
- 一个对象作为函数返回值,以值传递的方式从函数返回
- 一个对象用于给另外一个对象进行初始化(常称为复制初始化,即很明显的直接调用拷贝构造进行对一个对象进行构造初始化)
- 移动构造函数
- std::move() 函数,它可以将左值强制转换成对应的右值,由此便可以使用移动构造函数。
class demo
{
public:
//构造函数
demo()
{
cout << "construct!" << endl;
}
//拷贝构造
demo(const demo& d)
{
cout << "copy construct!" << endl;
}
//移动构造函数
demo(demo&& d) {
cout << "move construct!" << endl;
}
}
demo get_demo() {
demo d;
return d;
}
int main() {
demo a=get_demo(); //移动构造初始化
demo b(get_demo()); //拷贝构造初始化
demo c;
c=get_demo(); //拷贝赋值
return 0;
}
实际发生过程
- 执行 get_demo() 函数内部的 demo d 语句,即调用 demo 类的默认构造函数生成一个对象;
- 执行 return d 语句,会调用拷贝构造函数复制一份之前生成的匿名对象,并将其作为 get_demo() 函数的返回值(函数体执行完毕之前,匿名对象会被析构销毁);
- 执行 a = get_demo() 语句,再调用一次拷贝构造函数,将之前拷贝得到的临时对象复制给 a(此行代码执行完毕,get_demo() 函数返回的对象会被析构);
- 程序执行结束前,会自行调用 demo 类的析构函数销毁 a。
-
当类存在拷贝构造和移动构造函数时,会优先选择移动构造函数
-
定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作,否则这些成员默认地被定义为删除的
如果编译器不支持类内初始值,那么默认构造函数需要使用构造函数初始化列表,
如果是在构造函数体内进行赋值的话,等于是一次默认构造加一次赋值,而初始化列表只做一次赋值操作;所以初始化列表进行初始化比构造函数体内初始化要快
初始化列表不能初始化静态成员变量
foo(int num) :i(num) {}
但初始化顺序还是按照类内变量顺序,所以初始值列表最好按顺序初始化
析构函数
负责销毁类
构造函数不能为虚函数,而析构函数可以且常常是虚函数。
如果构造函数是虚函数,那么需要通过虚表指针访问虚表来调用,此时虚表指针都未被构造出来,虚表也未初始化,所以构造函数不可能为虚函数。
当需要调用虚函数时,虚表和虚表指针已存在,且因为虚函数可以识别对象类型,从而正确销毁对象
拷贝赋值函数
成员函数
成员函数只是在名义上是类里的。但其实成员函数的大小不在类的对象里面,同一个类的多个对象共享函数代码。
在编译期中,成员函数其实被编译成了与对象无关的普通函数,但知道它对应对象是谁,当需要访问对应对象内的成员变量时,this指针作为第一个参数被隐藏传入,如a.fun()是通过fun(a.this)来调用的。
this指针,是一个常量指针,存放对象的首地址,是连接对象与其成员函数的唯一桥梁
static成员函数
static void testStatic(){}
- 因为static成员函数没有this指针,所以静态成员函数不可以访问非静态成员变量/函数。
- 静态成员函数不可以同时声明为 virtual、const、volatile函数
const成员函数
const成员函数不能修改任何类内成员,除非被mutable修饰
mutable:可变的
被mutable修饰的变量,将永远处于可变的状态,即使在一个const函数中
//class Base里
int num ;
mutable int muaNum=0;
void testConst()const
{
num++; //报错
Base::muaNum++; //正确通过
}
成员变量
static成员变量
- 静态成员属于类作用域,但不属于类对象,它的生命周期和普通的静态变量一样,程序运行时进行分配内存和初始化,程序结束时则被释放。所以不能在类的构造函数中进行初始化。
- 静态成员变量不能在类的内部初始化。在类的内部只是声明,定义必须在类定义体的外部
//.h里
class Grade
{
Grade(){}
static int math;
}
//.cpp里
int Grade::math = 98;
- 若想要在类内部初始化则需满足
- 静态成员必须为字面值常量类型:constexpr。
- 给静态成员提供的初始值,必须为常量表达式。
static 一般用来控制变量的存储方式和可见性
当需要将函数中一个局部变量值进行存储,最简单粗暴是全局变量,但会破坏访问范围(使得在此函数中定义的变量,不仅仅受此函数控制),因此需要一个数据对象为整个类而非某个对象服务,同时又力求不破坏类的封装性,即要求此成员隐藏在类的内部,对外不可见。即静态成员变量
const成员变量
- C++11后允许在声明时初始化,但推荐使用初始化列表初始化
- 若声明时初始化了,初始化列表又用另一个值初始化,则前者行为不执行
类初始化规律
类声明后不初始化成员变量,除非构造函数有初始化列表
直到调用成员变量时再检查是否有初始化
若初始化列表有初始值,则声明变量处的初始化直接不执行
在C++98标准里,只有static const声明的整型成员能在类内部初始化,并且初始化值必须是常量表达式
C++11的基本思想是,允许非静态(non-static)数据成员在其声明处(在其所属类内部)进行初始化。
但C#里是
- 若有静态/普通构造函数,则先确保静态变量/普通变量已经初始化,再执行
- 顺序是:静态成员变量,静态构造函数,普通构造函数,普通成员变量
public class PrintInt
{
public PrintInt(int num)
{
Console.WriteLine(num + " Construct");
}
}
public class Base
{
public static PrintInt p1 = new PrintInt(1);
public PrintInt p2 = new PrintInt(2);
public Base()
{
PrintInt p3 = new PrintInt(3);
}
static Base()
{
PrintInt p4 = new PrintInt(4);
}
}
namespace HelloWorld
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello");
Base b = new Base();
//Base b先调用静态构造函数,new Base再调用普通构造函数
Console.ReadKey();
}
}
}