掘金 后端 ( ) • 2024-04-15 18:04

前言

曾经学过c++一段时间,这个语言涉及的东西太多,所以就好好研究一下,总结一下,把之前的笔记完善和归档一下,语言润色一下,希望能对读者有用。

我看过的关于C++几本书

只保证自己看过,觉得还可以,不保证一定是好书和永不过时的书。

  • effective c++
  • stl源码解析
  • c++ primer plus
  • 深入理解计算机系统
  • 设计模式沉思录

malloc的底层原理分析

手动内存管理是底层编程语言的标配,所以需要了解一下c语言中的malloc。

联系到操作系统课程上的内存管理那一章节了. 那个时候, 那一章讲了很多抽象的理论, 比如最佳分配, 最小分配, 静态分配, 动态分配, 但是都是纸上谈兵. 结合C语言里的malloc函数, 才能更好地理解.

malloc功能的底层原理是什么呢?

首先,记得,现在操作系统,是多道系统. 也就是说多个进程同时存在于内存中. 而他们又都使用了虚拟内存的概念.

接着,在我们管理真正的物理内存的时候, 有一个基本的大前提是, 我们需要有一个数据结构来管理真实可用的剩余内存空间.(比如上面说的, 分配原则,最佳匹配,最先匹配等等).所以,我们先要管理空闲内存块的!

因为系统使用的是虚拟内存. 所以在32位的系统上面, 每个进程都有2^32次方byte的内存. 大前提是我们现在都采用虚拟内存管理了.

在多道系统中, 我们所使用的的虚拟内存,其实是映射到驻留集(有一个相近的概念叫工作集)中去的, 所谓的驻留集, 就是操作系统分配给一个进程的实际使用的物理内存空间.

而所谓的malloc功能就是, 根据用户的虚拟内存的需求, 然后去申请物理内存.我猜测malloc底层肯定有调用了进入内核的系统调用,因为在考研计算机的操作系统那门课里, 讲到, 虚拟内存转换物理内存, 使用到地址转换的. 分配内存,是全局性的事, 肯定必须进入内核.

物理内存是要映射到真是的内存上的, 所以malloc肯定有一个功能是把虚拟内存映射到物理内存上, 而这一个环节, 就必须进入操作系统调用. 放在C语言里,有一个概念或者函数,叫 mmap, 做的就是这类工作. 它维护一个全局空闲链表, 哪个进程需要了, 就从空闲链表块摘下一个块内存. 如果程序调用 free, 那么理论上讲, 这个内存块又会重新回到全局空闲链表中去.

现在,我想说的另外一个问题是, 如果系统还剩下20M的内存,而你想要申请40M内存, 那么malloc会失败吗? 理论上会失败的. 为什么? 因为malloc分配的是真实的物理内存呀!! 都用完了,还拿什么分配!!! 但是呢, 别忘了, 操作系统使用的是虚拟内存, 而如今的内存又有几个级别, 从最抽象的来说, 有cache, 再到我们常说的内存条实体, 还有一个东西我们不能忘记, 那就是内存到磁盘的缓存区.

还记得我们说的,进程有一个状态是suspended吗?挂起状态, 如果一个进程长期阻塞, 那么就可以被挂起. 那么这个进程被挂起到什么地方呢?

肯定是内存以外的地方, 在linux系统, 内存到磁盘的高速映射区, 就是swap区了. 我们可以对swap区做更多的事情, 如果一个进程申请了超出系统剩余的内存, 我们还可以在swap区为其分配内存!! 既然是预分配, 那也未必立即用的上, 虽然速度可能慢了(在磁盘上,增加了换入换出的时间), 但是慢总比失败了好.

所以, 如果malloc的原理之一,是可以分配大于物理内存剩余空间的内存, 因为会把这些内存分配到swap区域,只要swap还有剩余空间.

另外, 我们知道, 陷入内核是非常高成本的. 而一个程序员可以在程序里频繁调用 malloc和free, 难道每次都要陷入一次内核吗? 这样做明显不够高效, 而且内存已经月来越便宜了, 反而人们对速度的追求越来越高了. 所以 malloc采用预分配机制, 比如一次要申请2KB,系统可能直接分配给了 4KB, 下次再 malloc一些小的内存, 就不用麻烦内核了. 实际上, 有两个方法, sbrk和brk方法, malloc底层调用的就是它, 还有mmap. 总之,就是malloc封装了这些方法.

参考

[1] malloc底层原理实现 https://blog.csdn.net/wz1226864411/article/details/77934941 [2] malloc底层分配的两种机制 https://blog.csdn.net/YL970302/article/details/86251381

内存模型

背景

实习和学习需要。

内存模型是一个cpp重要的问题,加上语言本身比较复杂,经常日常使用往往不会在意,所以特此探究一下。

本文大概会讲几个东西,(1)运算符的概念(2)sizeof (3)字符串的问题

运算符的概念

最大名鼎鼎的运算符,我举几个例子,+、-、*,、,*(解除指针),&取地址符,new(分配内存),今天来说说sizeof。

书上有抛出一个概念,sizeof 是一个运算符, 所以运算符不用带括号。比如

int a=10;
int b = sizeof a;
int c = sizeof(a);

提个问题:sizeof返回的是unsigned int, 赋给int 没有问题吧!!!这其实一个类型转换的问题. 只要不溢出就可以了

在Java语言中,instanceOf 这个关键字也曾经一度令我迷惑,为啥不带括号呢?做成一个函数多好。

一个那么长的单词,你配叫运算符吗?但是,sizeof 偏偏就是。运算符和函数大概最大的区别就是,函数调用要用栈,而运算符不用,成本极小。

sizeof

接着上面说,sizeof是运算符,但是我还是习惯以函数的形式使用它,另外有的时候必须用函数形式,比如

sizeof(int);
sizeof(double); // 加了括号是不是就按函数处理了呢? 这个有待考证.
sizeof(MyClass);// 自定义类型

sizeof 可以反映一个变量的真实物理空间。

一个大家都知道的概念是,数组在传入sizeof的时候,会返回数组的实际长度,但是数组被当做参数传入函数的时候,其形参就会退化成一个指针。这应该是传参的机制,和 sizeof 本身无关。 补充一下, 如果你给一个函数的形参形成 void f1(int arr[], int len);的形式, 编译器还是会把 arr[]看做一个指针的!!

另外再说一个概念, 内存对齐 。使用sizeof 涉及到内存对齐的概念,虽然这个知识点对于编程没啥用(纠正一下,对于初学者没啥用,但是对于成为高手(写编译器的高手)有用),但是知道总是好的。

举个例子:
struct my_struct{
	int a; //4Byte
	char b;	 //1Byte
	short c; // 2Byte
} test;
int siz = sizeof(test); // 返回8

有人就纳闷了,为什么是8而不是7呢?因为方便计算偏移量,编译器规定结构体大小是最宽的成员变量的大小的整数倍,所以要给 属性 b后面填充字符。 4+1+1 (填充字节) + 2 = 8

为什么要方便计算偏移量呢?因为为了方便读取。这和体系架构相关,有的CPU读取地址(这个也是道听途书,真的咱也不知道),从偶数位开始读,读奇数位不方便。(这是我看来的,想想底层实现,应该也是这个理)。

另外呢,对齐,确实方便计算属性的个数,方便管理。所以要有字节对齐。

问题来了,那么类的实例在内存中加载时里面是否有类似的特点呢?C++里面也是一样的. class关键字和strut关键字其实是一样的.

另外补充一点,sizeof 可以是在编译器运行的,所以它可以放在函数外面用作静态持续性变量的赋值。

再提出一个有趣的问题, 以下代码,调整类型的次序,打印的结果不一样。

void test_sizeof(){
    struct my_struct{
        int a; //4Byte
        char b;	 //1Byte
        short c; // 2Byte
    } test;

int siz = sizeof(test); // 返回8
    cout << siz << endl;

struct my_struct2{
        short c; // 2Byte
        int a; //4Byte
        char b;	 //1Byte

} test2;

int siz2 = sizeof(test2);
    cout << siz2 << endl; // 返回12

struct my_struct3{
      short c; // 2Byte
      char b;	 //1Byte
      int a; //4Byte
		} test3;

int siz3 = sizeof(test3);
    cout << siz3 << endl; // 返回8
}

根据上面所说,编译器规定结构体大小是最宽的成员变量的大小的整数倍。

我们看第二种情况,为什么test2的长度是12byte呢?按上面的规定,字节对齐的规则是,结构体的大小必须是最宽的成员的整数倍,12和8都是4的整数倍,只因为一个区别,最宽的成员位置不同,因而大小也就不同。

如果把最宽的放到中间,那么前后比较窄的成员都不能利用彼此剩余的空间,必须重新开辟一个最宽变量大小的内存,如果把最宽的放到最前面或者最后面,比较窄的成员变量,short和char类型的就可以挤一挤,只用补全一个字节,就可以对齐。

我们进一步提出,结构体和类的对齐方式是一致的。而且这种对齐原则,应该是适用于内置类型,自定义类型可能未必遵循这个原则。(这个有待确认)

sizeof遇到字符串

先来说默认字符串常量的问题。

我们都知道有个类叫做string, 还有C风格的字符串数组。看下面的例子:

class A{
public:
	A(char* a);
	A(string a);
	A(string& a);
}
int main(){
	A a("abcdefg");// 问题来了,请问那个函数会被调用呢?
}

问题来了,请问那个函数会被调用呢?经过我验证,是第一个函数,因为默认的字符串常量在C++中被当做字符串数组!

C++的哲学是:速度,速度,还他妈的是速度。不要用到可能不会用到的特质,因为都是有代价的。

string是一个类,成本比字符串数组高。

那么问题来了,sizeof 遇到字符串数组怎么办?

char * a = "abcd";
int siz = sizeof(a);

siz的值是多少呢?是5。因为他后面有个\0,这个特性是区分字符串终止的关键点。但是如果用 sizeof,就每次都要减去1来求得真正有意义的字符的长度。而且,数组的恼人特征是,如果当做参数传进去,长度信息会被丢弃,启动退化成一个指针,那么每次都必须带一个长度信息进去了。(用作参数的时候,真麻烦!!)

而有一个函数strlen,就很方便。把字符串数组传进参数的时候,它可以自行执行向后遍历,拿到字符串的长度,而且会只计算真正有意义的字符串的长度。

继承之下的sizeof

class Base2{ //测试sizeof 与 继承
    int a;
    int a2;
};

class Derived2: public Base2{
public:
    int b;
    void f1(){
        cout << "Derived f1" << endl;
    }
};

void main_f9(){
    Derived2 d;
    cout << sizeof(d) << endl; //12 ,3个int
    int * ptr=NULL;
    cout << sizeof(ptr) << endl; // 8, 我的电脑是64位的
}

在Java中,一个对象是有头信息的。也就是说,我们可以根据一个对象头知道其实际类的位置,在C++中,应该是不具有这样的内容的,一个实例的类型信息是单独被保存在其他位置的。

令人费解的是

class Base2{ //测试sizeof 与 继承
    int a;
    int a2;
public:
    //int pa; //4
    virtual void f1(){ //虚表8个字节
        cout << "Base f1" << endl;
    }
};

class Derived2: public Base2{
public:
    int b;
    void f1(){
        cout << "Derived f1" << endl;
    }
};

void main_f9(){
    Base2 base2;
    cout << sizeof(base2) << endl; // 16个字节, 8(虚表) + 4(int) + 4
    Derived2 d;
    cout << sizeof(d) << endl; 
    // 24个字节,3个整数,12个字节,又因为子类有虚表(8个字节),其中的元素最宽是8个字节, 这个是我不能理解的,这涉及到C++的对象模型
    int * ptr=NULL;
    cout << sizeof(ptr) << endl; // 8
}

类的实例的对齐方式是怎么样的?多层的类会导致类的实例的体积膨胀。

以下是一个例子:

#include <iostream>
#include <vector>
using namespace std;

class Foo {
public:
int val;
char bit1, bit2, bit3;
};

class A { // size 8,大小是最宽的元素的整数倍
public:
int val;
char bit1;
};

class B : public A { // size 12
public:
char bit2;
};

class C : public B { // size 12
public:
char bit3;
};

int main()
{
cout << "size Foo = " << sizeof(Foo) << endl; // 8
cout << "size A   = " << sizeof(A) << endl;  // 12
cout << "size B  = " << sizeof(B) << endl;  // 12
cout << "size C   = " << sizeof(C) << endl;  // 12
return 0;
}

继续补充,多态下的sizeof

继承之下的sizeof

sizeof是一个编译时就确定结果的运算符,所以sizeof不会动态检查一个对象的运行时类型,如果一个父类引用指向一个子类对象,尽管子类对象可能有额外属性,sizeof也只会返回父类引用类型的大小。

所以,在强调一次,sizeof 只支持静态运算。

其他问题

Q1如果一个类没有属性,没有继承其他类,那么请问它的大小是?它的内容是?

Q2:对于Java来说,他们的祖先类Object有很多方法,在C++里是如何做模拟的?比如 clone函数?equals函数 A:clone函数可以用赋值运算符来代替。equals函数可以用 operator== 重载吧(TODO)

new和malloc的区别

原文链接: https://blog.csdn.net/silly1195056983/article/details/111467994

一个是c语言里的关键字,一个是c++里的关键字.

malloc分配一块内存, 需要指定内存大小, 返回一个分配好的内存指针.

而news是一个运算符, 可以被重载, 它成功返回的也是一个指针.

但这两者的指针,返回的类型是不一样的, malloc返回的是无类型的指针,需要转换, 而new则本身返回的是带有类型的指针, 它的语法是,new加上类型的构造函数, 连起来解读的意思,就是new运算符先去找一块内存,或者接受一个内存地址, 接下来由构造函数对这块内存进行初始化.相比之下,

malloc就原始的多,它什么也不做.

因此上, new运算符默认应该是调用malloc的, new运算符是为了支撑面向对象开发,而设计的运算符.

因此, new是和构造函数成对出现的, 它的反操作就是delete, delete和析构函数一起出现, 而malloc的则是free.

当然,他们分配内存的位置, 正如我们所说, new底层一般是调用malloc的,所以他们的内存分配位置在动态内存区, 也就是堆区.

所谓的new关联的一个自由存储区, 它的范围概念大于堆内存, 这块我还不是很熟悉. 但凡使用new关键字的操作的内存区域,都是自由存储区.

new关键字还可以传入一个地址, 从而不分配内存, 在stl编程里面,就有展示相关的语法. 比如construct方法里面.

它的意思是,单纯根据一块原始地址去初始化内存空间,不必再次分配, 这种情况是在批量预分配内存的情况下出现的, 在stl容器中,大行其道,非常的常见.

在异常处理方面, malloc分配内存失败,会返回null, 所以我们检测是否分配成功, 也是判断指针知否为null, 而new

关键字一旦失败,则会抛出异常. 这是其中的区别,new抛出的异常是 bad_alloc. 如下:

try

{

int *a = new int();

}

catch (bad_alloc)

{

...

}

c++里面的异常系统和Java的还是不是很像, 至于有没有异常继承体系[参考1],这一块我还不是很熟悉.

在复合数据结构上, 例如数组, malloc的处理办法是, 整体分配一块内存, 然后到时候统一释放.

也就是说,调用malloc得到的这个指针维护了分配的内存的大小, free的时候,自然会去释放其分配时候的大小(这块存疑, 有待确定).

new关键字,则由针对数组的特殊语法, 它有指定分配的元素数量,为什么呢?因为new可以自动根据类型计算待分配的内存的大小, 所以调用者必须在使用 new

运算符的时候 指定元素个数.

int arr[3] = new int[3];

然后new作为一个运算符, 是可以重载的(这块只是看过,并没有用过). 而C语言里没有重载的概念.

new的重载

//这些版本可能抛出异常

void * operator new(size_t);

void * operator new[](size_t);

void * operator delete (void * )noexcept;

void * operator delete[](void *0)noexcept;

//这些版本承诺不抛出异常

void * operator new(size_t ,nothrow_t&) noexcept;

void * operator new[](size_t, nothrow_t& );

void * operator delete (void *,nothrow_t& )noexcept;

void * operator delete[](void *0,nothrow_t& )noexcept;

另外还有个版本的,禁止重载.

void * operator new (size_t,void *) //不允许重定义这个版本的operator new

这个operator new不分配任何的内存,它只是简单地返回指针实参,然后用new表达式负责在place_address指定的地址进行对象的初始化工作。

关于,二次分配内存的问题, malloc有一个兄弟, realloc, 可以判断该内存后序是不是空闲, 从而原地扩大内存, new则屏蔽了内存分配的细节,

也就是无从谈起二次分配了。

最后补充的一点是, new关键字有一个钩子函数, new_handler,应用与new失败后, 应该如何去做的步骤. (这个了解的也不多)

参考

[1] C++异常分配机制

http://www.cnblogs.com/QG-whz/p/5136883.htmlC++

[2]new和malloc的区别

https://www.cnblogs.com/QG-whz/p/5140930.html

指针

传统指针

new和delete运算符提供了一种比自动变量(就是函数内声明的变量,随着函数退出而结束)和静态变量更灵活的方法。它们管理了一个内存池,这在C++中被称为自由存储空间(free store)或堆(heap)。该内存池同用于静态变量和自动变量的内存是分开的。new和delete让您能够在一个函数中分配内存,而在另一个函数中释放它。因此,数据的生命周期不完全受程序或函数的生存时间控制。与使用常规变量相比,使用new和delete让程序员对程序如何使用内存有更大的控制权。然而,内存管理也更复杂了。在栈中,自动添加和删除机制使得占用的内存总是连续的,但new和delete的相互影响可能导致占用的自由存储区不连续,这使得跟踪新分配内存的位置更困难。

对空指针应用delete是安全的。

现在我们回过头来讨论动态数组。psome是指向一个int(数组第一个元素)的指针。您的责任是跟踪内存块中的元素个数。也就是说,由于编译器不能对psome是指向10个整数中的第1个这种情况进行跟踪,因此编写程序时,必须让程序跟踪元素的数目。实际上,程序确实跟踪了分配的内存量,以便以后使用 delete[]运算符时能够正确地释放这些内存。但这种信息不是公用的,例如,不能使用sizeof运算符来确定动态分配的数组包含的字节数。

若干问题

  1. 什么样的代码习惯可以避免内存泄漏,如何查有没有内存泄漏
  2. 各类智能指针又是怎么实现的呢?
  3. 智能指针适用于什么场景?不适用于什么场景?
  4. c++智能指针多线程下为什么会影响性能?
  5. 智能指针shared_ptr,线程安全性。 智能指针的线程安全性又如何呢?比如 shared_ptr的线程安全性?
  6. 类似于智能指针的例子在C++中还有别的吗?

智能指针是为了防止忘记释放指针指向内容,或者因为程序异常而释放指针代码不可达导致的内存泄漏而发明的一种模板类。

用法极其类似于原生指针却可以自动释放指向内容,本质是一个模板.

原理是对普通类型的指针进行一次封装,并对其进行作用域检测,一旦超出作用域,而内部封装的指针的引用减1.

也就是说, 智能指针, 用栈上的仿指针来管理堆上的指针, 用自动内存来管理手动内存.

智能指针为了调用析构函数,必须注意,操作的必须是使用new返回的指针。

有一个问题是, 智能指针如何跨方法传递呢?

智能指针中,公有三种, auto_ptr, unique_ptr, shared_ptr.

为什么 auto_ptr不如unique_ptr呢?

因为 auto_ptr 允许赋值运算,举个例子, p2 = p1, 指针p1指向的对象会被转移给另外一个指针p2,

这样做的目的是可以防止多次释放同一块内存. 也就是说, auto_ptr 赋值自带对象转移(或者拷贝构造函数).这即是其优点, 也是其缺点.

当p1再次被使用的时候(这是误操作,但是却难以避免), 却有可能发现这个指针已经被释放了(悬挂指针),从而引起程序崩溃, 这是运行时错误. 我们把p1

叫做悬挂指针.

而unique_ptr是不存在这种错误的.因为 unique_ptr 禁止了赋值运算符, 而这会被编译器提前检查出来, 所以提前暴露了错误, 属于编译错误.

但是, unique_ptr 可以接受临时右值对象(按值返回的时候)的赋值, 这样就不存在悬挂指针了.

如果非要对 unique_ptr 用赋值运算符, 使用move, 把左值变成右值. 但是这又会继续产生悬挂指针的问题!

智能指针的实现

// Created by zxzx on 2020/10/17.

//

#include`<iostream>`

#include`<memory>`

#include`<string>`

using namespace std;

template`<typename T>`

class SmartPrt{

public:

explictSmartPrt(T* p=0) { // 为什么要声明explict, 害怕隐式调用引起的类型转换, 可以较少用户代码错误!

m_refCount = new int(1);// 我们假设这个传入的指针一定是刚刚分配的堆内存的值,如果传入一个已有的指针,那么应该报错,因为可能会重复释放

m_p = p;

};

SmartPrt(const SmartPrt& sp){

m_p = sp.m_p; // 指向同一个内存位置

m_refCount = sp.m_refCount;

++*m_refCount; // 引用数加1

}

SmartPrt& operator=(SmartPrt& p){

if(this==&p) return *this; // 注意防止自己给自己赋值!!!

decr();

++*p.m_refCount;

m_p = p.m_p;

m_refCount = p.m_refCount;

return *this;

}

T* operator->(){

if(m_p) return m_p;

throw runtime_error("null pointer1");

};

T& operator*(){

if(m_p)

return *m_p;

throw runtime_error("null pointer2");

}

~SmartPrt(){

decr();

cout << "deconstructor" << endl;

}

int getRefCount(){

return*m_refCount;

}

private:

T* m_p;

int* m_refCount; // 这个指针

void decr(){

(*m_refCount)--;

if((*m_refCount) == 0){

cout << " free" << endl;

delete m_p;

delete m_refCount;

}

}

};

class Test{

public:

Test(){name = "";};

Test(string& i) {

name = i;

}

Test(string i) {

name = i;

}

Test(char* i) {

name = i;

}

~Test(){}

void showName(){

cout << name << endl;

}

string& getName(){

return name;

}

private:

string name;

};

void test_ptr(){

std::cout << "Hello, World!" << std::endl;

SmartPrt`<Test>` p1(new Test("abcd"));

p1->showName();

SmartPrt`<Test>` p2(p1);

*p2 = Test("defg");

cout << p1->getName() << endl;

cout <<"@1:" << p1.getRefCount() << endl;

SmartPrt`<Test>` p3 (new Test("xxx"));

p3 = p2;

cout <<"@2:" << p1.getRefCount() << endl;

}

#endif //ZZZ_SMARTPTR_H

unique_ptr指针的用法

在上面讲到, unique_ptr 是 auto_ptr 的改进, 那么什么时候用 unique_ptr 呢?

C++ primer plus上说, 只要不涉及多个指针指向同一个对象的时候,可以使用 unique_ptr. 比如new来的对象.

但是这种情况一般比较少见吧.

反而, 在stl容器中, 经常出现多个指针指向同一个元素, 因为要在元素上排序查找等操作时, 使用多个指针是很常见的事.

参考

[1] C++智能指针种类以及使用场景-阿里云开发者社区 (aliyun.com)

手动实现自己的智能指针shared_ptr和 unique_ptr

背景

智能指针是管理操作内存的重要手段

现在我来实现两个重要的指针,分别是shared_ptr和 unique_ptr,

源代码代码和测试都有,代码中带有注释,因为时间关系,有空再补设计思路和原理吧。

自己实现shared_ptr

源代码


//

// Created by zxzx on 2020/10/17.

//

#ifndef ZZZ_SharedPtr_H

#define ZZZ_SharedPtr_H

#include`<iostream>`

#include `<memory>`

#include `<string>`

using namespace std;


template`<typename T>` //首先,这是一个泛型类

class SharedPtr{

public: // 2个构造函数,一个普通指针的有参,一个拷贝构造函数,重载拷贝运算符,重载->,重载星号运算符。


SharedPtr(T* p=NULL) {

        m_refCount = new int(1);// 我们假设这个传入的指针一定是刚刚分配的堆内存的值,如果传入一个已有的指针,那么应该报错,因为可能会重复释放

        m_p = p;

    };


SharedPtr(const SharedPtr& sp){

        m_p = sp.m_p; // 指向同一个内存位置

        m_refCount = sp.m_refCount;

        ++*m_refCount; // 引用数加1,实际指向的值是公用的

    }


SharedPtr& operator=(SharedPtr& p){//指向新的其他对象

        if(this==&p) return *this; // 注意自己给自己赋值!!!,这里可以优化

        decr(); // 释放原来的资源

        m_p = p.m_p;

        m_refCount = p.m_refCount;

        ++*p.m_refCount; // p是一个对象,不是一个指针,所以不能写成 p->m_refCount

        return *this;

    }


T* operator->(){ // 获取指针

        if(m_p) return m_p;

        throw runtime_error("null pointer1"); // 这里注意异常抛出!!,关于异常,我们还可以继续优化!!

    };


T& operator*(){

        if(m_p)

            return *m_p;

        throw runtime_error("null pointer2");

    }


~SharedPtr(){

        decr();

        cout << "deconstructor" << endl;

    }


int getRefCount(){ // 这个必须有!

        return *m_refCount;

    }


private:

    T* m_p;

    int* m_refCount; // 这个指针


void decr(){

        --*m_refCount;

        if( *m_refCount == 0){

            cout << " free" << endl;

            delete m_p;

            delete m_refCount;

            m_p = NULL;

            m_refCount = NULL;

        }

    }

};


#endif //ZZZ_SharedPtr_H

测试代码


#include<memory>

#include<iostream>

#include"SharedPtr.h"

usingnamespace std;


classTest{


public:

Test(){name = "";};


Test(char*i) {

        name = i;

    }


voidshowName(){

        cout << name << endl;

    }


string&getName(){

return name;

    }


private:

    string name; // 不是堆上的对象,析构的时候会自动释放的!!

};

voidtest_ptr(){

    SharedPtr<Test>p1(newTest("abcd"));

p1->showName();

    SharedPtr<Test>p2(p1);

*p2 = Test("defg");

p1->showName();

    cout <<"@1:"<<p1.getRefCount() << endl;

    SharedPtr<Test>p3 (newTest("xxx"));

    p3 = p2;

    cout <<"@2:"<<p1.getRefCount() << endl;

}


intmain(){

test_ptr();

}

unique_ptr

源代码

//

// Created by zxzx on 2020/10/17.

//

#ifndef ZZZ_SMARTPTR_H

#define ZZZ_SMARTPTR_H

#include`<iostream>`

#include`<memory>`

#include`<string>`

using namespace std;

template`<typename T>` //首先,这是一个泛型类

class UniquePrt{

public: // 2个构造函数,一个普通指针的有参,一个拷贝构造函数,重载拷贝运算符,重载->,重载星号运算符。

UniquePrt(T* p= nullptr):m_p(p) {};

UniquePrt(const UniquePrt& sp) = delete;

UniquePrt& operator=(UniquePrt& p) = delete; // 改用reset命令,或者移动赋值

UniquePrt(const UniquePrt&& other):m_p(other.m_p){

other.m_p = nullptr;

};

UniquePrt`<T>`& operator=(UniquePrt `<T>`&& other){ // 转移赋值, 之前的是空的,所以不用处理。万一不是空的呢?这个系统会报异常的吗?

cout << "move assign" << endl;

swap(other); // other现在保存的是原来的指针,等离开命名空间,就会自动被析构,这块不用担心,这里我们也不用自己再释放了

return *this; //

};

~UniquePrt(){

if(m_p) delete m_p;

cout << "deconstructor" << endl;

}

T* operator->() const{ // 箭头重载

cout << "-> reload" << endl;

return m_p;

};

T& operator*() const{ // 指针解析运算符重载

return *m_p;

}

T* release(){ // 释放指针并返回原来的指针

T* ret = m_p;

m_p = nullptr; //

return ret;

}

void reset(T* p = nullptr){ // 释放以前的指针,换一个新的

T* old = m_p;

if(p != m_p){

if(m_p) delete m_p;

}

m_p = p;

}

void swap(UniquePrt& p){

using std::swap;

swap(m_p, p.m_p); // 注意交换指针属性,而不是别的

}

explicit operator bool() const noexcept {

return m_p != nullptr; // 这样就棒的多!!

}

private:

T* m_p;

};

#endif //ZZZ_SMARTPTR_H

测试代码

#include`<memory>`

#include`<iostream>`

#include "UniquePtr.h"

using namespace std;

class A{

public:

~A(){

cout <<"kill A\n";

}

};

// 如果程序执行过程中抛出了异常,unique_ptr就会释放它所指向的对象

// 传统的new 则不行

unique_ptr`<A>` fun1()

{

unique_ptr`<A>` p(new A());

//do something

return p;

}

void fun2()

{   //  unique_ptr具有移动语义

unique_ptr`<A>` p = fun1();// 使用移动构造函数

// do something

}// 在函数退出的时候,p以及它所指向的对象都被删除释放

class Test{

public:

Test(){name = "";};

Test(string& i) {

name = i;

}

Test(string i) {

name = i;

}

Test(char* i) {

name = i;

}

~Test(){}

void showName(){

cout << name << endl;

}

string& getName(){

return name;

}

private:

string name;

};

void test_ptr(){

UniquePrt`<Test>` p1(new Test("abcd"));

p1->showName();

//    UniquePrt`<Test>` p2;

//    p2 = UniquePrt`<Test>`(new Test("zzz"));

p1.reset(new Test("124"));

p1->showName();

Test* t = p1.release();

cout << "t:" <<  t->getName() << endl;

// 这里做了一个很顽皮的实验,如果把 unique_ptr指向的元素取出来,然后获取它的指针,然后让另外一个unique_ptr接管,那么就出问题了

//    p1.reset( &(*p2)); // 报错了pointer being freed was not allocated

//    UniquePrt`<Test>` p3 (new Test("xxx"));

}

int main(){

test_ptr();

}

面试爱问的几个关于智能指针的问题

1 shared_ptr是线程安全的吗?

答: 不是. 总体来说不是, 多线程读是安全的, 但是多线程读加写是不安全的, 因为计数的是原子的, 但是指针没有加锁. 详情阅读 参考[2],里面有详细的解释和举证.

参考列表

[1]手动实现一个 unique_ptr

https://www.jianshu.com/p/77c2988be336

[2]shared_ptr是线程安全的吗?

https://cloud.tencent.com/developer/article/1654442

面向对象

C++是C的超集,面向对象是其中重要的一个内容。如果你是一个Java程序员,那么它相比Java有哪些不一样的地方呢?如果你是C程序员,那么又该如何理解它的这些新特性呢?本文会介绍C++一些核心的面向对象的特点,简要分析其原理,并且尝试和Java和C做对比,以增强理解。

成员变量的初始化

在《C++ Primer plus 》第十四章里有提到详细的初始化过程。先初始化成员变量,如果成员变量是类,就调用其默认构造函数。一般变量就赋默认值。

对此我有些疑惑。比如如果成员变量是一个结构体,一个char,一个int的指针,都会赋什么值呢?

实例的初始化,先从初始化列表开始,然后给初始化列表以外的成员变量赋予默认值。

class Member{
public:
    int m=10;
};

class Test{
public:
    int b;
    int *c; //初始化为NULL
    Member *m;  //初始化为NULL
    Member m2;
    void f1(){
        cout << b << endl;
        cout << (c == NULL) << endl;
        cout << (m == NULL) << endl;
        cout << m2.m << endl;
    }
};

void main_f11(){
    Test* t = NULL;
    cout << t->b << endl;
    Test* t2 = new Test();
    cout << t2->b << endl;
    // error, code 11,没有打印
}

void main_f12(){
    Test* t2 = new Test();
    t2->f1();
    // 0
    //1
    //1
    //10
}

种类繁多的继承方式

为什么要设计这么多的继承方式?各自的目的是什么,各自的应用场景是什么?

为了正确的使用防止采坑,这是我们必须要知道的。

如果protect 继承,那么派生类得到的对应属性是什么类型的呢?

最常见的是public继承,也就是说基类的public属性会被派生类继承为public属性,而protect继承则会把大于等于protect的属性继承为protect属性,private同理。

有几种关系,分别为has-a, is-a。什么意思呢?has-a的意思就是,有一个类的实例,被当做其他类的成员,可以调用这个类的方法和属性,这在设计模式里叫做拥有,而is-a,则是继承了一个类,成为了这个类的子类,也可以拥有这个类的方法和属性。区别在于,能不能使用多态,如果需要多态,就要is-a模型,而如果has-a,则需要持有这个对象即可。

那么对于private继承来说,它有什么用呢?

private继承。《Effecitive C++》里提到,private继承单纯作为一种实现方式,举个例子,B private继承A,也就是仅仅把A里面的内容拷贝到B里面去,是一种工程实现,而非一种设计思想,单纯的为了代码复用而已。

总之,private继承和protect继承都是一种实现继承,单纯为了代码复用,没有多态的功能。只有public继承才有多态的效果,而且默认是private继承。

再来说说多继承,其缺点是什么呢?

如果有继承两个类,而这个两个类B和C继承自同一个基类A,则这两个类B和C又被D继承,那么A的属性会在D里面保留两份,这显然是不对的,如何避免,使用虚拟继承即可,virtual关键字,不仅仅修饰了方法,还作为状语修饰了继承动作,虚拟继承(virtual)的用处是解决菱形继承。

那么virtual继承又会引来哪些问题呢?又是如何实现的呢?TODO。

继承的模型

子类的实例里会有父类的隐藏实例。是的,这里有一篇详细的文章[参考5]讨论了派生对象的内存模型。这里说的和C++ Primer plus的13.4.2里面说的内存模型不一致。

我们说,一个基类如果有虚函数,那么就会有一个虚函数表,如果一个对应的派生类继承基类后,并没有自己添加新的虚函数,那么可能会共享虚函数表(vtbl),只有派生类新增了虚方法,才有必要完全拷贝一个虚函数表,也就是 copy on write 技术。这只是一种猜测,而看很多博客,结论应该是不同的编译器的实现是不同的。

继承的模型的话,派生类会有N代基类的全部属性,具体的占据内存的大小会根据内存对齐有所调整。至于成员变量的访问方式,根据继承方式的不同,会有不同的权限。这一块的控制应该还是由编译器去完成的,对于权限的控制是一个复杂的东西。我们做的,就是按照编译器的要求,正确的填写语法。

构造函数被隐式调用

构造函数分为好几种,复制构造函数,有参构造函数。

构造函数有一个修饰用的关键字叫做 explicit,这个问题需要我们认真深挖一下。explict关键字修饰构造函数的用处是防止隐形转换,从而调用构造函数。

我们知道,C++经常会偷偷的调用构造函数而让我们不知道,其中涉及隐式转换。在C++中常常调用隐式转换分为,(1)形参调用隐式转换(2)返回值调用隐式转换(3)赋值符号调用隐式转换。

举个返回值调用隐式转换的例子: TODO

class Base{
    int a;
public:
    Base():a(0){
        // cout << "Base" << endl;
    }

    Base(int pa):a(pa){}
    virtual ~Base(){
        //cout << "Base deconstructor" << endl;
    };

    Base(const Base & item){ //拷贝函数的形式必须是引用传参
        cout << "copy Base" << endl;
    }

    virtual void f1(){
        cout << "Base f1" << endl;
    }
};

class Derived: public Base{
    int *b;
public:
    Derived(): b(new int(0)){
        // cout << "Derived" << endl;
    }

    Derived(int pb): b(new int(pb)){}

    ~Derived(){
        // cout << "Derived deconstructor" << endl;
    }

    void f1(){
        cout << "Derived f1" << endl;
    }
};

void main_f4(){
    Base item = Derived(); //这里明显说明了调用了拷贝构造函数,发生了类型强转
    // 打印结果:
    // copy Base
		// Base f1
}

void main_f5(){
    Base * item = new Derived; //只有引用和指针不用类型强转
    item->f1();
    delete item;
    // 打印结果:Derived f1
}

再回顾一下,发生隐式转换的时候,编译器无疑会寻找最合适的构造函数来实现隐式转换。而这种隐式转换在带来方便的同时,也会因为我们疏忽大意带来意想不到的后果。explict就是为了防止意想不到而出现的关键字,它会禁止自动隐式转换。为此,我们需要在拷贝构造函数、普通构造函数前面加上explict。

封装与封装的后门友元函数

如果基类的一个属性是private,那么被继承过来了吗,有可能访问到吗?不能。

友元函数的设计思路是什么?友元的设计可以极大的方便函数式编程,把友元函数本身当做一个对象传进去。纯粹面向对象的缺点,以Java8及以前的版本为例,没有把抽象的过程当做一个对象的,而C++,则依旧保留着这个概念。

重载和重写的细节深挖

为什么编译器会在重载的时候自动把基类对应的同名不同参函数隐藏起来?

上次说了,我们说《Effective C++》6.33解释了几个原因。

(1)避免默认继承太多前几代的函数方法。

重载是一个点比较多的东西,它要求,函数同名,函数参数列表不同(跟参数名无关,跟参数类型有关)。但是重载的一个点是,参数列表中如果有修饰符呢?有无修饰符能区分他们吗?不能,举个例子:

void const_test_f1(Base b){
}
void const_test_f1(const Base b){ //编译器会报错,重复定义。
}

重写同样是一个点比较多的东西,它要求,函数同名,函数参数列表相同(跟参数名无关,跟参数类型有关),返回类型相同(可以是子类)。

模拟实现一套无错的String构造函数

拷贝构造函数的实现需要注意的事情有(1)如果有动态内存变量(指代指针和引用),深浅拷贝的问题。

赋值函数的实现需要注意的事情有(1)如果有动态内存变量(指代指针和引用),深浅拷贝的问题,(2)对于赋值函数,检测被赋值的变量是不是和赋值变量指向同一个对象 ,(3)如果(2)不满足,那么需要释放被赋值的原有变量指向的内存。

不同的类实现起来难度不一样,需要定制化,我们模拟普通的 string 类写一个拷贝构造函数来举例。

给定一个类的声明,请我们实现一个类的方法。

class String{
public:
    String(void);
    String(const char *chars=NULL); //普通构造函数
    String(const String& str); //拷贝构造函数
    String& operator=(const String& str);
    ~String(void); //析构
private:
    char* m_chars;  //这里需要说明的是,m_chars不能被声明为数组类型,char[] m_chars,因为数组名无法被赋值。
};

接下来,只要注意实现的几个点就可以了。

using namespace std;

class String{
public:
    String(void);
    String(const char *chars=NULL); //普通构造函数
    String(const String& str); //拷贝构造函数
    String& operator=(const String& str);
    ~String(void); //析构
private:
    char* m_chars;  //这里需要说明的是,m_chars不能被声明为数组类型,char[] m_chars,因为数组名无法被赋值。
};

String::String(){
    cout << "default constructor" << endl;
    m_chars = new char[1];
    *m_chars = '\0'; //非数组不能使用[]运算符,只能使用指针赋值
}

String::String(const char* chars){
    cout << "char pointer constructor" << endl;
    if(chars == NULL){
        m_chars = new char[1];
        *m_chars = '\0'; //非数组不能使用[]运算符,只能使用指针赋值
    }else{
        m_chars = new char[strlen(chars)+1];
        strcpy(m_chars, chars);
    }
}

String::String(const String &str){ //在拷贝构造函数里,可以访问私有属性,这点记住
    cout << "copy constructor" << endl;
    m_chars = new char[strlen(str.m_chars)+1];
    strcpy(m_chars, str.m_chars);
}

String::~String() {
    cout << "deconstructor" << endl;
    if(m_chars != NULL){ // 防止重复释放同一个内存导致系统崩溃,因为存在赋值和拷贝
        delete m_chars; //先释放原有原有内存,归还操作系统
        m_chars = NULL; //再把指针指向NULL,防止野指针
    }
}

String & String::operator=(const String &str) { // 赋值函数也可以访问私有属性,所以务必给赋值函数参数加上const
    cout << "operator= " << endl;
    if( &str == this) return *this; //是指向同一个内存,直接释放
    else{
        delete m_chars; //先释放原有原有内存,归还操作系统
    }
    m_chars = new char[strlen(str.m_chars)+1];
    strcpy(m_chars, str.m_chars);
    return *this; // 即便返回引用类型,我们只需要返回对象的本体即可,编译器会做处理,我们不用返回引用
}

void test_str_len(){
    char c[3];
    char cc[3] = "ab";
    strcpy(c, cc); // C 的库函数,需要学习一下
    cout << c << endl;
}

void test_String_f1(){
    String a = "abc";
    {
        String b = a;
        // delete b; delete无法操作普通对象,只能操作指针
        String *c = &a;
        // delete c; 注意!delete只能释放堆内存,不能释放栈内存,释放栈内存会报错:pointer being freed was not allocated

        String null_str(NULL);// 防止歧义
    }
    String d = "aefg";
    a = d;
    // 打印结果
    // char pointer constructor
    //copy constructor
    //char pointer constructor
    //deconstructor
    //char pointer constructor
    //operator=
    //deconstructor
    //deconstructor
    //deconstructor
}

void default_null(char * chars=NULL){
    cout << "call" << endl;
}

void test_default_null(){
    default_null();
}

int main() {
    //main_f4();
     test_String_f1();
    // test_default_null();
    return 0;
}

最后有一个问题,应该把拷贝构造函数返回的值放在堆内存里,还是栈内存里呢?应该是放在堆里面,因为我们操作栈内存。

这个难点在于能否熟练使用C语言字串函数

完善自定义String类的功能

来一个挑战,但是我这次不会做。先挖个坑,比如实现一个完整的String类的功能,要挑战一下吗?我先把类定义放在这里。

#include <iostream>
using namespace std;

//DIY_string
class String{
public:
    String(const char* chars=NULL);
    String(const String&);
    String(const String&, int n);
    String(char c, int n);
    ~String();
    String& operator=(const char *chars);
    String& operator=(String &str);
    String& operator+(String &str);
    String& operator+=(String &str);
    char operator[](int index);
    // char operator[](int start, int end); // 胆子再大一点,实现类似python的 a[1:3]这样的
    String& substr(int start, int offset);
    String& index(String &str, int start=0);  // 第一次出现的位置
    bool operator==(String &str);
    friend ostream& operator<<(ostream &out, String &str);
    friend istream& operator>>(istream &in, String &str);
private:
    char * m_chars;
public:
    int size();
    int size_; // 成员函数不能和成员变量重名
};

展示一段代码,更好的理解C++中的面向对象的模型

class Test{
public:
    int b;
    int *c;
    Member *m;
    Member m2;
    void f1(){
        cout << b << endl;
        cout << (c == NULL) << endl;
        cout << (m == NULL) << endl;
        cout << m2.m << endl;
    }
    void f2(){
        cout << "Test::f2" << endl;
    }
};

void main_f11(){
    Test* t = NULL;
    cout << t->b << endl;

    Test* t2 = new Test();
    cout << t2->b << endl;
    // 结果:
    // error, code 11
}

void test_f12(){
    // 注意一个Java程序员不能理解地方,如果一个对象是NULL,它是仍然可以调用这个方法的,只要这个方法可以避免使用this指针
    // 因为方法不用创建对象就已经存在
    Test* t = NULL;
    t->f2();
    // 结果:
    // Test::f2
}

this 指针

如果有人问我,关于this指针你知道哪些?我会告诉它,this指针,是一个const类型的指针,this指针是非静态成员函数里可以直接调用的,不可以指向其他地址。

this指针在构造函数体之前的初始化列表中不能使用,因为对象还没有创建,this指针还不存在。而在构造函数体内则是存在的。每一个成员变量前面都有一个隐藏的this指针。

问题自测

1 构造一个派生类的过程?为什么先构造虚表指针,然后再构造成员变量呢?

2 如果在类C的构造函数中调用C的虚函数,C被D继承。D的构造函数执行的过程中,构建C时,会调用D的对应的虚函数吗?

3 如果派生类没有重写基类的虚函数,那么派生类的虚函数表是会和基类共用一张吗?同理,对于成员变量呢?

4 基类B 有一个普通非虚方法f1,里面用到了一个公共属性 m1, 子类D public 继承了 基类B,D改写了m1的值, D的实例d调用方法 f1,请问d使用的是基类的成员变量还是派生类的成员变量?

class B{
public:
    int a = 1;
    int a2 = 1;
    void f1(){
        cout << a << endl;
    }
};

class D: public B{
public:
    int a = 2;
    void f2(){
        cout << a << endl;
    }
};

void test_extend(){
    B *b = new D;
    cout << sizeof(*b) << endl;
    b->f1();
    //8
    //1
}

void test_extend2(){
    D *b = new D;
    cout << sizeof(*b) << endl;
    b->f2();
    //12
    //2
}

void test_extend3(){
    B *b = NULL;
    cout << "size of NULL point b:" << sizeof(*b) << endl;
    // size of NULL point b:8
}

当我继续调整,

class B{
public:
    int a = 1;
    int a2 = 1;
    virtual void f1(){
        cout << "base "<< a << endl;
    }
};

class D: public B{
public:
    int a = 2;
    void f2(){
        cout << a << endl;
    }
};

void test_extend(){
    B *b = new D;
    cout << sizeof(*b) << endl;
    b->f1();
    // 16
    // base 1,这个时候
}

当我在D 中重写了f1方法,奇怪的事发生了。

class B{
public:
    int a = 1;
    int a2 = 1;
    virtual void f1(){
        cout << "base "<< a << endl; // 隐藏的this指针指向自己的 a
    }
};

class D: public B{
public:
    int a = 2;
    void f2(){
        cout << a << endl;
    }
    virtual void f1(){
        cout << "derived "<< a << endl;  // 隐藏的this指针指向自己的 a
    }
};

void test_extend(){
    B *b = new D;
    cout << sizeof(*b) << endl;
    b->f1();

    // 16
    //derived 2
}

每个属性前都有一个隐藏的 this指针,指向当前的本类对象。所以,结论是,你继承了一个virtual方法,但是不重写的话,还是调用的原来基类的属性。原理是每个属性前面都有一个 默认的this指针,指向当前类的实例

5 多重继承 class D:public B1, public B2;请问继承的顺序是怎么样的?先构造哪一个类?他们的成员变量再内存中的排布是怎么样的?

继承的顺序是B1,B2。先构造B1,在构造B2,最后构造D,成员变量的位置是,B1的成员变量在最前面,B2在后面,D的成员变量在最后。

参考

[1]c++继承详解之一——继承的三种方式、派生类的对象模型 https://blog.csdn.net/lixungogogo/article/details/51118524

[2]Effecitve C++

[3]C++ Primer Plus, 13.4.2章,虚函数和动态绑定

[4]C和C++ 程序员面试秘籍,董山海

[5]C++继承模型

https://www.cnblogs.com/claireyuancy/p/6905648.html

[6]《深度探索C++对象模型》 P99-P123.

[7] 派生类的虚函数表问题

https://blog.csdn.net/cyd_shuihan/article/details/52587982

(2)虚函数表分析 https://leehao.blog.csdn.net/article/details/50688337

(3)图解C++虚函数 https://linyt.blog.csdn.net/article/details/51811314

virtual关键字

virtual的作用和成本

面向对象的三大特征,封装,继承,多态。说到多态,就绕不开 virtual。C++的多态又如此的不一样,它分为静态绑定和动态绑定,其中有一个非常重要的关键字,它就是 virtual。

C++中的virtual关键字修饰方法 约等于 Java中 abstract 关键字修饰方法。

什么是静态绑定?什么是动态绑定?

virtual,望文生义。虚拟的意思。它是用来修饰一个方法的,当编译器看到这个关键字,就懂了。哦,这个方法调用的时候,要看实际对象的真正类型,而非它的指针或者引用的类型。简而言之,virtual,实现动态绑定。

这里由不得不涉及内存模型。一个指针变量,一个引用,他们都是有类型的,说到底,在执行的时候,他们都是一个地址,然而这个地址还附带一个类型信息。但是这个类型信息只是被声明的类型而非真正的类型,因为子类可以代替父类。

如果没有virtual修饰这个方法,那么这个方法就没有动态绑定的特征。也就是说,编译器直接认为这个方法调用的是这个指针变量或者引用的声明类型的方法。还是那句话,C++认为,速度就是哲学。

是的,virtual 虽然比较灵活,可以以统一的方法调用来执行不同的具体操作,屏蔽实现细节,非常的灵活,但是是有代价的。

说一说virtual的代价吧。首先,它是动态绑定,也就是在运行的时候才能确定调用哪个方法,为什么这样子呢?为什么要等到运行时才能决定哪个方法呢?因为virtual要根据实际创建的对象去调用方法。那有人说了,我看new后面的类的类名称不就知道调用哪个方法了吗?那不也可以在编译器就知道方法的位置了吗?说的好。我其实一开始也无法理解。 我猜测一个原因是,new 操作可能遇到内存不够用等等情况,所以要等到运行的时候才确定方法的位置。另外一个原因是**,一个指针变量(本质上是地址)只有一个被声明的类型,万一涉及运行过程中的类型转换,到底这个实例被转换成什么类型是不确定的**。而virtual的实现是,首先找到指针或者引用对应的动态内存中的对象,然后找到对象的类的虚拟方法表,根据虚拟方法表找到具体的方法的位置。而如果一个方法非virtual, 编译器的统一处理是直接根据指针变量(或者引用)的类型找到方法的位置,也就是静态绑定。 也就是说,virtual 不根据一个指针变量被声明的类型来选择方法,而根据内存中的对象持有的虚函数表vtbl去找放方法.

virtual的第二个成本是,它会在对象中引入一个虚拟方法表指针,一个隐藏成员。而对于类本身而言,增加了一个虚拟方法表数组,vtbl(virtual function table)。两次寻址,才能真正找到一个虚拟方法的位置。以上,是C++ primer plus说的,这里我也是蛮晕的。为什么要做这种设计?直接去找真正的实现类,然后根据那个类去找那个方法的位置不就好了吗?为什么要引入一个虚拟方法指针? (ps:这里再补充一下,后面看了一些其他的书,我有了一些其他的看法,要vtbl的意义在于简化 C++ 的内存模型设计。如果我们知道 Java里的内存对象模型, 我们就知道, 每个Java对象其实都有一个 markword, 指向它真正的类型,C++应该是没有的,所以它有其他的机制来做到这一点!!)

还有一个成本就是增加了一个指针的大小,这个可能导致和C语言,其他语言的内存模型不兼容,等等。

virtual 什么时候用

如果一个类的方法要派生类被定制化修改(重写),用virtual修饰这个方法。

如果假设以后派生类会新增成员变量,那么应该把这个派生类的基类的析构函数定义成virtual的,但是派生类如果没有必要被继承,就无需用virtual修饰派生类的方法,直接定义成普通方法即可。

因为在用多态的时候,一个变量被声明的父类型实际上是用子类new出来的,我们要调用子类的析构函数,需要把父类析构声明成virtual。如果我们要手动实现子类的析构,没有必要声明子类的析构为virtual。但是如果我们自己不声明,那么继承过来的子类的析构就是virtual的,因为virtual会一直传递下去。

另外,只有指针和引用才能使用多态,这点是个隐性知识,在C++ primer plus里 简笔带过,未有深究。

构造函数和析构函数

我们创建一个动态内存中的对象,调用delete,就会调用析构函数,在析构函数中,不要写 delete this,否则就会造成死循环或者栈溢出。

构造函数和析构函数的调用顺序是,对于构造函数而言,先调用基类构造函数,然后子类构造函数。对于析构函数而言,先调用子类析构函数,在调用基类析构函数。(如何理解,基类是内核,派生类是外壳,是不断加入的东西,是后面的)

这里可能有些难以理解,之前我甚至是必须依靠死记硬背。但是总结了一下,其中的逻辑是统一的,构造函数执行的时候,会包含一个基类的隐藏实例,所以要调用基类的构造函数。而且我们必须知道,即使派生类的构造函数里没有写明,编译器也会在派生类的构造函数最开始加上一句调用基类构造函数的语句,同理,即使派生类的析构函数里没有写明,编译器也会在派生类的析构函数最结尾加上一句调用基类析构函数的语句,我称之为“强制调用父类对应函数特质”。构造函数和析构函数是和其他成员函数不一样的,其他的函数是没有这个特质的,一旦重写父类方法,必须显式调用父类的对应方法。

有一个问题是,如果自己手动写了调用基类构造方法呢?如果第一句不是基类构造方法,而第二句是呢?(不是第一句可能会报错!)

构造方法是不会被继承的.只能被自己本类调用.析构同理. 之所以我们看起来它像是被继承了,其实是因为编译器替我们自动补全了调用基类构造和析构的代码而已!

构造函数不能是virtual的

构造函数不能是virtual的,为什么呢?

因为基类的构造函数必须被派生类调用,但不是继承,因此构造函数不能是被virtual修饰。

而析构函数则比较奇怪,首先调用派生类本身的析构函数释放派生类特有的动态内存性质的成员变量,然后再调用基类的析构函数自动释放所有成员变量的空间,即使你自己实现了析构函数,里面并没有写任何delete 语句表示要释放成员变量,但是编译器会给你自动加上的。所以,一再强调,析构函数和构造函数是比较神奇的,编译器会围绕这两个函数做大量自动化的补全工作,因为其意义重大,作用特殊。

为什么构造函数不是virtual的,而析构函数是被声明为virtual的呢?看一下具体的用法

class A{...};
class B:public A{...};
A *a = new B(); // new调用构造函数,这个时候是真真实实的调用某一个构造函数,不带向下钻的
delete a;// delete 接受的是一个指针而已

我们不理解C++语言的麻烦,就是因为我们不理解编译器的行为。我们学习语言的要点,就在于理解编译器的设计思路,虽然我们不能实现一个C++编译器,但是编译器的选择,却是我们必须理解的。

重载父类成员函数

如果我们重载了基类的一个函数,那么父类同名的其他多个函数会在子类中被隐藏起来。怎么个隐藏法?是声明为private了吗?

无论如何,如果我们重载了基类的一个函数,必须显示声明所有的同名不同参的函数,并且参数列表和返回参数保持一致(参数列表类型不能使用子类,但是返回参数类型可以),在方法体里直接调用基类对应的方法。

为什么会隐藏父类同名参数

我猜测,编译器基于以下原理,既然是重载,那么应该是不满意现有的参数列表,要不然为什么要重载。

这和编译器对构造函数的自动生成是一样的。如果你自己写了一个有参数的构造函数,那么编译器不会为你生成默认无参的构造函数,因为编译器觉得你可以很容易的自己声明一个无参的默认构造函数,却要花很大劲去禁止调用自动生成的无参构造函数,所以干脆就不帮你生成。(如果你自己不定义,那么编译器确定,你是需要自动生成的,如果你自己定义了,编译器无法知道,你到底需不需要一个无参的构造函数,有的时候你压根不想要,因为无参构造可能带来麻烦,因为有些初始值必须显示指定,所以编译器干脆直接放弃这个行为)

在《Effective C++》6.33里,又说到,避免使用遮掩继承而来的名称里对此也有详细的说明,更为权威一点,本质上是命名空间覆盖的问题。

构造函数能调用自己类的虚函数吗?

原则上来说,构造函数不应该调用其他的函数,因为构造函数的职责是负责初始化所有的成员变量。因此不应该调用其他的函数,除非这段代码在多处使用,为了省事才提炼代码成为单独的一个方法,否则在构造函数中调用成员函数会造成不可知的情况。比如成员函数语境里,以为所有的成员都已经初始化完成,而构造函数其实还没有执行完毕。其他的成员函数可能抛出异常,而构造函数抛出异常则会引起一些麻烦的问题

虚函数只针对非构造方法使用,也就是一个父类引用或者指针实际上指向的对象是一个子类,具体调用哪一个方法的问题。

构造函数的过程是创建一个对象,而这个过程才刚刚开始,而虚函数是在多态中使用的,如果在一个构造函数中调用了自己的虚函数,因为自己的类还没构造完成,对象尚且不完整,难以确定类的类型,所以是不合适的。

**如果父类B 有一个虚函数叫func_A,子类D 也实现这个函数,在子类D 的构造函数当中去调用这个func_A,运行的是谁的实现?**运行的是子类D 的实现。因为子类构造函数调用的时候对象的虚表指针指向的是子类的虚函数表,因为子类实现了func_A所以调用的是子类自己的func_A。(子类继承父类的时候,会根据子类的定义重新构建一个虚函数表,会有自己的虚函数表指针。在C++类对象模型设计里,子类结构是对父类结构的扩充,而非修改)

因此我们必须优先构造子类的虚函数表。我们必须知道的一点是,构造函数的调用,是晚于普通成员函数存在的(构造函数可以调用普通函数,用来做二次初始化)。而且虚函数表是在编译期完成的。

然而在构造函数期间调用虚函数,这样是否会造成不确定的情况?是的。

一些virtual的其他问题

virtual修饰一个方法, 多数时候表明这个方法要被重写,这个类要要用作基类,要发生多态场景。是的,这是第一反应。virtual修饰析构函数,尤其表名了这个类要当做基类。

那么反过来说,如果一个类的析构函数不是virtual,那么不要去继承这个类。否则若为多态使用,会早成内存泄漏。如何从编译的角度来实现类似Java里的Final呢?(新版本的C++已经有Final关键字修饰)

所以,我们正式的提出一个问题,如何自己实现类似 final的效果来避免 继承非virtual析构函数的类的坑呢?

第二个问题,如何实现禁止对象的拷贝呢?禁止拷贝构造函数和赋值运算函数。

第三个问题,什么是链接错误?什么是编译错误?

多态代码自测

最后,放一段神奇的代码,看看自己的了解程度怎么样。

首先定义两个类

using namespace std;

class Base{
    int a;
public:
    Base():a(0){
      cout << "Base default constructor" << endl;
    }
  
    Base(int pa):a(pa){
      cout << "Base parameter constructor" << endl;
    }
  
    virtual ~Base(){
       cout << "Base deconstructor" << endl;
    };
  
    virtual void f1(){
       cout << "Base f1" << endl;
    }
};

class Derived: public Base{
    int *b;
public:
    Derived(): b(new int(0)){  // TODO ,如何写调用哪一个基类构造方法呢?
      cout << "Derived default constructor" << endl;
    }

    Derived(int pb): b(new int(pb)){
      cout << "Derived parameter constructor" << endl;
    }

    ~Derived(){
       cout << "Derived deconstructor" << endl;
    }

    virtual void f1(){
        cout << "Derived f1" << endl;
    }
};

现在请求分析以下几个测试方法输出结果的原因:

void main_f2(){
    Base *item = new Derived(1);
    delete item;
}

输出结果:
Base default constructor
Derived parameter constructor
Derived deconstructor
Base deconstructor


void main_f3(){
    Base item = Derived(1); //这里生成了一个临时对象, 临时对象会先调用自己的析构,然后调用基类析构 
}
输出结果:

Base default constructor
Derived parameter constructor
Derived deconstructor
Base deconstructor  // 必须在这里解释的一点是,等号右边是一个临时对象,这个临时对象经历了构造和析构
Base deconstructor  // 为什么少一个析构函数呢?因为我没有写拷贝构造函数, = 调用了拷贝构造函数


需要注意的是

void main_f4(){
    Base item = Derived();
}

输出结果:
Base default constructor
Derived default constructor
Derived deconstructor
Base deconstructor
Base deconstructor
void main_f5(){
    Base * item = new Derived;
    item->f1();
    delete item;
}

输出结果:
Base default constructor
Derived default constructor
Derived f1
Derived deconstructor
Base deconstructor
void main_f6(){
    Base item = Derived();
    item.f1();
}
输出结果:
Base default constructor
Derived default constructor
Derived deconstructor
Base deconstructor
Base f1
Base deconstructor
void main_f7(){
    Base item = Derived();
    Base& item2 = item;
    item2.f1();
}

即便是引用,然而引用本身指向的对象是父类
  
输出结果
Base default constructor
Derived default constructor
Derived deconstructor
Base deconstructor
Base f1
Base deconstructor

再看最后一个例子

int main(){
  Base b = Base();
  cout << "####" << endl;
  Base b2 = Derived();
  cout << "@@@@" << endl;
}

输出结构是

Base default constructor
####
Base default constructor
Derived default constructor
Derived deconstructor
Base deconstructor
@@@@
Base deconstructor
Base deconstructor

相关工作

云梦泽1989在他的博客[1]里提到,C++11中的final关键字可以修饰类和方法。

参考

[1]云梦泽1989,C++11之final关键字,https://blog.csdn.net/u012333003/article/details/28696521

[2]Stanley B. Lippman,深入探索C++对象模型,https://book.douban.com/subject/1091086/

[3]Scott Meyers,Effective C++, https://book.douban.com/subject/1842426/

右值和移动构造函数的意义

右值到底是什么?

从字面上理解, 右值就是放在等号右边, 只能被赋值给变量, 而自己不能被赋值的"东西"的统称.

比如一个直接量, 一个数字, 一个字符串, 一个没有名字的自定义类型临时对象,甚至说, 一个const类型的变量, 那都是右值.

右值的意义, 在于什么地方? 右值的意义, 在于(1)提升传参效率(2)为了编程方便.

先说个简单的, 为了编程方便. 右值是不能被轻易引用的. 一个直接量之不能被传入一个要求按引用传参的函数.

这样就很麻烦, 要先声明一个常量引用, 然后再把这个引用传进去.

调用函数传参的时候, 有两种方式, 一个是按引用, 一个是按值.

按值传参, 就会调用拷贝构造函数.

如果一个自定义类体积很大, 或者成员很多, 调用拷贝构造函数, 成本就很高.

如果这个对象的目的就是单纯为了传参, 而不需要保留原来的值, 那么成本就确实显得很高, 调用构造函数毫无必要.

为了能顾把右值当做形式参数使用, 定义了 T&& 的形式作为右值. 也就有了相应的右值构造函数,或者叫转移构造函数,

它的目的就是满足一个变量仅仅作为传参使用, 而不必另做备份之用.

右值有的时候可以变成左值, 比如当一个形参是右值,然后被传入另一个函数内, 在这个函数里, 它就是左值.

使用move 可以把一个左值变成一个右值. move()函数.

有一个疑问就是, 既然凭借引用可以传参, 为什么还有右值传参呢? 说到底, 还是为了方便直接量, 或者临时对象.

举个例子

int main() {

string a;

a = "Hello";

std::vector`<string>` vec;

vec.push_back("World adaadsfadfasdfadfadfadsfadsfadfadfadsfasdfasdfasdfadsf");

}

使用一个直接量的时候, 系统会默认先使用移动构造函数, 如果没有的话,就是调用拷贝构造函数.(成本相对高).

编译器是可以自动识别左值和右值的.

最后补充一个内容. 为什么移动构造函数要用swap 而不是 检查是否是同一个对象呢?

因为每次检查是否是自身, 有成本, 而且真正发生频率也不高, 而且swap可以复用代码, 并且延迟释放原来指针. 总之, 好处大于坏处.

参考

1 C++11 中的右值引用与转移语义 - 知乎 (zhihu.com)

面试自测

如果你要面试c++相关岗位,下面是一些问题,可以自己检测对于这个语言的熟悉程度

1 const的用法

2 构造函数的explict关键字

3 inline的内涵

4 friend友元的用法

5 类型转换4种函数

6 右值的内涵

7 初始化列表作为构造函数的参数

8 多态的原理

9 虚继承的问题

10 delete this合法吗

11 如何声明一个只能在栈上或者堆上创建的类?

12 智能指针的用法?各类智能指针又是怎么实现的呢?

13 如何查有没有内存泄漏?

14 智能指针适用于什么场景?不适用于什么场景?

15 c++智能指针多线程下为什么会影响性能?

16 智能指针shared_ptr,线程安全性, 智能指针的线程安全性又如何呢?

17 类似于智能指针的例子在C++中还有别的吗?

19 什么时候用 unique_ptr?

20 右值和移动构造函数的意义

Cpp的断言

  1. C++ 是一个对开发者很不友好的语言,除非你有什么使命感非用它不可, 否则闪远点.
  2. C++牺牲了开发者的时间,换来了运行者的时间.
  3. Python 和 C++ 很像,又好用很多, 推荐先去用 python.
  4. Java 和 C++ 差距其实挺大的.
  5. 学会了 C++, 你经常还要被迫读一些C语言写的程序, 也就是说, 你学了C++, 还要被迫学一门C语言, 惊不惊喜意不意外!!!
  6. enum class 完全是个垃圾语法, 不值得用. 理由是成本太高. 用一个新特性不是让你来麻烦我的,而是让你提供便利的, 其提供的安全性不值一提(2021-02-02).

现在回过头来,我竟然在这么一个臃肿的语言上钻研了这久的时间,呜呼!

参考

[1]enum class https://blog.csdn.net/datase/article/details/82773937