文章目录
本章内容概述
本文用于笔者在学习 C++ 面试突破中面向对象的部分,对相关知识进行分析做出自己的理解,并记录部分笔记。
一、面向对象 & 三大特性
1.面向对象定义
面向对象,笔者在过往学习中对其的理解是,对象是用于解决某一类问题的方法,和解决该类问题用到的数据的集合,针对该类问题的不同子问题,可以体现在每个类实例化出的对象的成员数据不同,但是解决问题的方法大致相同,因此可以将成员函数和成员变量进行封装,在需要解决问题时,根据具体情况实例化出一个对象,从而调用成员函数解决问题。
2.三大特性
封装
,将具体的数据和过程进行包装,对外提供特定接口。
继承
,子类可以继承自父类,同时具备父类的全部成员函数和成员变量,并且可以重新定义其他的变量或者改写父类的变量,但不能修改 final 修饰的父类成员。
多态
,父类的引用指向子类的对象,调用同一个函数,但因不同子类对弗雷虚函数的重写不同,从而呈现出不同的操作结果。
二、函数的重载 & 重写 & 隐藏
1.重载
C++ 中,同一可访问区可以声明多个参数类型、参数个数、顺序、const 不同的同名函数,但需要注意的事,返回值类型不属于函数签名,因此不可做重载依据。
所谓“同名函数”,只是在程序员看来,实际上在编译阶段,这些函数各自有各自的地址,编译器根据调用情况判断应当调用哪个函数。可以尝试查看编译器对重载函数的函数签名,代码如下:
//max 参数个数不同的极大值函数
int max(int a,int b)
{ if(a>=b) { return a; } return b; }
int max(int a,int b,int c)
{ return max(max(a,b),c); }
以这两个函数为例,分别返回两个和三个数中的最大值,编译成功后查看汇编代码:
_Z3maxii:
{...}
_Z3maxiii:
{...}
可以看到,两者的函数签名结尾有所不同,分别表示两个 int 型 “ii” 和三个 int 型 “iii” 。
2.隐藏
子类重写父类的同名函数后,父类的函数将会被隐藏,无论重写后的函数与原函数参数列表是否相同,都会默认调用子类的该函数,如果需要调用父类的该函数,可以加作用域声明。
class test_dad
{ public: void speak() { cout << "dad is speaking" << endl; } };
class test_son :public test_dad
{ public: void speak() { cout << "son is speaking" << endl; } };
test_son ts;
ts.speak(); //son is speaking
ts.test_dad::speak(); //dad is speaking
3.重写
与隐藏类似,当弗雷考虑到允许子类重写某个函数时,可以用 virtual 修饰,表明允许子类可以选择重写该函数;当使用 virtual 和 =0 修饰时,表明要求子类必须重写该函数。
需要注意的是,这三者之间有相同之处,也有不同之处。
重载与重写相比,重载发生在同一可访问区内,如类内部、同一文件,重写发生在不同类之间,虽然两者函数名字都相同,但重载要求两个函数签名必须不同,重写无此要求。
而隐藏相比于重载和重写,隐藏父类的同名函数仅需在子类内重新定义即可,函数参数个数、类型、返回值无约束,但使用虚函数重写时,函数返回值、参数类型、参数个数都应与父类同名函数保持一致(否则影响多态实现),否则无需考虑。
隐藏发生在编译阶段,编译器已经确定需要调用父类或者子类函数,重写发生在运行阶段,查找虚函数表。
三、多态
多态的实现,进发生在父类的指针指向子类的对象时,根据子类对父类虚函数重写不同,虽然都是调用父类的函数,但根据传入子类的不同,函数结果也会发生变化,具体实现是依靠虚函数表实现的,此处不做详细解释,需要注意的是,若要实现多台,父类函数必须声明为虚函数,否则即便子类重写,即隐藏,但依然会根据父类引用类型调用父类的该函数,代码如下:
class test_dad
{ public: void speak() { cout << "dad is speaking" << endl; } };
class test_son :public test_dad
{ public: void speak() { cout << "son is speaking" << endl; } };
void speak(test_dad& td)
{ td.speak(); }
test_son ts;
speak(ts); // dad is speaking
因此,多态仅发生在:子类重写了父类的虚函数,并且存在父类的引用指向子类的对象时。
四、限制对象创建区域
在实例化对象时,无非两种情况:由编译器为对象在栈上分配内存,静态创建;主动申请堆区空间创建对象,动态创建。
1.限制对象只能创建在栈区
若要使得对象不能在堆区创建,首先分析在堆区创建对象的过程:new 操作符首先调用 operator new 获得空间,然后将此空间类型转换以后利用指针调用构造函数,考虑到栈区可以随意创建,因此构造函数权限必须保持公有,因此考虑,可以将operator new 函数设置为私有,这样 new 无法调用该函数获取空间,也就无法在堆区创建对象了,代码如下:
class A
{ public:
A() { cout << "A construct" << endl; }
private:
void* operator new(size_t t) { }
void operator delete(void* ptr) { } //重载 operator new 后必须重载 operator delete
};
A a; //A construct
//A* pa = new A; 报错:函数不可见
2.限制对象只能创建在堆区
若要使得编译器不能自主调用构造函数,从而使得对象不可被静态创建,仅将构造函数设置为私有是不可取的,因为 new 运算符也需要通过构造函数才能创建对象。
简单的方法有,通过将析构函数设置为私有属性,编译器发现无法自动释放静态建立的对象,则会拒绝静态创建,但同时为了使得在堆区创建的对像能够安全十防,应当同时编写 destroy 函数,用来完成 delete 的操作,代码如下:
class A
{
public: A() { }
void destroy() { delete this; }
private: ~A() { }
};
//A a; 报错:析构函数不可见
A* pa = new A();
pa->destroy();
但是,这样的实现方法也有局限性,当该类作为基类时,继承出的子类无法访问父类的析构函数,因此需要更好的解决办法。
若要允许子类访问,考虑将构造和析构设置为保护权限,并且对外提供公有权限的静态创建函数和销毁函数,从而满足动态创建、安全析构和继承问题,代码如下:
class A
{
protected: A() { }
~A() { }
public: static A* create() { return new A(); }
void destroy() { delete this; }
};
这样设计,既满足了静态无法创建,也满足了动态创建和销毁IDE需求,同时使得子类可以安全继承。
五、模板编程
模板编程(泛型编程)的内容笔者曾在其他文章中讨论过,此处就不再赘述,有兴趣的读者可以参考笔者的另一篇文章C++ 程序设计兼谈对象模型。
六、虚函数
虚函数,是 C++ 面向对象无论如何也无法离开的内容,可以说多态的特性,就是依赖于虚函数、纯虚函数才得以实现,笔者在此处主要阐述个人对虚函树的原理、使用情况的理解,希望对读者有所帮助。
在面向对象的继承特性中,函数可以分为三类:非虚函数 non-virtual,虚函数 virtual,纯虚函数 pure virtual,代码如下:
class shape
{
public:
// 纯虚函数,子类必须重新定义,无默认定义
void draw() const = 0;
// 虚函数,希望子类重新定义,已有默认定义
void error (const std::string& msg);
// 非虚函数,子类无法重新定义,仅可隐藏
int objectID() const;
}
具体的虚函数原理分析笔者已经在其与文章中分析过,此处不再赘述,有兴趣的读者可以参考笔者的另一篇文章C++ 程序设计兼谈对象模型。
此处讨论虚函数的使用注意:首先,构造函数不能声明为虚函数,因为当类实例化对象时,是会首先调用构造函数,并在构造函数中首先为虚指针赋值,即便程序员并未写此类语句,编译器会在需要时在构造函数中添加为虚指针赋值的语句,换一种说法,虚函数表是在创建对象后才有的,若构造函数被声明为虚函数,则会陷入死循环中,因此构造函数一定不能被声明为虚函数。
析构函数,作为最常被声明为虚函数、纯虚函数的函数类型,常用于类内存在堆区申请空间的情况,为了保证子类正确释放堆区空间,避免内存泄露,从而将析构函数声明为虚函数,当使用 delete 时,才会正确释放堆区内存。
需要注意的是,静态函数不能被声明为虚函数。静态函数不属于某个对象,因此 this 指针无法访问静态函数,但是虚函数调用需要 this 指针访问虚指针进而访问虚函数表来实现,因此静态函数无法被声明为虚函数。并且,静态函数地址本身就是固定的,无需动态绑定,没有被声明为虚函数的必要。与之类似的,内联函数也不可被声明为虚函数。
七、类
1.内存
空类,默认大小为1,因为必须分配一字节的内存用作起始地址,如果该类继承自虚函数的话,那么类中包含一个虚指针,大小则为4或8。即便是空类,编译器会在需要时生成默认构造、拷贝构造、析构函数、重载赋值运算符、重载两个取址运算符,共计六个函数,以满足基本使用。
类的大小一般仅取决于类内普通成员变量,在遵循内存对齐原则的前提下所占内存的大小,与成员函数和静态成员变量无关。除此之外,虚继承会增加一个虚基表指针,虚函数也会增加一个虚指针,都会占用每个对象的内存。
2.对象初始化顺序
2.1构造函数调用顺序
构造函数的调用顺序,首先按照继承顺序调用基类的构造函数,如果有虚继承,则优先调用虚继承的基类的构造函数,在按照派生类中成员变量的声明顺序依次调用各成员变量的构造函数(参考初始化列表),最后调用自身的构造函数,析构顺序一般与构造顺序相反。
2.2成员的初始化顺序
普通成员参考声明顺序进行初始化,若有初始化列表则在初始化时参考书初始化列表,但顺序依旧是声明顺序;若在构造函数内进行初始化,则参考构造函数内部顺序,类成员在定义时不支持被初始化。需要注意的是, const 修饰的常量成员必须在初始化列表中初始化,static 修饰的静态成员必须在类外初始化,且静态成员在 main 函数执行前已经被初始化完成。
3.禁止一个类被实例化
当需要禁止一个类被初始化时,可以根据情况选择以下三种方法:
在类内定义一个纯虚函数;
将所有构造函数声明为 private;
将所有构造函数用 =delete 修饰。
4.成员列表初始化的效率
在类中,一般具有两种类型成员变量:内置数据类型和自定义数据类型。对于打自定义数据类型,列表初始化的效率就会高一些,原因就是,在初始化对象时,在调用构造函数之前,会利用默认构造对声明的变量进行初始化,如果有初始化列表,则会直接调用该构造函数,否则,会先利用默认构造初始化改成元,然后再在构造函数内部对该成员变量再次赋值,相当于多调用了一次默认构造,需要注意的是,如果改成员变量没有默认构造,则必须在初始化列表内初始化。
可以编写程序进行实验,代码如下:
class A
{ public: A(int a = 0) { m_a = a; cout << "A construct " << m_a << endl; }
private: int m_a; };
class B
{ public: A a; B(int b = 1) { a = b; } };
B b;
//输出:
//A construct 0
//A construct 1
加入初始化列表后,代码如下:
class A
{ public: A(int a = 0) { m_a = a; cout << "A construct " << m_a << endl; }
private: int m_a; };
class B
{ public: A a; B(int b = 1) : a(b) { } };
B b;
//输出:
//A construct 1
从结果分析,可以看出初始化列表可以减少一次默认构造的调用,提高了效率。
5.实例化对象的阶段
5.1分配空间
在创建对象前,首先为对象分配内存,分配内存的时机根据对象属性不同也有所差异,全局对象、静态对象和在栈区分配的对象是在编译阶段进行内存分配,在堆区创建的对象在程序运行阶段分配内存。
5.2初始化
首先需要明确,初始化与赋值并不相同,初始化是从无到有的过程,而赋值是给现有的对象赋予值,初始化的过程是随对象的创建过程一同进行的,这个过程利用初始化列表完成,如果没有初始化列表,则用默认构造完成初始化。
5.3赋值
给完成初始化的各个成员变量赋值,利用构造函数内部代码完成,在执行完构造函数后,一个对象的实例化过程也就完成了。
除此之外,对于拥有虚函数的类的对象,还有两种特殊情况需要注意:如果没有继承关系,则分配完内存后首先给虚指针赋值,然后再初始化和赋值;如果存在继承关系,则分配内存后首先调用基类构造函数,然后给虚指针赋值,再初始化和赋值。
6.友元
友元,可以使得类内的私有成员和保护成员对部分外界可见,增强类的联系,但是也使得封装被撕开了一个口,需要谨慎使用。
7.静态绑定和动态绑定
虚函数的地址被存放在一张虚函数表中,这张虚函数表的地址,会在这个类每次实例化出一个对象时,在分配内存后,在初始化之前,赋给这个对象里的虚指针,当这个对象需要调用虚函数时,会根据虚指针找到虚函数表,进而找到待调用的虚函数的地址,从而完成调用。这个过程不同于普通函数在编译阶段就完成了地址绑定,而是需要在程序运行阶段查找该表,因而成为动态绑定。
8.深拷贝与浅拷贝
在一个类中,凡是成员变量有指针类型的,必须重写拷贝构造、重载赋值运算符和析构函数,避免浅拷贝发生。
本章总结
本章重要探讨了面向对象必备的、常见的知识点,这些知识点必须了然于胸,才能算得上对面向对象有所了解,很喜欢侯捷老师的一句话“万丈高楼平地起,勿在浮沙筑高台”,所谓磨刀不误砍柴工,将语言特性了解全面,想清楚在这一项项功能实现的背后,编译器究竟做了怎样的工作,又为什么要支持此类功能,才能更好的完成后期的开发。
最后,我是Alkaid#3529,一个追求不断进步的学生,期待你的关注!