Top K

1、智能指针的特点

简略
  1. C++中的智能指针有4种,分别为:shared_ptr、unique_ptr、weak_ptr、auto_ptr,其中auto_ptr被C++11弃用。

  2. 为什么要使用智能指针:智能指针的作用是管理一个指针,因为存在申请的空间在函数结束时忘记释放,造成内存泄漏的情况。使用智能指针可以很大程度上避免这个问题,因为智能指针就是一个类,当超出了类的作用域时,类会自动调用析构函数,自动释放资源。

  3. 四种指针各自特性

    (1)auto_ptr

    auto指针存在的问题是,两个智能指针同时指向一块内存,就会两次释放同一块资源,自然报错。

    (2)unique_ptr

    unique指针规定一个智能指针独占一块内存资源。当两个智能指针同时指向一块内存,编译报错。

    实现原理: 将拷贝构造函数和赋值拷贝构造函数申明为 private 或 delete 。不允许拷贝构造函数和赋值操作符,但是支持移动构造函数,通过 std:move 把一个对象指针变成右值之后可以移动给另一个 unique_ptr

    (3)shared_ptr

    共享指针可以实现多个智能指针指向相同对象,该对象和其相关资源会在引用为0时被销毁释放。

    实现原理:有一个引用计数的指针类型变量,专门用于引用计数,使用拷贝构造函数和赋值拷贝构造函数时,引用计数加1,当引用计数为0时,释放资源。

    (4)weak_ptr
    **注意:**shared_ptr 存在一个问题,当两个shared_ptr指针相互引用时,那么这两个指针的引用计数不会下降为0,资源得不到释放。因此引入weak_ptr,weak_ptr是弱引用,weak_ptr 的构造和析构不会引起引用计数的增加或减少

详细

智能指针是为了解决动态内存分配时带来的内存泄漏以及多次释放同一块内存空间而提出的。C++11 中封装在了 <memory> 头文件中。

C++11 中智能指针包括以下三种:

  • 共享指针(shared_ptr):资源可以被多个指针共享,使用计数机制表明资源被几个指针共享。通过 use_count() 查看资源的所有者的个数,可以通过 unique_ptrweak_ptr 来构造,调用 release() 释放资源的所有权,计数减一,当计数减为 0 时,会自动释放内存空间,从而避免了内存泄漏。
  • 独占指针(unique_ptr):独享所有权的智能指针,资源只能被一个指针占有,该指针不能拷贝构造和赋值。但可以进行移动构造和移动赋值构造(调用 move() 函数),即一个 unique_ptr 对象赋值给另一个 unique_ptr 对象,可以通过该方法进行赋值。
  • 弱指针(weak_ptr):指向 share_ptr 指向的对象,能够解决由 shared_ptr 带来的循环引用问题。

智能指针的实现原理: 计数原理。

  1. C++中的智能指针有4种,分别为:shared_ptr、unique_ptr、weak_ptr、auto_ptr,其中auto_ptr被C++11弃用。

  2. 使用智能指针的原因

    申请的空间(即new出来的空间),在使用结束时,需要delete掉,否则会形成内存碎片。在程序运行期间,new出来的对象,在析构函数中delete掉,但是这种方法不能解决所有问题,因为有时候new发生在某个全局函数里面,该方法会给程序员造成精神负担。此时,智能指针就派上了用场。使用智能指针可以很大程度上避免这个问题,因为智能指针就是一个类,当超出了类的作用域时,类会自动调用析构函数,析构函数会自动释放资源。所以,智能指针的作用原理就是在函数结束时自动释放内存空间,避免了手动释放内存空间。

  3. 四种指针分别解决的问题以及各自特性如下:

    (1)auto_ptr(C++98的方案,C++11已经弃用)

    采用所有权模式。

    1
    2
    3
    auto_ptr<string> p1(new string("I reigned loney as a cloud."));
    auto_ptr<string> p2;
    p2=p1; //auto_ptr不会报错

    此时不会报错,p2剥夺了p1的所有权,但是当程序运行时访问p1将会报错。所以auto_ptr的缺点是:存在潜在的内存崩溃问题。

    (2)unique_ptr(替换auto_ptr)

    unique_ptr实现独占式拥有或严格拥有概念,保证同一时间内只有一个智能指针可以指向该对象。它对于避免资源泄露,例如,以new创建对象后因为发生异常而忘记调用delete时的情形特别有用。

    采用所有权模式,和上面例子一样。

    1
    2
    3
    unique_ptr<string> p3(new string("I reigned loney as a cloud."));
    unique_ptr<string> p4;
    p4=p3; //此时会报错

    编译器认为P4=P3非法,避免了p3不再指向有效数据的问题。因此,unique_ptr 比 auto_ptr 更安全。 另外 unique_ptr 还有更聪明的地方:当程序试图将一个 unique_ptr 赋值给另一个时,如果源 unique_ptr 是个临时右值,编译器允许这么做;如果源 unique_ptr 将存在一段时间,编译器将禁止这么做,比如:

    1
    2
    3
    4
    5
    unique_ptr<string> pu1(new string ("hello world"));
    unique_ptr<string> pu2;
    pu2 = pu1; // #1 not allowed
    unique_ptr<string> pu3;
    pu3 = unique_ptr<string>(new string ("You")); // #2 allowed

    其中#1留下悬挂的 unique_ptr(pu1),这可能导致危害。而#2不会留下悬挂的unique_ptr,因为它调用 unique_ptr 的构造函数,该构造函数创建的临时对象在其所有权让给 pu3 后就会被销毁。这种随情况而已的行为表明,unique_ptr 优于允许两种赋值的auto_ptr 。

    **注意:**如果确实想执行类似与#1的操作,要安全的重用这种指针,可给它赋新值。C++有一个标准库函数std::move(),让你能够将一个 unique_ptr 赋给另一个。例如:

    1
    2
    3
    4
    5
    unique_ptr<string> ps1, ps2;
    ps1 = demo("hello");
    ps2 = move(ps1);
    ps1 = demo("alexia");
    cout << *ps2 << *ps1 << endl;

    (3)shared_ptr(非常好使)

    shared_ptr实现共享式拥有概念。多个智能指针可以指向相同对象,该对象和其相关资源会在“最后一个引用被销毁”时候释放。从名字share就可以看出了资源可以被多个指针共享,它使用计数机制来表明资源被几个指针共享。可以通过成员函数use_count()来查看资源的所有者个数。除了可以通过new来构造,还可以通过传入auto_ptr , unique_ptr , weak_ptr 来构造。当我们调用 release() 时,当前指针会释放资源所有权,计数减一。当计数等于0时,资源会被释放。

    shared_ptr 是为了解决 auto_ptr 在对象所有权上的局限性( auto_ptr 是独占的), 在使用引用计数的机制上提供了可以共享所有权的智能指针。

    成员函数:

    • use_count 返回引用计数的个数

    • unique 返回是否是独占所有权( use_count 为 1)

    • swap 交换两个 shared_ptr 对象(即交换所拥有的对象)

    • reset 放弃内部对象的所有权或拥有对象的变更, 会引起原有对象的引用计数的减少

    • get 返回内部对象(指针), 由于已经重载了()方法, 因此和直接使用对象是一样的.如 shared_ptr sp(new int(1)); sp 与 sp.get()是等价的

    (4)weak_ptr

    weak_ptr 是一种不控制所指向对象生存期的智能指针,它指向由一个 shared_ptr 管理的对象。将一个 weak_ptr 绑定到一个 shared_ptr 不会改变 shared_ptr 的引用计数。 一旦最后一个指向对象的 shared_ptr 被销毁,对象就会被释放。 即使有 weak_ptr 指向对象,对象也还是会被释放。

    image-20220222165322539

    weak_ptr 是一种不控制对象生命周期的智能指针, 它指向一个 shared_ptr 管理的对象。进行该对象的内存管理的是那个强引用的 shared_ptr。weak_ptr只是提供了对管理对象的一个访问手段。weak_ptr 设计的目的是为配合 shared_ptr 而引入的一种智能指针来协助 shared_ptr 工作,它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造, 它的构造和析构不会引起引用记数的增加或减少。weak_ptr 是用来解决 shared_ptr 相互引用时的死锁问题,如果说两个 shared_ptr 相互引用,那么这两个指针的引用计数永远不可能下降为0, 资源永远不会释放。它是对对象的一种弱引用,不会增加对象的引用计数,和 shared_ptr 之间可以相互转化,shared_ptr 可以直接赋值给它,它可以通过调用 lock 函数来获得 shared_ptr。

    由于对象可能不存在,我们不能使用 weak_ptr 直接访问对象,而必须调用 lock 。此函数检查 weak_ptr 指向的对象是否仍存在。如果存在,lock 返回一个指向共享对象的 shared_ptr。与任何其他 shared_ptr 类似,只要此 shared_ptr 存在,它所指向的底层对象也就会一直存在。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    class B;
    class A {
    public:
    shared_ptr<B> pb_;
    ~A() { cout << "A delete\n"; }
    };
    class B {
    public:
    shared_ptr<A> pa_;
    ~B() { cout << "B delete\n"; }
    };
    void fun() {
    shared_ptr<A> pa(new A());
    shared_ptr<B> pb(new B());

    pb->pa_ = pa;
    pa->pb_ = pb;

    cout << pb.use_count() << endl;
    cout << pa.use_count() << endl;
    }
    int main() {
    fun();
    return 0;
    }

    可以看到fun函数中pa ,pb之间互相引用,两个资源的引用计数为2,当要跳出函数时,智能指针pa,pb析构时两个资源引用计数会减一,但是两者引用计数还是为1,导致跳出函数时资源没有被释放(A B的析构函数没有被调用),如果把其中一个改为weak_ptr就可以了,我们把类A里面的 shared_ptr pb_ ; 改为 weak_ptr pb_ ; 这样的话,资源B的引用开始就只有1,当pb析构时,B的计数变为0,B得到释放,B释放的同时也会使A的计数减一,同时pa析构时使A的计数减一,那么A的计数为0,A得到释放。

    注意:我们不能通过 weak_ptr 直接访问对象的方法,比如 B 对象中有一个方法 print() , 我们不能这样访问 pa->pb_->print(); 英文 pb_ 是一个 weak_ptr,应该先把它转化为 shared_ptr,如:shared_ptr p = pa->pb_.lock(); p->print();

2、new 与 malloc 的区别

  1. new 是操作符,而 malloc 是函数。
  2. new 在调用的时候先分配内存,再调用构造函数,释放的时候调用析构函数;而 malloc 没有构造函数和析构函数。
  3. malloc 需要给定申请内存的大小,返回的指针 void* 需要强转;new 会调用构造函数,不用指定内存的大小,返回指针不用强转,是对象的指针类型。
  4. new 可以被重载;malloc 不行
  5. new 分配内存更直接和安全。
  6. new 发生错误抛出异常,malloc 返回 null

更多理解:

  1. 分配内存的位置
    malloc是从堆上动态分配内存,new是从自由存储区为对象动态分配内存。
    自由存储区的位置取决于operator new的实现。自由存储区不仅可以为堆,还可以是静态存储区,这都看operator new在哪里为对象分配内存。

  2. 返回类型安全性
    malloc内存分配成功后返回void*,然后再强制类型转换为需要的类型;new操作符分配内存成功后返回与对象类型相匹配的指针类型;因此new是符合类型安全的操作符。

  3. 内存分配失败返回值
    malloc内存分配失败后返回NULL;
    new分配内存失败则会抛异常(bac_alloc)。

  4. 分配内存的大小的计算
    使用 new 操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算,而 malloc 则需要显式地指出所需内存的尺寸。

  5. 是否调用构造函数/析构函数
    使用new操作符来分配对象内存时会经历三个步骤:

    • 第一步:调用 operator new 函数(对于数组是 operator new[])分配一块足够大的,原始的,未命名的内存空间以便存储特定类型的对象。
    • 第二步:编译器运行相应的构造函数以构造对象,并为其传入初值。
    • 第三步:对象构造完成后,返回一个指向该对象的指针。

    使用delete操作符来释放对象内存时会经历两个步骤:

    • 第一步:调用对象的析构函数。
    • 第二步:编译器调用operator delete(或operator delete[])函数释放内存空间。

    总之来说,new/delete会调用对象的构造函数/析构函数以完成对象的构造/析构;而malloc则不会。

  6. 对数组的处理

    C++提供了new []和delete []用来专门处理数组类型。它会调用构造函数初始化每一个数组元素,然后释放对象时它会为每个对象调用析构函数,但是二者一定要配套使用;至于malloc,它并不知道你要在这块空间放置数组还是其他的东西,就只给一块原始的空间,再给一个内存地址就完事,如果要动态开辟一个数组的内存,还需要我们手动自定数组的大小。

    1
    2
    3
    A * ptr = new A[10];//分配10个A对象
    delete [] ptr;
    int * ptr = (int *) malloc( sizeof(int) * 10);//分配一个10个int元素的数组
  7. new与malloc是否可以相互调用
    new/delete 的实现可以基于 malloc,而 malloc 的实现不可以去调用 new

  8. 是否可以被重载
    new/delete 可以被重载。而 malloc/free 则不能重载。

  9. 分配内存时内存不足
    malloc 动态分配内存后,如果不够用可以使用 realloc 函数重新分配实现内存的扩充;而 new 则没有这样的操作;

malloc 底层实现: 当开辟的空间小于 128K 时,调用 brk() 函数;当开辟的空间大于 128K 时,调用 mmap()。malloc函数用于动态分配内存。为了减少内存碎片和系统调用的开销,malloc其采用内存池的方式,先申请大块内存作为堆区,然后将堆区分为多个内存块,以块作为内存管理的基本单位。当用户申请内存时,直接从堆区分配一块合适的空闲块。Malloc采用隐式链表结构将堆区分成连续的、大小不一的块,包含已分配块和未分配块;同时malloc采用显示链表结构来管理所有的空闲块,即使用一个双向链表将空闲块连接起来,每一个空闲块记录了一个连续的、未分配的地址。

当进行内存分配时,Malloc会通过隐式链表遍历所有的空闲块,选择满足要求的块进行分配;当进行内存合并时,malloc采用边界标记法,根据每个块的前后块是否已经分配来决定是否进行块合并。

malloc 的原理:

  • 当开辟的空间小于 128K 时,调用 brk() 函数,通过移动 _enddata 来实现;
  • 当开辟空间大于 128K 时,调用 mmap() 函数,通过在虚拟地址空间中开辟一块内存空间来实现。

malloc 的底层实现:

  • brk() 函数实现原理:向高地址的方向移动指向数据段的高地址的指针 _enddata
  • mmap 内存映射原理:
    1. 进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域;
    2. 调用内核空间的系统调用函数 mmap(),实现文件物理地址和进程虚拟地址的一一映射关系;
    3. 进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝。

new 底层实现: 关键字 new 在调用构造函数的时候实际上进行了如下的几个步骤:

  1. 创建一个新的对象
  2. 将构造函数的作用域赋值给这个新的对象(因此 this 指向了这个新的对象)
  3. 执行构造函数中的代码(为这个新对象添加属性)
  4. 返回新对象

堆区和自由存储区的区别与联系:
(1)malloc申请的内存在堆上,使用free释放。new申请的内存在自由存储区,用delete释放
(2)堆(heap)是c语言和操作系统的术语。堆是操作系统所维护的一块特殊内存,它提供了动态分配的功能,当程序运行时调用malloc()时就会从中分配,调用free可把内存交换。而自由存储区是C++中通过new和delete动态分配和释放对象的抽象概念,通过new来申请的内存区域可称为自由存储区。基本上,所有的C++编译器默认用堆来实现自由存储区,也即是缺省的全局运算符new和delete也许会按照malloc和free的方式来实现,这时由new运算符分配的对象,说它在堆上也对,说它在自由存储区也对。但程序员也可以通过重载操作符,改用其他内存来实现自由存储,例如全局变量做的对象池,这时自由存储区就不位于堆上了。

记住:
(1)堆是c语言和操作系统的术语,是操作系统维护的一块内存。自由存储是C++中通过new和delete动态分配和释放对象的抽象概念。
(2)new所申请的内存区域在C++中称为自由存储区,编译器用malloc和free实现new和delete操作符时,new申请的内存可以说是在堆上。
(3)堆和自由内存区有相同之处,但并不等价。

3、虚函数与纯虚函数的区别

  • 虚函数和纯虚函数可以出现在同一个类中。(含有纯虚函数的类称为抽象基类)
  • 使用方式不同:虚函数可以直接使用,也可以被子类重载以后,以多态的形式调用;纯虚函数必须在派生类中实现后才能使用,纯虚函数在基类有声明而没有定义;
  • 定义形式不同:虚函数在定义时在普通函数的基础上加上 virtual 关键字,纯虚函数定义时除了加上virtual 关键字还需要加上 = 0;
  • 虚函数必须实现,否则编译器会报错;
  • 对于实现纯虚函数的派生类,该纯虚函数在派生类中被称为虚函数,虚函数和纯虚函数都可以在派生类中重写;

在虚函数和纯虚函数的定义中不能有static标识符,原因很简单,被static修饰的函数在编译时要求前期绑定, 然而虚函数却是动态绑定,而且被两者修饰的函数生命周期也不一样

虚函数和纯虚函数通常存在于抽象基类之中,被继承的子类重载,目的是提供一个统一的接口

析构函数最好定义为虚函数,特别是对于含有继承关系的类;(被多态使用的基类的析构函数应该定义为虚函数)。如果一个类没有被继承,以后也不会被继承,那么,它的析构函数不应该是虚函数。因为虚函数是由额外开销的,需要一个指针,vptr,来实现虚函数。

析构函数可以定义为纯虚函数,此时,其所在的类为抽象基类,不能创建实例化对象。

  1. 我们举个虚函数的例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class A {
    public:
    virtual void foo() { cout << "A::foo() is called" << endl; }
    };
    class B : public A {
    public:
    void foo() { cout << "B::foo() is called" << endl; }
    };
    int main(void) {
    A *a = new B();
    a->foo(); // 在这里,a虽然是指向A的指针,但是被调用的函数(foo)却是B的!
    return 0;
    }

    这个例子是虚函数的一个典型应用,通过这个例子,也许你就对虚函数有了一些概念。它虚就虚在所谓“推迟联编”或者“动态联编”上,一个类函数的调用并不是在编译时刻被确定的,而是在运行时刻被确定的。由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以被成为“虚”函数。
    虚函数只能借助于指针或者引用来达到多态的效果。

  2. 纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加“=0”

    virtual void funtion1()=0

    为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。

    在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。

    为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数(方法:virtual ReturnType Function()= 0;),则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。

    声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。

    纯虚函数最显著的特征是:它们必须在继承类中重新声明函数(不要后面的=0,否则该派生类也不能实例化),而且它们在抽象类中往往没有定义。

    定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。

    纯虚函数的意义,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。

4、STL 中 vector 与 list 具体是怎么实现的?常见操作的时间复杂度是多少?

  1. vector 一维数组(元素在内存连续存放)

    是动态数组,在堆中分配内存,元素连续存放,有保留内存,如果减少大小后,内存也不会释放([vector的内存释放](https://www.cnblogs.com/summerRQ/articles/2407974.html#:~:text=swap,()是交换函数,使vector离开其自身的作用域,从而强制释放vector所占的内存空间,总而言之,释放vector内存最简单的方法是vector.swap (nums)。));如果新增大小后小于当前大小时才会重新分配内存。

    扩容方式:

    a. 倍放开辟2倍的内存

    b. 旧的数据开辟到新的内存

    c. 释放旧的内存

    d. 指向新内存

  2. list 双向链表(元素存放在堆中)

    元素存放在堆中,每个元素都是放在一块内存中,它的内存空间可以是不连续的,通过指针来进行数据的访问,这个特点,使得它的随机存取变得非常没有效率,因此它没有提供[]操作符的重载。但是由于链表的特点,它可以很有效的支持任意地方的删除和插入操作。

    特点:

    a. 随机访问不方便

    b. 删除插入操作方便

  3. 常见时间复杂度

    vector:

    • 随机访问——常数 𝓞(1)
    • 在末尾插入或移除元素——均摊常数 𝓞(1)
    • 插入或移除元素:与到 vector 结尾的距离成线性 𝓞(n)

    list: 插入、查找、删除时间复杂度分别为:O(1)、O(n)、O(1)。

vector 和 list 的区别,分别适用于什么场景?

vector和list区别在于底层实现机理不同,因而特性和适用场景也有所不同。

vector:一维数组

  • 特点:元素在内存连续存放,动态数组,在中分配内存,元素连续存放,有保留内存,如果减少大小后内存也不会释放。
  • 优点:和数组类似开辟一段连续的空间,并且支持随机访问,所以它的查找效率高其时间复杂度O(1)。
  • 缺点:由于开辟一段连续的空间,所以插入删除会需要对数据进行移动比较麻烦,时间复杂度O(n),另外当空间不足时还需要进行扩容。
  • 删除某个元素以后,该元素后边的每个元素的迭代器都会失效,后边每个元素都往前移动一位,erase返回下一个有效的迭代器。
  • 对vector的任何操作,一旦引起空间重新配置,指向原vector的所有迭代器就都失效了

list:双向链表

  • 特点:元素在中存放,每个元素都是存放在一块内存中,它的内存空间可以是不连续的,通过指针来进行数据的访问。
  • 优点:底层实现是双向链表,当对大量数据进行插入删除时,其时间复杂度O(1)。
  • 缺点:底层没有连续的空间,只能通过指针来访问,所以查找数据需要遍历其时间复杂度O(n),没有提供[]操作符的重载。
  • 在 list 内或在数个 list 间添加、移除和移动元素不会非法化迭代器或引用。迭代器仅在对应元素被删除时非法化。

应用场景

vector拥有一段连续的内存空间,因此支持随机访问,如果需要高效的随即访问,而不在乎插入和删除的效率,使用vector。

list拥有一段不连续的内存空间,如果需要高效的插入和删除,而不关心随机访问,则应使用list。

5、vector 的实现原理

vector底层实现原理为一维数组(元素在空间连续存放)。

  1. 新增元素

    Vector通过一个连续的数组存放元素,如果集合已满,在新增数据的时候,就要分配一块更大的内存,将原来的数据复制过来,释放之前的内存,再插入新增的元素。插入新的数据分在最后插入 push_back 和通过迭代器在任何位置插入,这里说一下通过迭代器插入,通过迭代器与第一个元素的距离知道要插入的位置,即 int index = iter - begin()。这个元素后面的所有元素都向后移动一个位置,在空出来的位置上存入新增的元素。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    //新增元素
    void insert(const_iterator iter, const T& t) {
    int index = iter - begin();
    if (index < size_) {
    if (size_ == capacity_) {
    int capa = calculateCapacity();
    newCapacity(capa);
    }
    memmove(buf + index + 1, buf + index, (size_ - index) * sizeof(T));
    buf[index] = t;
    size_++;
    }
    }
  2. 删除元素

    删除和新增差不多,也分两种,删除最后一个元素 pop_back 和通过迭代器删除任意一个元素 erase(iter)。通过迭代器删除还是先找到要删除元素的位置,即 int index = iter - begin(); 这个位置后面的每个元素都想前移动一个元素的位置。同时我们知道 erase 不释放内存只初始化成默认值。

    删除全部元素 clear:只是循环调用了 erase,所以删除全部元素的时候,不释放内存。clear、erase都不释放内存,内存是在析构函数中释放的。或者使用swap、shrink_to_fit释放内存

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //删除元素
    iterator erase(const_iterator iter) {
    int index = iter - begin();
    if (index < size_ && size_ > 0) {
    memmove(buf + index, buf + index + 1, (size_ - index) * sizeof(T));
    buf[--size_] = T();
    }
    return iterator(iter);
    }
  3. 迭代器iteraotr

    迭代器iteraotr是STL的一个重要组成部分,通过iterator可以很方便的存储集合中的元素.STL为每个集合都写了一个迭代器, 迭代器其实是对一个指针的包装,实现一些常用的方法,如++,–,!=,==,*,->等, 通过这些方法可以找到当前元素或是别的元素. vector是STL集合中比较特殊的一个,因为vector中的每个元素都是连续的,所以在自己实现vector的时候可以用指针代替。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //迭代器的实现
    template <class _Category, class _Ty, class _Diff = ptrdiff_t,
    class _Pointer = _Ty *,
    class _Reference = _Ty &>
    struct iterator { // base type for all iterator classes
    typedef _Category iterator_category;
    typedef _Ty value_type;
    typedef _Diff difference_type;
    typedef _Diff distance_type; // retained
    typedef _Pointer pointer;
    typedef _Reference reference;
    };

6、C++11 新特性

C++新特性主要包括包含语法改进和标准库扩充两个方面,主要包括以下11点:

  1. 语法的改进

    (1)统一的初始化方法(列表初始化,可以用于任何类型对象的初始化)

    (2)成员变量默认初始化

    (3)auto关键字用于定义变量,编译器可以自动判断的类型(前提:定义一个变量时对其进行初始化)

    (4)decltype 求表达式的类型

    (5)智能指针

    (6)nullptr 空指针(原来NULL)

    (7)范围的 for 循环

    (8)右值引用和 move 语义:让程序员有意识减少进行深拷贝操作

    • delete 函数:= delete 表示该函数不能被调用。
    • default 函数:= default 表示编译器生成默认的函数,例如:生成默认的构造函数。
    • final:final 用于修饰一个类,表示禁止该类进一步派生和虚函数的进一步重载
    • override:用于修饰派生类中的成员函数,标明该函数重写了基类函数,如果一个函数声明了 override 但父类却没有这个虚函数,编译报错,使用 override 关键字可以避免开发者在重写基类函数时无意产生的错误
    • explicit:explicit 专用于修饰构造函数,表示只能显式构造,不可以被隐式转换
  2. 标准库扩充(往STL里新加进一些模板类,比较好用)

    (9)无序容器(unordered_map,unordered_set) 用法和功能同map一模一样,区别在于哈希表的效率更高

    (10)正则表达式 可以认为正则表达式实质上是一个字符串,该字符串描述了一种特定模式的字符串

    (11)Lambda表达式、bind

c++11新特性,所有知识点都在这了

7、智能指针和指针的区别是什么?

区别:智能指针实际上是对普通指针加了一层封装机制,区别是它负责自动释放所指的对象,这样的一层封装机制的目的是为了使得智能指针可以方便的管理一个对象的生命期

  1. 智能指针

    如果在程序中使用 new 从堆(自由存储区)分配内存,等到不需要时,应使用 delete 将其释放。C++ 引用了智能指针 auto_ptr,以帮助自动完成这个过程。随后的编程体验(尤其是使用STL)表明,需要有更精致的机制。基于程序员的编程体验和BOOST库提供的解决方案,C++11 摒弃了auto_ptr,并新增了三种智能指针:unique_ptr、shared_ptr 和 weak_ptr 。所有新增的智能指针都能与STL容器和移动语义协同工作。

  2. 指针

    C 语言规定所有变量在使用前必须先定义,指定其类型,并按此分配内存单元。指针变量不同于整型变量和其他类型的变量,它是专门用来存放地址的,所以必须将它定义为“指针类型”。

8、右值引用与移动语义

  1. 右值引用

    一般来说,不能取地址的表达式,就是右值引用,能取地址的,就是左值。

    右值引用主要用于移动语义和完美转发

    • 消除两个对象交互时不必要的对象拷贝,节省运算存储资源,提高效率。
    • 能够更简洁明确地定义泛型函数。
    1
    2
    3
    class A { };
    A & r = A(); //error,A()是无名变量,是右值
    A && r = A(); //ok,r是右值引用
  2. 移动语义

    移动语义可以将资源 ( 堆,系统对象等 ) 从一个对象转移到另一个对象,这样能够减少不必要的临时对象的创建、拷贝以及销毁,能够大幅度提高 C++ 应用程序的性能。临时对象的维护 ( 创建和销毁 ) 对性能有严重影响。通过转移语义,临时对象中的资源能够转移其它的对象里。

    move() 本意为 “移动”,但该函数并不能移动任何数据,它的功能很简单,就是将某个左值强制转化为右值。基于 move() 函数特殊的功能,其常用于实现移动语义。

C++11的右值引用和转移语义

  1. 右值引用

    C++98/03 标准中就有引用,使用 “&” 表示。但此种引用方式有一个缺陷,即正常情况下只能操作 C++ 中的左值,无法对右值添加引用。举个例子:

    1
    2
    3
    int num = 10;
    int &b = num; //正确
    int &c = 10; //错误

    如上所示,编译器允许我们为 num 左值建立一个引用,但不可以为 10 这个右值建立引用。因此,C++98/03 标准中的引用又称为左值引用。

    注意,虽然 C++98/03 标准不支持为右值建立非常量左值引用,但允许使用常量左值引用操作右值。也就是说,常量左值引用既可以操作左值,也可以操作右值,例如:

    1
    2
    3
    int num = 10;
    const int &b = num;
    const int &c = 10;

    我们知道,右值往往是没有名称的,因此要使用它只能借助引用的方式。这就产生一个问题,实际开发中我们可能需要对右值进行修改(实现移动语义时就需要),显然左值引用的方式是行不通的。

    为此,C++11 标准新引入了另一种引用方式,称为右值引用,用 “&&” 表示。

    需要注意的,和声明左值引用一样,右值引用也必须立即进行初始化操作,且只能使用右值进行初始化,比如:

    1
    2
    3
    int num = 10;
    //int && a = num; //右值引用不能初始化为左值
    int && a = 10;

    和常量左值引用不同的是,右值引用还可以对右值进行修改。例如:

    1
    2
    3
    int && a = 10;
    a = 100;
    cout << a << endl; // 100

    另外值得一提的是,C++ 语法上是支持定义常量右值引用的,例如:

    1
    const int&& a = 10;//编译器不会报错

    但这种定义出来的右值引用并无实际用处。一方面,右值引用主要用于移动语义和完美转发,其中前者需要有修改右值的权限;其次,常量右值引用的作用就是引用一个不可修改的右值,这项工作完全可以交给常量左值引用完成。

  2. move语义

    move 本意为 “移动”,但该函数并不能移动任何数据,它的功能很简单,就是将某个左值强制转化为右值。基于 move() 函数特殊的功能,其常用于实现移动语义。move() 函数的用法也很简单,其语法格式如下:

    1
    move( arg ) //其中,arg 表示指定的左值对象。该函数会返回 arg 对象的右值形式。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    //程序实例
    #include <iostream>
    using namespace std;
    class first {
    public:
    first() : num(new int(0)) { cout << "construct!" << endl; }
    //移动构造函数
    first(first &&d) : num(d.num) {
    d.num = NULL;
    cout << "first move construct!" << endl;
    }

    public: //这里应该是 private,使用 public 是为了更方便说明问题
    int *num;
    };
    class second {
    public:
    second() : fir() {}
    //用 first 类的移动构造函数初始化 fir
    second(second &&sec) : fir(move(sec.fir)) {
    cout << "second move construct" << endl;
    }

    public: //这里也应该是 private,使用 public 是为了更方便说明问题
    first fir;
    };
    int main() {
    second oth;
    second oth2 = move(oth);
    // cout << *oth.fir.num << endl; //程序报运行时错误
    return 0;
    }

    /* 程序运行结果:
    construct!
    first move construct!
    second move construct
    */

9、多态是怎么实现的

由于派生类重写基类方法,然后用基类引用指向派生类对象,调用方法时候会进行动态绑定,这就是多态。 多态分为静态多态和动态多态:

  1. 静态多态:重载。编译器在编译期间完成的,编译器会根据实参类型来推断该调用哪个函数,如果有对应的函数,就调用,没有则在编译时报错。

    比如一个简单的加法函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    #include <iostream>
    using namespace std;

    int Add(int a, int b) { // 1
    return a + b;
    }

    char Add(char a, char b) { // 2
    return a + b;
    }

    int main() {
    cout << Add(666, 888) << endl; // 1
    cout << Add('1', '2'); // 2
    return 0;
    }

    显然,第一条语句会调用函数1,而第二条语句会调用函数2,这绝不是因为函数的声明顺序,不信你可以将顺序调过来试试。

  2. 动态多态:其实要实现动态多态,需要几个条件——即动态绑定条件:

    1. 虚函数。基类中必须有虚函数,在派生类中必须重写虚函数。
    2. 通过基类类型的指针或引用来调用虚函数。

    说到这,得插播一条概念:重写——也就是基类中有一个虚函数,而在派生类中也要重写一个原型(返回值、名字、参数)都相同的虚函数。不过协变例外。协变是重写的特例,基类中返回值是基类类型的引用或指针,在派生类中,返回值为派生类类型的引用或指针。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    //协变测试函数
    #include <iostream>
    using namespace std;

    class Base {
    public:
    virtual Base* FunTest() {
    cout << "victory" << endl;
    return this;
    }
    };

    class Derived : public Base {
    public:
    virtual Derived* FunTest() {
    cout << "yeah" << endl;
    return this;
    }
    };

    int main() {
    Base b;
    Derived d;

    b.FunTest(); // victory
    d.FunTest(); // yeah

    return 0;
    }
什么是多态?多态如何实现?

多态:多态就是不同继承类的对象,对同一消息做出不同的响应,基类的指针指向或绑定到派生类的对象,使得基类指针呈现不同的表现方式。在基类的函数前加上 virtual 关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。
实现方法:多态是通过虚函数实现的,虚函数的地址保存在虚函数表中,虚函数表的地址保存在含有虚函数的类的实例对象的内存空间中。

实现过程

  1. 在类中用 virtual 关键字声明的函数叫做虚函数;
  2. 存在虚函数的类都有一个虚函数表,当创建一个该类的对象时,该对象有一个指向虚函数表的虚表指针(虚函数表和类对应的,虚表指针是和对象对应);
  3. 当基类指针指向派生类对象,基类指针调用虚函数时,基类指针指向派生类的虚表指针,由于该虚表指针指向派生类虚函数表,通过遍历虚表,寻找相应的虚函数。
什么是多态?除了虚函数,还有什么方式能实现多态?
  1. 多态是面向对象的重要特性之一,它是一种行为的封装,就是不同对象对同一行为会有不同的状态。(举例 : 学生和成人都去买票时,学生会打折,成人不会)

  2. 多态是以封装和继承为基础的。在C++中多态分为静态多态(早绑定)和动态多态(晚绑定)两种,其中动态多态是通过虚函数实现,静态多态通过函数重载实现,代码如下:

    1
    2
    3
    4
    5
    class A {
    public:
    void do(int a);
    void do(int a, int b);
    };
编译时多态和运行时多态的区别
  • 编译时多态:在程序编译过程中出现,发生在模板和函数重载中(泛型编程)。
  • 运行时多态:在程序运行过程中出现,发生在继承体系中,是指通过基类的指针或引用访问派生类中的虚函数。

编译时多态和运行时多态的区别:

  • 时期不同:编译时多态发生在程序编译过程中,运行时多态发生在程序的运行过程中;
  • 实现方式不同:编译时多态运用泛型编程来实现,运行时多态借助虚函数来实现。

10、const, static 关键字有什么区别,能否同时使用

static
  1. static局部变量 将一个变量声明为函数的局部变量,那么这个局部变量在函数执行完成之后不会被释放,而是继续保留在内存中
  2. static 全局变量 表示一个变量在当前文件的全局内可访问
  3. static 函数 表示一个函数只能在当前文件中被访问
  4. static 类成员变量 表示这个成员为全类所共有
  5. static 类成员函数 表示这个函数为全类所共有,而且只能访问静态成员变量
const
  1. const 常量:定义时就初始化,以后不能更改。
  2. const 形参:func(const int a){};该形参在函数里不能改变
  3. const修饰类成员函数:该函数对成员变量只能进行只读操作
static关键字的作用

(1)函数体内static变量的作用范围为该函数体,该变量的内存只被分配一次,因此其值在下次调用时仍维持上次的值;
(2)在模块内的static全局变量和函数可以被模块内的函数访问,但不能被模块外其它函数访问;
(3)在类中的static成员变量属于整个类所拥有,对类的所有对象只有一份拷贝;
(4)在类中的static成员函数属于整个类所拥有,这个函数不接收this指针,因而只能访问类的static成员变量,不能访问非静态成员。

const关键字的作用

(1)阻止一个变量被改变
(2)声明常量指针和指针常量
(3)const修饰形参,表明它是一个输入参数,在函数内部不能改变其值;
(4)对于类的成员函数,若指定其为const类型,则表明其是一个常函数,不能修改类的成员变量;
(5)对于类的成员函数,有时候必须指定其返回值为const类型,以使得其返回值不为”左值”。

const 和 static 不能同时修饰成员函数

const 的成员函数的时候为了确保该函数不能修改类的实例的状态,会在函数中添加一个隐式的参数const this*。但当一个成员为static的时候,该函数是没有this指针的。也就是说此时const的用法和static是冲突的。

我们也可以这样理解:两者的语意是矛盾的。static的作用是表示该函数只作用在类型的静态变量上,与类的实例没有关系;而const的作用是确保函数不能修改类的实例的状态,与类型的静态变量没有关系。因此不能同时用它们。

11、C++ 内存对齐

使用场景

内存对齐应用于三种数据类型中:struct/class/union

struct/class/union内存对齐原则有四个:

  1. 数据成员对齐规则:结构(struct)或联合(union)的数据成员,第一个数据成员放在 offset 为 0 的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小的整数倍开始。

  2. 结构体作为成员: 如果一个结构里有某些结构体成员,则结构体成员要从其内部"最宽基本类型成员"的整数倍地址开始存储。(struct a 里存有 struct b , b 里有 char , int , double 等元素,那 b 应该从 8 的整数倍开始存储)。

  3. 收尾工作: 结构体的总大小,也就是 sizeof 的结果,必须是其内部最大成员的"最宽基本类型成员"的整数倍。不足的要补齐。(基本类型不包括struct/class/uinon)。

  4. sizeof(union),以结构里面 size 最大元素为 union 的 size,因为在某一时刻,union 只有一个成员真正存储于该地址。

解析:

  1. 什么是内存对齐?

    那么什么是字节对齐?在C语言中,结构体是一种复合数据类型,其构成元素既可以是基本数据类型(如int、long、float等)的变量,也可以是一些复合数据类型(如数组、结构体、联合体等)的数据单元。在结构体中,编译器为结构体的每个成员按其自然边界(alignment)分配空间。 各个成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构体的地址相同。

    为了使CPU能够对变量进行快速的访问,变量的起始地址应该具有某些特性,即所谓的“对齐”,比如 4 字节的 int 型,其起始地址应该位于 4 字节的边界上,即起始地址能够被 4 整除,也即“对齐”跟数据在内存中的位置有关。如果一个变量的内存地址正好位于它长度的整数倍,他就被称做自然对齐。

    比如在32位cpu下,假设一个整型变量的地址为0x00000004(为4的倍数),那它就是自然对齐的,而如果其地址为0x00000002(非4的倍数)则是非对齐的。现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。

  2. 为什么要字节对齐?

    需要字节对齐的根本原因在于 CPU 访问数据的效率问题。假设上面整型变量的地址不是自然对齐,比如为0x00000002,则 CPU 如果取它的值的话需要访问两次内存,第一次取从0x00000002-0x00000003 的一个 short,第二次取从 0x00000004-0x00000005 的一个 short 然后组合得到所要的数据,如果变量在 0x00000003 地址上的话则要访问三次内存,第一次为 char,第二次为 short,第三次为 char,然后组合得到整型数据。

    而如果变量在自然对齐位置上,则只要一次就可以取出数据。一些系统对对齐要求非常严格,比如sparc系统,如果取未对齐的数据会发生错误,而在x86上就不会出现错误,只是效率下降。

    各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数据。显然在读取效率上下降很多。

  3. 字节对齐实例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    union example {  
    int a[5]; // 20
    char b; // 1
    double c; // 8
    };
    int result = sizeof(example); // 24
    /*
    如果以最长20字节为准,内部double占8字节,这段内存的地址0x00000020并不是double的整数倍,只有当最小为0x00000024时可以满足整除double(8Byte)同时又可以容纳int a[5]的大小,所以正确的结果应该是 result=24
    */

    struct example {
    int a[5]; // 20
    char b; // 1
    double c; // 8
    }test_struct;
    int result = sizeof(test_struct);
    /*
    如果我们不考虑字节对齐,那么内存地址0x0021不是double(8Byte)的整数倍,所以需要字节对齐,那么此时满足是double(8Byte)的整数倍的最小整数是0x0024,说明此时char b对齐int扩充了三个字节。所以最后的结果是result=32
    */

    struct example {
    char b; // 1
    double c; // 8
    int a; // 4
    }test_struct;
    int result = sizeof(test_struct);
    /*
    字节对齐除了内存起始地址要是数据类型的整数倍以外,还要满足一个条件,那就是占用的内存空间大小需要是结构体中占用最大内存空间的类型的整数倍,所以 20 不是double(8Byte)的整数倍,我们还要扩充四个字节,最后的结果是result=24
    */
什么是内存对齐?内存对齐的原则?为什么要进行内存对齐,有什么优点?

内存对齐:编译器将程序中的每个“数据单元”安排在字的整数倍的地址指向的内存之中
内存对齐的原则:

  1. 结构体变量的首地址能够被其最宽基本类型成员大小与对齐基数中的较小者所整除;
  2. 结构体每个成员相对于结构体首地址的偏移量 (offset) 都是该成员大小与对齐基数中的较小者的整数倍,如有需要编译器会在成员之间加上填充字节 (internal padding);
  3. 结构体的总大小为结构体最宽基本类型成员大小与对齐基数中的较小者的整数倍,如有需要编译器会在最末一个成员之后加上填充字节 (trailing padding)。

进行内存对齐的原因:(主要是硬件设备方面的问题)

  1. 某些硬件设备只能存取对齐数据,存取非对齐的数据可能会引发异常;
  2. 某些硬件设备不能保证在存取非对齐数据的时候的操作是原子操作;
  3. 相比于存取对齐的数据,存取非对齐的数据需要花费更多的时间;
  4. 某些处理器虽然支持非对齐数据的访问,但会引发对齐陷阱(alignment trap);
  5. 某些硬件设备只支持简单数据指令非对齐存取,不支持复杂数据指令的非对齐存取。

内存对齐的优点:

  1. 便于在不同的平台之间进行移植,因为有些硬件平台不能够支持任意地址的数据访问,只能在某些地址处取某些特定的数据,否则会抛出异常;
  2. 提高内存的访问效率,因为 CPU 在读取内存时,是一块一块的读取。

12、指针和引用的区别是什么?

  • 是否可变:指针所指向的内存空间在程序运行过程中可以改变;而引用所绑定的对象一旦绑定就不能改变绑定的对象。
  • 是否占内存:指针本身在内存中占有内存空间;引用相当于变量的别名,在内存中不占内存空间(具体取决于编译器的实现)。
  • 是否可为空:指针可以为空;但是引用必须绑定对象。
  • 是否能为多级:指针可以有多级;但是引用只能一级。

引用是否占内存,取决于编译器的实现。
如果编译器用指针实现引用,那么它占内存。
如果编译器直接将引用替换为其所指的对象,则其不占内存(毕竟,替换掉之后,该引用实际就不存在了)。

顺便一提,你无法用 sizeof 得到引用的大小,sizeof 作用于引用时,你得到的是它对应的对象的大小。

13、类模板和模板类的区别

  1. 类模板是模板的定义,不是一个实实在在的类,定义中用到通用类型参数

  2. 模板类是实实在在的类定义,是类模板的实例化。类定义中参数被实际类型所代替。

解析

  1. 类模板的类型参数可以有一个或多个,每个类型前面都必须加class,如template <class T1,class T2>class someclass{…};在定义对象时分别代入实际的类型名,如 someclass<int,double> obj;

  2. 和使用类一样,使用类模板时要注意其作用域,只能在其有效作用域内用它定义对象。

  3. 模板可以有层次,一个类模板可以作为基类,派生出派生模板类。

14、C++ 内联函数

inline 内联函数 作用及使用方法

内联函数的作用

inline 是一个关键字,可以用于定义内联函数。内联函数,像普通函数一样被调用,但是在调用时并不通过函数调用的机制而是直接在调用点处展开,这样可以大大减少由函数调用带来的开销,从而提高程序的运行效率。

  1. 消除函数调用的开销。
    在内联函数出现之前,程序员通常用 #define 定义一些“函数”来消除调用这些函数的开销。内联函数设计的目的之一,就是取代 #define 的这项功能(因为使用 #define 定义的那些“函数”,编译器不会检查其参数的正确性等,而使用 inline 定义的函数,和普通函数一样,可以被编译器检查,这样有利于尽早发现错误)。
  2. 去除函数只能定义一次的限制。
    内联函数可以在头文件中被定义,并被多个 .cpp 文件 include,而不会有重定义错误。这也是设计内联函数的主要目的之一。

使用方法:

  1. 类内定义成员函数默认是内联函数
    在类内定义成员函数,可以不用在函数头部加 inline 关键字,因为编译器会自动将类内定义的函数(构造函数、析构函数、普通成员函数等)声明为内联函数,代码如下:

  2. 类外定义成员函数,若想定义为内联函数,需用关键字声明
    声明函数和定义函数处只要有一个地方加上 inline 即可,也可以都加上;只要确保在调用该函数之前把 inline 的信息告知编译器即可

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class A {
    public:
    int var;
    A (int tmp) { var = tmp; }
    void fun() { cout << var << endl; } // 类内定义成员函数默认是内联函数
    void fun1();
    };

    inline void A::fun1() { cout << var << endl; } // 声明和定义只要有一处有 inline 即可,或都加上

关于减少函数调用的开销:

  1. 内联函数一定会被编译器在调用点展开吗?
    错,inline 只是对编译器的建议,而非命令。编译器可以选择忽视 inline。当程序员定义的 inline 函数包含复杂递归,或者 inlinie 函数本身比较长,编译器一般不会将其展开,而仍然会选择函数调用。
  2. “调用” 普通函数时,一定是调用吗?
    错,即使是普通函数,编译器也可以选择进行优化,将普通函数在“调用”点展开。
  3. 既然内联函数在编译阶段已经在调用点被展开,那么程序运行时,对应的内存中不包含内联函数的定义,对吗?
    错。
    首先,如第一点所言,编译器可以选择调用内联函数,而非展开内联函数。因此,内存中仍然需要一份内联函数的定义,以供调用
    而且,一致性是所有语言都应该遵守的准则。普通函数可以有指向它的函数指针,那么,内联函数也可以有指向它的函数指针,因此,内存中需要一份内联函数的定义,使得这样的函数指针可以存在。

关于去除函数只能定义一次的限制:

  • 下述程序会报错:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 文件1
    #include <iostream>
    using namespace std;
    void myPrint() {
    cout << "function 1";
    }

    // 文件2
    #include <iostream>
    using namespace std;
    void myPrint() {
    cout << "function 2";
    }

    int main() {
    myPrint(); // error,会出现链接时错误, myPrint 函数被定义了两次。
    }
  • 而下述程序不会报错

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 文件1
    #include <iostream>
    using namespace std;

    inline void myPrint() {
    cout << "inline function 1";
    }

    // 文件2
    #include <iostream>
    using namespace std;

    inline void myPrint() {
    cout << "inline function 2";
    }

    int main() {
    myPrint()// 正常运行;
    }
  • 可见,内联函数可以在头文件中定义(即多个 .cpp 源文件可以定义函数名、参数都一样的内联函数,而不会有重定义错误)。

inline 函数工作原理
  • 内联函数不是在调用时发生控制转移关系,而是在编译阶段将函数体嵌入到每一个调用该函数的语句块中,编译器会将程序中出现内联函数的调用表达式用内联函数的函数体来替换。
  • 普通函数是将程序执行转移到被调用函数所存放的内存地址,当函数执行完后,返回到执行此函数前的地方。转移操作需要保护现场,被调函数执行完后,再恢复现场,该过程需要较大的资源开销。
内联函数和宏函数的区别
  1. 内联函数是在编译时展开,而宏在编译预处理时展开;在编译的时候,内联函数直接被嵌入到目标代码中去,而宏只是一个简单的文本替换。
  2. 内联函数是真正的函数,和普通函数调用的方法一样,在调用点处直接展开,避免了函数的参数压栈操作,减少了调用的开销。而宏定义不是函数,编写较为复杂,常需要增加一些括号来避免歧义。
  3. 宏定义只进行文本替换,不会对参数的类型、语句能否正常编译等进行检查。而内联函数是真正的函数,会对参数的类型、函数体内的语句编写是否正确等进行检查。

区别:

  1. 宏定义不是函数,但是使用起来像函数。预处理器用复制宏代码的方式代替函数的调用,省去了函数压栈退栈过程,提高了效率;而内联函数本质上是一个函数,内联函数一般用于函数体的代码比较简单的函数,不能包含复杂的控制语句如 while、switch,并且内联函数本身不能直接调用自身。
  2. 宏函数是在预编译的时候把所有的宏名用宏体来替换,简单的说就是字符串替换 ;而内联函数则是在编译的时候进行代码插入,编译器会在每处调用内联函数的地方直接把内联函数的内容展开,这样可以省去函数的调用的开销,提高效率
  3. 宏定义是没有类型检查的,无论对还是错都是直接替换;而内联函数在编译的时候会进行类型的检查,内联函数满足函数的性质,比如有返回值、参数列表等

解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//宏定义示例
#define MAX(a, b) ((a)>(b)?(a):(b))
MAX(a,"Hello"); //错误地比较int和字符串,没有参数类型检查

//内联函数示例
#include <stdio.h>
inline int add(int a, int b){
return (a + b);
}
int main(void){
int a;
a = add(1, 2);
printf("a+b=%d\n", a);
return 0;
}
//以上a = add(1, 2);处在编译时将被展开为:a = (a + b);

1、使用时的一些注意事项:

  • 使用宏定义一定要注意错误情况的出现,比如宏定义函数没有类型检查,可能传进来任意类型,从而带来错误,如举例。还有就是括号的使用,宏在定义时要小心处理宏参数,一般用括号括起来,否则容易出现二义性
  • inline 函数一般用于比较小的,频繁调用的函数,这样可以减少函数调用带来的开销。只需要在函数返回类型前加上关键字 inline,即可将函数指定为 inline 函数。
  • 同其它函数不同的是,最好将 inline 函数定义在头文件,而不仅仅是声明,因为编译器在处理 inline 函数时,需要在调用点内联展开该函数,所以仅需要函数声明是不够的。

2、内联函数使用的条件:

  • 内联是以代码膨胀(复制)为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。以下情况不宜使用内联:
  • (1)如果函数体内的代码比较长,使用内联将导致内存消耗代价较高。
  • (2)如果函数体内出现循环,那么执行函数体内代码的时间要比函数调用的开销大。
  • 内联不是什么时候都能展开的,一个好的编译器将会根据函数的定义体,自动地取消不符合要求的内联。
内联函数和函数的区别,内联函数的作用。
  1. 内联函数比普通函数多了关键字inline
  2. 内联函数避免了函数调用的开销;普通函数有调用的开销
  3. 普通函数在被调用的时候,需要寻址(函数入口地址);内联函数不需要寻址。
  4. 内联函数有一定的限制,内联函数体要求代码简单,不能包含复杂的结构控制语句;普通函数没有这个要求。

内联函数的作用:内联函数在调用时,是将调用表达式用内联函数体来替换。避免函数调用的开销。

解析:

在使用内联函数时,应注意如下几点:

  1. 在内联函数内不允许用循环语句和开关语句。

    如果内联函数有这些语句,则编译将该函数视同普通函数那样产生函数调用代码,递归函数是不能被用来做内联函数的。内联函数只适合于只有1~5行的小函数。对一个含有许多语句的大函数,函数调用和返回的开销相对来说微不足道,所以也没有必要用内联函数实现。

  2. 内联函数的定义必须出现在内联函数第一次被调用之前。

15、C++ 编译、链接的过程

编译过程分为四个过程:编译(编译预处理、编译、优化),汇编,链接。

  • 编译预处理:处理以 # 开头的指令;

  • 编译、优化:将源码 .cpp 文件翻译成 .s 汇编代码;

  • 汇编:将汇编代码 .s 翻译成机器指令 .o 文件;

  • 链接:汇编程序生成的目标文件,即 .o 文件,并不会立即执行,因为可能会出现:.cpp 文件中的函数引用了另一个 .cpp 文件中定义的符号或者调用了某个库文件中的函数。那链接的目的就是将这些文件对应的目标文件连接成一个整体,从而生成可执行的程序 .exe 文件。

    image.png

链接分为两种:

  • 静态链接:代码从其所在的静态链接库中拷贝到最终的可执行程序中,在该程序被执行时,这些代码会被装入到该进程的虚拟地址空间中。
  • 动态链接:代码被放到动态链接库或共享对象的某个目标文件中,链接程序只是在最终的可执行程序中记录了共享对象的名字等一些信息。在程序执行时,动态链接库的全部内容会被映射到运行时相应进行的虚拟地址的空间。

二者的优缺点:

  • 静态链接:浪费空间,每个可执行程序都会有目标文件的一个副本,这样如果目标文件进行了更新操作,就需要重新进行编译链接生成可执行程序(更新困难);优点就是执行的时候运行速度快,因为可执行程序具备了程序运行的所有内容。
  • 动态链接:节省内存、更新方便,但是动态链接是在程序运行时,每次执行都需要链接,相比静态链接会有一定的性能损失。

16、C++ 中哪些函数不能被声明为虚函数?

常见的不不能声明为虚函数的有:普通函数(非成员函数),静态成员函数,内联成员函数,构造函数,友元函数。

  1. 为什么 C++ 不支持普通函数为虚函数?

    普通函数(非成员函数)只能被 overload,不能被 override,声明为虚函数也没有什么意思,因此编译器会在编译时绑定函数。

  2. 为什么 C++ 不支持构造函数为虚函数?

    • 从存储空间的角度考虑:构造函数是在实例化对象的时候进行调用,如果此时将构造函数定义成虚函数,需要通过访问该对象所在的内存空间才能进行虚函数的调用(因为需要通过指向虚函数表的指针调用虚函数表,虽然虚函数表在编译时就有了,但是没有虚函数的指针,虚函数的指针只有在创建了对象才有),但是此时该对象还未创建,便无法进行虚函数的调用。所以构造函数不能定义成虚函数。
    • 从使用的角度考虑:虚函数是基类的指针指向派生类的对象时,通过该指针实现对派生类的虚函数的调用;构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用。
    • 从实现上考虑:虚表指针是在创建对象之后才有的,因此构造函数不能定义成虚函数。创建对象需要调用构造函数,此时构造函数如果是虚函数,而虚函数的调用需要通过虚函数指针寻址才能调用,悖论;
    • 从类型上考虑:在创建对象时需要明确其类型。构造函数的作用是提供初始化,在对象生命期只执行一次,不是对象的动态行为,也没有太大的必要成为虚函数
  3. 为什么 C++ 不支持内联成员函数为虚函数?

    其实很简单,那内联函数就是为了在代码中直接展开,减少函数调用花费的代价,虚函数是为了在继承后对象能够准确的执行自己的动作,这是不可能统一的。

    内联函数是在编译时期展开, 而虚函数的特性是运行时才动态绑定, 两者矛盾, 不能定义内联函数为虚函数

  4. 为什么 C++ 不支持静态成员函数为虚函数?

    这也很简单,静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,他也没有要动态绑定的必要性。

    静态成员函数属于一个类而非某一对象, 没有 this 指针, 它无法进行对象的判别

  5. 为什么 C++ 不支持友元函数为虚函数?

    因为 C++ 不支持友元函数的继承,对于没有继承特性的函数没有虚函数的说法。

17、C++ 内存管理

C/C++内存管理详解 | ShinChan’s Blog (chenqx.github.io)

image-20211118095816959
  1. 内存分配方式

    在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

    ,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。

    ,就是那些由malloc等分配的内存块,和自由存储区是十分相似的,不过是用free来结束自己的生命。

    全局/静态存储区,全局变量和静态变量被分配到同一块内存中

    常量存储区,这是一块比较特殊的存储区,里面存放的是常量,不允许修改。

    自由存储区,就是那些由new分配的内存块,一般一个new就要对应一个delete。)

  2. 常见的内存错误及其对策

    (1)分配失败:内存分配未成功,却使用了它。

    (2)未初始化:内存分配虽然成功,但是尚未初始化就引用它。

    (3)访问越界:内存分配成功并且已经初始化,但操作越过了内存的边界。

    (4)忘记释放:忘记了释放内存,造成内存泄露。

    (5)释放还使用:释放了内存却继续使用它。

    对策:

    (1)定义指针时,先初始化为 NULL。

    (2)用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。

    (3)不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。

    (4)避免数字或指针的下标越界,特别要当心发生“多1”或者“少1”操作

    (5)动态内存的申请与释放必须配对,防止内存泄漏

    (6)用free或delete释放了内存之后,立即将指针设置为NULL,防止“野指针”

    (7)使用智能指针。

  3. 内存泄露及解决办法

    什么是内存泄露?

    简单地说就是申请了一块内存空间,使用完毕后没有释放掉。(1)new和malloc申请资源使用后,没有用delete和free释放;(2)子类继承父类时,父类析构函数不是虚函数。(3)Windows句柄资源使用后没有释放。

    怎么检测?

    第一:良好的编码习惯,使用了内存分配的函数,一旦使用完毕, 要记得使用其相应的函数释放掉。

    第二:将分配的内存的指针以链表的形式自行管理,使用完毕之后从链表中删除,程序结束时可检查改链表。

    第三:使用智能指针。

    第四:一些常见的工具插件,如ccmalloc、Dmalloc、Leaky、Valgrind等等。

内存模型(内存布局):

如上图,从低地址到高地址,一个程序由代码段、数据段、 BSS 段组成。

  1. 代码区: 存放程序执行代码的一块内存区域。只读,代码段的头部还会包含一些只读的常数变量。

  2. 全局区/静态区

    1. 数据段: 存放程序中已初始化的全局变量和静态变量的一块内存区域。
    2. BSS 段:存放程序中未初始化的全局变量和静态变量的一块内存区域。
  3. 可执行程序在运行时又会多出两个区域:堆区和栈区。

    堆区: 动态申请内存用。堆从低地址向高地址增长。

    栈区: 存储局部变量、函数参数值。栈从高地址向低地址增长。是一块连续的空间。

  4. 最后还有一个文件映射区,位于堆和栈之间。

堆 heap :一般由程序员分配释放,若程序员不释放,程序结束时可能由 OS 回收 。分配方式类似于链表。

栈 stack :由编译器自动分配释放,存放为运行函数而分配的局部变量、函数参数、返回数据、返回地址等。其操作方式类似于数据结构中的栈。

常量存储区 :存放常量,不允许修改。

18、C++ 的重载和重写?

C++ 的重载和重写是如何实现的

重载:C++ 利用命名倾轧(name mangling)技术,来改名函数名,区分参数不同的同名函数。命名倾轧是在编译阶段完成的。

C++ 定义同名重载函数:

1
2
3
int func(int a,double b) { return ((a)+(b)); }
int func(double a,float b) { return ((a)+(b)); }
int func(float a,int b) { return ((a)+(b)); }

重写:在基类的函数前加上 virtual 关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。

  1. 用 virtual 关键字申明的函数叫做虚函数,虚函数肯定是类的成员函数。
  2. 存在虚函数的类都有一个一维的虚函数表叫做虚表,类的对象有一个指向虚表开始的虚指针。虚表是和类对应的,虚表指针是和对象对应的。
  3. 多态性是一个接口多种实现,是面向对象的核心,分为类的多态性和函数的多态性。
  4. 重写用虚函数来实现,结合动态绑定。
  5. 纯虚函数是虚函数再加上 = 0。
  6. 抽象类是指包括至少一个纯虚函数的类。

纯虚函数:virtual void fun() = 0。即抽象类必须在子类实现这个函数,即先有名称,没有内容,在派生类实现内容。

重载、重写、隐藏的区别
  • 重载:是指同一可访问区内被声明几个具有不同参数列(参数的类型、个数、顺序)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型

    1
    2
    3
    4
    5
    6
    7
    8
    class A {
    public:
    void fun(int tmp);
    void fun(float tmp); // 重载 参数类型不同(相对于上一个函数)
    void fun(int tmp, float tmp1); // 重载 参数个数不同(相对于上一个函数)
    void fun(float tmp, int tmp1); // 重载 参数顺序不同(相对于上一个函数)
    int fun(int tmp); // error: 'int A::fun(int)' cannot be overloaded 错误:注意重载不关心函数返回类型
    };
  • 重写(覆盖):是指派生类中存在重新定义的函数。函数名、参数列表、返回值类型(协变返回类型除外)都必须同基类中被重写的函数一致,只有函数体不同。派生类调用时会调用派生类的重写函数,不会调用被重写函数。重写的基类中被重写的函数必须有 virtual 修饰。

    在 C++ 中,只要原来的返回类型是指向基类的指针或引用,新的返回类型是指向派生类的指针或引用,覆盖的方法就可以改变返回类型。这样的类型称为协变返回类型(Covariant returns type)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    #include <iostream>
    using namespace std;

    class Base {
    public:
    virtual void fun(int tmp) { cout << "Base::fun(int tmp) : " << tmp << endl; }
    };

    class Derived : public Base {
    public:
    virtual void fun(int tmp) { cout << "Derived::fun(int tmp) : " << tmp << endl; } // 重写基类中的 fun 函数
    };

    int main() {
    Base *p = new Derived();
    p->fun(3); // Derived::fun(int) : 3
    return 0;
    }
  • 隐藏:是指派生类的函数屏蔽了与其同名的基类函数,只要函数名相同,不管参数列表是否相同,基类函数都会被隐藏。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    #include <iostream>
    using namespace std;

    class Base {
    public:
    void fun(int tmp, float tmp1) { cout << "Base::fun(int tmp, float tmp1)" << endl; }
    };

    class Derive : public Base {
    public:
    void fun(int tmp) { cout << "Derive::fun(int tmp)" << endl; } // 隐藏基类中的同名函数
    };

    int main() {
    Derive ex;
    ex.fun(1); // Derive::fun(int tmp)
    ex.fun(1, 0.01); // error: candidate expects 1 argument, 2 provided
    return 0;
    }

    说明:上述代码中 ex.fun(1, 0.01); 出现错误,说明派生类中将基类的同名函数隐藏了。若是想调用基类中的同名函数,可以加上类型名指明 ex.Base::fun(1, 0.01);,这样就可以调用基类中的同名函数。

重写和重载的区别

  • 范围区别:对于类中函数的重载或者重写而言,重载发生在同一个类的内部,重写发生在不同的类之间(子类和父类之间)。
  • 参数区别:重载的函数需要与原函数有相同的函数名、不同的参数列表,不关注函数的返回值类型;重写的函数的函数名、参数列表和返回值类型(协变返回类型除外)都需要和原函数相同,父类中被重写的函数需要有 virtual 修饰。
  • virtual 关键字:重写的函数基类中必须有 virtual关键字的修饰,重载的函数可以有 virtual 关键字的修饰也可以没有。

隐藏和重写,重载的区别

  • 范围区别:隐藏与重载范围不同,隐藏发生在不同类中。
  • 参数区别:隐藏函数和被隐藏函数参数列表可以相同,也可以不同,但函数名一定相同;当参数不同时,无论基类中的函数是否被 virtual 修饰,基类函数都是被隐藏,而不是重写。

19、内存中堆与栈的区别是什么?

申请方式 堆是程序员主动申请 栈是系统自动分配
申请效率 堆是由程序员主动申请,效率低,使用起来方便但是容易产生碎片 栈是有系统自动分配,申请效率高,但程序员无法控制
内存分配 堆在内存中的空间(向高地址扩展)是不连续的 栈在内存中是连续的一块空间(向低地址扩展),最大容量是系统预定好的
存放内容 堆中存放的内容由程序员控制 栈中存放的是局部变量,函数的参数
申请后系统响应 申请堆空间,堆在内存中呈现的方式类似于链表(记录空闲地址空间的链表),在链表上寻找第一个大于申请空间的节点分配给程序,将该节点从链表中删除。大多数系统中该块空间的首地址存放的是本次分配空间的大小,便于释放,将该块空间上的剩余空间再次连接在空闲链表上。 分配栈空间,如果剩余空间大于申请空间则分配成功,否则分配失败栈溢出
  1. 堆栈空间分配不同。栈由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等;堆一般由程序员分配释放。
  2. 堆栈缓存方式不同。栈使用的是一级缓存, 它们通常都是被调用时处于存储空间中,调用完毕立即释放;堆则是存放在二级缓存中,速度要慢些。
  3. 堆栈数据结构不同。堆类似数组结构;栈类似栈结构,先进后出。

20、STL 中的内存分配器原理

一、STL泛型容器 与 内存管理

1.1 STL泛型容器中隐藏了内存管理工作

  • STL提供了很多泛型容器,如vector,list,map等。程序员使用时之关心如何存放对象,不用关心如何管理内存。
  • 容器会根据需要自动增长内存,在退出其作用域时,也会自动销毁占有的内存。
  • STL容器巧妙的避开了繁琐而且容易出错的内存管理工作。

二、STL默认的内存分配器

2.1 STL默认的内存分配器

  • 隐藏在容器后的内存管理工作是通过STL提供的 一个默认的allocator实现的。

2.2 定制allocator

  • 用户可以定制自己的allocator,只需要实现allocator模板所定义的接口方法即可,然后通过将自定义的allocator作为模板参数传递给STL容器。

  • 创建一个使用自定义allocator的STL容器对象,如下:

    1
    vector<int, UserDefinedAllocator>  vec;
  • 大多数情况下,STL默认的allocator就已经足够了。

三、STL默认内存分配器实现原理

3.1 分配器原理:两级分配器

allocator是一个由两级分配器构成的内存管理器。

  1. 当申请的内存大小 > 128 byte时,启动第一级内存分配器,通过malloc直接向系统的堆空间分配。

  2. 当申请的内存大小 < 128 byte时,启动第二级内存分配器,从一个预先分配好的内存池中取一块内存交给用户。

    这个内存池由16个不同大小(8个倍数,8~128byte)的空闲列表组成,allocator会 申请 的大小(将这个大小round up成8的倍数),从对应的空闲块列表取头块给用户。

3.2 优点

  1. 小对象的快速分配。
  2. 避免了内存碎片的生成

21、构造函数和析构函数可以被声明为虚函数吗?

构造函数不能定义为虚函数,原因:

  • 从存储空间的角度考虑:构造函数是在实例化对象的时候进行调用,如果此时将构造函数定义成虚函数,需要通过访问该对象所在的内存空间才能进行虚函数的调用(因为需要通过指向虚函数表的指针调用虚函数表,虽然虚函数表在编译时就有了,但是没有虚函数的指针,虚函数的指针只有在创建了对象才有),但是此时该对象还未创建,便无法进行虚函数的调用。所以构造函数不能定义成虚函数。
  • 从使用的角度考虑:虚函数是基类的指针指向派生类的对象时,通过该指针实现对派生类的虚函数的调用;构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用。
  • 从实现上考虑:虚表指针是在创建对象之后才有的,因此构造函数不能定义成虚函数。创建对象需要调用构造函数,此时构造函数如果是虚函数,而虚函数的调用需要通过虚函数指针寻址才能调用,悖论;
  • 从类型上考虑:在创建对象时需要明确其类型。构造函数的作用是提供初始化,在对象生命期只执行一次,不是对象的动态行为,也没有太大的必要成为虚函数

析构函数一般定义成虚函数,原因:

  • 析构函数定义成虚函数是为了防止内存泄漏,因为当基类的指针或者引用指向或绑定到派生类的对象时,如果未将基类的析构函数定义成虚函数,会调用基类的析构函数,那么只能将基类的成员所占的空间释放掉,派生类中特有的就会无法释放内存空间导致内存泄漏。
  • 析构函数最好定义为虚函数,特别是对于含有继承关系的类;(被多态使用的基类的析构函数应该定义为虚函数)。如果一个类没有被继承,以后也不会被继承,那么,它的析构函数不应该是虚函数。因为虚函数是由额外开销的,需要一个指针,vptr,来实现虚函数。
  1. 虚析构:将可能会被继承的父类的析构函数设置为虚函数,可以保证当我们new一个子类,然后使用基类指针指向该子类对象,释放基类指针时可以释放掉子类的空间,防止内存泄漏。如果基类的析构函数不是虚函数,在特定情况下会导致派生来无法被析构。

    1. 用派生类类型指针绑定派生类实例,析构的时候,不管基类析构函数是不是虚函数,都会正常析构
    2. 用基类类型指针绑定派生类实例,析构的时候,如果基类析构函数不是虚函数,则只会析构基类,不会析构派生类对象,从而造成内存泄漏。为什么会出现这种现象呢,个人认为析构的时候如果没有虚函数的动态绑定功能,就只根据指针的类型来进行的,而不是根据指针绑定的对象来进行,所以只是调用了基类的析构函数;如果基类的析构函数是虚函数,则析构的时候就要根据指针绑定的对象来调用对应的析构函数了。

    C++默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚表指针,占用额外的内存。而对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。因此C++默认的析构函数不是虚函数,而是只有当需要当作父类时,设置为虚函数。

22、类默认的构造函数是什么?

默认构造函数是可以不用实参进行调用的构造函数,它包括了以下两种情况:

  1. 没有带明显形参的构造函数。
  2. 提供了默认实参的构造函数。

类设计者可以自己写一个默认构造函数。编译器帮我们写的默认构造函数,称为“合成的默认构造函数”。

强调“没有带明显形参”的原因是,编译器总是会为我们的构造函数形参表插入一个隐含的this指针,所以”本质上”是没有不带形参的构造函数的,只有不带明显形参的构造函数,它就是默认构造函数。

只有当一个类没有定义构造函数时,编译器才会自动生成一个默认构造函数。但惟有当这些函数被需要(被调用),它们才会被编译器创建出来。(“这些函数“指的是编译器版本的复制构造函数、赋值操作符和析构函数,还包括了默认构造函数。)

编译器需要默认构造函数的四种情况,总结起来就是:

a) 调用对象成员或基类的默认构造函数。

b) 为对象初始化虚表指针与虚基类指针。

image-20220312114443244

23、lambda 函数的特点,和普通函数相比有什么优点?

  • lambda 表达式是一个可调度的代码单元,可以视为一个未命名的内部函数
  • lambda 函数是一个函数对象,编译器在编译时会生成一个 lambda 对象的类,然后再生成一个该命令未命名的对象
  • 比如你代码里有一些小函数,而这些函数一般只被调用一次(比如函数指针),这时你就可以用lambda表达式替代他们,这样代码看起来更简洁些,用起来也方便
  • 距离:定义离使用的地方更近
  • 简洁:代码简洁
  • 效率:函数指针方法阻止了内联。而lambda通常不会阻止内联
  • 功能:可以访问作用域内的任何动态变量,自动捕获上下文中的变量,比普通函数更方便
  • Lambda表达式的作用域更容易控制,有助于减少命名冲突

lambda 的形式如下:
[捕获列表] (参数列表) -> 返回类型 { 函数部分 }
[capture list] (parameter list) -> return type { function body }

  1. capture list 捕获列表是 lambda 函数所定义的函数的局部变量列表, 通常为空

    • 一个 lambda 只有在其捕获列表中捕获一个所在函数中的局部变量,才能在函数体中使用该变量。

    • 捕获列表只用于局部非 static 变量。 lambda 可以直接使用局部 static 变量 和在它所在函数之外的声明的名字。

    • 捕获列表的变量可以分为 值 或 引用传递。

    • 值传递: lambda 捕获的变量在 lambda 函数 创建时 就发生了拷贝而非调用时。

    • 隐式捕获:
      编译器可以根据 lambda 中的代码推导使用的变量,为指示编译器推断捕获列表,应该在捕获列表中写一个 & 或 =

      • & 告知编译器采用引用传递方式
      • = 告知编译器采用值传递方式
    • 当混合使用时,捕获列表第一个参数必须是 & 或 = ,此符号指定了默认捕获方式为引用或值。且显示捕获的变量必须和隐式捕获使用不同的传递方式。

  2. pameter list

    参数列表和普通函数类似,但是 lambda 不能有默认参数【lambda 实参和形参数目一定相等】

  3. return type

    • 与普通函数不同的是: lambda 必须使用 尾置返回 来指定返回类型。

    • 如果忽略返回类型,lambda 表达式会根据函数体中的代码推断出返回类型

    • 若函数体只有一个 return 语句, 则返回类型从返回表达式的类型推断而来,否则,若未指定返回类型,返回类型为 void

    • Note: 如果 lambda 的函数体包含任意单一 return 之外的内容, 且未指定返回类型,则返回 void

    • 当需要为 lambda 定义返回类型时,必须使用尾置返回类型

  4. function body

    • 与常规函数类似

所谓匿名函数,简单地理解就是没有名称的函数,又常被称为 lambda 函数或者 lambda 表达式。

我们可以忽略参数列表和返回类型,但必须永远包含捕获列表和函数体,不能有默认参数

定义

lambda 匿名函数很简单,可以套用如下的语法格式:

1
2
3
[外部变量访问方式说明符] (参数) mutable noexcept/throw() -> 返回值类型  {  
函数体;
};

其中各部分的含义分别为:

  1. [外部变量方位方式说明符]
    [ ] 方括号用于向编译器表明当前是一个 lambda 表达式,其不能被省略。在方括号内部,可以注明当前 lambda 函数的函数体中可以使用哪些“外部变量”。
    所谓外部变量,指的是和当前 lambda 表达式位于同一作用域内的所有局部变量。

  2. (参数)
    和普通函数的定义一样,lambda 匿名函数也可以接收外部传递的多个参数。和普通函数不同的是,如果不需要传递参数,可以连同 () 小括号一起省略;

  3. mutable

    image-20220222163614001

    此关键字可以省略,如果使用则之前的 () 小括号将不能省略(参数个数可以为 0)。默认情况下,对于以值传递方式引入的外部变量,不允许在 lambda 表达式内部修改它们的原来值,修改的是拷贝的值。而如果想修改它们,就必须使用 mutable 关键字。

    1
    2
    3
    4
    5
    6
    7
    8
    int main() {
    int v = 20;
    auto f = [v]() mutable {
    return ++v;
    };
    cout << f() << endl; // 21
    cout << v << endl; // 20
    }

    **注意:**对于以值传递方式引入的外部变量,lambda 表达式修改的是拷贝的那一份,并不会修改真正的外部变量;

    image-20220222163418189
  4. noexcept/throw()
    可以省略,如果使用,在之前的 () 小括号将不能省略(参数个数可以为 0)。默认情况下,lambda 函数的函数体中可以抛出任何类型的异常。而标注 noexcept 关键字,则表示函数体内不会抛出任何异常;使用 throw() 可以指定 lambda 函数内部可以抛出的异常类型。

  5. -> 返回值类型
    指明 lambda 匿名函数的返回值类型。值得一提的是,如果 lambda 函数体内只有一个 return 语句,或者该函数返回 void,则编译器可以自行推断出返回值类型,此情况下可以直接省略"-> 返回值类型"。

  6. 函数体
    和普通函数一样,lambda 匿名函数包含的内部代码都放置在函数体中。该函数体内除了可以使用指定传递进来的参数之外,还可以使用指定的外部变量以及全局范围内的所有全局变量。

24、父类和子类是不是在同一个虚函数表

父类的虚函数表和子类的虚函数表不是同一个表

C++多态虚函数表详解(多重继承、多继承情况)

25、STL 中的 map 的实现原理

map是关联式容器,它们的底层容器都是红黑树。map 的所有元素都是 pair,同时拥有实值(value)和键值(key)。pair 的第一元素被视为键值,第二元素被视为实值。所有元素都会根据元素的键值自动被排序。不允许键值重复。

  1. map的特性如下

    (1)map以 RBTree 作为底层容器;

    (2)所有元素都是 键 + 值 存在;

    (3)不允许键重复;

    (4)所有元素是通过键进行自动排序的;

    (5)map的键是不能修改的,但是其键对应的值是可以修改的。

26、红黑树相关

1.stl中的set底层用的什么数据结构?

红黑树

2.红黑树的数据结构怎么定义?

1
2
3
4
5
6
7
8
enum Color { RED = 0, BLACK = 1 };

struct RBTreeNode {
struct RBTreeNode *left, *right, *parent;
int key;
int data;
Color color;
};

3.红黑树有哪些性质?

img

一般的,红黑树,满足以下性质,即只有满足以下全部性质的树,我们才称之为红黑树:
1)每个结点要么是红的,要么是黑的。
2)根结点是黑的。
3)每个叶结点(叶结点即指树尾端NIL指针或NULL结点)是黑的。
4)如果一个结点是红的,那么它的俩个儿子都是黑的。
5)对于任一结点而言,其到叶结点树尾端NIL指针的每一条路径都包含相同数目的黑结点。

正是红黑树的这5条性质,使一棵n个结点的红黑树始终保持了 logn 的高度,从而也就解释了上面所说的“红黑树的查找、插入、删除的时间复杂度最坏为 O(log n)”这一结论成立的原因。

(注:上述第3、5点性质中所说的NULL结点,包括wikipedia.算法导论上所认为的叶子结点即为树尾端的NIL指针,或者说NULL结点。然百度百科以及网上一些其它博文直接说的叶结点,则易引起误会,因,此叶结点非子结点)

4.红黑树的各种操作的时间复杂度是多少?

能保证在最坏情况下,基本的动态几何操作的时间均为 O(logn)

5.红黑树相比于BST和AVL树有什么优点?

红黑树是牺牲了严格的高度平衡的优越条件为代价,它只要求部分地达到平衡要求,降低了对旋转的要求,从而提高了性能。红黑树能够以 O(logn) 的时间复杂度进行搜索、插入、删除操作。此外,由于它的设计,任何不平衡都会在三次旋转之内解决。当然,还有一些更好的,但实现起来更复杂的数据结构能够做到一步旋转之内达到平衡,但红黑树能够给我们一个比较“便宜”的解决方案。

相比于BST,因为红黑树可以能确保树的最长路径不大于两倍的最短路径的长度,所以可以看出它的查找效果是有最低保证的。在最坏的情况下也可以保证O(logN)的,这是要好于二叉查找树的。因为二叉查找树最坏情况可以让查找达到O(N)。

红黑树的算法时间复杂度和AVL相同,但统计性能比AVL树更高,所以AVL在插入和删除中所做的后期维护操作肯定会比红黑树要耗时好多,但是他们的查找效率都是O(logN),所以红黑树应用还是高于AVL树的。 实际上插入 AVL 树和红黑树的速度取决于你所插入的数据:如果你的数据分布较好, 则比较宜于采用 AVL树(例如随机产生系列数), 但是如果你想处理比较杂乱的情况, 则红黑树是比较快的。

6.红黑树相对于哈希表,在选择使用的时候有什么依据?

权衡三个因素: 查找速度, 数据量, 内存使用,可扩展性。
  总体来说,hash 查找速度会比 map 快,而且查找速度基本和数据量大小无关,属于常数级别; 而 map 的查找速度是log(n)级别。并不一定常数就比log(n) 小,hash还有hash函数的耗时,明白了吧,如果你考虑效率,特别是在元素达到一定数量级时,考虑考虑hash。但若你对内存使用特别严格, 希望程序尽可能少消耗内存,那么一定要小心,hash可能会让你陷入尴尬,特别是当你的hash对象特别多时,你就更无法控制了,而且 hash的构造速度较慢。

红黑树并不适应所有应用树的领域。如果数据基本上是静态的,那么让他们待在他们能够插入,并且不影响平衡的地方会具有更好的性能。如果数据完全是静态的,例如,做一个哈希表,性能可能会更好一些。

在实际的系统中,例如,需要使用动态规则的防火墙系统,使用红黑树而不是散列表被实践证明具有更好的伸缩性。Linux内核在管理vm_area_struct时就是采用了红黑树来维护内存块的。

红黑树通过扩展节点域可以在不改变时间复杂度的情况下得到结点的秩。

7.如何扩展红黑树来获得比某个结点小的元素有多少个?

这其实就是求节点元素的顺序统计量,当然任意的顺序统计量都可以需要在O(lgn)时间内确定。

在每个节点添加一个size域,表示以结点 x 为根的子树的结点树的大小,则有

size[x] = size[[left[x]] + size [right[x]] + 1;

这时候红黑树就变成了一棵顺序统计树。

利用size域可以做两件事:

1). 找到树中第i小的结点;

1
2
3
4
5
6
7
OS-SELECT(x;,i)  
r = size[left[x]] + 1;
if i == r
return x
elseif i < r
return OS-SELECT(left[x], i)
else return OS-SELECT(right[x], i)

思路:size[left[x]]表示在对x为根的子树进行中序遍历时排在x之前的个数,递归调用的深度不会超过O(lgn);

2).确定某个结点之前有多少个结点,也就是我们要解决的问题;

1
2
3
4
5
6
7
8
OS-RANK(T,x)  
r = x.left.size + 1;
y = x;
while y != T.root
if y == y.p.right
r = r + y.p.left.size +1
y = y.p
return r

思路:x的秩可以视为在对树的中序遍历种,排在x之前的结点个数加上一。最坏情况下,OS-RANK运行时间与树高成正比,所以为O (lgn).

27、C++ 的内存分区

C++内存分区

28、vector 和 list 中,如果删除末尾的元素,其指针和迭代器如何变化?若删除的是中间的元素呢?

  1. 迭代器和指针之间的区别

    **迭代器不是指针,是类模板,表现的像指针。**他只是模拟了指针的一些功能,重载了指针的一些操作符,–>、++、–等。迭代器封装了指针,是一个”可遍历STL( Standard Template Library)容器内全部或部分元素”的对象,本质是封装了原生指针,是指针概念的一种提升,提供了比指针更高级的行为,相当于一种智能指针,他可以根据不同类型的数据结构来实现不同的++,–等操作。

    迭代器返回的是对象引用而不是对象的值,所以cout只能输出迭代器使用取值后的值而不能直接输出其自身。

  2. vector和list特性

    vector特性 动态数组。元素在内存连续存放。随机存取任何元素都在常数时间完成。在尾端增删元素具有较大的性能(大部分情况下是常数时间)。

    list特性 双向链表。元素在内存不连续存放。在任何位置增删元素都能在常数时间完成。不支持随机存取。

  3. vector增删元素

    对于vector而言,删除某个元素以后,该元素后边的每个元素的迭代器都会失效,后边每个元素都往前移动一位,erase返回下一个有效的迭代器。

  4. list增删元素

    对于list而言,删除某个元素,只有“指向被删除元素”的那个迭代器失效,其它迭代器不受任何影响。

29、菱形继承问题

上述程序的继承关系如下:(菱形继承)

image.png

上述代码中存的问题:
对于派生类 Derive 上述代码中存在直接继承关系和间接继承关系。

  • 直接继承:Base2Base3

  • 间接继承:Base1

  • 问题:对于派生类中继承的的成员变量 var1 ,从继承关系来看,实际上保存了两份,一份是来自基类 Base2,一份来自基类 Base3。因此,出现了命名冲突,二义性问题

解决方法 1: 声明出现冲突的成员变量来源于哪个类

解决方法 2: 虚继承

使用虚继承的目的:保证存在命名冲突的成员变量在派生类中只保留一份,即让间接基类中的成员在派生类中只保留一份。解决二义性问题。在菱形继承关系中,间接基类称为虚基类,直接基类和间接基类之间的继承关系称为虚继承。

实现方式:在继承方式前面加上 virtual 关键字。

类之间的继承关系:

image.png
  1. 下面的图表可以用来解释菱形继承问题。
  • 假设我们有类B和类C,它们都继承了相同的类A。另外我们还有类D,类D通过多重继承机制继承了类B和类C。因为上述图表的形状类似于菱形,因此这个问题被形象地称为菱形继承问题。现在,我们将上面的图表翻译成具体的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //Animal类对应于图表的类A*
    class Animal { // 基类
    int weight;
    public:
    int getWeight() { return weight; };
    };
    class Tiger : public Animal { /* ... */};
    class Lion : public Animal { /* ... */};
    class Liger : public Tiger, public Lion { /* ... */;}

    在上面的代码中,我们给出了一个具体的菱形继承问题例子。Animal类对应于最顶层类(图表中的A),Tiger和Lion分别对应于图表的B和C,Liger类(狮虎兽,即老虎和狮子的杂交种)对应于D。

    现在,问题是如果我们有这种继承结构会出现什么样的问题。

    看看下面的代码后再来回答问题吧。

    1
    2
    3
    4
    5
    int main() {
    Liger lg;
    /*编译错误,下面的代码不会被任何C++编译器通过 */
    int weight = lg.getWeight();
    }
  • 在我们的继承结构中,我们可以看出Tiger和Lion类都继承自Animal基类。所以问题是:因为Liger多重继承了Tiger和Lion类,因此Liger类会有两份Animal类的成员(数据和方法),Liger对象"lg"会包含Animal基类的两个子对象。

    所以,你会问Liger对象有两个Animal基类的子对象会出现什么问题?再看看上面的代码-调用 lg.getWeight()将会导致一个编译错误。这是因为编译器并不知道是调用 Tiger 类的 getWeight() 还是调用 Lion 类的 getWeight() 。所以,调用 getWeight 方法是不明确的,因此不能通过编译。

  1. 我们给出了菱形继承问题的解释,但是现在我们要给出一个菱形继承问题的解决方案。如果Lion类和Tiger类在分别继承Animal类时都用virtual来标注,对于每一个Liger对象,C++会保证只有一个Animal类的子对象会被创建。看看下面的代码:

    1
    2
    class Tiger : virtual public Animal { /* ... */ };
    class Lion : virtual public Animal { /* ... */ }
  • 你可以看出唯一的变化就是我们在类Tiger和类Lion的声明中增加了"virtual"关键字。现在类Liger对象将会只有一个Animal子对象,下面的代码编译正常:

    1
    2
    3
    4
    5
    int main() {
    Liger lg;
    //既然我们已经在Tiger和Lion类的定义中声明了"virtual"关键字,于是下面的代码编译OK
    int weight = lg.getWeight();
    }

30、内存泄漏,怎么确定内存泄漏?

什么是内存泄露?

简单地说就是申请了一块内存空间,使用完毕后没有释放掉。(1)new和malloc申请资源使用后,没有用delete和free释放;(2)子类继承父类时,父类析构函数不是虚函数。(3)Windows句柄资源使用后没有释放。

怎么检测?

第一:良好的编码习惯,使用了内存分配的函数,一旦使用完毕,要记得使用其相应的函数释放掉。

第二:将分配的内存的指针以链表的形式自行管理,使用完毕之后从链表中删除,程序结束时可检查改链表。

第三:使用智能指针。

第四:一些常见的工具插件,如 ccmalloc、Dmalloc、Leaky、Valgrind 等等。

内存泄漏:由于疏忽或错误导致的程序未能释放已经不再使用的内存。
进一步解释:

  • 并非指内存从物理上消失,而是指程序在运行过程中,由于疏忽或错误而失去了对该内存的控制,从而造成了内存的浪费。

  • 常指堆内存泄漏,因为堆是动态分配的,而且是用户来控制的,如果使用不当,会产生内存泄漏。

  • 使用 malloccallocreallocnew 等分配内存时,使用完后要调用相应的 freedelete 释放内存,否则这块内存就会造成内存泄漏。

  • 指针重新赋值

    1
    2
    3
    char *p = (char *)malloc(10);
    char *p1 = (char *)malloc(10);
    p = np;

    开始时,指针 pp1 分别指向一块内存空间,但指针 p 被重新赋值,导致 p 初始时指向的那块内存空间无法找到,从而发生了内存泄漏。

防止内存泄漏的方法:

  1. 内部封装:将内存的分配和释放封装到类中,在构造的时候申请内存,析构的时候释放内存。
  2. 智能指针
    智能指针是 C++ 中已经对内存泄漏封装好了一个工具,可以直接拿来使用

31、只定义析构函数,会自动生成哪些构造函数?

只定义了析构函数,编译器将自动为我们生成默认构造函数和拷贝构造函数、拷贝赋值运算符。

  1. 默认构造函数和初始化构造函数。
    在定义类的对象的时候,完成对象的初始化工作。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class Student {
    public:
    Student() { //默认构造函数
    num = 1001;
    age = 18;
    }
    Student(int n, int a) : num(n), age(a) {} //初始化构造函数
    private:
    int num;
    int age;
    };
    int main() {
    Student s1; //用默认构造函数初始化对象S1
    Student s2(1002, 18); //用初始化构造函数初始化对象S2
    return 0;
    }
  2. 拷贝构造函数、拷贝赋值运算符
    有了有参的构造了,编译器就不提供默认的构造函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    class Test {
    int i;
    int* p;

    public:
    Test(int ai, int value) {
    i = ai;
    p = new int(value);
    }
    ~Test() { delete p; }
    Test(const Test& t) {
    this->i = t.i;
    this->p = new int(*t.p);
    }
    };
    //复制构造函数用于复制本类的对象
    int main(int argc, char* argv[]) {
    Test t1(1, 2);
    Test t2(t1); //将对象t1复制给t2。注意复制和赋值的概念不同。
    return 0;
    }

    拷贝构造函数默认实现的是值拷贝(浅拷贝)。
    如果类外面有这样一个函数:

    1
    2
    3
    4
    5
    HasPtr f(HasPtr hp) {
    HasPtr ret = hp;
    ///... 其他操作
    return ret;
    }

    当函数执行完了之后,将会调用 hp 和 ret 的析构函数,将 hp 和 ret 的成员 ps 给 delete 掉,但是由于 ret 和 hp 指向了同一个对象,因此该对象的 ps 成员被 delete 了两次,这样产生一个未定义的错误,所以说,如果一个类定义了析构函数,那么它要定义自己的拷贝构造函数和默认构造函数。

32、变量的声明和定义区别?

一、变量的声明和定义

1.1 声明

声明是用来告诉编译器变量的名称和类型,而不分配内存。

例如:

1
2
3
4
extern int var; // 声明 
extern int ble = 10; // 定义
typedef int INT; // 声明
struct Node; // 声明

上面代码中,语句 extern int var;表示 var 在别的文件中已经定义,提示编译器遇到此变量时在其它模块中寻找其定义。语句 extern int ble = 10;表示定义了变量 ble,这一点需要注意。

**注意:**即使是 extern ,如果给变量赋值了,就是定义了

1.2 定义

定义是为了给变量分配内存,可以为变量赋初值。

**注意:**全局变量或静态变量初始值为0,局部变量初始化为随机值。

在 C/C++ 中,变量的声明和定义区别并不大,定义和声明往往是同时发生,变量定义时,会根据变量类型分配空间,如下所示:

1
2
3
4
5
int value ; //声明 + 定义 
struct Node { // 声明 + 定义
int left;
int right;
};

上面代码中,变量名为 value,类型为 int ,分配 4 字节的内存(不同编译器会有差异)。

1.3 区分定义和声明

通常变量的定义和声明是同时发生的,注意:extern 变量类型 变量名 仅是声明。

二、函数的声明和定义

2.1 函数声明

函数的声明是通知编译器函数名称、参数数量和类型以及函数返回类型,例如:

1
int Max(int x, int y)

在上面的代码中,函数名为 Max,返回类型为 int 、参数为 int x,int y

2.2 函数定义

函数的定义是为函数分配内存,例如:

1
int Max(int x, int y) { return x > y ? x : y; }

函数定义包含了函数的具体实现。

2.3 函数声明和定义区分

函数只要有实现(存在函数体 { …… } )即为定义,否则为声明。可以这样理解:函数声明是说明函数是什么,函数定义是说明函数做什么。

三、声明和定义的区别

3.1 声明可多次,定义只一次

变量/函数可以声明多次,变量/函数的定义只能一次。

3.2 分配内存

声明不会分配内存,定义会分配内存。

3.3 做了什么

声明是告诉编译器变量或函数的类型和名称等,定义是告诉编译器变量的值,函数具体干什么。

33、C++ 从代码到可执行二进制文件的过程

C++ 和 C 语言类似,一个 C++ 程序从源码到执行文件,有四个过程,预编译、编译、汇编、链接

解析:

  1. 预编译:这个过程主要的处理操作如下:

    (1) 将所有的 #define\#define 删除,并且展开所有的宏定义

    (2) 处理所有的条件预编译指令,如 #if#ifdef\#if、\#ifdef

    (3) 处理 #include\# include 预编译指令,将被包含的文件插入到该预编译指令的位置。

    (4) 过滤所有的注释

    (5) 添加行号和文件名标识。

  2. 编译:这个过程主要的处理操作如下:

    (1) 词法分析:将源代码的字符序列分割成一系列的记号。

    (2) 语法分析:对记号进行语法分析,产生语法树。

    (3) 语义分析:判断表达式是否有意义。

    (4) 代码优化:

    (5) 目标代码生成:生成汇编代码。

    (6) 目标代码优化:

  3. 汇编:这个过程主要是将汇编代码转变成机器可以执行的指令。

  4. 链接:将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。

    链接分为静态链接和动态链接。

    静态链接,是在链接的时候就已经把要调用的函数或者过程链接到了生成的可执行文件中,就算你在去把静态库删除也不会影响可执行程序的执行;生成的静态链接库,Windows 下以. lib 为后缀,Linux 下以. a 为后缀。

    动态链接,是在链接的时候没有把调用的函数代码链接进去,而是在执行的过程中,再去找要链接的函数,生成的可执行文件中没有函数代码,只包含函数的重定位信息,所以当你删除动态库时,可执行程序就不能运行。生成的动态链接库,Windows 下以. dll 为后缀,Linux 下以. so 为后缀。

语言基础

简述下 C++ 语言的特点

面向对象,三大特性(封装、继承、多态),更安全,复用性高

  1. C++ 在 C 语言基础上引入了面对对象的机制,同时也兼容 C 语言
  2. C++ 有三大特性(1)封装。(2)继承。(3)多态
  3. C++ 语言编写出的程序结构清晰、易于扩充,程序可读性好
  4. C++ 生成的代码质量高,运行效率高,仅比汇编语言慢 10%~20%;
  5. C++ 更加安全,增加了 const 常量、引用、四类 cast 转换(static_cast、dynamic_cast、const_cast、reinterpret_cast)、智能指针、try—catch 等等;
  6. C++ 可复用性高,C++ 引入了模板的概念,后面在此基础上,实现了方便开发的标准模板库 STL(Standard Template Library)。
  7. 同时,C++ 是不断在发展的语言。C++ 后续版本更是发展了不少新特性,如 C++11 中引入了 nullptr、auto 变量、Lambda 匿名函数、右值引用、智能指针。

C 语言和 C++ 的区别

  1. C 语言是 C++ 的子集,C++ 可以很好兼容 C 语言。但是 C++ 又有很多新特性,如引用、智能指针、auto 变量等。
  2. C++ 是面向对象的编程语言;C 语言是面向过程的编程语言。
  3. C 语言有一些不安全的语言特性,如指针使用的潜在危险、强制转换的不确定性、内存泄露等。而 C++ 对此增加了不少新特性来改善安全性,如 const 常量、引用、cast 转换、智能指针、try—catch 等等;
  4. C++ 可复用性高,C++ 引入了模板的概念,后面在此基础上,实现了方便开发的标准模板库 STL。C++ 的 STL 库相对于 C 语言的函数库更灵活、更通用

C++ 中 struct 和 class 的区别

  • 默认的访问、继承权限
  • 定义模板参数
  1. struct 一般用于描述一个数据结构集合,而 class 是对一个对象数据的封装;

  2. struct 中默认的访问控制权限是 public 的,而 class 中默认的访问控制权限是 private 的,例如:

    1
    2
    3
    4
    5
    6
    struct A{
    int iNum; // 默认访问控制权限是 public
    }
    class B{
    int iNum; // 默认访问控制权限是 private
    }
  3. 在继承关系中,struct 默认是公有继承,而 class 是私有继承

  4. class 关键字可以用于定义模板参数,就像 typename,而 struct 不能用于定义模板参数,例如:

    1
    2
    3
    4
    template<typename T, typename Y>    // 可以把typename 换成 class 
    int Func(const T& t, const Y& y) {
    //TODO
    }

include 头文件的顺序以及双引号 “” 和尖括号 <> 的区别

  1. 区别:

    (1)尖括号 <> 的头文件是系统文件,双引号 “” 的头文件是自定义文件

    (2)编译器预处理阶段查找头文件的路径不一样。

  2. 查找路径:

    (1)使用尖括号 <> 的头文件的查找路径:编译器设置的头文件路径 --> 系统变量。

    (2)使用双引号 “” 的头文件的查找路径:当前头文件目录 --> 编译器设置的头文件路径 --> 系统变量。

C++ 结构体和 C 结构体的区别

  1. C 的结构体内不允许有函数存在,C++ 允许有内部成员函数,且允许该函数是虚函数。

  2. C 的结构体对内部成员变量的访问权限只能是 public,而 C++ 允许 public,protected,private 三种。

  3. C 语言的结构体是不可以继承的,C++ 的结构体是可以从其他的结构体或者类继承过来的。

  4. C++ 中的 struct 是对 C 中的 struct 进行了扩充,它们在声明时的区别如下:

    CC++
    成员函数不能有可以
    静态成员不能有可以
    访问控制默认public,不能修改public/private/protected
    继承关系不可以继承可从类或者其他结构体继承
    初始化不能直接初始化数据成员可以
  5. 使用时的区别:C 中使用结构体需要加上 struct 关键字,或者对结构体使用 typedef 取别名,而 C++ 中可以省略 struct 关键字直接使用,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    struct Student{
    int iAgeNum;
    string strName;
    }
    typedef struct Student Student2; //C中取别名

    struct Student stu1; // C 中正常使用
    Student2 stu2; // C 中通过取别名的使用

    Student stu3; // C++ 中使用

导入 C 函数的关键字是什么,C++ 编译时和 C 有什么不同?

  1. 关键字: 在 C++ 中,导入 C 函数的关键字是 extern,表达形式为 extern “C”, extern “C” 的主要作用就是为了能够正确实现 C++ 代码调用其他 C 语言代码。加上 extern “C” 后,会指示编译器这部分代码按 C 语言的进行编译,而不是 C++ 的。

  2. 编译区别: 由于 C++ 支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而 C 语言并不支持函数重载,因此编译 C 语言代码的函数时不会带上函数的参数类型,一般只包括函数名

1
2
3
4
5
6
7
8
9
10
//extern示例
//在C++程序里边声明该函数,会指示编译器这部分代码按C语言的进行编译
extern "C" int strcmp(const char *s1, const char *s2);

//在C++程序里边声明该函数
extern "C"{
#include <string.h>//string.h里边包含了要调用的C函数的声明
}

//两种不同的语言,有着不同的编译规则,比如一个函数fun,可能C语言编译的时候为_fun,而C++则是__fun__

C++ 从代码到可执行二进制文件的过程

C++ 和 C 语言类似,一个 C++ 程序从源码到执行文件,有四个过程,预编译、编译、汇编、链接

解析:

  1. 预编译:这个过程主要的处理操作如下:

    (1) 将所有的 #define\#define 删除,并且展开所有的宏定义

    (2) 处理所有的条件预编译指令,如 #if#ifdef\#if、\#ifdef

    (3) 处理 #include\# include 预编译指令,将被包含的文件插入到该预编译指令的位置。

    (4) 过滤所有的注释

    (5) 添加行号和文件名标识。

  2. 编译:这个过程主要的处理操作如下:

    (1) 词法分析:将源代码的字符序列分割成一系列的记号。

    (2) 语法分析:对记号进行语法分析,产生语法树。

    (3) 语义分析:判断表达式是否有意义。

    (4) 代码优化:

    (5) 目标代码生成:生成汇编代码。

    (6) 目标代码优化:

  3. 汇编:这个过程主要是将汇编代码转变成机器可以执行的指令。

  4. 链接:将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。

    链接分为静态链接和动态链接。

    静态链接,是在链接的时候就已经把要调用的函数或者过程链接到了生成的可执行文件中,就算你在去把静态库删除也不会影响可执行程序的执行;生成的静态链接库,Windows 下以. lib 为后缀,Linux 下以 . a 为后缀。

动态链接,是在链接的时候没有把调用的函数代码链接进去,而是在执行的过程中,再去找要链接的函数,生成的可执行文件中没有函数代码,只包含函数的重定位信息,所以当你删除动态库时,可执行程序就不能运行。生成的动态链接库,Windows 下以 .dll 为后缀,Linux 下以 .so 为后缀。

static 关键字的作用

(1)内存只分配一次,维护前值:函数体内static变量的作用范围为该函数体,该变量的内存只被分配一次,因此其值在下次调用时仍维持上次的值;
(2)本源文件范围:在模块内的static全局变量和函数可以被模块内的函数访问,但不能被模块外其它函数访问;
(3)类中只有一份,全类共有:在类中的static成员变量属于整个类所拥有,对类的所有对象只有一份拷贝;
(4)无this,不能访问非静态成员:在类中的static成员函数属于整个类所拥有,这个函数不接收this指针,因而只能访问类的static成员变量,不能访问非静态成员。

  1. 定义全局静态变量和局部静态变量:在变量前面加上 static 关键字。初始化的静态变量会在数据段分配内存,未初始化的静态变量会在 BSS 段分配内存。直到程序结束,静态变量始终会维持前值。只不过全局静态变量和局部静态变量的作用域不一样;

  2. 定义静态函数:在函数返回类型前加上 static 关键字,函数即被定义为静态函数。静态函数只能在本源文件中使用;

  3. 在变量类型前加上 static 关键字,变量即被定义为静态变量。静态变量只能在本源文件中使用

    1
    2
    3
    //示例
    static int a;
    static void func();
  4. 在 c++ 中,static 关键字可以用于定义类中的静态成员变量:使用静态数据成员,它既可以被当成全局变量那样去存储,但又被隐藏在类的内部。类中的 static 静态数据成员拥有一块单独的存储区,而不管创建了多少个该类的对象。所有这些对象的静态数据成员都共享这一块静态存储空间。

  5. 在 c++ 中,static 关键字可以用于定义类中的静态成员函数:与静态成员变量类似,类里面同样可以定义静态成员函数。只需要在函数前加上关键字 static 即可。如静态成员函数也是类的一部分,而不是对象的一部分。所有这些对象的静态数据成员都共享这一块静态存储空间。

为什么静态成员函数不能访问非静态成员?

当调用一个对象的非静态成员函数时,系统会把该对象的起始地址赋给成员函数的 this 指针。而静态成员函数不属于任何一个对象,因此 C++ 规定静态成员函数没有 this 指针。既然它没有指向某一对象,也就无法对一个对象中的非静态成员进行访问。

数组和指针的区别

  1. 概念:

    (1)数组:数组是用于储存多个相同类型数据的集合。数组名是首元素的地址。

    (2)指针:指针相当于一个变量,但是它和不同变量不一样,它存放的是其它变量在内存中的地址。指针名指向了内存的首地址。

  2. 区别:

    (1)赋值:同类型指针变量可以相互赋值;数组不行,只能一个一个元素的赋值或拷贝

    (2)存储方式

    数组:数组在内存中是连续存放的,开辟一块连续的内存空间。数组是根据数组的下标进行访问的,数组的存储空间,不是在静态区就是在栈上。

    指针:指针很灵活,它可以指向任意类型的数据。指针的类型说明了它所指向地址空间的内存。由于指针本身就是一个变量,再加上它所存放的也是变量,所以指针的存储空间不能确定。

    (3)求 sizeof

    整个数组占用内存大小:sizeof(数组名)

    数组中元素个数:sizeof(数组名)/ sizeof(数据类型)

    在 32 位平台下,无论指针的类型是什么,sizeof(指针名)都是 4,在 64 位平台下,无论指针的类型是什么,sizeof(指针名)都是 8。

    (4)初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 数组
    int a[5] = { 0 };
    char b[] = "Hello";    // 按字符串初始化,大小为6
    char c[] = { 'H','e','l','l','o','\0' };    // 按字符初始化
    int* arr = new int[10];    // 动态创建一维数组

    // 指针
    // 指向对象的指针
    int* p = new int(0);
    delete p;
    // 指向数组的指针
    int* p1 = new int[10];
    delete[] p1;
    // 指向类的指针:
    string* p2 = new string;
    delete p2;
    // 指向指针的指针(二级指针)
    int** pp = &p;
    **pp = 10;

    (5)指针操作:

    数组名的指针操作

    1
    2
    3
    4
    5
    6
    7
    8
    int a[3][4];  
    int (*p)[4]; //该语句是定义一个数组指针,指向含4个元素的一维数组
    p = a; //将该二维数组的首地址赋给p,也就是a[0]或&a[0][0]
    p++; //该语句执行过后,也就是p=p+1;p跨过行a[0][]指向了行a[1][]
    //所以数组指针也称指向一维数组的指针,亦称行指针。
    //访问数组中第i行j列的一个元素,有几种操作方式:
    //*(p[i]+j)、*(*(p+i)+j)、(*(p+i))[j]、p[i][j]。其中,优先级:()>[]>*。
    //这几种操作方式都是合法的。

    指针变量的数据操作:

    1
    2
    3
    4
       char *str = "hello,douya!";
    str[2] = 'a';
    *(str+2) = 'b';
    //这两种操作方式都是合法的。

什么是函数指针,如何定义函数指针,有什么使用场景

  1. 概念: 函数指针就是指向函数的指针变量。每一个函数都有一个入口地址,该入口地址就是函数指针所指向的地址。

  2. 定义形式如下:

1
2
3
int func(int a);  
int (*f)(int a);
f = &func;
  1. 函数指针的应用场景回调(callback)。我们调用别人提供的 API 函数 (Application Programming Interface, 应用程序编程接口),称为 Call;如果别人的库里面调用我们的函数,就叫 Callback。

解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//以库函数qsort排序函数为例,它的原型如下:
void qsort(void *base,//void*类型,代表原始数组
size_t nmemb, //第二个是size_t类型,代表数据数量
size_t size, //第三个是size_t类型,代表单个数据占用空间大小
int(*compar)(const void *, const void *)//第四个参数是函数指针
);
//第四个参数告诉qsort,应该使用哪个函数来比较元素,即只要我们告诉qsort比较大小的规则,它就可以帮我们对任意数据类型的数组进行排序。在库函数qsort调用我们自定义的比较函数,这就是回调的应用。

//示例
int num[100];
int cmp_int(const void* _a , const void* _b){//参数格式固定
int *a = (int*)_a; //强制类型转换
int *b = (int*)_b;
return *a - *b;  
}
qsort(num,100,sizeof(num[0]),cmp_int); //回调

静态变量什么时候初始化?

C 语言:全局和静态变量,初始化发生在任何代码执行之前,属于编译期初始化。

C++ 标准:全局或静态对象当且仅当对象首次用到时才进行构造。

解析:

  1. 作用域:C++ 里作用域可分为 6 种:全局,局部,类,语句,命名空间和文件作用域。

    静态全局变量 :全局作用域 + 文件作用域,所以无法在其他文件中使用。

    静态局部变量 :局部作用域,只被初始化一次,直到程序结束。

    类静态成员变量:类作用域。

  2. 所在空间:都在静态存储区。因为静态变量都在静态存储区,所以下次调用函数的时候还是能取到原来的值。

  3. 生命周期:静态全局变量、静态局部变量都在静态存储区,直到程序结束才会回收内存。类静态成员变量在静态存储区,当超出类作用域时回收内存。

nullptr 调用成员函数可以吗?为什么?

能。

原因:因为在编译时对象就绑定了函数地址,和指针空不空没关系。

解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//给出实例
class animal{
public:
void sleep(){ cout << "animal sleep" << endl; }
void breathe(){ cout << "animal breathe haha" << endl; }
};
class fish :public animal{
public:
void breathe(){ cout << "fish bubble" << endl; }
};
int main(){
animal *pAn=nullptr;
pAn->breathe(); // 输出:animal breathe haha
fish *pFish = nullptr;
pFish->breathe(); // 输出:fish bubble
return 0;
}

原因:因为在编译时对象就绑定了函数地址,和指针空不空没关系。pAn->breathe(); 编译的时候,函数的地址就和指针 pAn 绑定了;调用 breathe(*this), this 就等于 pAn。由于函数中没有需要解引用 this 的地方,所以函数运行不会出错,但是若用到 this,因为 this=nullptr,运行出错。

什么是野指针,怎么产生的,如何避免?

  1. 概念: 野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)

  2. 产生原因:释放内存后指针不及时置空(野指针),依然指向了该内存,那么可能出现非法访问的错误。这些我们都要注意避免。

  3. 避免办法:

    (1)初始化置 NULL

    (2)申请内存后判空

    (3)指针释放后置 NULL

    (4)使用智能指针

解析:

产生原因:释放内存后指针不及时置空(野指针),依然指向了该内存,那么可能出现非法访问的错误。这些我们都要注意避免。如:

1
2
3
4
5
6
7
char *p = (char *)malloc(sizeof(char)*100);  
strcpy(p, "Douya");
free(p);//p所指向的内存被释放,但是p所指的地址仍然不变
...
if (p != NULL){//没有起到防错作用
strcpy(p, "hello, Douya!");//出错
}

避免办法:

(1)初始化置 NULL

(2)申请内存后判空

(3)指针释放后置 NULL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int *p = NULL; //初始化置NULL
p = (int *)malloc(sizeof(int)*n); //申请n个int内存空间
assert(p != NULL); //判空,防错设计
p = (int *) realloc(p, 25);//重新分配内存, p 所指向的内存块会被释放并分配一个新的内存地址
free(p);
p = NULL; //释放后置空

int *p1 = NULL; //初始化置NULL
p1 = (int *)calloc(n, sizeof(int)); //申请n个int内存空间同时初始化为0
assert(p1 != NULL); //判空,防错设计
free(p1);
p1 = NULL; //释放后置空

int *p2 = NULL; //初始化置NULL
p2 = new int[n]; //申请n个int内存空间
assert(p2 != NULL); //判空,防错设计
delete []p2;
p2 = nullptr; //释放后置空

静态局部变量,全局变量,局部变量的特点,以及使用场景

  1. 首先从作用域考虑:C++ 里作用域可分为 6 种:全局,局部,类,语句,命名空间和文件作用域。

    全局变量:全局作用域,可以通过 extern 作用于其他非定义的源文件。

    静态全局变量 :全局作用域 + 文件作用域,所以无法在其他文件中使用。

    局部变量:局部作用域,比如函数的参数,函数内的局部变量等等。

    静态局部变量 :局部作用域,只被初始化一次,直到程序结束。

  2. 从所在空间考虑:除了局部变量在栈上外,其他都在静态存储区。因为静态变量都在静态存储区,所以下次调用函数的时候还是能取到原来的值。

  3. 生命周期: 局部变量在栈上,出了作用域就回收内存;而全局变量、静态全局变量、静态局部变量都在静态存储区,直到程序结束才会回收内存。

  4. 使用场景:从它们各自特点就可以看出各自的应用场景,不再赘述。

内联函数和宏函数的区别

区别:

  1. 宏定义不是函数,但是使用起来像函数。预处理器用复制宏代码的方式代替函数的调用,省去了函数压栈退栈过程,提高了效率;而内联函数本质上是一个函数,内联函数一般用于函数体的代码比较简单的函数,不能包含复杂的控制语句,while、switch,并且内联函数本身不能直接调用自身。
  2. 宏函数是在预编译的时候把所有的宏名用宏体来替换,简单的说就是字符串替换 ;而内联函数则是在编译的时候进行代码插入,编译器会在每处调用内联函数的地方直接把内联函数的内容展开,这样可以省去函数的调用的开销,提高效率
  3. 宏定义是没有类型检查的,无论对还是错都是直接替换;而内联函数在编译的时候会进行类型的检查,内联函数满足函数的性质,比如有返回值、参数列表等

解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//宏定义示例
#define MAX(a, b) ((a)>(b)?(a):(b))
MAX(a,"Hello"); //错误地比较int和字符串,没有参数类型检查

//内联函数示例
#include <stdio.h>
inline int add(int a, int b){
return (a + b);
}
int main(void){
int a;
a = add(1, 2);
printf("a+b=%d\n", a);
return 0;
}
//以上a = add(1, 2);处在编译时将被展开为:a = (a + b);

1、使用时的一些注意事项:

  • 使用宏定义一定要注意错误情况的出现,比如宏定义函数没有类型检查,可能传进来任意类型,从而带来错误,如举例。还有就是括号的使用,宏在定义时要小心处理宏参数,一般用括号括起来,否则容易出现二义性
  • inline 函数一般用于比较小的,频繁调用的函数,这样可以减少函数调用带来的开销。只需要在函数返回类型前加上关键字 inline,即可将函数指定为 inline 函数。
  • 同其它函数不同的是,最好将 inline 函数定义在头文件,而不仅仅是声明,因为编译器在处理 inline 函数时,需要在调用点内联展开该函数,所以仅需要函数声明是不够的。

2、内联函数使用的条件:

  • 内联是以代码膨胀(复制)为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。以下情况不宜使用内联:
  • (1)如果函数体内的代码比较长,使用内联将导致内存消耗代价较高。
  • (2)如果函数体内出现循环,那么执行函数体内代码的时间要比函数调用的开销大。
  • 内联不是什么时候都能展开的,一个好的编译器将会根据函数的定义体,自动地取消不符合要求的内联。

运算符 i++ 和 ++i 的区别

先看到实现代码:

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
int main(){
int i = 2;
int j = 2;
j += i++; //先赋值后加
printf("i= %d, j= %d\n",i, j); //i= 3, j= 4
i = 2;
j = 2;
j += ++i; //先加后赋值
printf("i= %d, j= %d",i, j); //i= 3, j= 5
}
  1. 赋值顺序不同:++ i 是先加后赋值;i ++ 是先赋值后加;++i 和 i++ 都是分两步完成的。

  2. 效率不同:后置 ++ 执行速度比前置的慢。

  3. i++ 不能作为左值,而 ++i 可以

    1
    2
    3
    4
    5
    int i = 0;
    int *p1 = &(++i);//正确
    int *p2 = &(i++);//错误
    ++i = 1; //正确
    i++ = 1; //错误
  4. 两者都不是原子操作。

new 和 malloc 的区别,各自底层实现原理。

在使用的时候 newdelete 搭配使用,mallocfree 搭配使用。

  • mallocfree 是库函数,而newdelete 是关键字。
    -new 申请空间时,无需指定分配空间的大小,编译器会根据类型自行计算;malloc 在申请空间时,需要确定所申请空间的大小。
  • new 申请空间时,返回的类型是对象的指针类型,无需强制类型转换,是类型安全的操作符;malloc 申请空间时,返回的是 void* 类型,需要进行强制类型的转换,转换为对象类型的指针。
  • new 分配失败时,会抛出 bad_alloc 异常,malloc 分配失败时返回空指针。
  • 对于自定义的类型,new 首先调用 operator new() 函数申请空间(底层通过 malloc 实现),然后调用构造函数进行初始化,最后返回自定义类型的指针;delete 首先调用析构函数,然后调用 operator delete() 释放空间(底层通过 free 实现)。mallocfree 无法进行自定义类型的对象的构造和析构。
  • new 操作符从自由存储区上为对象动态分配内存,而 malloc 函数从堆上动态分配内存。(自由存储区不等于堆)
  1. new 是操作符,而 malloc 是函数。
  2. new 在调用的时候先分配内存,再调用构造函数,释放的时候调用析构函数;而 malloc 没有构造函数和析构函数。
  3. malloc 需要给定申请内存的大小,返回的指针需要强转;new 会调用构造函数,不用指定内存的大小,返回指针不用强转。
  4. new 可以被重载;malloc 不行
  5. new 分配内存更直接和安全。
  6. new 发生错误抛出异常,malloc 返回 null

解析:

malloc 底层实现: 当开辟的空间小于 128K 时,调用 brk()函数;当开辟的空间大于 128K 时,调用 mmap()。malloc 采用的是内存池的管理方式,以减少内存碎片。Malloc函数用于动态分配内存。为了减少内存碎片和系统调用的开销,malloc其采用内存池的方式,先申请大块内存作为堆区,然后将堆区分为多个内存块,以块作为内存管理的基本单位。当用户申请内存时,直接从堆区分配一块合适的空闲块。Malloc采用隐式链表结构将堆区分成连续的、大小不一的块,包含已分配块和未分配块;同时malloc采用显示链表结构来管理所有的空闲块,即使用一个双向链表将空闲块连接起来,每一个空闲块记录了一个连续的、未分配的地址。

当进行内存分配时,Malloc会通过隐式链表遍历所有的空闲块,选择满足要求的块进行分配;当进行内存合并时,malloc采用边界标记法,根据每个块的前后块是否已经分配来决定是否进行块合并。

new 底层实现: 关键字 new 在调用构造函数的时候实际上进行了如下的几个步骤:

  1. 创建一个新的对象
  2. 将构造函数的作用域赋值给这个新的对象(因此 this 指向了这个新的对象)
  3. 执行构造函数中的代码(为这个新对象添加属性)
  4. 返回新对象

const 和 define 的区别。

const 用于定义常量;而 define 用于定义宏,而宏也可以用于定义常量。都用于常量定义时,它们的区别有:

  1. const 生效于编译的阶段;define 生效于预处理阶段。
  2. const 定义的常量,在 C 语言中是存储在内存中、需要额外的内存空间的;define 定义的常量,运行时是直接的操作数,并不会存放在内存中。
  3. const 定义的常量是带类型的;define 定义的常量不带类型。因此 define 定义的常量不利于类型检查。

C++中函数指针和指针函数的区别。

  1. 定义不同
    指针函数本质是一个函数,其返回值为指针。
    函数指针本质是一个指针,其指向一个函数。

  2. 写法不同

    1
    2
    指针函数:int *fun(int x,int y);
    函数指针:int (*fun)(int x,int y);
  3. 用法不同

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    //指针函数示例
    typedef struct _Data{
    int a;
    int b;
    }Data;
    //指针函数
    Data *f(int a,int b){
    Data *data = new Data;
    //...
    return data;
    }
    int main(){
    //调用指针函数
    Data *myData = f(4,5);
    //Data *myData = static_cast<Data*>(f(4,5));
    //...
    }

    //函数指针示例
    int add(int x,int y){
    return x + y;
    }
    //函数指针
    int (*fun)(int x,int y);
    //赋值
    fun = add;
    //调用
    cout << "(*fun)(1,2) = " << (*fun)(1,2) ;
    //输出结果
    //(*fun)(1,2) = 3

const int *a, int const *a, const int a, int *const a, const int *const a分别是什么,有什么特点。

1
2
3
4
5
const int a;     //指的是a是一个常量,不允许修改。
const int *a; //a指针所指向的内存里的值不变,即(*a)不变
int const *a; //同const int *a;
int *const a; //a指针所指向的内存地址不变,即a不变
const int *const a; //都不变,即(*a)不变,a也不变

使用指针需要注意什么?

  1. 定义指针时,先初始化为NULL。
  2. 用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。
  3. 不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。
  4. 避免数字或指针的下标越界,特别要当心发生“多1”或者“少1”操作
  5. 动态内存的申请与释放必须配对,防止内存泄漏
  6. 用free或delete释放了内存之后,立即将指针设置为NULL,防止“野指针”

解析:

(1)初始化置NULL

(2)申请内存后判空

(3)指针释放后置NULL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int *p = NULL; //初始化置NULL
p = (int *)malloc(sizeof(int)*n); //申请n个int内存空间
assert(p != NULL); //判空,防错设计
p = (int *) realloc(p, 25);//重新分配内存, p 所指向的内存块会被释放并分配一个新的内存地址
free(p);
p = NULL; //释放后置空

int *p1 = NULL; //初始化置NULL
p1 = (int *)calloc(n, sizeof(int)); //申请n个int内存空间同时初始化为0
assert(p1 != NULL); //判空,防错设计
free(p1);
p1 = NULL; //释放后置空

int *p2 = NULL; //初始化置NULL
p2 = new int[n]; //申请n个int内存空间
assert(p2 != NULL); //判空,防错设计
delete []p2;
p2 = nullptr; //释放后置空

内联函数和函数的区别,内联函数的作用。

  1. 内联函数比普通函数多了关键字inline
  2. 内联函数避免了函数调用的开销;普通函数有调用的开销
  3. 普通函数在被调用的时候,需要寻址(函数入口地址);内联函数不需要寻址。
  4. 内联函数有一定的限制,内联函数体要求代码简单,不能包含复杂的结构控制语句;普通函数没有这个要求。

内联函数的作用:内联函数在调用时,是将调用表达式用内联函数体来替换。避免函数调用的开销。

解析:

在使用内联函数时,应注意如下几点:

  1. 在内联函数内不允许用循环语句和开关语句。

    如果内联函数有这些语句,则编译将该函数视同普通函数那样产生函数调用代码,递归函数是不能被用来做内联函数的。内联函数只适合于只有1~5行的小函数。对一个含有许多语句的大函数,函数调用和返回的开销相对来说微不足道,所以也没有必要用内联函数实现。

  2. 内联函数的定义必须出现在内联函数第一次被调用之前。

C++有几种传值方式,之间的区别是什么?

传参方式有这三种:值传递、引用传递、指针传递

  1. 值传递:形参即使在函数体内值发生变化,也不会影响实参的值;

  2. 引用传递:形参在函数体内值发生变化,会影响实参的值;

  3. 指针传递:在指针指向没有发生改变的前提下,形参在函数体内值发生变化,会影响实参的值;

解析:

值传递用于对象时,整个对象会拷贝一个副本,这样效率低;而引用传递用于对象时,不发生拷贝行为,只是绑定对象,更高效;指针传递同理,但不如引用传递安全。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//代码示例
#include <iostream>
using namespace std;

void testfunc(int a, int *b, int &c){//形参a值发生了改变,但是没有影响实参i的值;但形参*b、c的值发生了改变,影响到了实参*j、k的值
a += 1;
(*b) += 1;
c += 1;
printf("a= %d, b= %d, c= %d\n",a,*b,c);//a= 2, b= 2, c= 2
}
int main(){
int i = 1;
int a = 1;
int *j = &a;
int k = 1;
testfunc(i, j, k);
printf("i= %d, j= %d, k= %d\n",i,*j,k);//i= 1, j= 2, k= 2
return 0;
}

const(星号)和(星号)const的区别

1
2
3
4
//const* 是常量指针,*const 是指针常量

int const *a; //a指针所指向的内存里的值不变,即(*a)不变
int *const a; //a指针所指向的内存地址不变,即a不变

C++内存

C++ 程序编译过程

编译过程分为四个过程:编译(编译预处理、编译、优化),汇编,链接。

  • 编译预处理:处理以 # 开头的指令;

  • 编译、优化:将源码 .cpp 文件翻译成 .s 汇编代码;

  • 汇编:将汇编代码 .s 翻译成机器指令 .o 文件;

  • 链接:汇编程序生成的目标文件,即 .o 文件,并不会立即执行,因为可能会出现:.cpp 文件中的函数引用了另一个 .cpp 文件中定义的符号或者调用了某个库文件中的函数。那链接的目的就是将这些文件对应的目标文件连接成一个整体,从而生成可执行的程序 .exe 文件。

    image.png

链接分为两种:

  • 静态链接:代码从其所在的静态链接库中拷贝到最终的可执行程序中,在该程序被执行时,这些代码会被装入到该进程的虚拟地址空间中。
  • 动态链接:代码被放到动态链接库或共享对象的某个目标文件中,链接程序只是在最终的可执行程序中记录了共享对象的名字等一些信息。在程序执行时,动态链接库的全部内容会被映射到运行时相应进行的虚拟地址的空间。

二者的优缺点:

  • 静态链接:浪费空间,每个可执行程序都会有目标文件的一个副本,这样如果目标文件进行了更新操作,就需要重新进行编译链接生成可执行程序(更新困难);优点就是执行的时候运行速度快,因为可执行程序具备了程序运行的所有内容。
  • 动态链接:节省内存、更新方便,但是动态链接是在程序运行时,每次执行都需要链接,相比静态链接会有一定的性能损失。

全局变量、局部变量、静态全局变量、静态局部变量的区别

C++ 变量根据定义的位置的不同的生命周期,具有不同的作用域,作用域可分为 6 种:全局作用域,局部作用域,语句作用域,类作用域,命名空间作用域和文件作用域。

从作用域看:

  • 全局变量:具有全局作用域。全局变量只需在一个源文件中定义,就可以作用于所有的源文件。当然,其他不包含全局变量定义的源文件需要用 extern 关键字再次声明这个全局变量。
  • 静态全局变量:具有文件作用域。它与全局变量的区别在于如果程序包含多个文件的话,它作用于定义它的文件里,不能作用到其它文件里,即被 static 关键字修饰过的变量具有文件作用域。这样即使两个不同的源文件都定义了相同名字的静态全局变量,它们也是不同的变量。
  • 局部变量:具有局部作用域。它是自动对象(auto),在程序运行期间不是一直存在,而是只在函数执行期间存在,函数的一次调用执行结束后,变量被撤销,其所占用的内存也被收回。
  • 静态局部变量:具有局部作用域。它只被初始化一次,自从第一次被初始化直到程序运行结束都一直存在,它和全局变量的区别在于全局变量对所有的函数都是可见的,而静态局部变量只对定义自己的函数体始终可见。

从分配内存空间看:

  • 静态存储区:全局变量,静态局部变量,静态全局变量。
  • 栈:局部变量。

说明:

  • 静态变量和栈变量(存储在栈中的变量)、堆变量(存储在堆中的变量)的区别:静态变量会被放在程序的静态数据存储区(.data 段)中(静态变量会自动初始化),这样可以在下一次调用的时候还可以保持原来的赋值。而栈变量或堆变量不能保证在下一次调用的时候依然保持原来的值。
  • 静态变量和全局变量的区别:静态变量用 static 告知编译器,自己仅仅在变量的作用范围内可见。

全局变量定义在头文件中有什么问题?

如果在头文件中定义全局变量,当该头文件被多个文件 include 时,该头文件中的全局变量就会被定义多次,导致重复定义,因此不能头文件中定义全局变量。

简述一下堆和栈的区别

  • 申请方式:栈是系统自动分配,堆是程序员主动申请。
  • 申请后系统响应:分配栈空间,如果剩余空间大于申请空间则分配成功,否则分配失败栈溢出;申请堆空间,堆在内存中呈现的方式类似于链表(记录空闲地址空间的链表),在链表上寻找第一个大于申请空间的节点分配给程序,将该节点从链表中删除,大多数系统中该块空间的首地址存放的是本次分配空间的大小,便于释放,将该块空间上的剩余空间再次连接在空闲链表上。
  • 栈在内存中是连续的一块空间(向低地址扩展),最大容量是系统预定好的;堆在内存中的空间(向高地址扩展)是不连续的。
  • 申请效率:栈是有系统自动分配,申请效率高,但程序员无法控制;堆是由程序员主动申请,效率低,使用方便但是容易产生碎片。
  • 存放的内容:栈中存放的是局部变量,函数的参数;堆中存放的内容由程序员控制。
  1. 堆栈空间分配不同。栈由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等;堆一般由程序员分配释放。
  2. 堆栈缓存方式不同。栈使用的是一级缓存, 它们通常都是被调用时处于存储空间中,调用完毕立即释放;堆则是存放在二级缓存中,速度要慢些。
  3. 堆栈数据结构不同。堆类似数组结构;栈类似栈结构,先进后出。

C++的内存管理

C/C++内存管理详解 | ShinChan’s Blog (chenqx.github.io)

image-20211118095816959
  1. 内存分配方式

    在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

    ,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。

    ,就是那些由malloc等分配的内存块,和自由存储区是十分相似的,不过是用free来结束自己的生命。

    全局/静态存储区(数据段),全局变量和静态变量被分配到同一块内存中

    常量存储区(代码段),这是一块比较特殊的存储区,里面存放的是常量,不允许修改。

    自由存储区,就是那些由new分配的内存块,一般一个new就要对应一个delete。)

  2. 常见的内存错误及其对策

    (1)分配失败:内存分配未成功,却使用了它。

    (2)未初始化:内存分配虽然成功,但是尚未初始化就引用它。

    (3)访问越界:内存分配成功并且已经初始化,但操作越过了内存的边界。

    (4)忘记释放:忘记了释放内存,造成内存泄露。

    (5)释放还使用:释放了内存却继续使用它。

    对策:

    (1)定义指针时,先初始化为 NULL。

    (2)用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。

    (3)不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。

    (4)避免数字或指针的下标越界,特别要当心发生“多1”或者“少1”操作

    (5)动态内存的申请与释放必须配对,防止内存泄漏

    (6)用free或delete释放了内存之后,立即将指针设置为NULL,防止“野指针”

    (7)使用智能指针。

  3. 内存泄露及解决办法

    什么是内存泄露?

    简单地说就是申请了一块内存空间,使用完毕后没有释放掉。(1)new和malloc申请资源使用后,没有用delete和free释放;(2)子类继承父类时,父类析构函数不是虚函数。(3)Windows句柄资源使用后没有释放。

    怎么检测?

    第一:良好的编码习惯,使用了内存分配的函数,一旦使用完毕, 要记得使用其相应的函数释放掉。

    第二:将分配的内存的指针以链表的形式自行管理,使用完毕之后从链表中删除,程序结束时可检查改链表。

    第三:使用智能指针。

    第四:一些常见的工具插件,如ccmalloc、Dmalloc、Leaky、Valgrind等等。

内存模型(内存布局):

如上图,从低地址到高地址,一个程序由代码段、数据段、 BSS 段组成。

  1. 代码区: 存放程序执行代码的一块内存区域。只读,代码段的头部还会包含一些只读的常数变量。

  2. 全局区/静态区

    1. 数据段: 存放程序中已初始化的全局变量和静态变量的一块内存区域。
    2. BSS 段:存放程序中未初始化的全局变量和静态变量的一块内存区域。
  3. 可执行程序在运行时又会多出两个区域:堆区和栈区。

    堆区: 动态申请内存用。堆从低地址向高地址增长。

    栈区: 存储局部变量、函数参数值。栈从高地址向低地址增长。是一块连续的空间。

  4. 最后还有一个文件映射区,位于堆和栈之间。

堆 heap :一般由程序员分配释放,若程序员不释放,程序结束时可能由 OS 回收 。分配方式类似于链表。

栈 stack :由编译器自动分配释放,存放为运行函数而分配的局部变量、函数参数、返回数据、返回地址等。其操作方式类似于数据结构中的栈。

常量存储区 :存放常量,不允许修改。

malloc和局部变量分配在堆还是栈?

  • malloc是在堆上分配内存,需要程序员自己回收内存,操作系统中有一个记录空闲内存地址的链表,当操作系统收到程序的申请时,就会遍历链表;

  • 局部变量是在栈中分配内存,超过作用域就自动回收。

如何限制类的对象只能在堆上创建?如何限制对象只能在栈上创建?

说明:C++ 中的类的对象的建立分为两种:静态建立、动态建立。

  • 静态建立(栈上):由编译器为对象在栈空间上分配内存,直接调用类的构造函数创建对象。例如:A a;
  • 动态建立(堆上):使用 new 关键字在堆空间上创建对象,底层首先调用 operator new() 函数,在堆空间上寻找合适的内存并分配;然后,调用类的构造函数创建对象。例如:A *p = new A();
  1. 限制对象只能建立在堆上:

    • 最直观的思想:避免直接调用类的构造函数,因为对象静态建立时,会调用类的构造函数创建对象。

      注意:但是直接将类的构造函数设为私有并不可行,因为当构造函数设置为私有后,不能在类的外部调用构造函数来构造对象,只能用 new 来建立对象。但是由于 new 创建对象时,底层也会调用类的构造函数,将构造函数设置为私有后,那就无法在类的外部使用 new 创建对象了。因此,这种方法不可行。

    • 解决方法 1:

      • 将析构函数设置为私有

      • 原因:静态对象建立在栈上,是由编译器分配和释放内存空间,编译器为对象分配内存空间时,会对类的非静态函数进行检查,即编译器会检查析构函数的访问性。当析构函数设为私有时,编译器创建的对象就无法通过访问析构函数来释放对象的内存空间,因此,编译器不会在栈上为对象分配内存。

        1
        2
        3
        4
        5
        6
        7
        class A {
        public:
        A() {}
        void destory() { delete this; }
        private:
        ~A() {}
        };

        该方法存在的问题:

        • new 创建的对象,通常会使用 delete 释放该对象的内存空间,但此时类的外部无法调用析构函数,因此类内必须定义一个 destory() 函数,用来释放 new 创建的对象。
        • 无法解决继承问题,因为如果这个类作为基类,析构函数要设置成 virtual,然后在派生类中重写该函数,来实现多态。但此时,析构函数是私有的,派生类中无法访问。
    • 解决方法 2:

      • 构造函数设置为 protected,并提供一个 public 的静态函数来完成构造,而不是在类的外部使用 new 构造;

      • 将析构函数设置为 protected

      • 原因:类似于单例模式,也保证了在派生类中能够访问析构函数。通过调用 create() 函数在堆上创建对象。

        1
        2
        3
        4
        5
        6
        7
        8
        9
        class A {
        protected:
        A() {}
        ~A() {}

        public:
        static A *create() { return new A(); }
        void destory() { delete this; }
        };
  2. 限制对象只能建立在栈上:

    • 解决方法:将 operator new() 设置为私有。原因:当对象建立在堆上时,是采用 new 的方式进行建立,其底层会调用 operator new() 函数,因此只要对该函数加以限制,就能够防止对象建立在堆上。

      1
      2
      3
      4
      5
      6
      7
      8
      class A {
      private:
      void* operator new(size_t t) {} // 注意函数的第一个参数和返回值都是固定的
      void operator delete(void *ptr) {} // 重载了 new 就需要重载 delete
      public:
      A() {}
      ~A() {}
      };

程序有哪些section,分别的作用?程序启动的过程?怎么判断数据分配在栈上还是堆上?

一个程序有哪些section:

  1. 代码区: 存放程序执行代码的一块内存区域。只读,代码段的头部还会包含一些只读的常数变量。

  2. 全局区/静态区

    1. 数据段: 存放程序中已初始化的全局变量和静态变量的一块内存区域。
    2. BSS 段:存放程序中未初始化的全局变量和静态变量的一块内存区域。
  3. 可执行程序在运行时又会多出两个区域:堆区和栈区。

    堆区: 动态申请内存用。堆从低地址向高地址增长。

    栈区: 存储局部变量、函数参数值。栈从高地址向低地址增长。是一块连续的空间。

  4. 最后还有一个文件映射区,位于堆和栈之间。

堆 heap :一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收 。分配方式类似于链表。

栈 stack :由编译器自动分配释放,存放为运行函数而分配的局部变量、函数参数、返回数据、返回地址等。其操作方式类似于数据结构中的栈。

常量存储区 :存放常量,不允许修改。

程序启动的过程:

  1. 操作系统首先创建相应的进程并分配私有的进程空间,然后操作系统的加载器负责把可执行文件的数据段和代码段映射到进程的虚拟内存空间中。
  2. 加载器读入可执行程序的导入符号表,根据这些符号表可以查找出该可执行程序的所有依赖的动态链接库。
  3. 加载器针对该程序的每一个动态链接库调用LoadLibrary
    (1)查找对应的动态库文件,加载器为该动态链接库确定一个合适的基地址。
    (2)加载器读取该动态链接库的导入符号表和导出符号表,比较应用程序要求的导入符号是否匹配该库的导出符号。
    (3)针对该库的导入符号表,查找对应的依赖的动态链接库,如有跳转,则跳到3
    (4)调用该动态链接库的初始化函数
  4. 初始化应用程序的全局变量,对于全局对象自动调用构造函数。
  5. 进入应用程序入口点函数开始执行。

怎么判断数据分配在栈上还是堆上: 首先局部变量分配在栈上;而通过 malloc 和 new 申请的空间是在堆上。

初始化为 0 的全局变量在 bss 还是 data

在 BSS 段

BSS 段通常是指用来存放程序中未初始化的或者初始化为0的全局变量和静态变量的一块内存区域。特点是可读写的,在程序执行之前BSS段会自动清 0。

什么是内存泄露,内存泄露怎么检测?

什么是内存泄露?

简单地说就是申请了一块内存空间,使用完毕后没有释放掉。

(1)new和malloc申请资源使用后,没有用delete和free释放;

(2)子类继承父类时,父类析构函数不是虚函数。

(3)Windows句柄资源使用后没有释放。

怎么检测?

第一:良好的编码习惯,使用了内存分配的函数,一旦使用完毕,要记得使用其相应的函数释放掉。

第二:将分配的内存的指针以链表的形式自行管理,使用完毕之后从链表中删除,程序结束时可检查改链表。

第三:使用智能指针。

第四:一些常见的工具插件,如ccmalloc、Dmalloc、Leaky、Valgrind等等。

请简述一下 atomoic 内存顺序。

有六个内存顺序选项可应用于对原子类型的操作:

  1. memory_order_relaxed:在原子类型上的操作以自由序列执行,没有任何同步关系,仅对此操作要求原子性。

  2. memory_order_consume:memory_order_consume只会对其标识的对象保证该对象存储先行于那些需要加载该对象的操作。

  3. memory_order_acquire:使用memory_order_acquire的原子操作,当前线程的读写操作都不能重排到此操作之前。

  4. memory_order_release:使用memory_order_release的原子操作,当前线程的读写操作都不能重排到此操作之后。

  5. memory_order_acq_rel:memory_order_acq_rel在此内存顺序的读-改-写操作既是获得加载又是释放操作。没有操作能够从此操作之后被重排到此操作之前,也没有操作能够从此操作之前被重排到此操作之后。

  6. memory_order_seq_cst:memory_order_seq_cst比std::memory_order_acq_rel更为严格。memory_order_seq_cst不仅是一个"获取释放"内存顺序,它还会对所有拥有此标签的内存操作建立一个单独全序。

除非你为特定的操作指定一个顺序选项,否则内存顺序选项对于所有原子类型默认都是 memory_order_seq_cst。

简述C++中内存对齐的使用场景

内存对齐应用于三种数据类型中:struct/class/union

struct/class/union内存对齐原则有四个:

  1. 数据成员对齐规则:结构(struct)或联合(union)的数据成员,第一个数据成员放在 offset 为0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小的整数倍开始。

  2. 结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部"最宽基本类型成员"的整数倍地址开始存储。(struct a 里存有 struct b , b 里有 char , int , double 等元素,那b应该从8的整数倍开始存储)。

  3. 收尾工作: 结构体的总大小,也就是 sizeof 的结果,必须是其内部最大成员的"最宽基本类型成员"的整数倍。不足的要补齐。(基本类型不包括struct/class/uinon)。

  4. sizeof(union),以结构里面size最大元素为union的size,因为在某一时刻,union只有一个成员真正存储于该地址。

解析:

  1. 什么是内存对齐?

    那么什么是字节对齐?在C语言中,结构体是一种复合数据类型,其构成元素既可以是基本数据类型(如int、long、float等)的变量,也可以是一些复合数据类型(如数组、结构体、联合体等)的数据单元。在结构体中,编译器为结构体的每个成员按其自然边界(alignment)分配空间。 各个成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构体的地址相同。

    为了使CPU能够对变量进行快速的访问,变量的起始地址应该具有某些特性,即所谓的“对齐”,比如4字节的int型,其起始地址应该位于4字节的边界上,即起始地址能够被4整除,也即“对齐”跟数据在内存中的位置有关。如果一个变量的内存地址正好位于它长度的整数倍,他就被称做自然对齐。

    比如在32位cpu下,假设一个整型变量的地址为0x00000004(为4的倍数),那它就是自然对齐的,而如果其地址为0x00000002(非4的倍数)则是非对齐的。现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。

  2. 为什么要字节对齐?

    需要字节对齐的根本原因在于 CPU 访问数据的效率问题。对齐后一次访存,非对齐需要两次访存。

    假设上面整型变量的地址不是自然对齐,比如为0x00000002,则CPU如果取它的值的话需要访问两次内存,第一次取从0x00000002-0x00000003的一个short,第二次取从0x00000004-0x00000005的一个short然后组合得到所要的数据,如果变量在0x00000003地址上的话则要访问三次内存,第一次为char,第二次为short,第三次为char,然后组合得到整型数据。

    而如果变量在自然对齐位置上,则只要一次就可以取出数据。一些系统对对齐要求非常严格,比如sparc系统,如果取未对齐的数据会发生错误,而在x86上就不会出现错误,只是效率下降。

    各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数据。显然在读取效率上下降很多。

  3. 字节对齐实例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    union example {  
    int a[5];
    char b;
    double c;
    };
    int result = sizeof(example);
    /*
    如果以最长20字节为准,内部double占8字节,这段内存的地址0x00000020并不是double的整数倍,只有当最小为0x00000024时可以满足整除double(8Byte)同时又可以容纳int a[5]的大小,所以正确的结果应该是result=24
    */

    struct example {
    int a[5];
    char b;
    double c;
    }test_struct;
    int result = sizeof(test_struct);
    /*
    如果我们不考虑字节对齐,那么内存地址0x0021不是double(8Byte)的整数倍,所以需要字节对齐,那么此时满足是double(8Byte)的整数倍的最小整数是0x0024,说明此时char b对齐int扩充了三个字节。所以最后的结果是result=32
    */

    struct example {
    char b;
    double c;
    int a;
    }test_struct;
    int result = sizeof(test_struct);
    /*
    字节对齐除了内存起始地址要是数据类型的整数倍以外,还要满足一个条件,那就是占用的内存空间大小需要是结构体中占用最大内存空间的类型的整数倍,所以20不是double(8Byte)的整数倍,我们还要扩充四个字节,最后的结果是result=24
    */

什么是内存对齐?内存对齐的原则?为什么要进行内存对齐,有什么优点?

内存对齐:编译器将程序中的每个“数据单元”安排在字的整数倍的地址指向的内存之中

内存对齐的原则:

  1. 结构体变量的首地址能够被其最宽基本类型成员大小与对齐基数中的较小者所整除;
  2. 结构体每个成员相对于结构体首地址的偏移量 (offset) 都是该成员大小与对齐基数中的较小者的整数倍,如有需要编译器会在成员之间加上填充字节 (internal padding);
  3. 结构体的总大小为结构体最宽基本类型成员大小与对齐基数中的较小者的整数倍,如有需要编译器会在最末一个成员之后加上填充字节 (trailing padding)。

进行内存对齐的原因:(主要是硬件设备方面的问题)

  1. 某些硬件设备只能存取对齐数据,存取非对齐的数据可能会引发异常;
  2. 某些硬件设备不能保证在存取非对齐数据的时候的操作是原子操作;
  3. 相比于存取对齐的数据,存取非对齐的数据需要花费更多的时间;
  4. 某些处理器虽然支持非对齐数据的访问,但会引发对齐陷阱(alignment trap);
  5. 某些硬件设备只支持简单数据指令非对齐存取,不支持复杂数据指令的非对齐存取。

内存对齐的优点:

  1. 便于在不同的平台之间进行移植,因为有些硬件平台不能够支持任意地址的数据访问,只能在某些地址处取某些特定的数据,否则会抛出异常;
  2. 提高内存的访问效率,因为 CPU 在读取内存时,是一块一块的读取

malloc 和 new 区别

  1. malloc/free 是标准库函数;new/delete 是C++运算符
  2. malloc 失败返回空;new 失败抛异常
  3. new/delete 会调用构造、析构函数;malloc/free不会,所以他们无法满足动态对象的要求。
  4. new返回有类型的指针;malloc返回无类型的指针

更多理解:

  1. 分配内存的位置
    malloc 是从堆上动态分配内存,new 是从自由存储区为对象动态分配内存。
    自由存储区的位置取决于 operator new 的实现。自由存储区不仅可以为堆,还可以是静态存储区,这都看 operator new 在哪里为对象分配内存。

  2. 返回类型安全性
    malloc 内存分配成功后返回 void*,然后再强制类型转换为需要的类型;new 操作符分配内存成功后返回与对象类型相匹配的指针类型;因此 new 是符合类型安全的操作符。

  3. 内存分配失败返回值
    malloc 内存分配失败后返回 NULL;
    new 分配内存失败则会抛异常(bac_alloc)。

  4. 分配内存的大小的计算
    使用 new 操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算,而 malloc 则需要显式地指出所需内存的尺寸。

  5. 是否调用构造函数/析构函数
    使用new操作符来分配对象内存时会经历三个步骤:

    • 第一步:调用 operator new 函数(对于数组是 operator new[])分配一块足够大的,原始的,未命名的内存空间以便存储特定类型的对象。
    • 第二步:编译器运行相应的构造函数以构造对象,并为其传入初值。
    • 第三步:对象构造完成后,返回一个指向该对象的指针。

    使用delete操作符来释放对象内存时会经历两个步骤:

    • 第一步:调用对象的析构函数。
    • 第二步:编译器调用 operator delete(或operator delete[]) 函数释放内存空间。

    总之来说,new/delete 会调用对象的构造函数/析构函数以完成对象的构造/析构;而 malloc 则不会。

  6. 对数组的处理

    C++ 提供了 new [] 和 delete [] 用来专门处理数组类型。它会调用构造函数初始化每一个数组元素,然后释放对象时它会为每个对象调用析构函数,但是二者一定要配套使用;至于 malloc,它并不知道你要在这块空间放置数组还是其他的东西,就只给一块原始的空间,再给一个内存地址就完事,如果要动态开辟一个数组的内存,还需要我们手动自定数组的大小。

    1
    2
    3
    A * ptr = new A[10];//分配10个A对象
    delete [] ptr;
    int * ptr = (int *) malloc(sizeof(int) * 10);//分配一个10个int元素的数组
  7. new与malloc是否可以相互调用
    new/delete 的实现可以基于 malloc,而 malloc 的实现不可以去调用 new

  8. 是否可以被重载
    new/delete 可以被重载。而 malloc/free 则不能重载。

  9. 分配内存时内存不足
    malloc 动态分配内存后,如果不够用可以使用 realloc 函数重新分配实现内存的扩充;而 new 则没有这样的操作;

有了 malloc/free 为什么还要 new/delete?

  1. new 运算不需要进行强制类型转换,使用简单方便;
  2. new 运算是通过调用构造函数初始化动态创建的对象,执行效率更高;
  3. 使用 new 能够进行异常处理,使用更安全

类大小的计算

说明:类的大小是指类的实例化对象的大小,用 sizeof 对类型名操作时,结果是该类型的对象的大小。

每个对象所占用的存储空间只是该对象的数据部分(虚函数指针和虚基类指针也属于数据部分)所占用的存储空间,而不包括函数代码所占用的存储空间

img

计算原则:

  • 遵循结构体的对齐原则。
  • 与普通成员变量有关,与成员函数和静态成员无关。即普通成员函数,静态成员函数,静态数据成员,静态常量数据成员均对类的大小无影响。因为静态数据成员被类的对象共享,并不属于哪个具体的对象。
  • 虚函数对类的大小有影响,是因为虚函数表指针的影响
  • 虚继承对类的大小有影响,是因为虚基表指针带来的影响
  • 空类的大小是一个特殊情况,空类的大小为 1,当用 new 来创建一个空类的对象时,为了保证不同对象的地址不同(用于区分对象),空类也占用存储空间。

什么是内存泄露

内存泄漏由于疏忽或错误导致的程序未能释放已经不再使用的内存

进一步解释:

  • 并非指内存从物理上消失,而是指程序在运行过程中,由于疏忽或错误而失去了对该内存的控制,从而造成了内存的浪费。
  • 常指堆内存泄漏,因为堆是动态分配的,而且是用户来控制的,如果使用不当,会产生内存泄漏。
  1. 使用 malloccallocreallocnew 等分配内存时,使用完后要调用相应的 freedelete 释放内存,否则这块内存就会造成内存泄漏。

  2. 指针重新赋值

    1
    2
    3
    char *p = (char *)malloc(10);
    char *p1 = (char *)malloc(10);
    p = np;

    开始时,指针 pp1 分别指向一块内存空间,但指针 p 被重新赋值,导致 p 初始时指向的那块内存空间无法找到,从而发生了内存泄漏。

防止内存泄漏的方法:

  1. 内部封装将内存的分配和释放封装到类中,在构造的时候申请内存,析构的时候释放内存
  2. 智能指针:智能指针是 C++ 中已经对内存泄漏封装好了一个工具,可以直接拿来使用

面向对象

什么是面向对象

面向对象:对象是指具体的某一个事物,这些事物的抽象就是类,类中包含数据(成员变量)和动作(成员方法)。

任何东西看作一个对象进行封装:对象属性 + 操作属性的函数

  1. 面向对象是一种编程思想,把一切东西看成是一个个对象,比如人、耳机、鼠标、水杯等,他们各自都有属性,比如:耳机是白色的,鼠标是黑色的,水杯是圆柱形的等等,把这些对象拥有的属性变量和操作这些属性变量的函数打包成一个类来表示

  2. 面向过程和面向对象的区别

    面向过程:根据业务逻辑从上到下写代码

    面向对象:将数据与函数绑定到一起,进行封装,这样能够更快速的开发程序,减少了重复代码的重写过程

面向对象的三大特征

封装、继承、多态

  1. 封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互

    封装本质上是一种管理:我们如何管理兵马俑呢?比如如果什么都不管,兵马俑就被随意破坏了。那么我们首先建了一座房子把兵马俑给封装起来。但是我们目的全封装起来,不让别人看。所以我们开放了售票通 道,可以买票突破封装在合理的监管机制下进去参观。类也是一样,不想给别人看到的,我们使用 protected/private 把成员封装起来。开放一些共有的成员函数对成员合理的访问。所以封装本质是一种管理。

  2. 继承:可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。

    三种继承方式

    继承方式 private继承 protected继承 public继承
    基类的private成员 不可见 不可见 不可见
    基类的protected成员 变为private成员 仍为protected成员 仍为protected成员
    基类的public成员 变为private成员 变为protected成员 仍为public成员仍为public成员
  3. 多态:用基类的指针指向其派生类的实例,然后通过基类的指针调用实际派生类类的成员函数。

    实现多态,有二种方式,重写,重载。

重载和重写,以及它们的区别

  1. 重写

    是指派生类中存在重新定义的函数。其函数名,参数列表,返回值类型,所有都必须同基类中被重写的函数一致。只有函数体不同(花括号内),派生类对象调用时会调用派生类的重写函数,不会调用被重写函数。重写的基类中被重写的函数必须有 virtual 修饰。

    注意:协变的返回类型可以不同。协变是重写的特例,基类中返回值是基类类型的引用或指针,在派生类中,返回值为派生类类型的引用或指针

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #include<bits/stdc++.h>
    using namespace std;
    class A {
    public:
    virtual void fun() { cout << "A"; }
    };
    class B :public A {
    public:
    virtual void fun() { cout << "B"; }
    };
    int main(void) {
    A* a = new B();
    a->fun();//输出B,A类中的fun在B类中重写
    }
  2. 重载

    函数重载是指同一可访问区内被声明的几个具有不同参数列(参数的类型,个数,顺序不同)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型

    我们在平时写代码中会用到几个函数但是他们的实现功能相同,但是有些细节却不同。例如:交换两个数的值其中包括(int, float,char,double) 这些个类型。在 C 语言中我们是利用不同的函数名来加以区分。这样的代码不美观而且给程序猿也带来了很多的不便。于是在 C++ 中人们提出了用一个函数名定义多个函数,也就是所谓的函数重载。

    1
    2
    3
    4
    5
    6
    7
    8
    #include<bits/stdc++.h>
    using namespace std;
    class A {
    void fun() {};
    void fun(int i) {};
    void fun(int i, int j) {};
    void fun1(int i,int j){};
    };

重载、重写、隐藏的区别

重载:函数名相同,根据参数列表区分。同一个类内部。

重写(覆盖):必须是虚函数,只有函数体不同,其他都相同(协变返回类型可以不同)

隐藏:函数名相同,其他可同可不同。不同类之间。

  • 重载:是指同一可访问区内被声明几个具有不同参数列(参数的类型、个数、顺序)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型。

    1
    2
    3
    4
    5
    6
    7
    8
    class A {
    public:
    void fun(int tmp);
    void fun(float tmp); // 重载 参数类型不同(相对于上一个函数)
    void fun(int tmp, float tmp1); // 重载 参数个数不同(相对于上一个函数)
    void fun(float tmp, int tmp1); // 重载 参数顺序不同(相对于上一个函数)
    int fun(int tmp); // error: 'int A::fun(int)' cannot be overloaded 错误:注意重载不关心函数返回类型
    };
  • 隐藏:是指派生类的函数屏蔽了与其同名的基类函数,只要函数名相同,不管参数列表是否相同,基类函数都会被隐藏

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    #include <iostream>
    using namespace std;
    class Base {
    public:
    void fun(int tmp, float tmp1) { cout << "Base::fun(int tmp, float tmp1)" << endl; }
    };
    class Derive : public Base {
    public:
    void fun(int tmp) { cout << "Derive::fun(int tmp)" << endl; } // 隐藏基类中的同名函数
    };
    int main() {
    Derive ex;
    ex.fun(1); // Derive::fun(int tmp)
    ex.fun(1, 0.01); // error: candidate expects 1 argument, 2 provided
    return 0;
    }

    说明:上述代码中 ex.fun(1, 0.01); 出现错误,说明派生类中将基类的同名函数隐藏了。若是想调用基类中的同名函数,可以加上类型名指明 ex.Base::fun(1, 0.01);,这样就可以调用基类中的同名函数。

  • 重写(覆盖):是指派生类中存在重新定义的函数。函数名、参数列表、返回值类型都必须同基类中被重写的函数一致只有函数体不同。派生类调用时会调用派生类的重写函数,不会调用被重写函数。重写的基类中被重写的函数必须有 virtual 修饰。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include <iostream>
    using namespace std;
    class Base {
    public:
    virtual void fun(int tmp) { cout << "Base::fun(int tmp) : " << tmp << endl; }
    };
    class Derived : public Base {
    public:
    virtual void fun(int tmp) { cout << "Derived::fun(int tmp) : " << tmp << endl; } // 重写基类中的 fun 函数
    };
    int main() {
    Base *p = new Derived();
    p->fun(3); // Derived::fun(int) : 3
    return 0;
    }

重写和重载的区别

  • 范围区别:对于类中函数的重载或者重写而言,重载发生在同一个类的内部,重写发生在不同的类之间(子类和父类之间)。
  • 参数区别:重载的函数需要与原函数有相同的函数名、不同的参数列表,不关注函数的返回值类型;重写的函数的函数名、参数列表和返回值类型都需要和原函数相同,父类中被重写的函数需要有 virtual 修饰。
  • virtual 关键字:重写的函数基类中必须有 virtual关键字的修饰,重载的函数可以有 virtual 关键字的修饰也可以没有。

隐藏和重写,重载的区别

  • 范围区别:隐藏与重载范围不同,隐藏发生在不同类中。
  • 参数区别:隐藏函数和被隐藏函数参数列表可以相同,也可以不同,但函数名一定相同;当参数不同时,无论基类中的函数是否被 virtual 修饰,基类函数都是被隐藏,而不是重写。

C++ 的重载和重写是如何实现的

  1. C++ 利用命名倾轧(name mangling)技术,来改名函数名,区分参数不同的同名函数。命名倾轧是在编译阶段完成的。

    C++ 定义同名重载函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #include<iostream>
    using namespace std;
    int func(int a,double b) {
    return ((a)+(b));
    }
    int func(double a,float b) {
    return ((a)+(b));
    }
    int func(float a,int b) {
    return ((a)+(b));
    }
    int main() {
    return 0;
    }
  2. 在基类的函数前加上 virtual 关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。

    1. 用 virtual 关键字申明的函数叫做虚函数,虚函数肯定是类的成员函数。
    2. 存在虚函数的类都有一个一维的虚函数表叫做虚表,类的对象有一个指向虚表开始的虚指针。虚表是和类对应的,虚表指针是和对象对应的。
    3. 多态性是一个接口多种实现,是面向对象的核心,分为类的多态性和函数的多态性。
    4. 重写用虚函数来实现,结合动态绑定。
    5. 纯虚函数是虚函数再加上 = 0。
    6. 抽象类是指包括至少一个纯虚函数的类。

    纯虚函数:virtual void fun()=0 。即抽象类必须在子类实现这个函数,即先有名称,没有内容,在派生类实现内容。

C 语言如何实现 C++ 语言中的重载

c 语言中不允许有同名函数,因为编译时函数命名是一样的,不像 c++ 会添加参数类型和返回类型作为函数编译后的名称,进而实现重载。如果要用 c 语言显现函数重载,可通过以下方式来实现:

  1. 使用函数指针来实现,重载的函数不能使用同名称,只是类似的实现了函数重载功能
  2. 重载函数使用可变参数,方式如打开文件 open 函数
  3. gcc 有内置函数,程序使用编译函数可以实现函数重载

示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include<stdio.h>
void func_int(void * a) {
printf("%d\n",*(int*)a); //输出int类型,注意 void * 转化为int
}
void func_double(void * b) {
printf("%.2f\n",*(double*)b);
}
typedef void (*ptr)(void *); //typedef申明一个函数指针
void c_func(ptr p,void *param) {
p(param); //调用对应函数
}
int main() {
int a = 23;
double b = 23.23;
c_func(func_int,&a);
c_func(func_double,&b);
return 0;
}

构造函数有几种,分别什么作用

C++ 中的构造函数可以分为 4 类:默认构造函数、一般(重载)构造函数、拷贝构造函数、移动构造函数。

  1. 默认构造函数和一般(重载)构造函数。 在定义类的对象的时候,完成对象的初始化工作。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    class Student {
    public:
    //默认构造函数
    Student() {
    num = 1001;
    age = 18;
    }
    //初始化构造函数
    Student(int n, int a) : num(n), age(a) {}

    private:
    int num;
    int age;
    };
    int main() {
    //用默认构造函数初始化对象S1
    Student s1;
    //用初始化构造函数初始化对象S2
    Student s2(1002, 18);
    return 0;
    }

    有了自定义的构造了,编译器就不提供默认的构造函数。

  2. 拷贝构造函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    #include "iostream.h"
    #include "stdafx.h"

    class Test {
    int i;
    int* p;

    public:
    Test(int ai, int value) {
    i = ai;
    p = new int(value);
    }
    ~Test() { delete p; }
    Test(const Test& t) {
    this->i = t.i;
    this->p = new int(*t.p);
    }
    };
    //复制构造函数用于复制本类的对象
    int main(int argc, char* argv[]) {
    Test t1(1, 2);
    Test t2(t1); //将对象t1复制给t2。注意复制和赋值的概念不同
    return 0;
    }

    拷贝构造函数默认实现的是值拷贝(浅拷贝)。如果类外面有这样一个函数:

    1
    2
    3
    4
    5
    HasPtr f(HasPtr hp) {
    HasPtr ret = hp;
    ///... 其他操作
    return ret;
    }

    当函数执行完了之后,将会调用 hp 和 ret 的析构函数,将 hp 和 ret 的成员 ps 给 delete 掉,但是由于 ret 和 hp 指向了同一个对象,因此该对象的 ps 成员被 delete 了两次,这样产生一个未定义的错误,所以说,如果一个类定义了析构函数,那么它要定义自己的拷贝构造函数和默认构造函数。

  3. 移动构造函数。用于将其他类型的变量,隐式转换为本类对象。下面的转换构造函数,将 int 类型的 r 转换为 Student 类型的对象,对象的 age 为 r,num 为 1004.

    1
    2
    3
    4
    Student(int r) {
    int num = 1004;
    int age = r;
    }

只定义析构函数,会自动生成哪些构造函数

只定义了析构函数,编译器将自动为我们生成默认构造函数和拷贝构造函数、拷贝赋值运算符。

注意:需要析构函数的类也需要拷贝和赋值操作,因为默认生成的拷贝和赋值操作是浅拷贝,易照成错误(如指针浅拷贝,重复释放同一块内存的错误)

  1. 默认构造函数和初始化构造函数。
    在定义类的对象的时候,完成对象的初始化工作。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class Student {
    public:
    Student() { //默认构造函数
    num = 1001;
    age = 18;
    }
    Student(int n, int a) : num(n), age(a) {} //初始化构造函数
    private:
    int num;
    int age;
    };
    int main() {
    Student s1; //用默认构造函数初始化对象S1
    Student s2(1002, 18); //用初始化构造函数初始化对象S2
    return 0;
    }

    有了有参的构造了,编译器就不提供默认的构造函数。

  2. 拷贝构造函数、拷贝赋值运算符

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    class Test {
    int i;
    int* p;

    public:
    Test(int ai, int value) {
    i = ai;
    p = new int(value);
    }
    ~Test() { delete p; }
    Test(const Test& t) {
    this->i = t.i;
    this->p = new int(*t.p);
    }
    };
    //复制构造函数用于复制本类的对象
    int main(int argc, char* argv[]) {
    Test t1(1, 2);
    Test t2(t1); //将对象t1复制给t2。注意复制和赋值的概念不同。
    return 0;
    }

    拷贝构造函数默认实现的是值拷贝(浅拷贝)。
    如果类外面有这样一个函数:

    1
    2
    3
    4
    5
    HasPtr f(HasPtr hp) {
    HasPtr ret = hp;
    ///... 其他操作
    return ret;
    }

    当函数执行完了之后,将会调用 hp 和 ret 的析构函数,将 hp 和 ret 的成员 ps 给 delete 掉,但是由于 ret 和 hp 指向了同一个对象,因此该对象的 ps 成员被 delete 了两次,这样产生一个未定义的错误,所以说,如果一个类定义了析构函数,那么它要定义自己的拷贝构造函数和默认构造函数。

一个类默认会生成哪些函数

定义一个空类

1
class Empty {};

默认会生成以下几个函数

  1. 默认构造函数

    在定义类的对象的时候,完成对象的初始化工作。

    1
    Empty() {}
  2. 拷贝构造函数

    拷贝构造函数用于复制本类的对象

    1
    Empty(const Empty& copy) {}
  3. 拷贝赋值运算符

    1
    Empty& operator = (const Empty& copy) {}
  4. 析构函数(非虚)

    1
    ~Empty() {}

C++ 类对象的初始化顺序,有多重继承情况下的顺序

  1. 创建派生类的对象,基类的构造函数优先被调用(也优先于派生类里的成员类);

  2. 如果类里面有成员类,成员类的构造函数优先被调用;(也优先于该类本身的构造函数)

  3. 基类构造函数如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序而不是它们在成员初始化表中的顺序;

  4. 成员类对象构造函数如果有多个成员类对象,则构造函数的调用顺序是对象在类中被声明的顺序而不是它们出现在成员初始化表中的顺序;

  5. 派生类构造函数,作为一般规则派生类构造函数应该不能直接向一个基类数据成员赋值而是把值传递给适当的基类构造函数,否则两个类的实现变成紧耦合的(tightly coupled)将更加难于正确地修改或扩展基类的实现。(基类设计者的责任是提供一组适当的基类构造函数)

  6. 综上可以得出,初始化顺序:

    父类构造函数–>成员类对象构造函数–>自身构造函数

    其中成员变量的初始化与声明顺序有关,构造函数的调用顺序是类派生列表中的顺序。

    析构顺序和构造顺序相反。

向上转型和向下转型

  1. 子类转换为父类:向上转型,这种转换相对来说比较安全不会有数据的丢失;

  2. 父类转换为子类:向下转型,可以使用强制转换,这种转换是不安全的,会导致数据的丢失,原因是父类的指针或者引用的内存中可能不包含子类的成员的内存。

深拷贝和浅拷贝,如何实现深拷贝

  1. 浅拷贝:又称值拷贝,将源对象的值拷贝到目标对象中去,本质上来说源对象和目标对象共用一份实体,只是所引用的变量名不同,地址其实还是相同的。

  2. 深拷贝:拷贝的时候先开辟出和源对象大小一样的空间,然后将源对象里的内容拷贝到目标对象中去,这样两个指针就指向了不同的内存位置。并且里面的内容是一样的,这样不但达到了我们想要的目的,还不会出现问题,两个指针先后去调用析构函数,分别释放自己所指向的位置。即每次增加一个指针,便申请一块新的内存,并让这个指针指向新的内存,深拷贝情况下,不会出现重复释放同一块内存的错误。

  3. 深拷贝的实现:深拷贝的拷贝构造函数和赋值运算符的重载传统实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    STRING(const STRING& s) {
    //_str = s._str;
    _str = new char[strlen(s._str) + 1];
    strcpy_s(_str, strlen(s._str) + 1, s._str);
    }
    STRING& operator=(const STRING& s) {
    if (this != &s) {
    // this->_str = s._str;
    delete[] _str;
    this->_str = new char[strlen(s._str) + 1];
    strcpy_s(this->_str, strlen(s._str) + 1, s._str);
    }
    return *this;
    }

    这里的拷贝构造函数我们很容易理解,先开辟出和源对象一样大的内存区域,然后将需要拷贝的数据复制到目标拷贝对象 , 那么这里的赋值运算符的重载是怎么样做的呢?

    这种方法解决了我们的指针悬挂问题,通过不断的开空间让不同的指针指向不同的内存,以防止同一块内存被释放两次的问题。

C++ 中的多态

派生类重写基类方法,然后用基类引用指向派生类对象,调用方法时候会进行动态绑定,这就是多态。

多态分为静态多态和动态多态:

  1. 静态多态:重载。编译器在编译期间完成的,编译器会根据实参类型来推断该调用哪个函数,如果有对应的函数,就调用,没有则在编译时报错。

  2. 动态多态:重写。其实要实现动态多态,需要几个条件——即动态绑定条件:

    1. 虚函数。基类中必须有虚函数,在派生类中必须重写虚函数。
    2. 通过基类类型的指针或引用来调用虚函数。

    说到这,得插播一条概念:重写——也就是基类中有一个虚函数,而在派生类中也要重写一个原型(返回值、名字、参数)都相同的虚函数。不过协变例外。协变是重写的特例,基类中返回值是基类类型的引用或指针,在派生类中,返回值为派生类类型的引用或指针。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    //协变测试函数
    #include <iostream>
    using namespace std;

    class Base {
    public:
    virtual Base* FunTest() {
    cout << "victory" << endl;
    return this;
    }
    };

    class Derived : public Base {
    public:
    virtual Derived* FunTest() {
    cout << "yeah" << endl;
    return this;
    }
    };

    int main() {
    Base b;
    Derived d;

    b.FunTest(); // victory
    d.FunTest(); // yeah

    return 0;
    }

为什么要虚析构,为什么不能虚构造

  1. 虚析构:将可能会被继承的父类的析构函数设置为虚函数,可以保证当我们 new 一个子类,然后使用基类指针指向该子类对象,释放基类指针时可以释放掉子类的空间,防止内存泄漏。如果基类的析构函数不是虚函数,在特定情况下会导致派生来无法被析构。

    1. 用派生类类型指针绑定派生类实例,析构的时候,不管基类析构函数是不是虚函数,都会正常析构
    2. 用基类类型指针绑定派生类实例,析构的时候,如果基类析构函数不是虚函数,则只会析构基类,不会析构派生类对象,从而造成内存泄漏。为什么会出现这种现象呢,个人认为析构的时候如果没有虚函数的动态绑定功能,就只根据指针的类型来进行的,而不是根据指针绑定的对象来进行,所以只是调用了基类的析构函数;如果基类的析构函数是虚函数,则析构的时候就要根据指针绑定的对象来调用对应的析构函数了。

    C++默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚表指针,占用额外的内存。而对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。因此 C++ 默认的析构函数不是虚函数,而是只有当需要当作父类时,设置为虚函数。

  2. 不能虚构造:

    1. 存储空间的角度:构造函数是在实例化对象的时候进行调用,如果此时将构造函数定义成虚函数,需要通过访问该对象所在的内存空间才能进行虚函数的调用(因为需要通过指向虚函数表的指针调用虚函数表,虽然虚函数表在编译时就有了,但是没有虚函数的指针,虚函数的指针只有在创建了对象才有),但是此时该对象还未创建,便无法进行虚函数的调用。所以构造函数不能定义成虚函数。
    2. 实现角度:虚表指针是在创建对象之后才有的,因此构造函数不能定义成虚函数。创建对象需要调用构造函数,此时构造函数如果是虚函数,而虚函数的调用需要通过虚函数指针寻址才能调用,悖论;
    3. 使用角度:虚函数是基类的指针指向派生类的对象时,通过该指针实现对派生类的虚函数的调用;构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用。
    4. 类型角度:在创建对象时需要明确其类型。构造函数的作用是提供初始化,在对象生命期只执行一次,不是对象的动态行为,也没有太大的必要成为虚函数

模板类是在什么时候实现的

  1. 模板实例化:

    • 显示实例化:研发人员明确的告诉模板应该使用什么样的类型去生成具体的类或函数
    • 隐式实例化:在编译的过程中由编译器来决定使用什么类型来实例化一个模板
    • 不管是显示实例化或隐式实例化,最终生成的类或函数完全是按照模板的定义来实现的
  2. 模板具体化:当模板使用某种类型类型实例化后生成的类或函数不能满足需要时,可以考虑对模板进行具体化。具体化时可以修改原模板的定义,当使用该类型时,按照具体化后的定义实现,具体化相当于对某种类型进行特殊处理。

  3. 代码示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    #include <iostream>
    using namespace std;

    // #1 模板定义
    template <class T>
    struct TemplateStruct {
    TemplateStruct() { cout << sizeof(T) << endl; }
    };

    // #2 模板显示实例化
    template struct TemplateStruct<int>;

    // #3 模板具体化
    template <>
    struct TemplateStruct<double> {
    TemplateStruct() { cout << "--8--" << endl; }
    };

    int main() {
    TemplateStruct<int> intStruct;
    TemplateStruct<double> doubleStruct;

    // #4 模板隐式实例化
    TemplateStruct<char> llStruct;
    }

    运行结果:

    1
    2
    3
    4
    --8--
    1

类继承时,派生类对不同关键字修饰的基类方法的访问权限

public:用该关键字修饰的成员表示公有成员,该成员不仅可以在类内可以被访问,在类外也是可以被访问的,是类对外提供的可访问接口,在派生类中可以被访问;

private:用该关键字修饰的成员表示私有成员,该成员仅在类内可以被访问,在类体外是隐藏状态,在派生类中也是隐藏的,在派生类中不可以被访问;

protected:用该关键字修饰的成员表示保护成员,保护成员在类体外同样是隐藏状态,但是对于该类的派生类来说,相当于公有成员,在派生类中可以被访问。

image-20220222124023852

类中的成员可以分为三种类型,分别为public成员、protected成员、public成员。类中可以直接访问自己类的public、protected、private成员,但类对象只能访问自己类的public成员。

基类成员权限 public继承
派生类的成员及友元
public继承
派生类对象
protected继承
派生类的成员及友元
protected继承
派生类对象
private继承
派生类的成员及友元
private继承
派生类对象
public public public public protected,无法访问 public private,无法访问
protected protected protected,无法访问 protected protected,无法访问 protected private,无法访问
private private,无法访问 private,无法访问 private,无法访问 private,无法访问 private,无法访问 private,无法访问
  1. public继承:
    • 派生类可以访问基类的public、protected成员,不可以访问基类的private成员;
    • 派生类对象可以访问基类的public成员,不可以访问基类的protected、private成员。
  2. protected继承:
    • 派生类可以访问基类的public、protected成员,不可以访问基类的private成员;
    • 派生类对象不可以访问基类的public、protected、private成员。
  3. private继承:
    • 派生类可以访问基类的public、protected成员,不可以访问基类的private成员;
    • 派生类对象不可以访问基类的public、protected、private成员。

移动构造函数,什么库用到了这个函数?

C++11 中新增了移动构造函数。与拷贝类似,移动也使用一个对象的值设置另一个对象的值。但是,又与拷贝不同的是,移动实现的是对象值真实的转移(源对象到目的对象):源对象将丢失其内容,其内容将被目的对象占有。移动操作的发生的时候,是当移动值的对象是未命名的对象的时候。这里未命名的对象就是那些临时变量,甚至都不会有名称。典型的未命名对象就是函数的返回值或者类型转换的对象。使用临时对象的值初始化另一个对象值,不会要求对对象的复制:因为临时对象不会有其它使用,因而,它的值可以被移动到目的对象。做到这些,就要使用移动构造函数和移动赋值:当使用一个临时变量对对象进行构造初始化的时候,调用移动构造函数。类似的,使用未命名的变量的值赋给一个对象时,调用移动赋值操作。

移动操作的概念对对象管理它们使用的存储空间很有用的,诸如对象使用 new 和 delete 分配内存的时候。在这类对象中,拷贝和移动是不同的操作:从 A 拷贝到 B 意味着,B 分配了新内存,A 的整个内容被拷贝到为 B 分配的新内存上。而从 A 移动到 B 意味着分配给 A 的内存转移给了 B,没有分配新的内存,它仅仅包含简单地拷贝指针。

看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 移动构造函数和赋值
#include <iostream>
#include <string>
using namespace std;

class Example6 {
string* ptr;

public:
Example6(const string& str) : ptr(new string(str)) {}
~Example6() { delete ptr; }
// 移动构造函数,参数x不能是const Pointer&& x,
// 因为要改变x的成员数据的值;
// C++98不支持,C++0x(C++11)支持
Example6(Example6&& x) : ptr(x.ptr) { x.ptr = nullptr; }
// move assignment
Example6& operator=(Example6&& x) {
delete ptr;
ptr = x.ptr;
x.ptr = nullptr;
return *this;
}
// access content:
const string& content() const { return *ptr; }
// addition:
Example6 operator+(const Example6& rhs) {
return Example6(content() + rhs.content());
}
};
int main() {
Example6 foo("Exam"); // 构造函数
// Example6 bar = Example6("ple"); // 拷贝构造函数
Example6 bar(move(foo)); // 移动构造函数
// 调用move之后,foo变为一个右值引用变量,
// 此时,foo所指向的字符串已经被"掏空",
// 所以此时不能再调用foo
bar = bar + bar; // 移动赋值,在这儿"="号右边的加法操作,
// 产生一个临时值,即一个右值
// 所以此时调用移动赋值语句
cout << bar.content() << endl; // ExamExam
cout << foo.content() << endl; // 出错,foo中的ptr已经被置空为nullptr
return 0;
}

C++ 类内可以定义引用数据成员吗?

c++类内可以定义引用成员变量,但要遵循以下三个规则:

  1. 不能用默认构造函数初始化,必须提供构造函数来初始化引用成员变量。否则会造成引用未初始化错误。
  2. 构造函数的形参也必须是引用类型
  3. 不能在构造函数里初始化,必须在初始化列表中进行初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;

class A {
public:
A(int &target) : a(target) { cout << "构造函数" << endl; }
void printA() { cout << "a is:" << a << endl; }

private:
int &a;
};
int main() {
int a = 20;
A r(a); //依旧使用自定义的构造函数
r.printA();

int &b = a;
A r1(b);
r1.printA();

system("pause");
return 0;
}

构造函数为什么不能被声明为虚函数?

  1. 存储空间的角度:构造函数是在实例化对象的时候进行调用,如果此时将构造函数定义成虚函数,需要通过访问该对象所在的内存空间才能进行虚函数的调用(因为需要通过指向虚函数表的指针调用虚函数表,虽然虚函数表在编译时就有了,但是没有虚函数的指针,虚函数的指针只有在创建了对象才有),但是此时该对象还未创建,便无法进行虚函数的调用。所以构造函数不能定义成虚函数。
  2. 实现角度:虚表指针是在创建对象之后才有的,因此构造函数不能定义成虚函数。创建对象需要调用构造函数,此时构造函数如果是虚函数,而虚函数的调用需要通过虚函数指针寻址才能调用,悖论;
  3. 使用角度:虚函数是基类的指针指向派生类的对象时,通过该指针实现对派生类的虚函数的调用;构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用。
  4. 类型角度:在创建对象时需要明确其类型。构造函数的作用是提供初始化,在对象生命期只执行一次,不是对象的动态行为,也没有太大的必要成为虚函数

什么是常函数,有什么作用

类的成员函数后面加 const,表明这个函数不会对这个类对象的数据成员(准确地说是非静态数据成员)作任何改变

常量(即 const)对象可以调用 const 成员函数,而不能调用非const修饰的函数。

在设计类的时候,一个原则就是对于不改变数据成员的成员函数都要在后面加const,而对于改变数据成员的成员函数不能加 const。所以 const 关键字对成员函数的行为作了更明确的限定:有 const 修饰的成员函数(指 const 放在函数参数表的后面,而不是在函数前面或者参数表内),只能读取数据成员,不能改变数据成员;没有 const 修饰的成员函数,对数据成员则是可读可写的。除此之外,在类的成员函数后面加 const 还有什么好处呢?那就是常量(即 const)对象可以调用 const 成员函数,而不能调用非const修饰的函数。正如非const类型的数据可以给const类型的变量赋值一样,反之则不成立。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
using namespace std;

class CStu {
public:
int a;
CStu() { a = 12; }

void Show() const {
// a = 13; //常函数不能修改数据成员
cout << a << "I am show()" << endl;
}
};

int main() {
CStu st;
st.Show();
system("pause");
return 0;
}

什么是虚继承,解决什么问题,如何实现?

虚继承是解决 C++ 多重继承问题的一种手段,从不同途径继承来的同一基类,会在子类中存在多份拷贝

这将存在两个问题:其一,浪费存储空间;第二,存在二义性问题,通常可以将派生类对象的地址赋值给基类对象,实现的具体方式是,将基类指针指向继承类(继承类有基类的拷贝)中的基类对象的地址,但是多重继承可能存在一个基类的多份拷贝,这就出现了二义性。虚继承可以解决多种继承前面提到的两个问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
using namespace std;
class A {
public:
int _a;
};

class B : virtual public A {
public:
int _b;
};

class C : virtual public A {
public:
int _c;
};

class D : public B, public C {
public:
int _d;
};
//菱形继承和菱形虚继承的对象模型
int main() {
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
cout << sizeof(D) << endl;
return 0;
}

分别从菱形继承和虚继承来分析:

菱形继承

菱形继承中 A 在 B,C,D 中各有一份,虚继承中,A共享。

上面的虚继承表实际上是一个指针数组。B、C 实际上是虚基表指针,指向虚基表。

虚基表:存放相对偏移量,用来找虚基类

简述一下虚函数和纯虚函数,以及实现原理

  1. C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。

    • 如果调用非虚函数,则无论实际对象是什么类型,都执行基类类型所定义的函数。非虚函数总是在编译时根据调用该函数的对象,引用或指针的类型而确定。
    • 如果调用虚函数,则直到运行时才能确定调用哪个函数,运行的虚函数是引用所绑定或指针所指向的对象所属类型定义的版本。
    • 虚函数必须是基类的非静态成员函数。虚函数的作用是实现动态联编,也就是在程序的运行阶段动态地选择合适的成员函数,在定义了虚函数后,可以在基类的派生类中对虚函数重新定义,在派生类中重新定义的函数应与虚函数具有相同的形参个数和形参类型。以实现统一的接口,不同定义过程。如果在派生类中没有对虚函数重新定义,则它继承其基类的虚函数。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class Person {
    public:
    //虚函数
    virtual void GetName() { cout << "PersonName:xiaosi" << endl; };
    };
    class Student : public Person {
    public:
    void GetName() { cout << "StudentName:xiaosi" << endl; };
    };
    int main() {
    //指针
    Person *person = new Student();
    //基类调用子类的函数
    person->GetName(); // StudentName:xiaosi
    }

    虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。这样,在有虚函数的类的实例中这个表被分配在了这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。

  2. 纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加 =0 virtual void GetName() = 0。在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。为了解决上述问题,将函数定义为纯虚函数,则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。将函数定义为纯虚函数能够说明,该函数为后代类型提供了可以覆盖的接口,但是这个类中的函数绝不会调用。声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。必须在继承类中重新声明函数(不要后面的=0)否则该派生类也不能实例化,而且它们在抽象类中往往没有定义。定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。纯虚函数的意义,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //抽象类
    class Person {
    public:
    //纯虚函数
    virtual void GetName() = 0;
    };
    class Student : public Person {
    public:
    Student(){};
    void GetName() { cout << "StudentName:xiaosi" << endl; };
    };
    int main() { Student student; }

纯虚函数能实例化吗,为什么?派生类要实现吗,为什么?

  1. 纯虚函数不可以实例化,但是可以用其派生类实例化,示例如下:

    1
    2
    3
    4
    5
    class Base {
    public:
    virtual void func() = 0;
    };

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    #include <iostream>
    using namespace std;

    class Base {
    public:
    virtual void func() = 0;
    };

    class Derived : public Base {
    public:
    void func() override { cout << "哈哈" << endl; }
    };

    int main() {
    Base *b = new Derived();
    b->func();
    }
  2. 虚函数的原理采用 vtable。类中含有纯虚函数时,其 vtable 不完全,有个空位。

    即“纯虚函数在类的 vtable 表中对应的表项被赋值为 0。也就是指向一个不存在的函数。由于编译器绝对不允许有调用一个不存在的函数的可能,所以该类不能生成对象。在它的派生类中,除非重写此函数,否则也不能生成对象。”

    所以纯虚函数不能实例化。

  3. 纯虚函数是在基类中声明的虚函数,它要求任何派生类都要定义自己的实现方法,以实现多态性。

  4. 定义纯虚函数是为了实现一个接口,用来规范派生类的行为,也即规范继承这个类的程序员必须实现这个函数。派生类仅仅只是继承函数的接口。纯虚函数的意义在于,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但基类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。

C++中虚函数与纯虚函数的区别

  1. 虚函数和纯虚函数可以定义在同一个类中,含有纯虚函数的类被称为抽象类,而只含有虚函数的类不能被称为抽象类。

  2. 虚函数可以被直接使用,也可以被子类重载以后,以多态的形式调用,而纯虚函数必须在子类中实现该函数才可以使用,因为纯虚函数在基类有声明而没有定义。

  3. 虚函数和纯虚函数都可以在子类中被重载,以多态的形式被调用。

  4. 虚函数和纯虚函数通常存在于抽象基类之中,被继承的子类重载,目的是提供一个统一的接口。

  5. 虚函数的定义形式:virtual{};纯虚函数的定义形式:virtual {} = 0; 在虚函数和纯虚函数的定义中不能有static标识符,原因很简单,被static修饰的函数在编译时要求前期绑定, 然而虚函数却是动态绑定,而且被两者修饰的函数生命周期也不一样

解析

  1. 我们举个虚函数的例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class A {
    public:
    virtual void foo() { cout << "A::foo() is called" << endl; }
    };
    class B : public A {
    public:
    void foo() { cout << "B::foo() is called" << endl; }
    };
    int main(void) {
    A *a = new B();
    a->foo(); // 在这里,a虽然是指向A的指针,但是被调用的函数(foo)却是B的!
    return 0;
    }

    这个例子是虚函数的一个典型应用,通过这个例子,也许你就对虚函数有了一些概念。它虚就虚在所谓“推迟联编”或者“动态联编”上,一个类函数的调用并不是在编译时刻被确定的,而是在运行时刻被确定的。由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以被成为“虚”函数。
    虚函数只能借助于指针或者引用来达到多态的效果。

  2. 纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加“=0”

    virtual void funtion1()=0

    为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。

    在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。

    为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数(方法:virtual ReturnType Function()= 0;),则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。

    声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。

    纯虚函数最显著的特征是:它们必须在继承类中重新声明函数(不要后面的=0,否则该派生类也不能实例化),而且它们在抽象类中往往没有定义。

    定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。

    纯虚函数的意义,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。

C++ 中什么是菱形继承问题,如何解决

  1. 下面的图表可以用来解释菱形继承问题。
  • 假设我们有类B和类C,它们都继承了相同的类A。另外我们还有类D,类D通过多重继承机制继承了类B和类C。因为上述图表的形状类似于菱形,因此这个问题被形象地称为菱形继承问题。现在,我们将上面的图表翻译成具体的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    /*
    *Animal类对应于图表的类A*
    */

    class Animal { /* ... */ }; // 基类
    {
    int weight;

    public:

    int getWeight() { return weight;};

    };

    class Tiger : public Animal { /* ... */ };

    class Lion : public Animal { /* ... */ }

    class Liger : public Tiger, public Lion { /* ... */ }

    在上面的代码中,我们给出了一个具体的菱形继承问题例子。Animal类对应于最顶层类(图表中的A),Tiger和Lion分别对应于图表的B和C,Liger类(狮虎兽,即老虎和狮子的杂交种)对应于D。

    现在,问题是如果我们有这种继承结构会出现什么样的问题。

    看看下面的代码后再来回答问题吧。

    1
    2
    3
    4
    5
    6
    int main( )
    {
    Liger lg ;
    /*编译错误,下面的代码不会被任何C++编译器通过 */
    int weight = lg.getWeight();

  • 在我们的继承结构中,我们可以看出Tiger和Lion类都继承自Animal基类。所以问题是:因为Liger多重继承了Tiger和Lion类,因此Liger类会有两份Animal类的成员(数据和方法),Liger对象"lg"会包含Animal基类的两个子对象。

    所以,你会问Liger对象有两个Animal基类的子对象会出现什么问题?再看看上面的代码-调用 lg.getWeight()将会导致一个编译错误。这是因为编译器并不知道是调用 Tiger 类的 getWeight() 还是调用 Lion 类的 getWeight() 。所以,调用 getWeight 方法是不明确的,因此不能通过编译。

  1. 我们给出了菱形继承问题的解释,但是现在我们要给出一个菱形继承问题的解决方案。如果Lion类和Tiger类在分别继承Animal类时都用virtual来标注,对于每一个Liger对象,C++会保证只有一个Animal类的子对象会被创建。看看下面的代码:

    1
    2
    3
    class Tiger : virtual public Animal { /* ... */ };

    class Lion : virtual public Animal { /* ... */ }
  • 你可以看出唯一的变化就是我们在类Tiger和类Lion的声明中增加了"virtual"关键字。现在类Liger对象将会只有一个Animal子对象,下面的代码编译正常:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    int main( )
    {
    Liger lg ;

    /*既然我们已经在Tiger和Lion类的定义中声明了"virtual"关键字,于是下面的代码编译OK */

    int weight = lg.getWeight();
    }

构造函数中能不能调用虚方法

image-20220316105809759

在构造或析构函数中调用虚函数会执行与之所属类型相对应的虚函数版本

构造函数中调用虚函数,虚函数表现为该类中虚函数的行为,即在父类构造函数中调用虚函数,虚函数的表现就是父类定义的函数的表现

【09】绝不在构造和析构过程中调用virtual方法

1、原因很简单,对于前者,这种情况下,子类专有成分还没有构造,对于后者,子类专有成分已经销毁,因此调用的并不是子类重写的方法,这不是程序员所期望的。

2、在构造方法和析构方法中,直接调用virtual方法,很容易识别。还有一种情况要注意,那就是间接调用。比如:构造方法调用init方法,而init方法调用virtual方法。

3、在构造过程中,不能使用virtual从上到下调用,哪有什么办法弥补呢? 可以将子类必要的信息向上传递给父类构造方法。

拷贝构造函数的参数是什么传递方式(引用传递),为什么

  1. 拷贝构造函数的参数必须使用引用传递

  2. 如果拷贝构造函数中的参数不是一个引用,即形如 CClass(const CClass c_class),那么就相当于采用了传值的方式(pass-by-value),而传值的方式会调用该类的拷贝构造函数,从而造成无穷递归地调用拷贝构造函数。因此拷贝构造函数的参数必须是一个引用。

    需要澄清的是,传指针其实也是传值,如果上面的拷贝构造函数写成CClass(const CClass* c_class),也是不行的。事实上,只有传引用不是传值外,其他所有的传递方式都是传值。

类方法和数据的权限有哪几种

  1. C++通过 public、protected、private 三个关键字来控制成员变量和成员函数的访问权限,它们分别表示公有的、受保护的、私有的,被称为成员访问限定符。

    关键字权限
    public可以被任意实体访问
    protected只允许子类及本类的成员函数访问
    private只允许本类的成员函数访问
  2. 下面介绍一个例子。

    父类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class Person {
    public:
    Person(const string& name, int age) : m_name(name), m_age(age) {}

    void ShowInfo() {
    cout << "姓名:" << m_name << endl;
    cout << "年龄:" << m_age << endl;
    }

    protected:
    string m_name; //姓名

    private:
    int m_age; //年龄
    };

    子类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class Teacher : public Person {
    public:
    Teacher(const string& name, int age, const string& title)
    : Person(name, age), m_title(title) {}

    void ShowTeacherInfo() {
    ShowInfo(); //正确,public属性子类可见
    cout << "姓名:" << m_name << endl; //正确,protected属性子类可见
    cout << "年龄:" << m_age << endl; //错误,private属性子类不可见

    cout << "职称:" << m_title << endl; //正确,本类中可见自己的所有成员
    }

    private:
    string m_title; //职称
    };

    调用方:

    1
    2
    3
    4
    5
    6
    void test() {
    Person person("张三", 22);
    person.ShowInfo(); //public属性,对外部可见
    cout << person.m_name << endl; //错误,protected属性,对外部不可见
    cout << person.m_age << endl; //错误,private属性,对外部不可见
    }

如何理解抽象类?

  1. 抽象类的定义如下:

    纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加“=0”,有纯虚函数的类就叫做抽象类。

  2. 抽象类有如下几个特点:

    1)抽象类只能用作其他类的基类,不能建立抽象类对象。

    2)抽象类不能用作参数类型、函数返回类型或显式转换的类型。

    3)可以定义指向抽象类的指针和引用,此指针可以指向它的派生类,进而实现多态性。

什么是多态?多态如何实现?

多态:多态就是不同继承类的对象,对同一消息做出不同的响应,基类的指针指向或绑定到派生类的对象,使得基类指针呈现不同的表现方式。在基类的函数前加上 virtual 关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。

实现方法:多态是通过虚函数实现的,虚函数的地址保存在虚函数表中,虚函数表的地址保存在含有虚函数的类的实例对象的内存空间中。

实现过程

  1. 在类中用 virtual 关键字声明的函数叫做虚函数;
  2. 存在虚函数的类都有一个虚函数表,当创建一个该类的对象时,该对象有一个指向虚函数表的虚表指针(虚函数表和类对应的,虚表指针是和对象对应);
  3. 当基类指针指向派生类对象,基类指针调用虚函数时,基类指针指向派生类的虚表指针,由于该虚表指针指向派生类虚函数表,通过遍历虚表,寻找相应的虚函数。

什么是多态?除了虚函数,还有什么方式能实现多态?

  1. 多态是面向对象的重要特性之一,它是一种行为的封装,就是不同对象对同一行为会有不同的状态。(举例 : 学生和成人都去买票时,学生会打折,成人不会)

  2. 多态是以封装和继承为基础的。在C++中多态分为静态多态(早绑定)和动态多态(晚绑定)两种,其中动态多态是通过虚函数实现,静态多态通过函数重载实现,代码如下:

虚析构函数,什么作用

  1. 虚析构函数,是将基类的析构函数声明为virtual,举例如下:

    1
    2
    3
    4
    5
    class TimeKeeper {
    public:
    TimeKeeper() {}
    virtual ~TimeKeeper() {}
    };
  2. 虚析构函数的主要作用是防止内存泄露

    定义一个基类的指针p,在delete p时,如果基类的析构函数是虚函数,这时只会看p所赋值的对象,如果p赋值的对象是派生类的对象,就会调用派生类的析构函数(毫无疑问,在这之前也会先调用基类的构造函数,再调用派生类的构造函数,然后调用派生类的析构函数,基类的析构函数,所谓先构造的后释放);如果p赋值的对象是基类的对象,就会调用基类的析构函数,这样就不会造成内存泄露。

    如果基类的析构函数不是虚函数,在delete p时,调用析构函数时,只会看指针的数据类型,而不会去看赋值的对象,这样就会造成内存泄露。

解析

  • 我们创建一个TimeKeeper基类和一些及其它的派生类作为不同的计时方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class TimeKeeper {
    public:
    TimeKeeper() {}
    ~TimeKeeper() {} //非virtual的
    };

    //都继承与TimeKeeper
    class AtomicClock :public TimeKeeper{};
    class WaterClock :public TimeKeeper {};
    class WristWatch :public TimeKeeper {};
  • 如果客户想要在程序中使用时间,不想操作时间如何计算等细节,这时候我们可以设计factory(工厂)函数,让函数返回指针指向一个计时对象。该函数返回一个基类指针,这个基类指针是指向于派生类对象的

    1
    2
    3
    TimeKeeper* getTimeKeeper() {
    //返回一个指针,指向一个TimeKeeper派生类的动态分配对象
    }
  • 因为函数返回的对象存在于堆中,因此为了在不使用时我们需要使用释放该对象(delete)

    1
    2
    3
    TimeKeeper* ptk = getTimeKeeper();

    delete ptk;
  • 此处基类的析构函数是非virtual的,因此通过一个基类指针删除派生类对象是错误的

  • 解决办法: 将基类的析构函数改为virtual就正确了

    1
    2
    3
    4
    5
    6
    class TimeKeeper
    {
    public:
    TimeKeeper() {}
    virtual ~TimeKeeper() {}
    };
  • 声明为virtual之后,通过基类指针删除派生类对象就会释放整个对象(基类+派生类)

什么是虚基类,可否被实例化?

  1. 在被继承的类前面加上virtual关键字,这时被继承的类称为虚基类,代码如下:

    1
    2
    3
    4
    class A
    class B1:public virtual A;
    class B2:public virtual A;
    class D:public B1,public B2;
  2. 虚继承的类可以被实例化,举例如下:

    1
    2
    3
    4
    5
    class Animal {/* ... */ };

    class Tiger : virtual public Animal { /* ... */ };

    class Lion : virtual public Animal { /* ... */ }
    1
    2
    3
    4
    5
    6
    7
    int main( ) {
    Liger lg ;

    /*既然我们已经在Tiger和Lion类的定义中声明了"virtual"关键字,于是下面的代码编译OK */
    int weight = lg.getWeight();
    }

拷贝赋值和移动赋值?

  1. 拷贝赋值是通过拷贝构造函数来赋值,在创建对象时,使用同一类中之前创建的对象来初始化新创建的对象。

  2. 移动赋值是通过移动构造函数来赋值,二者的主要区别在于

    1)拷贝构造函数的形参是一个左值引用,而移动构造函数的形参是一个右值引用

    2)拷贝构造函数完成的是整个对象或变量的拷贝,而移动构造函数是生成一个指针指向源对象或变量的地址,接管源对象的内存,相对于大量数据的拷贝节省时间和内存空间。

仿函数/函数对象?有什么作用

  1. 仿函数(functor)又称为函数对象(function object)是一个能行使函数功能的类。仿函数的语法几乎和我们普通的函数调用一样,不过作为仿函数的类,都必须重载 operator() 运算符,举个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    class Func{
    public:
    void operator() (const string& str) const { // 仿函数必须重载 operator()
    cout<<str<<endl;
    }
    };
    Func myFunc;
    myFunc("helloworld!");
  2. 仿函数既能像普通函数一样传入给定数量的参数,还能存储或者处理更多我们需要的有用信息。我们可以举个例子:

    假设有一个vector<string>,你的任务是统计长度小于5的string的个数,如果使用count_if函数的话,你的代码可能长成这样:

    1
    2
    3
    4
    bool LengthIsLessThanFive(const string& str) {
    return str.length() < 5;
    }
    int res = count_if(vec.begin(), vec.end(), LengthIsLessThanFive);

    其中count_if函数的第三个参数是一个函数指针,返回一个bool类型的值。一般的,如果需要将特定的阈值长度也传入的话,我们可能将函数写成这样:

    1
    2
    3
    bool LenthIsLessThan(const string& str, int len) {
    return str.length() < len;
    }

    这个函数看起来比前面一个版本更具有一般性,但是他不能满足count_if函数的参数要求:count_if要求的是 unary function(仅带有一个参数)作为它的最后一个参数。如果我们使用仿函数,是不是就豁然开朗了呢:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class ShorterThan {
    public:
    explicit ShorterThan(int maxLength) : length(maxLength) {}
    bool operator() (const string& str) const {
    return str.length() < length;
    }
    private:
    const int length;
    };

C++ 中哪些函数不能被声明为虚函数?

常见的不不能声明为虚函数的有:非成员函数,静态成员函数(仅一份,无需要),内联成员函数(编译时展开),构造函数,友元函数(不能被继承)。

  1. 为什么C++不支持普通函数为虚函数?

    普通函数(非成员函数)只能被overload,不能被override,声明为虚函数也没有什么意思,因此编译器会在编译时绑定函数。

  2. 为什么C++不支持构造函数为虚函数?

    1. 存储空间的角度:构造函数是在实例化对象的时候进行调用,如果此时将构造函数定义成虚函数,需要通过访问该对象所在的内存空间才能进行虚函数的调用(因为需要通过指向虚函数表的指针调用虚函数表,虽然虚函数表在编译时就有了,但是没有虚函数的指针,虚函数的指针只有在创建了对象才有),但是此时该对象还未创建,便无法进行虚函数的调用。所以构造函数不能定义成虚函数。
    2. 实现角度:虚表指针是在创建对象之后才有的,因此构造函数不能定义成虚函数。创建对象需要调用构造函数,此时构造函数如果是虚函数,而虚函数的调用需要通过虚函数指针寻址才能调用,悖论;
    3. 使用角度:虚函数是基类的指针指向派生类的对象时,通过该指针实现对派生类的虚函数的调用;构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用。
    4. 类型角度:在创建对象时需要明确其类型。构造函数的作用是提供初始化,在对象生命期只执行一次,不是对象的动态行为,也没有太大的必要成为虚函数
  3. 为什么C++不支持内联成员函数为虚函数?

    其实很简单,那内联函数就是为了在代码中直接展开,减少函数调用花费的代价,虚函数是为了在继承后对象能够准确的执行自己的动作,这是不可能统一的。(再说了,inline函数在编译时被展开,虚函数在运行时才能动态的绑定函数

    内联函数是在编译时期展开, 而虚函数的特性是运行时才动态联编, 所以两者矛盾, 不能定义内联函数为虚函数

  4. 为什么C++不支持静态成员函数为虚函数?

    这也很简单,静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,他也没有要动态绑定的必要性。

    静态成员函数属于一个类而非某一对象, 没有this指针, 它无法进行对象的判别

  5. 为什么C++不支持友元函数为虚函数?

    因为C++不支持友元函数的继承,对于没有继承特性的函数没有虚函数的说法。

C++ 中类模板和模板类的区别

  1. 类模板是模板的定义,不是一个实实在在的类,定义中用到通用类型参数

  2. 模板类是实实在在的类定义,是类模板的实例化。类定义中参数被实际类型所代替。

解析

  1. 类模板的类型参数可以有一个或多个,每个类型前面都必须加class,如template <class T1,class T2>class someclass{…};在定义对象时分别代入实际的类型名,如 someclass<int,double> obj;

  2. 和使用类一样,使用类模板时要注意其作用域,只能在其有效作用域内用它定义对象。

  3. 模板可以有层次,一个类模板可以作为基类,派生出派生模板类。

虚函数表里存放的内容是什么时候写进去的?

vptr本身是要在构造时才初始化的,但是编译时,vptr就被编译器放入了类的内部。vptr编译期就已经成为类的成员了(虽然看不见,但实际上是会占用内存的)。vptr要在构造时才被初始化。

在编译阶段,虚指针就已经存在在类当中了,不管实例化与否。实例化调用构造函数时,这个指针才有了具体的指向(指向类的虚表)。

一:虚函数表指针(vptr)初始化时机
vptr跟着对象走,所以对象什么时候创建出来,vptr就什么时候初始化出来,也就是运行的时候。
当程序在编译期间,编译器会为构造函数中增加为vptr赋值的代码(这是编译器的行为),当程序在运行时,遇到创建对象的代码,执行对象的构造函数,那么这个构造函数里有为这个对象的vptr赋值的语句。

二:虚函数表创建时机

虚函数表创建时机是在编译期间。编译期间编译器就为每个类确定好了对应的虚函数表里的内容。
所以在程序运行时,编译器会把虚函数表的首地址赋值给虚函数表指针,所以,这个虚函数表指针就有值了。

  1. 虚函数表是一个存储虚函数地址的数组, 以NULL结尾。虚表(vtable)在编译阶段生成,对象内存空间开辟以后,写入对象中的 vptr,然后调用构造函数。即:虚表在构造函数之前写入
  2. 除了在构造函数之前写入之外,我们还需要考虑到虚表的二次写入机制,通过此机制让每个对象的虚表指针都能准确的指向到自己类的虚表,为实现动多态提供支持。

1. 每个类都有虚指针和虚表;

2. 如果不是虚继承,那么子类将父类的虚指针继承下来,并指向自身的虚表(发生在对象构造时)。有多少个虚函数,虚表里面的项就会有多少。多重继承时,可能存在多个的基类虚表与虚指针;

3. 如果是虚继承,那么子类会有两份虚指针,一份指向自己的虚表,另一份指向虚基表,多重继承时虚基表与虚基表指针有且只有一份。

类相关

什么是虚函数?什么是纯虚函数?

虚函数:被 virtual 关键字修饰的成员函数,就是虚函数。

纯虚函数

  • 纯虚函数在类中声明时,加上 = 0
  • 含有纯虚函数的类称为抽象类(只要含有纯虚函数这个类就是抽象类),类中只有接口,没有具体的实现方法;
  • 继承纯虚函数的派生类,如果没有完全实现基类纯虚函数,依然是抽象类,不能实例化对象。

说明:

  • 抽象类对象不能作为函数的参数,不能创建对象,不能作为函数返回类型;
  • 可以声明抽象类指针,可以声明抽象类的引用;
  • 子类必须继承父类的纯虚函数,并全部实现后,才能创建子类的对象。

虚函数和纯虚函数的区别?

  • 虚函数和纯虚函数可以出现在同一个类中,该类称为抽象基类。(含有纯虚函数的类称为抽象基类)
  • 使用方式不同:虚函数可以直接使用,纯虚函数必须在派生类中实现后才能使用;
  • 定义形式不同:虚函数在定义时在普通函数的基础上加上 virtual 关键字,纯虚函数定义时除了加上virtual 关键字还需要加上 = 0;
  • 虚函数必须实现,否则编译器会报错;
  • 对于实现纯虚函数的派生类,该纯虚函数在派生类中被称为虚函数,虚函数和纯虚函数都可以在派生类中重写;
  • 析构函数最好定义为虚函数,特别是对于含有继承关系的类;析构函数可以定义为纯虚函数,此时,其所在的类为抽象基类,不能创建实例化对象。

被继承的基类的析构函数应该定义为虚函数(或者,更准确的说,被多态使用的基类的析构函数应该定义为虚函数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Base {
public:
int baseData;
~Base() {}
};

class Derived : public Base {
public:
int derivedData;
};

int main() {
Base* b = new Derived();
delete b; // 由于 Base 的析构函数不是虚函数,
// 这里调用的是 Base 的析构函数,
// 而非 Derived 的析构函数,
// 因此,Derived 里的 derivedData 没有被释放,
// 因此,内存泄漏。
}
  • 可以看到,上面的代码会造成内存泄漏。
    那么,是不是把所有的析构函数都定义成虚函数就行了呢?
    很可惜,不是。
    虽然这样做可以避免内存泄露,但会有其他的问题,下面予以说明。
  • 如果一个类没有被继承,以后也不会被继承,那么,它的析构函数不应该是虚函数。
    因为虚函数是由额外开销的,需要一个指针,vptr,来实现虚函数。
    一个指针占8个字节(在32位机上是4个字节),对于一些比较“小”,且使用频繁的类,这是个不小的开销。(遗憾的是,“小”且使用频繁的类经常出现,比如表示坐标轴上点的Point类。)
  • 最佳实践:
    如果一个类被其他类所继承,将其析构函数声明为虚函数。
    如果一个类(现在和将来)不会被其他类继承,构造函数则不应该声明为虚函数。

虚函数的实现机制

实现机制:虚函数通过虚函数表来实现。虚函数的地址保存在虚函数表中,在类的对象所在的内存空间中,保存了指向虚函数表的指针(称为“虚表指针”),通过虚表指针可以找到类对应的虚函数表。虚函数表解决了基类和派生类的继承问题和类中成员函数的覆盖问题,当用基类的指针来操作一个派生类的时候,这张虚函数表就指明了实际应该调用的函数。

虚函数表相关知识点:

  • 虚函数表存放的内容:类的虚函数的地址。
  • 虚函数表建立的时间:编译阶段,即程序的编译过程中会将虚函数的地址放在虚函数表中。
  • 虚表指针保存的位置:虚表指针存放在对象的内存空间中最前面的位置,这是为了保证正确取到虚函数的偏移量。

注:虚函数表和类绑定,虚表指针和对象绑定。即类的不同的对象的虚函数表是一样的,但是每个对象都有自己的虚表指针,来指向类的虚函数表。

实例:

无虚函数覆盖的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
using namespace std;

class Base {
public:
virtual void B_fun1() { cout << "Base::B_fun1()" << endl; }
virtual void B_fun2() { cout << "Base::B_fun2()" << endl; }
virtual void B_fun3() { cout << "Base::B_fun3()" << endl; }
};

class Derive : public Base {
public:
virtual void D_fun1() { cout << "Derive::D_fun1()" << endl; }
virtual void D_fun2() { cout << "Derive::D_fun2()" << endl; }
virtual void D_fun3() { cout << "Derive::D_fun3()" << endl; }
};
int main() {
Base *p = new Derive();
p->B_fun1(); // Base::B_fun1()
return 0;
}

基类和派生类的继承关系:

image.png

基类的虚函数表:

image.png

派生类的虚函数表:

image.png

主函数中基类的指针 p 指向了派生类的对象,当调用函数 B_fun1() 时,通过派生类的虚函数表找到该函数的地址,从而完成调用。

单继承和多继承的虚函数表结构

编译器处理虚函数表:

  • 编译器将虚函数表的指针放在类的实例对象的内存空间中,该对象调用该类的虚函数时,通过指针找到虚函数表,根据虚函数表中存放的虚函数的地址找到对应的虚函数。
  • 如果派生类没有重新定义基类的虚函数 A,则派生类的虚函数表中保存的是基类的虚函数 A 的地址,也就是说基类和派生类的虚函数 A 的地址是一样的。
  • 如果派生类重写了基类的某个虚函数 B,则派生的虚函数表中保存的是重写后的虚函数 B 的地址,也就是说虚函数 B 有两个版本,分别存放在基类和派生类的虚函数表中。
  • 如果派生类重新定义了新的虚函数 C,派生类的虚函数表保存新的虚函数 C 的地址。

两个父类的多继承(其他以此类推)总结:

  • 两个虚函数指针分别指两个虚函数表。每个虚函数表保存每个父类的虚函数地址。
  • 内存布局与继承的父类的顺序有关,子类的虚函数插入到第一个虚指针所指的虚函数表中。
  • 特别关注子类的虚析构函数。第二个虚指针调用虚析构函数时,会跳转到第一个虚函数表调用子类虚析构函数。
  • 子类的虚函数表中虚函数的顺序与父类一样,若子类重写父类虚函数,即在虚函数表中原位置覆盖即可。
  1. 单继承无虚函数覆盖的情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
using namespace std;

class Base {
public:
virtual void B_fun1() { cout << "Base::B_fun1()" << endl; }
virtual void B_fun2() { cout << "Base::B_fun2()" << endl; }
virtual void B_fun3() { cout << "Base::B_fun3()" << endl; }
};

class Derive : public Base {
public:
virtual void D_fun1() { cout << "Derive::D_fun1()" << endl; }
virtual void D_fun2() { cout << "Derive::D_fun2()" << endl; }
virtual void D_fun3() { cout << "Derive::D_fun3()" << endl; }
};
int main() {
Base *p = new Derive();
p->B_fun1(); // Base::B_fun1()
return 0;
}

基类和派生类的继承关系:

image.png

基类的虚函数表:

image.png

派生类的虚函数表:

image.png
  1. 单继承有虚函数覆盖的情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
using namespace std;

class Base {
public:
virtual void fun1() { cout << "Base::fun1()" << endl; }
virtual void B_fun2() { cout << "Base::B_fun2()" << endl; }
virtual void B_fun3() { cout << "Base::B_fun3()" << endl; }
};

class Derive : public Base {
public:
virtual void fun1() { cout << "Derive::fun1()" << endl; }
virtual void D_fun2() { cout << "Derive::D_fun2()" << endl; }
virtual void D_fun3() { cout << "Derive::D_fun3()" << endl; }
};
int main() {
Base *p = new Derive();
p->fun1(); // Derive::fun1()
return 0;
}

派生类的虚函数表:

image.png
  1. 多继承无虚函数覆盖的情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
using namespace std;

class Base1 {
public:
virtual void B1_fun1() { cout << "Base1::B1_fun1()" << endl; }
virtual void B1_fun2() { cout << "Base1::B1_fun2()" << endl; }
virtual void B1_fun3() { cout << "Base1::B1_fun3()" << endl; }
};
class Base2 {
public:
virtual void B2_fun1() { cout << "Base2::B2_fun1()" << endl; }
virtual void B2_fun2() { cout << "Base2::B2_fun2()" << endl; }
virtual void B2_fun3() { cout << "Base2::B2_fun3()" << endl; }
};
class Base3 {
public:
virtual void B3_fun1() { cout << "Base3::B3_fun1()" << endl; }
virtual void B3_fun2() { cout << "Base3::B3_fun2()" << endl; }
virtual void B3_fun3() { cout << "Base3::B3_fun3()" << endl; }
};

class Derive : public Base1, public Base2, public Base3 {
public:
virtual void D_fun1() { cout << "Derive::D_fun1()" << endl; }
virtual void D_fun2() { cout << "Derive::D_fun2()" << endl; }
virtual void D_fun3() { cout << "Derive::D_fun3()" << endl; }
};

int main() {
Base1 *p = new Derive();
p->B1_fun1(); // Base1::B1_fun1()
return 0;
}

基类和派生类的关系:

image.png

派生类的虚函数表:(基类的顺序和声明的顺序一致)

image.png
  1. 多继承有虚函数覆盖的情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>
using namespace std;

class Base1 {
public:
virtual void fun1() { cout << "Base1::fun1()" << endl; }
virtual void B1_fun2() { cout << "Base1::B1_fun2()" << endl; }
virtual void B1_fun3() { cout << "Base1::B1_fun3()" << endl; }
};
class Base2 {
public:
virtual void fun1() { cout << "Base2::fun1()" << endl; }
virtual void B2_fun2() { cout << "Base2::B2_fun2()" << endl; }
virtual void B2_fun3() { cout << "Base2::B2_fun3()" << endl; }
};
class Base3 {
public:
virtual void fun1() { cout << "Base3::fun1()" << endl; }
virtual void B3_fun2() { cout << "Base3::B3_fun2()" << endl; }
virtual void B3_fun3() { cout << "Base3::B3_fun3()" << endl; }
};

class Derive : public Base1, public Base2, public Base3 {
public:
virtual void fun1() { cout << "Derive::fun1()" << endl; }
virtual void D_fun2() { cout << "Derive::D_fun2()" << endl; }
virtual void D_fun3() { cout << "Derive::D_fun3()" << endl; }
};

int main() {
Base1 *p1 = new Derive();
Base2 *p2 = new Derive();
Base3 *p3 = new Derive();
p1->fun1(); // Derive::fun1()
p2->fun1(); // Derive::fun1()
p3->fun1(); // Derive::fun1()
return 0;
}

基类和派生类的关系:

image.png

派生类的虚函数表:

image.png

如何禁止构造函数的使用?

为类的构造函数增加 = delete 修饰符,可以达到虽然声明了构造函数但禁止使用的目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
using namespace std;

class A {
public:
int var1, var2;
A() {
var1 = 10;
var2 = 20;
}
A(int tmp1, int tmp2) = delete;
};

int main() {
A ex1;
A ex2(12,13); // error: use of deleted function 'A::A(int, int)'
// 说明:上述代码中,使用了已经删除 `delete` 的构造函数,程序出现错误。
return 0;
}

用途举例:

  • 当类中含有不能默认初始化的成员变量时,可以禁止默认构造函数的生成

    1
    2
    myClass() = delete;//表示删除默认构造函数
    myClass() = default;//表示默认存在构造函数
  • 当类中含有不能默认拷贝成员变量时,可以禁止默认构造函数的生成

    1
    2
    myClass(const myClass&) = delete;//表示删除默认拷贝构造函数,即不能进行默认拷贝
    myClass & operator=(const myClass&) = delete;//表示删除赋值操作符,即不能进行赋值操作
  • 比如c++11的新特性 unique_ptr ,只能使用移动构造,不能使用拷贝构造,为的是节省空间。

构造函数、析构函数是否需要定义成虚函数?为什么?

构造函数一般不定义为虚函数,原因:

  • 从存储空间的角度考虑:构造函数是在实例化对象的时候进行调用,如果此时将构造函数定义成虚函数,需要通过访问该对象所在的内存空间才能进行虚函数的调用(因为需要通过指向虚函数表的指针调用虚函数表,虽然虚函数表在编译时就有了,但是没有虚函数的指针,虚函数的指针只有在创建了对象才有),但是此时该对象还未创建,便无法进行虚函数的调用。所以构造函数不能定义成虚函数。
  • 从使用的角度考虑:虚函数是基类的指针指向派生类的对象时,通过该指针实现对派生类的虚函数的调用,构造函数是在创建对象时自动调用的。
  • 从实现上考虑:虚表指针是在创建对象之后才有的,因此构造函数不能定义成虚函数。创建对象需要调用构造函数,此时构造函数如果是虚函数,而虚函数的调用需要通过虚函数指针寻址才能调用,悖论;
  • 从类型上考虑:在创建对象时需要明确其类型。

析构函数一般定义成虚函数,原因:

  • 析构函数定义成虚函数是为了防止内存泄漏,因为当基类的指针或者引用指向或绑定到派生类的对象时,如果未将基类的析构函数定义成虚函数,会调用基类的析构函数,那么只能将基类的成员所占的空间释放掉,派生类中特有的就会无法释放内存空间导致内存泄漏。

如何避免拷贝?

  1. private
    最直观的想法是:将类的拷贝构造函数和拷贝赋值运算符声明为私有 private,但对于类的成员函数和友元函数依然可以调用,达不到完全禁止类的对象被拷贝的目的,而且程序会出现错误,因为未对函数进行定义。

    解决方法:声明一个基类,具体做法如下。

    • 定义一个基类,将其中的拷贝构造函数和拷贝赋值运算符声明为私有 private

    • 派生类以私有 private 的方式继承基类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Uncopyable {
    public:
    Uncopyable() {}
    ~Uncopyable() {}
    private:
    Uncopyable(const Uncopyable &); // 拷贝构造函数
    Uncopyable &operator=(const Uncopyable &); // 赋值构造函数
    };
    class A : private Uncopyable {
    };

    简单解释:

    • 能够保证,在派生类 A 的成员函数和友元函数中无法进行拷贝操作,因为无法调用基类 Uncopyable 的拷贝构造函数或拷贝赋值运算符。同样,在类的外部也无法进行拷贝操作。
    • 不管 public 继承和 private 继承派生类都不能直接访问 (注:派生类采用 public 继承后,基类的私有成员派生类是不可访问的,派生类只能访问基类的公有成员和保护成员。所以跟private继承一样派生类无法直接访问基类的拷贝构造函数和拷贝赋值运算符。) 拷贝构造函数和拷贝赋值运算符。
    • 但其实不管那种继承方式都可以通过在派生类成员函数中调用基类的public成员函数间接访问。因此上述做法只能避免直接拷贝,仍然无法避免间接拷贝。
  2. C++ 11 可以使用弃置函数 delete 关键字

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class noncopyable {
    protected:
    noncopyable() = default;
    ~noncopyable() = default;
    public:
    noncopyable(const noncopyable&) = delete;
    noncopyable& operator=(const noncopyable&) = delete;
    };

    class foo : private noncopyable {
    };

    关于继承应该用 private 还是 public 的争论
    虽然public继承也可以达到要求,但最好还是用private继承

    原因见《Effective C++》书中以下条款:
    条款06:若不想使用编译器自动生成的函数,就该明确拒绝
    条款32:确定你的public继承塑造出is-a关系
    条款39:明智而审慎地使用private继承

如何减少构造函数开销?

在构造函数中使用类初始化列表,会减少调用默认的构造函数产生的开销

因为对于非内置类型,少了调用一次默认构造函数和一次赋值构造函数的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <iostream>
using namespace std;
class A {
private:
int val;

public:
A() { cout << "A()" << endl; }
A(int tmp) {
val = tmp;
cout << "A(int " << val << ")" << endl;
}
A& operator=(const A& a) {
if (this == &a) {
return *this;
}
this->val = a.val;
cout << "A& operator= (const& A) " << endl;
return *this;
}
};

class Test1 {
private:
A ex;

public:
Test1()
: ex(1) // 成员列表初始化方式
{}
};

class Test2 {
private:
A ex;

public:
Test2() // 函数体中赋值的方式
{
ex = A(2);
}
};
int main() {
Test1 ex1;
cout << endl;
Test2 ex2;
return 0;
}

//运行结果

/*
A(int 1)

A()
A(int 2)
A& operator= (const& A)
*/

多重继承时会出现什么状况?如何解决?

多重继承(多继承):是指从多个直接基类中产生派生类。

多重继承容易出现的问题:命名冲突和数据冗余问题菱形继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include <iostream>
using namespace std;

// 间接基类
class Base1 {
public:
int var1;
};

// 直接基类
class Base2 : public Base1 {
public:
int var2;
};

// 直接基类
class Base3 : public Base1 {
public:
int var3;
};

// 错误用法派生类
class Derive : public Base2, public Base3 {
public:
void set_var1(int tmp) { var1 = tmp; } // error: reference to 'var1' is ambiguous. 命名冲突
void set_var1(int tmp) { Base2::var1 = tmp; } // 正确,这里声明成员变量来源于类 Base2
void set_var2(int tmp) { var2 = tmp; }
void set_var3(int tmp) { var3 = tmp; }
void set_var4(int tmp) { var4 = tmp; }

private:
int var4;
};

// 解决方法 1: 声明出现冲突的成员变量来源于哪个类
class Derive : public Base2, public Base3 {
public:
void set_var1(int tmp) { Base2::var1 = tmp; } // 正确,这里声明成员变量来源于类 Base2
void set_var2(int tmp) { var2 = tmp; }
void set_var3(int tmp) { var3 = tmp; }
void set_var4(int tmp) { var4 = tmp; }

private:
int var4;
};

// 解决方法 2: 虚继承

class Base1 { // 间接基类,即虚基类

public:
int var1;
};

// 直接基类
class Base2 : virtual public Base1 { // 虚继承
public:
int var2;
};

// 直接基类
class Base3 : virtual public Base1 { // 虚继承
public:
int var3;
};

// 派生类
class Derive : public Base2, public Base3 {
public:
void set_var1(int tmp) { var1 = tmp; }
void set_var2(int tmp) { var2 = tmp; }
void set_var3(int tmp) { var3 = tmp; }
void set_var4(int tmp) { var4 = tmp; }

private:
int var4;
};

上述程序的继承关系如下:(菱形继承)

image.png

上述代码中存的问题:
对于派生类 Derive 上述代码中存在直接继承关系和间接继承关系。

  • 直接继承:Base2Base3
  • 间接继承:Base1

对于派生类中继承的的成员变量 var1 ,从继承关系来看,实际上保存了两份,一份是来自基类 Base2,一份来自基类 Base3。因此,出现了命名冲突。

解决方法 1: 声明出现冲突的成员变量来源于哪个类

解决方法 2: 虚继承

使用虚继承的目的:保证存在命名冲突的成员变量在派生类中只保留一份,即让间接基类中的成员在派生类中只保留一份。解决二义性问题。在菱形继承关系中,间接基类称为虚基类,直接基类和间接基类之间的继承关系称为虚继承。

实现方式:在继承方式前面加上 virtual 关键字。

类之间的继承关系:

image.png

空类占多少字节?C++ 编译器会给一个空类自动生成哪些函数?

  1. 空类声明:空类声明时编译器不会生成任何成员函数:
    对于空类,声明时编译器不会生成任何的成员函数,只会生成 1 个字节的占位符

    1
    2
    3
    4
    5
    6
    7
    8
    9
    #include <iostream>
    using namespace std;

    class A {};

    int main() {
    cout << "sizeof(A):" << sizeof(A) << endl; // sizeof(A):1
    return 0;
    }
  2. 空类定义对象后:空类定义时编译器会生成 6 个成员函数:
    当空类 A 定义对象时,sizeof(A) 仍是为 1,但编译器会生成 6 个成员函数:缺省的构造函数、拷贝构造函数、拷贝赋值运算符、析构函数、两个取址运算符。
    “只有当一个类没有定义任何自己的版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。”

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    #include <iostream>
    using namespace std;
    /*
    class A
    {}; 该空类的等价写法如下:
    */
    class A {
    public:
    A(){}; // 缺省构造函数
    A(const A &tmp){}; // 拷贝构造函数
    ~A(){}; // 析构函数
    A &operator=(const A &tmp){}; // 赋值运算符
    A *operator&() { return this; }; // 取址运算符
    const A *operator&() const { return this; }; // 取址运算符(const 版本)
    };

    int main() {
    A *p = new A();
    cout << "sizeof(A):" << sizeof(A) << endl; // sizeof(A):1
    delete p;
    return 0;
    }

为什么拷贝构造函数必须为引用?

原因:避免拷贝构造函数无限制的递归,最终导致栈溢出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
using namespace std;

class A {
private:
int val;
public:
A(int tmp) : val(tmp) { // 带参数构造函数
cout << "A(int tmp)" << endl;
}

A(const A &tmp) { // 拷贝构造函数
cout << "A(const A &tmp)" << endl;
val = tmp.val;
}

A &operator=(const A &tmp) { // 赋值函数(赋值运算符重载)
cout << "A &operator=(const A &tmp)" << endl;
val = tmp.val;
return *this;
}

void fun(A tmp) {}
};

int main() {
A ex1(1); // A(int tmp)
A ex2(2); // A(int tmp)
A ex3 = ex1; // A(const A &tmp) // ex3 还没有实例化,因此调用的是拷贝构造函数,构造出 ex3,而不是赋值函数
ex2 = ex1; // A &operator=(const A &tmp) // 对象 ex2 已经实例化了,不需要构造,此时只是将 ex1 赋值给 ex2,只会调用赋值函数
ex2.fun(ex1); // A(const A &tmp)
return 0;
}
  • 说明 1:ex2 = ex1;A ex3 = ex1; 为什么调用的函数不一样?
    对象 ex2 已经实例化了,不需要构造,此时只是将 ex1 赋值给 ex2,只会调用赋值函数;但是 ex3 还没有实例化,因此调用的是拷贝构造函数,构造出 ex3,而不是赋值函数,这里涉及到构造函数的隐式调用。
  • 说明 2:如果拷贝构造函数中形参不是引用类型,A ex3 = ex1;会出现什么问题?
    构造 ex3,实质上是 ex3.A(ex1);,假如拷贝构造函数参数不是引用类型,那么将使得 ex3.A(ex1); 相当于 ex1 作为函数 A(const A tmp)的形参 temp 的实参,在参数传递时相当于 A tmp = ex1,因为 tmp 没有被初始化,所以在 A tmp = ex1 中继续调用拷贝构造函数,接下来的是构造 tmp,也就是 tmp.A(ex1) ,必然又会有 ex1 作为函数 A(const A tmp); 的形参 temp 的实参,在参数传递时相当于即 A tmp = ex1,那么又会触发拷贝构造函数,就这下永远的递归下去。
  • 说明 3:为什么 ex2.fun(ex1); 会调用拷贝构造函数?
    ex1 作为参数传递给 fun 函数, 即 A tmp = ex1;,这个过程会调用拷贝构造函数进行初始化。

C++ 类对象的初始化顺序

构造函数调用顺序:

  • 按照派生类继承基类的顺序,即派生列表中声明的顺序,依次调用基类的构造函数;

  • 按照派生类中成员变量的声名顺序,依次调用派生类中成员变量所属类的构造函数;

  • 执行派生类自身的构造函数。

综上可以得出,类对象的初始化顺序:基类构造函数–>派生类成员变量的构造函数–>自身构造函数
注:

  • 基类构造函数的调用顺序与派生类的派生列表中的顺序有关;
  • 成员变量的初始化顺序与声明顺序有关;
  • 析构顺序和构造顺序相反。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
using namespace std;

class A {
public:
A() { cout << "A()" << endl; }
~A() { cout << "~A()" << endl; }
};

class B {
public:
B() { cout << "B()" << endl; }
~B() { cout << "~B()" << endl; }
};

class Test : public A,
public B // 派生列表
{
public:
Test() { cout << "Test()" << endl; }
~Test() { cout << "~Test()" << endl; }

private:
B ex1;
A ex2;
};

int main() {
Test ex;
return 0;
}
/*
运行结果:
A()
B()
B()
A()
Test()
~Test()
~A()
~B()
~B()
~A()
*/

程序运行结果分析:

  • 首先调用基类 A 和 B 的构造函数,按照派生列表 public A, public B 的顺序构造;
  • 然后调用派生类 Test 的成员变量 ex1 和 ex2 的构造函数,按照派生类中成员变量声明的顺序构造;
  • 最后调用派生类的构造函数;
  • 接下来调用析构函数,和构造函数调用的顺序相反。

如何禁止一个类被实例化

  1. 方法一:在类中定义一个纯虚函数使该类成为抽象基类,因为不能创建抽象基类的实例化对象;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    #include <iostream>
    using namespace std;

    class A {
    public:
    int var1, var2;
    A() {
    var1 = 10;
    var2 = 20;
    }
    virtual void fun() = 0; // 纯虚函数
    };

    int main() {
    A ex1; // error: cannot declare variable 'ex1' to be of abstract type 'A'
    return 0;
    }
  2. 方法二:禁止使用构造函数,即将类的构造函数声明为私有 private 或者使用 delete

    注意:类的构造函数声明为私有可以禁止直接实例化,不能禁止间接实例化。

为什么用成员初始化列表会快一些?

初始化列表先于构造函数体内的代码执行,初始化列表执行的是数据成员的初始化过程

说明:数据类型可分为内置类型和用户自定义类型(类类型),对于用户自定义类型,利用成员初始化列表效率高。

原因用户自定义类型如果使用类初始化列表,直接调用该成员变量对应的构造函数即完成初始化

  • 如果在构造函数中初始化,因为 C++ 规定,对象的成员变量的初始化动作发生在进入构造函数本体之前,那么在执行构造函数的函数体之前首先调用默认的构造函数为成员变量设初值,在进入函数体之后,调用该成员变量对应的构造函数。
  • 因此,使用列表初始化会减少一次调用默认的构造函数以及一次调用拷贝赋值运算符的过程,效率高。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <iostream>
using namespace std;
class A {
private:
int val;

public:
A() { cout << "A()" << endl; }
A(int tmp) {
val = tmp;
cout << "A(int " << val << ")" << endl;
}
A& operator=(const A& a) {
if (this == &a) {
return *this;
}
this->val = a.val;
cout << "A& operator= (const& A) " << endl;
return *this;
}
};

class Test1 {
private:
A ex;

public:
Test1()
: ex(1) // 成员列表初始化方式
{}
};

class Test2 {
private:
A ex;

public:
// 在进入构造函数体之前,调用默认构造函数初始化ex
Test2() { // 函数体中赋值的方式
ex = A(2); // 生成临时对象后,调用拷贝赋值运算符
}
};
int main() {
Test1 ex1;
cout << endl;
Test2 ex2;
return 0;
}

//运行结果

/*
A(int 1)

A()
A(int 2)
A& operator= (const& A)
*/

说明:
从程序运行结果可以看出,使用成员列表初始化的方式会省去一次调用默认的构造函数的过程以及一次调用拷贝赋值运算符的过程。

初始化数据成员与对数据成员赋值的含义是什么?有什么区别?

首先把数据成员按类型分类并分情况说明:

  1. 内置数据类型,复合类型(指针,引用)- 在成员初始化列表和构造函数体内进行,在性能和结果上都是一样的
  2. 用户定义类型(类类型)- 结果上相同,但是性能上存在很大的差别。因为类类型的数据成员对象在进入函数体前已经构造完成,也就是说在成员初始化列表处进行构造对象的工作,调用构造函数,在进入函数体之后,进行的是对已经构造好的类对象的赋值,又调用个拷贝赋值操作符才能完成(如果并未提供,则使用编译器提供的默认按成员赋值行为)

实例化一个对象需要哪几个阶段

  1. 分配空间

    创建类对象首先要为该对象分配内存空间。不同的对象,为其分配空间的时机未必相同。全局对象、静态对象、分配在栈区域内的对象,在编译阶段进行内存分配;存储在堆空间的对象,是在运行阶段进行内存分配。

    在C++中,类对象的建立方式有两种:

    • 静态建立类对象,如A a;

      是指全局对象,静态对象,以及分配在栈区域内的对象,编译器对它们的内存分配是在编译阶段就完成的,是通过直接移动栈顶指针,挪出适当的空间,然后在这片内存空间上调用构造函数形成一个栈对象。使用这种方法,直接调用类的构造函数。

    • 动态建立类对象,如A* p = new A;

      分配堆区域内的对象,编译器对他们的内存分配是在运行时动态分配的,(使用new运算符将对象建立在堆空间中。这个过程分为两步,第一步,执行operator new()函数,在对中搜索合适的内存并进行分配,第二步,调用构造函数构造对象,初始化这片内存空间。使用这种方法,间接调用类的构造函数。

  2. 初始化

    首先明确一点:初始化不同于赋值。初始化发生在赋值之前,初始化随对象的创建而进行,而赋值是在对象创建好后,为其赋上相应的值。这一点可以联想下上一个问题中提到:初始化列表先于构造函数体内的代码执行,初始化列表执行的是数据成员的初始化过程,这个可以从成员对象的构造函数被调用看的出来。

  3. 赋值

    对象初始化完成后,可以对其进行赋值。对于一个类的对象,其成员变量的赋值过程发生在类的构造函数的函数体中。当执行完该函数体,也就意味着类对象的实例化过程完成了。(总结:构造函数实现了对象的初始化和赋值两个过程,对象的初始化是通过初始化列表来完成,而对象的赋值则才是通过构造函数的函数体来实现。)

注:对于拥有虚函数的类的对象,还需要给虚表指针赋值

  • 没有继承关系的类,分配完内存后,首先给虚表指针赋值,然后再列表初始化以及执行构造函数的函数体,即上述中的初始化和赋值操作。
  • 有继承关系的类,分配内存之后,首先进行基类的构造过程,然后给该派生类的虚表指针赋值,最后再列表初始化以及执行构造函数的函数体,即上述中的初始化和赋值操作。

友元函数的作用及使用场景

作用:

  • 友元提供了不同类的成员函数之间、类的成员函数与一般函数之间进行数据共享的机制。
  • 通过友元,一个不同函数或另一个类中的成员函数可以访问类中的私有成员和保护成员

使用场景:

  1. 普通函数定义为友元函数,使普通函数能够访问类的私有成员。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    #include <iostream>
    using namespace std;

    class A {
    friend ostream &operator<<(ostream &_cout, const A &tmp); // 声明为类的友元函数

    public:
    A(int tmp) : var(tmp) {}

    private:
    int var;
    };

    ostream &operator<<(ostream &_cout, const A &tmp) {
    _cout << tmp.var;
    return _cout;
    }

    int main() {
    A ex(4);
    cout << ex << endl; // 4
    return 0;
    }
  2. 友元类:类之间共享数据。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    #include <iostream>
    using namespace std;

    class A {
    friend class B;

    public:
    A() : var(10) {}
    A(int tmp) : var(tmp) {}
    void fun() { cout << "fun():" << var << endl; }

    private:
    int var;
    };

    class B {
    public:
    B() {}
    void fun() {
    cout << "fun():" << ex.var << endl; // 访问类 A 中的私有成员
    }

    private:
    A ex;
    };

    int main() {
    B ex;
    ex.fun(); // fun():10
    return 0;
    }

静态绑定和动态绑定

静态类型和动态类型:

  • 静态类型:变量在声明时的类型,是在编译阶段确定的。静态类型不能更改。
  • 动态类型:目前所指对象的类型,是在运行阶段确定的。动态类型可以更改。

静态绑定和动态绑定:

  • 静态绑定是指程序在 编译阶段 确定对象的类型(静态类型)。
  • 动态绑定是指程序在 运行阶段 确定对象的类型(动态类型)。

静态绑定和动态绑定的区别:

  • 发生的时期不同:如上。
  • 对象的静态类型不能更改,动态类型可以更改。

注:对于类的成员函数,只有虚函数是动态绑定,其他都是静态绑定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

using namespace std;

class Base {
public:
virtual void fun() { cout << "Base::fun()" << endl; }
};
class Derive : public Base {
public:
void fun() { cout << "Derive::fun()"; }
};

int main() {
Base *p = new Derive(); // p 的静态类型是 Base*,动态类型是 Derive*
p->fun(); // fun 是虚函数,运行阶段进行动态绑定
return 0;
}
/*
运行结果:
Derive::fun()
*/

深拷贝和浅拷贝的区别

如果一个类拥有资源,该类的对象进行复制时,如果资源重新分配,就是深拷贝,否则就是浅拷贝。

  • 深拷贝:该对象和原对象占用不同的内存空间,既拷贝存储在栈空间中的内容,又拷贝存储在堆空间中的内容。
  • 浅拷贝:该对象和原对象占用同一块内存空间,仅拷贝类中位于栈空间中的内容。

当类的成员变量中有指针变量时,最好使用深拷贝。因为当两个对象指向同一块内存空间,如果使用浅拷贝,当其中一个对象的删除后,该块内存空间就会被释放,另外一个对象指向的就是垃圾内存。

浅拷贝实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
using namespace std;

class Test {
private:
int *p;

public:
Test(int tmp) {
this->p = new int(tmp);
cout << "Test(int tmp)" << endl;
}
~Test() {
if (p != NULL) {
delete p;
}
cout << "~Test()" << endl;
}
};

int main() {
Test ex1(10);
// Test ex2 = ex1; *** Error in `./a.out': double free or corruption (fasttop): 0x0000000000d21c20 ***
return 0;
}
/*
运行结果:
Test(int tmp)
~Test()
*/

说明:上述代码中,类对象 ex1、ex2 中的指针 p 实际上是指向同一块内存空间,对象析构时,ex2 先将内存释放了一次,之后析构对象 ex1 时又将这块已经被释放过的内存再释放一次。对同一块内存空间释放了两次,会导致程序崩溃。

深拷贝实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
using namespace std;

class Test {
private:
int *p;

public:
Test(int tmp) {
p = new int(tmp);
cout << "Test(int tmp)" << endl;
}
~Test() {
if (p != NULL) {
delete p;
}
cout << "~Test()" << endl;
}
Test(const Test &tmp) { // 定义拷贝构造函数
p = new int(*tmp.p);
cout << "Test(const Test &tmp)" << endl;
}
};

int main() {
Test ex1(10);
Test ex2 = ex1;
return 0;
}
/*
Test(int tmp)
Test(const Test &tmp)
~Test()
~Test()
*/

编译时多态和运行时多态的区别

  • 编译时多态:在程序编译过程中出现,发生在模板和函数重载中(泛型编程)。
  • 运行时多态:在程序运行过程中出现,发生在继承体系中,是指通过基类的指针或引用访问派生类中的虚函数。

编译时多态和运行时多态的区别:

  • 时期不同:编译时多态发生在程序编译过程中,运行时多态发生在程序的运行过程中;
  • 实现方式不同:编译时多态运用泛型编程来实现,运行时多态借助虚函数来实现。

实现一个类成员函数,要求不允许修改类的成员变量?

如果想达到一个类的成员函数不能修改类的成员变量,只需用 const 关键字来修饰该函数即可。

该问题本质是考察 const 关键字修饰成员函数的作用,以实例的方式来考察,应熟练掌握 const 关键字的作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
using namespace std;

class A {
public:
int var1, var2;
A() {
var1 = 10;
var2 = 20;
}
void fun() const { // 不能在 const 修饰的成员函数中修改成员变量的值,除非该成员变量用 mutable 修饰
var1 = 100; // error: assignment of member 'A::var1' in read-only object
}
};

int main() {
A ex1;
return 0;
}

如何让类不能被继承(final)?

  • 借助 final 关键字,用该关键字修饰的类不能被继承
1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
using namespace std;

class Base final {};

class Derive : public Base {}; // error: cannot derive from 'final' base 'Base' in derived type 'Derive'

int main() {
Derive ex;
return 0;
}
  • 借助友元、虚继承和私有构造函数来实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
using namespace std;

template <typename T>
class Base {
friend T;

private:
Base() { cout << "base" << endl; }
~Base() {}
};

class B : virtual public Base<B> { //一定注意 必须是虚继承
public:
B() { cout << "B" << endl; }
};

class C : public B {
public:
C() {
} // error: 'Base<T>::Base() [with T = B]' is private within this context
};

int main() {
B b;
return 0;
}

说明:在上述代码中 B 类是不能被继承的类。
具体原因:

  • 虽然 Base 类构造函数和析构函数被声明为私有 private,在 B 类中,由于 BBase 的友元,因此可以访问 Base 类构造函数,从而正常创建 B 类的对象;
  • B 类继承 Base 类采用虚继承的方式,创建 C 类的对象时,C 类的构造函数要负责 Base 类的构造,但是 Base 类的构造函数私有化了,C 类没有权限访问。因此,无法创建 C 类的对象, B 类是不能被继承的类。

注意:在继承体系中,友元关系不能被继承,虽然 C 类继承了 B 类,B 类是 Base 类的友元,但是 C 类和 Base 类没有友元关系。

这里采用虚继承的原因是,直接由最低层次的派生类构造函数初始化虚基类。这是因为在菱形继承中,可能会存在对虚基类的多次初始化问题,为了避免出现该问题,在采用虚继承的时候,直接由最低层次的派生类构造函数直接负责虚基类类的构造。如果不加virtual的话,在构造函数的顺序中,每个类只负责自己的直接基类的初始化,所以还是可以生成对象的。加上了virtual之后,C直接负责Base类的构造,但是Base类的构造函数和析构函数都是private,C无法访问,所以不能生成对象。

STL 相关

STL 的基本组成部分

标准模板库(Standard Template Library, 简称STL)简单说,就是一些常用数据结构和算法的模板的集合。

广义上讲,STL分为3类:Algorithm(算法)、Container(容器)和Iterator(迭代器),容器和算法通过迭代器可以进行无缝地连接。

详细的说,STL由6部分组成:容器(Container)、算法(Algorithm)、 迭代器(Iterator)、仿函数(Function object)、适配器(Adaptor)、空间配制器(Allocator)。

解析

标准模板库STL主要由6大组成部分:

  1. 容器(Container)

    是一种数据结构, 如list, vector, 和deques,以模板类的方法提供。为了访问容器中的数据,可以使用由容器类输出的迭代器。

  2. 算法(Algorithm)

    是用来操作容器中的数据的模板函数。例如,STL 用 sort() 来对一个 vector 中的数据进行排序,用 find() 来搜索一个 list 中的对象, 函数本身与他们操作的数据的结构和类型无关,因此他们可以用于从简单数组到高度复杂容器的任何数据结构上。

  3. 迭代器(Iterator)

    提供了访问容器中对象的方法。例如,可以使用一对迭代器指定 list 或 vector 中的一定范围的对象。 迭代器就如同一个指针。事实上,C++ 的指针也是一种迭代器。 但是,迭代器也可以是那些定义了 operator*() 以及其他类似于指针的操作符方法的类对象;

  4. 仿函数(Function object)

    仿函数又称之为函数对象, 其实就是重载了操作符的 struct, 没有什么特别的地方。

  5. 适配器(Adaptor)

    简单的说就是一种接口类,专门用来修改现有类的接口,提供一中新的接口;或调用现有的函数来实现所需要的功能。主要包括 3 中适配器 Container Adaptor、Iterator Adaptor、Function Adaptor。

  6. 空间配制器(Allocator)

    为 STL 提供空间配置的系统。其中主要工作包括两部分:

    (1)对象的创建与销毁;

    (2)内存的获取与释放。

STL 中常见的容器,并介绍一下实现原理

容器可以用于存放各种类型的数据(基本类型的变量,对象等)的数据结构,都是模板类,分为顺序容器、关联式容器、容器适配器三种类型,三种类型容器特性分别如下:

  1. 顺序容器

    容器并非排序的,元素的插入位置同元素的值无关。包含vector、deque、list,具体实现原理如下:

    • vector 头文件
      动态数组。元素在内存连续存放。随机存取任何元素都能在常数时间完成。在尾端增删元素具有较佳的性能。

    • deque 头文件
      双向队列。元素在内存连续存放。随机存取任何元素都能在常数时间完成(仅次于vector)。在两端增删元素具有较佳的性能(大部分情况下是常数时间)。

    • list 头文件
      双向链表。元素在内存不连续存放。在任何位置增删元素都能在常数时间完成。不支持随机存取。

  2. 关联式容器

    元素是排序的;插入任何元素,都按相应的排序规则来确定其位置;在查找时具有非常好的性能;通常以平衡二叉树的方式实现。包含set、multiset、map、multimap,具体实现原理如下:

    • set/multiset 头文件

      set 即集合。set 中不允许相同元素,multiset 中允许存在相同元素。

    • map/multimap 头文件

      map 与 set 的不同在于 map 中存放的元素有且仅有两个成员变量,一个名为 first ,另一个名为 second , map 根据 first 值对元素从小到大排序,并可快速地根据 first 来检索元素。

    **注意:**map 同 multimap 的不同在于是否允许相同 first 值的元素。

  3. 容器适配器

    封装了一些基本的容器,使之具备了新的函数功能,比如把 deque 封装一下变为一个具有 stack 功能的数据结构。这新得到的数据结构就叫适配器。包含 stack , queue , priority_queue,具体实现原理如下:

    • stack 头文件

      • 栈是项的有限序列,并满足序列中被删除、检索和修改的项只能是最进插入序列的项(栈顶的项)。后进先出。
      • 默认用 deque 实现,也可以用 vector、list 实现
    • queue 头文件

      • 队列。插入只可以在尾部进行,删除只允许从头部进行。先进先出。
      • 默认用 deque 实现,也可以用 vector、list 实现
    • priority_queue 头文件

      • 优先级队列。内部维持某种有序,然后确保优先级最高的元素总是位于头部。最高优先级元素总是第一个出列。
      • 默认用 vector 实现,也可以用 deque 实现

STL 中 map hashtable deque list 的实现原理

map、hashtable、deque、list实现机理分别为红黑树、函数映射、双向队列、双向链表,他们的特性分别如下:

  1. map 实现原理

    map 内部实现了一个红黑树(红黑树是非严格平衡的二叉搜索树,而 AVL 是严格平衡二叉搜索树),红黑树有自动排序的功能,因此map内部所有元素都是有序的,红黑树的每一个节点都代表着 map 的一个元素。因此,对于 map 进行的查找、删除、添加等一系列的操作都相当于是对红黑树进行的操作。map 中的元素是按照二叉树(又名二叉查找树、二叉排序树)存储的,特点就是左子树上所有节点的键值都小于根节点的键值,右子树所有节点的键值都大于根节点的键值。使用中序遍历可将键值按照从小到大遍历出来。

  2. hashtable(也称散列表,直译作哈希表)实现原理

    hashtable 采用了函数映射的思想记录的存储位置与记录的关键字关联起来,从而能够很快速地进行查找。这决定了哈希表特殊的数据结构,它同数组、链表以及二叉排序树等相比较有很明显的区别,它能够快速定位到想要查找的记录,而不是与表中存在的记录的关键字进行比较来进行查找。

  3. deque实现原理

    deque内部实现的是一个双向队列。元素在内存连续存放。随机存取任何元素都在常数时间完成(仅次于vector)。所有适用于vector的操作都适用于deque。在两端增删元素具有较佳的性能(大部分情况下是常数时间)。

  4. list实现原理

    list内部实现的是一个双向链表。元素在内存不连续存放。在任何位置增删元素都能在常数时间完成。不支持随机存取。无成员函数,给定一个下标i,访问第i个元素的内容,只能从头部挨个遍历到第i个元素。

STL 的空间配置器(allocator)

一般情况下,一个程序包括数据结构和相应的算法,而数据结构作为存储数据的组织形式,与内存空间有着密切的联系。在 C++ STL 中,空间配置器便是用来实现内存空间(一般是内存,也可以是硬盘等空间)分配的工具,他与容器联系紧密,每一种容器的空间分配都是通过空间分配器 alloctor 实现的。

解析

  1. 两种 C++ 类对象实例化方式的异同

    在c++中,创建类对象一般分为两种方式:

    • 一种是直接利用构造函数, 直接构造类对象,如 Test test();
    • 另一种是通过 new 来实例化一个类对象,如 Test *pTest = new Test;那么,这两种方式有什么异同点呢?

    我们知道,内存分配主要有三种方式:

    • 静态存储区分配:内存在程序编译的时候已经分配好,这块内存在程序的整个运行空间内都存在。如全局变量, 静态变量等。
    • 栈空间分配:程序在运行期间,函数内的局部变量通过栈空间来分配存储(函数调用栈),当函数执行完毕返回时,相对应的栈空间被立即回收。主要是局部变量。
    • 堆空间分配:程序在运行期间,通过在堆空间上为数据分配存储空间,通过 malloc 和 new 创建的对象都是从堆空间分配内存,这类空间需要程序员自己来管理,必须通过 free() 或者是 delete() 函数对堆空间进行释放,否则会造成内存溢出。

    那么,从内存空间分配的角度来对这两种方式的区别,就比较容易区分:

    • 对于第一种方式来说,是直接通过调用 Test 类的构造函数来实例化 Test 类对象的, 如果该实例化对象是一个局部变量,则其是在栈空间分配相应的存储空间。
    • 对于第二种方式来说, 就显得比较复杂。这里主要以 new 类对象来说明一下。new 一个类对象, 其实是执行了两步操作:首先, 调用 new 在堆空间分配内存, 然后调用类的构造函数构造对象的内容;同样,使用 delete 释放时,也是经历了两个步骤:首先调用类的析构函数释放类对象,然后调用 delete 释放堆空间
  2. C++ STL 空间配置器实现

    很容易想象,为了实现空间配置器,完全可以利用 new 和 delete 函数并对其进行封装实现 STL 的空间配置器,的确可以这样。但是,为了最大化提升效率,SGI STL版本并没有简单的这样做,而是采取了一定的措施,实现了更加高效复杂的空间分配策略。由于以上的构造都分为两部分,所以,在SGI STL中,将对象的构造切分开来,分成空间配置和对象构造两部分。

    • 内存配置操作: 通过alloc::allocate()实现
    • 内存释放操作: 通过alloc::deallocate()实现
    • 对象构造操作: 通过::construct()实现
    • 对象释放操作: 通过::destroy()实现

    关于内存空间的配置与释放,SGI STL采用了两级配置器:一级配置器主要是考虑大块内存空间,利用 malloc 和 free实现;二级配置器主要是考虑小块内存空间而设计的(为了最大化解决内存碎片问题,进而提升效率),采用链表 free_list 来维护内存池(memory pool),free_list 通过 union 结构实现,空闲的内存块互相挂接在一块,内存块一旦被使用,则被从链表中剔除,易于维护。

STL 容器用过哪些,查找的时间复杂度是多少,为什么?

STL中常用的容器有vector、deque、list、map、set、multimap、multiset、unordered_map、unordered_set等。容器底层实现方式及时间复杂度分别如下:

  1. vector

    采用一维数组实现,元素在内存连续存放,不同操作的时间复杂度为:

    • 随机访问——常数 𝓞(1)
    • 在末尾插入或移除元素——均摊常数 𝓞(1)
    • 插入或移除元素——与到 vector 结尾的距离成线性 𝓞(n)
  2. deque

    采用双向队列实现,元素在内存连续存放,不同操作的时间复杂度为:

    • 随机访问——常数 O(1)
    • 在结尾或起始插入或移除元素——常数 O(1)
    • 插入或移除元素——线性 O(n)
  3. list

    采用双向链表实现,元素存放在堆中,不同操作的时间复杂度为:

    插入: O(1)

    查看: O(N)

    删除: O(1)

  4. map、set、multimap、multiset

    上述四种容器采用红黑树实现,红黑树是平衡二叉树的一种。不同操作的时间复杂度近似为:

    插入: O(logN)

    查看: O(logN)

    删除: O(logN)

  5. unordered_map、unordered_set、unordered_multimap、 unordered_multiset

    上述四种容器采用哈希表实现,不同操作的时间复杂度为:
    插入: O(1),最坏情况O(N)

    查看: O(1),最坏情况O(N)

    删除: O(1),最坏情况O(N)

    **注意:**容器的时间复杂度取决于其底层实现方式。

迭代器?什么时候会失效?

常用容器迭代器失效情形如下。

  1. 对于序列容器 vector,deque 来说,使用 erase 后,后边的每个元素的迭代器都会失效,后边每个元素都往前移动一位,erase 返回下一个有效的迭代器。

  2. 对于关联容器 map,set 来说,使用了 erase 后,当前元素的迭代器失效,但是其结构是红黑树,删除当前元素,不会影响下一个元素的迭代器,所以在调用 erase 之前,记录下一个元素的迭代器即可。

  3. 对于 list 来说,它使用了不连续分配的内存,并且它的 erase 方法也会返回下一个有效的迭代器,因此上面两种方法都可以使用。

STL中迭代器的作用,有指针为何还要迭代器?

  1. 迭代器的作用

    (1)用于指向顺序容器和关联容器中的元素

    (2)通过迭代器可以读取它指向的元素

    (3)通过非 const 迭代器还可以修改其指向的元素

  2. 迭代器和指针的区别

    **迭代器不是指针,是类模板,表现的像指针。**他只是模拟了指针的一些功能,重载了指针的一些操作符,–>、++、–等。迭代器封装了指针,是一个 “可遍历STL( Standard Template Library)容器内全部或部分元素” 的对象,本质是封装了原生指针,是指针概念的一种提升,提供了比指针更高级的行为,相当于一种智能指针,他可以根据不同类型的数据结构来实现不同的++,–等操作。

    迭代器返回的是对象引用而不是对象的值,所以 cout 只能输出迭代器使用取值后的值而不能直接输出其自身。

  3. 迭代器产生的原因

    Iterator 类的访问方式就是把不同集合类的访问逻辑抽象出来,使得不用暴露集合内部的结构而达到循环遍历集合的效果

解析

  1. 迭代器

    Iterator(迭代器)模式又称游标(Cursor)模式,用于提供一种方法顺序访问一个聚合对象中各个元素, 而又不需暴露该对象的内部表示。 或者这样说可能更容易理解:Iterator 模式是运用于聚合对象的一种模式,通过运用该模式,使得我们可以在不知道对象内部表示的情况下,按照一定顺序(由iterator提供的方法)访问聚合对象中的各个元素。 由于 Iterator 模式的以上特性:与聚合对象耦合,在一定程度上限制了它的广泛运用,一般仅用于底层聚合支持类,如 STL 的 list、vector、stack 等容器类及 ostream_iterator 等扩展Iterator。

  2. 迭代器示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <vector>
#include <iostream>
using namespace std;

int main() {
vector<int> v; //一个存放int元素的数组,一开始里面没有元素
v.push_back(1);
v.push_back(2);
v.push_back(3);
v.push_back(4);
vector<int>::const_iterator i; //常量迭代器
for (i = v.begin(); i != v.end(); ++i) //v.begin()表示v第一个元素迭代器指针,++i指向下一个元素
cout << *i << ","; //*i表示迭代器指向的元素
cout << endl;

vector<int>::reverse_iterator r; //反向迭代器
for (r = v.rbegin(); r != v.rend(); r++)
cout << *r << ",";
cout << endl;
vector<int>::iterator j; //非常量迭代器
for (j = v.begin();j != v.end();j++)
*j = 100;
for (i = v.begin();i != v.end();i++)
cout << *i << ",";
return 0;
}

/* 运行结果:
1,2,3,4,
4,3,2,1,
100,100,100,100,
*/

STL 迭代器是怎么删除元素的

这是主要考察迭代器失效的问题。

  1. 对于序列容器 vector,deque 来说,使用 erase 后,后边的每个元素的迭代器都会失效,后边每个元素都往前移动一位,erase 返回下一个有效的迭代器;

  2. 对于关联容器 map,set 来说,使用了 erase 后,当前元素的迭代器失效,但是其结构是红黑树,删除当前元素,不会影响下一个元素的迭代器,所以在调用 erase 之前,记录下一个元素的迭代器即可;

  3. 对于 list 来说,它使用了不连续分配的内存,并且它的 erase 方法也会返回下一个有效的迭代器,因此上面两种方法都可以使用。

解析

容器上迭代器分类如下表(详细实现过程请翻阅相关资料详细了解):

容器容器上的迭代器类别
vector随机访问
deque随机访问
list双向
set/multiset双向
map/multimap双向
stack不支持迭代器
queue不支持迭代器
priority_queue不支持迭代器

STL 中 resize 和 reserve 的区别

  1. 首先必须弄清楚两个概念:

    • capacity:该值在容器初始化时赋值,指的是容器能够容纳的最大的元素的个数。不能通过下标等访问,因为此时容器中还没有创建任何对象。
    • size:指的是此时容器中实际的元素个数。可以通过下标访问 0 ~ (size - 1)范围内的对象。
  2. resize 和 reserve 区别主要有以下几点:

    • resize 既分配了空间,也创建了对象;reserve 表示容器预留空间,但并不是真正的创建对象,需要通过 insert() 或 push_back() 等创建对象。
    • resize 既修改 capacity 大小,也修改 size 大小;reserve 只修改 capacity 大小,不修改 size 大小。
    • 两者的形参个数不一样。 resize 带两个参数,一个表示容器大小,一个表示初始值(默认为0);reserve 只带一个参数,表示容器预留的大小。

问题延伸:

resize 和 reserve 既有差别,也有共同点。两个接口的共同点是**它们都保证了 vector 的空间大小 (capacity) 最少达到它的参数所指定的大小。**下面就他们的细节进行分析。

为实现 resize 的语义,resize 接口做了两个保证:

(1)保证区间 [0, new_size) 范围内数据有效,如果下标 index 在此区间内,vector[indext] 是合法的;

(2)保证区间 [0, new_size) 范围以外数据无效,如果下标 index 在区间外,vector[indext] 是非法的。

reserve 只是保证 vector 的空间大小 (capacity) 最少达到它的参数所指定的大小 n。在区间 [0, n) 范围内,如果下标是 index,vector[index] 这种访问有可能是合法的,也有可能是非法的,视具体情况而定。

image-20220316201849161 image-20220316201756078

以下是两个接口的源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
void resize(size_type new_size) { 
resize(new_size, T());
}

void resize(size_type new_size, const T& x) {
if (new_size < size())
erase(begin() + new_size, end()); // erase区间范围以外的数据,确保区间以外的数据无效
else
insert(end(), new_size - size(), x); // 填补区间范围内空缺的数据,确保区间内的数据有效
}


#include<iostream>
#include<vector>
using namespace std;
int main()
{
vector<int> a;
cout<<"initial capacity:"<<a.capacity()<<endl;
cout<<"initial size:"<<a.size()<<endl;

/*resize改变capacity和size*/
a.resize(20);
cout<<"resize capacity:"<<a.capacity()<<endl;
cout<<"resize size:"<<a.size()<<endl;


vector<int> b;
/*reserve改变capacity,不改变size*/
b.reserve(100);
cout<<"reserve capacity:"<<b.capacity()<<endl;
cout<<"reserve size:"<<b.size()<<endl;
return 0;
}

/* 运行结果:
initial capacity:0
initial size:0
resize capacity:20
resize size:20
reserve capacity:100
reserve size:0
*/

**注意:**如果 n 大于当前的 vector 的容量(是容量,并非 vector 的 size),将会引起自动内存分配。所以现有的 pointer, references, iterators 将会失效。而内存的重新配置会很耗时间。

STL 容器动态链接可能产生的问题?

  1. 可能产生的问题

    容器是一种动态分配内存空间的一个变量集合类型变量。在一般的程序函数里,局部容器,参数传递容器,参数传递容器的引用,参数传递容器指针都是可以正常运行的,而在动态链接库函数内部使用容器也是没有问题的,但是给动态库函数传递容器的对象本身,则会出现内存堆栈破坏的问题。

  2. 产生问题的原因

    容器和动态链接库相互支持不够好,动态链接库函数中使用容器时,参数中只能传递容器的引用,并且要保证容器的大小不能超出初始大小,否则导致容器自动重新分配,就会出现内存堆栈破坏问题。

map 和 unordered_map 的区别?底层实现

map和unordered_map的区别在于他们的实现基理不同

  1. map实现机理

    map内部实现了一个红黑树(红黑树是非严格平衡的二叉搜索树,而AVL是严格平衡二叉搜索树),红黑树有自动排序的功能,因此 map 内部所有元素都是有序的,红黑树的每一个节点都代表着 map 的一个元素。因此,对于 map 进行的查找、删除、添加等一系列的操作都相当于是对红黑树进行的操作。map 中的元素是按照二叉树(又名二叉查找树、二叉排序树)存储的,特点就是左子树上所有节点的键值都小于根节点的键值,右子树所有节点的键值都大于根节点的键值。使用中序遍历可将键值按照从小到大遍历出来。

  2. unordered_map实现机理

    unordered_map内部实现了一个哈希表(也叫散列表),通过把关键码值映射到 Hash 表中一个位置来访问记录,查找时间复杂度可达 O(1),其中在海量数据处理中有着广泛应用。因此,元素的排列顺序是无序的。

vector 和 list 的区别,分别适用于什么场景?

vector和list区别在于底层实现机理不同,因而特性和适用场景也有所不同。

vector:一维数组

  • 特点:元素在内存连续存放,动态数组,在中分配内存,元素连续存放,有保留内存,如果减少大小后内存也不会释放。
  • 优点:和数组类似开辟一段连续的空间,并且支持随机访问,所以它的查找效率高其时间复杂度O(1)。
  • 缺点:由于开辟一段连续的空间,所以插入删除会需要对数据进行移动比较麻烦,时间复杂度O(n),另外当空间不足时还需要进行扩容。
  • 删除某个元素以后,该元素后边的每个元素的迭代器都会失效,后边每个元素都往前移动一位,erase返回下一个有效的迭代器。
  • 对vector的任何操作,一旦引起空间重新配置,指向原vector的所有迭代器就都失效了

list:双向链表

  • 特点:元素在中存放,每个元素都是存放在一块内存中,它的内存空间可以是不连续的,通过指针来进行数据的访问。
  • 优点:底层实现是双向链表,当对大量数据进行插入删除时,其时间复杂度O(1)。
  • 缺点:底层没有连续的空间,只能通过指针来访问,所以查找数据需要遍历其时间复杂度O(n),没有提供[]操作符的重载。
  • 在 list 内或在数个 list 间添加、移除和移动元素不会非法化迭代器或引用。迭代器仅在对应元素被删除时非法化。

应用场景

vector拥有一段连续的内存空间,因此支持随机访问,如果需要高效的随即访问,而不在乎插入和删除的效率,使用vector。

list拥有一段不连续的内存空间,如果需要高效的插入和删除,而不关心随机访问,则应使用list。

vector 的实现原理

vector底层实现原理为一维数组(元素在空间连续存放)。

  1. 新增元素

    Vector通过一个连续的数组存放元素,如果集合已满,在新增数据的时候,就要分配一块更大的内存,将原来的数据复制过来,释放之前的内存,再插入新增的元素。插入新的数据分在最后插入push_back和通过迭代器在任何位置插入,这里说一下通过迭代器插入,通过迭代器与第一个元素的距离知道要插入的位置,即 int index = iter-begin()。这个元素后面的所有元素都向后移动一个位置,在空出来的位置上存入新增的元素。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    //新增元素
    void insert(const_iterator iter, const T& t) {
    int index = iter - begin();
    if (index < size_) {
    if (size_ == capacity_) {
    int capa = calculateCapacity();
    newCapacity(capa);
    }
    memmove(buf + index + 1, buf + index, (size_ - index) * sizeof(T));
    buf[index] = t;
    size_++;
    }
    }
  2. 删除元素

    删除和新增差不多,也分两种,删除最后一个元素pop_back和通过迭代器删除任意一个元素erase(iter)。通过迭代器删除还是先找到要删除元素的位置,即int index = iter-begin();这个位置后面的每个元素都想前移动一个元素的位置。同时我们知道erase不释放内存只初始化成默认值。

    删除全部元素 clear:只是循环调用了erase,所以删除全部元素的时候,不释放内存。内存是在析构函数中释放的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //删除元素
    iterator erase(const_iterator iter) {
    int index = iter - begin();
    if (index < size_ && size_ > 0) {
    memmove(buf + index, buf + index + 1, (size_ - index) * sizeof(T));
    buf[--size_] = T();
    }
    return iterator(iter);
    }
  3. 迭代器iteraotr

    迭代器iteraotr是STL的一个重要组成部分,通过iterator可以很方便的存储集合中的元素.STL为每个集合都写了一个迭代器, 迭代器其实是对一个指针的包装,实现一些常用的方法,如++,–,!=,==,*,->等, 通过这些方法可以找到当前元素或是别的元素. vector是STL集合中比较特殊的一个,因为vector中的每个元素都是连续的,所以在自己实现vector的时候可以用指针代替。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //迭代器的实现
    template <class _Category, class _Ty, class _Diff = ptrdiff_t,
    class _Pointer = _Ty *,
    class _Reference = _Ty &>
    struct iterator { // base type for all iterator classes
    typedef _Category iterator_category;
    typedef _Ty value_type;
    typedef _Diff difference_type;
    typedef _Diff distance_type; // retained
    typedef _Pointer pointer;
    typedef _Reference reference;
    };

STL 中的 map 的实现原理

map是关联式容器,它们的底层容器都是红黑树。map 的所有元素都是 pair,同时拥有实值(value)和键值(key)。pair 的第一元素被视为键值,第二元素被视为实值。所有元素都会根据元素的键值自动被排序。不允许键值重复。

  1. map的特性如下

    (1)map以RBTree作为底层容器;

    (2)所有元素都是键+值存在;

    (3)不允许键重复;

    (4)所有元素是通过键进行自动排序的;

    (5)map的键是不能修改的,但是其键对应的值是可以修改的。

C++ 的 vector 和 list中,如果删除末尾的元素,其指针和迭代器如何变化?若删除的是中间的元素呢?

  1. 迭代器和指针之间的区别

    **迭代器不是指针,是类模板,表现的像指针。**他只是模拟了指针的一些功能,重载了指针的一些操作符,–>、++、–等。迭代器封装了指针,是一个”可遍历STL( Standard Template Library)容器内全部或部分元素”的对象,本质是封装了原生指针,是指针概念的一种提升,提供了比指针更高级的行为,相当于一种智能指针,他可以根据不同类型的数据结构来实现不同的++,–等操作。

    迭代器返回的是对象引用而不是对象的值,所以cout只能输出迭代器使用取值后的值而不能直接输出其自身。

  2. vector和list特性

    vector特性 动态数组。元素在内存连续存放。随机存取任何元素都在常数时间完成。在尾端增删元素具有较大的性能(大部分情况下是常数时间)。

    list特性 双向链表。元素在内存不连续存放。在任何位置增删元素都能在常数时间完成。不支持随机存取。

  3. vector增删元素

    对于vector而言,删除某个元素以后,该元素后边的每个元素的迭代器都会失效,后边每个元素都往前移动一位,erase返回下一个有效的迭代器。

  4. list增删元素

    对于list而言,删除某个元素,只有“指向被删除元素”的那个迭代器失效,其它迭代器不受任何影响。

map 和 set 有什么区别,分别又是怎么实现的?

  1. set是一种关联式容器,其特性如下:

    (1)set以RBTree作为底层容器

    (2)所得元素的只有key没有value,value就是key

    (3)不允许出现键值重复

    (4)所有的元素都会被自动排序

    (5)不能通过迭代器来改变set的值,因为set的值就是键,set的迭代器是const的

  2. map和set一样是关联式容器,其特性如下:

    (1)map以RBTree作为底层容器

    (2)所有元素都是键+值存在

    (3)不允许键重复

    (4)所有元素是通过键进行自动排序的

    (5)map的键是不能修改的,但是其键对应的值是可以修改的

    综上所述,map和set底层实现都是红黑树;map和set的区别在于map的值不作为键,键和值是分开的。

push_back 和 emplace_back 的区别

如果要将一个临时变量 push 到容器的末尾

  • push_back() 需要先构造临时对象,再将这个对象拷贝到容器的末尾
  • emplace_back() 则直接在容器的末尾构造对象,这样就省去了拷贝的过程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <cstring>
#include <iostream>
#include <vector>
using namespace std;

class A {
public:
A(int i) {
str = to_string(i);
cout << "构造函数" << endl;
}
~A() {}
A(const A& other) : str(other.str) { cout << "拷贝构造" << endl; }

public:
string str;
};

int main() {
vector<A> vec;
vec.reserve(10);
for (int i = 0; i < 10; i++) {
vec.push_back(A(i)); //调用了10次构造函数和10次拷贝构造函数,
// vec.emplace_back(i);
// //调用了10次构造函数一次拷贝构造函数都没有调用过
}

STL 中 vector 与 list 具体是怎么实现的?常见操作的时间复杂度是多少?

  1. vector 一维数组(元素在内存连续存放)

    是动态数组,在堆中分配内存,元素连续存放,有保留内存,如果减少大小后,内存也不会释放;如果新增大小后小于当前大小时才会重新分配内存。

    扩容方式:

    a. 倍放开辟 2 倍的内存

    b. 旧的数据开辟到新的内存

    c. 释放旧的内存

    d. 指向新内存

  2. list 双向链表(元素存放在堆中)

    元素存放在堆中,每个元素都是放在一块内存中,它的内存空间可以是不连续的,通过指针来进行数据的访问,这个特点,使得它的随机存取变得非常没有效率,因此它没有提供 [] 操作符的重载。但是由于链表的特点,它可以很有效的支持任意地方的删除和插入操作。

    特点:

    a. 随机访问不方便

    b. 删除插入操作方便

  3. 常见时间复杂度

    (1)vector插入、查找、删除时间复杂度分别为:O(n)、O(1)、O(n);

    (2)list插入、查找、删除时间复杂度分别为:O(1)、O(n)、O(1)。

C++ 新特性

C++11 的新特性有哪些

C++新特性主要包括包含语法改进和标准库扩充两个方面,主要包括以下11点:

  1. 语法的改进

    (1)初始化列表,可以用于任何类型对象的初始化

    (2)成员变量默认初始化

    (3)auto关键字用于定义变量,编译器可以自动判断的类型(前提:定义一个变量时对其进行初始化)

    (4)decltype 求表达式的类型

    (5)智能指针 shared_ptr

    (6)空指针 nullptr(原来NULL)

    (7)基于范围的 for 循环

    (8)右值引用和move语义:让程序员有意识减少进行深拷贝操作

    • delete 函数:= delete 表示该函数不能被调用。
    • default 函数:= default 表示编译器生成默认的函数,例如:生成默认的构造函数。
    • final:final 用于修饰一个类,表示禁止该类进一步派生和虚函数的进一步重写
    • override:用于修饰派生类中的成员函数,标明该函数重写了基类函数,如果一个函数声明了 override 但父类却没有这个虚函数,编译报错,使用 override 关键字可以避免开发者在重写基类函数时无意产生的错误
    • explicit:explicit 专用于修饰构造函数,表示只能显式构造,不可以被隐式转换
  2. 标准库扩充(往STL里新加进一些模板类,比较好用)

    (9)无序容器(哈希表) 用法和功能同map一模一样,区别在于哈希表的效率更高

    (10)正则表达式 可以认为正则表达式实质上是一个字符串,该字符串描述了一种特定模式的字符串

    (11)Lambda表达式

统一的初始化方法

C++98/03 可以使用初始化列表(initializer list)进行初始化:

1
2
3
4
5
6
int i_arr[3] = { 1, 2, 3 };
long l_arr[] = { 1, 3, 2, 4 };
struct A {
int x;
int y;
} a = { 1, 2 };

但是这种初始化方式的适用性非常狭窄,只有上面提到的这两种数据类型可以使用初始化列表。在 C++11 中,初始化列表的适用性被大大增加了。它现在可以用于任何类型对象的初始化,实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Foo
{
public:
Foo(int) {}
private:
Foo(const Foo &);
};
int main(void)
{
Foo a1(123);
Foo a2 = 123; //error: 'Foo::Foo(const Foo &)' is private
Foo a3 = { 123 };
Foo a4 { 123 };
int a5 = { 3 };
int a6 { 3 };
return 0;
}

在上例中,a3、a4 使用了新的初始化方式来初始化对象,效果如同 a1 的直接初始化。a5、a6 则是基本数据类型的列表初始化方式。可以看到,它们的形式都是统一的。这里需要注意的是,a3 虽然使用了等于号,但它仍然是列表初始化,因此,私有的拷贝构造并不会影响到它。a4 和 a6 的写法,是 C++98/03 所不具备的。在 C++11 中,可以直接在变量名后面跟上初始化列表,来进行对象的初始化。

成员变量默认初始化

好处:构建一个类的对象不需要用构造函数初始化成员变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//程序实例
#include<iostream>
using namespace std;
class B {
public:
int m = 1234; //成员变量有一个初始值
int n;
};
int main()
{
B b;
cout << b.m << endl;
return 0;
}

auto关键字

用于定义变量,编译器可以自动判断的类型(前提:定义一个变量时对其进行初始化)。

1
2
3
4
5
6
7
8
9
//程序实例
#include <vector>
using namespace std;
int main(){
vector<vector<int>> v;
vector<vector<int>>::iterator i = v.begin();
auto j = v.begin();
return 0;
}

可以看出来,定义迭代器 i 的时候,类型书写比较冗长,容易出错。然而有了 auto 类型推导,我们大可不必这样,只写一个 auto 即可。

decltype 求表达式的类型

decltype 是 C++11 新增的一个关键字,它和 auto 的功能一样,都用来在编译时期进行自动类型推导。

  • 为什么要有decltype

因为 auto 并不适用于所有的自动类型推导场景,在某些特殊情况下 auto 用起来非常不方便,甚至压根无法使用,所以 decltype 关键字也被引入到 C++11 中。

auto 和 decltype 关键字都可以自动推导出变量的类型,但它们的用法是有区别的:

1
2
auto varname = value;
decltype(exp) varname = value;

其中,varname 表示变量名,value 表示赋给变量的值,exp 表示一个表达式。

auto 根据 “=” 右边的初始值 value 推导出变量的类型,而 decltype 根据 exp 表达式推导出变量的类型,跟"="右边的 value 没有关系。

另外,auto 要求变量必须初始化,而 decltype 不要求。这很容易理解,auto 是根据变量的初始值来推导出变量类型的,如果不初始化,变量的类型也就无法推导了。decltype 可以写成下面的形式:

1
decltype(exp) varname;
1
2
3
4
5
// decltype 用法举例
int a = 0;
decltype(a) b = 1; //b 被推导成了 int
decltype(10.8) x = 5.5; //x 被推导成了 double
decltype(x + 100) y; //y 被推导成了 double

智能指针 shared_ptr

和 unique_ptr、weak_ptr 不同之处在于,多个 shared_ptr 智能指针可以共同使用同一块堆内存。并且,由于该类型智能指针在实现上采用的是引用计数机制,即便有一个 shared_ptr 指针放弃了堆内存的“使用权”(引用计数减 1),也不会影响其他指向同一堆内存的 shared_ptr 指针(只有引用计数为 0 时,堆内存才会被自动释放)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <memory>
using namespace std;
int main() {
//构建 2 个智能指针
std::shared_ptr<int> p1(new int(10));
std::shared_ptr<int> p2(p1);
//输出 p2 指向的数据
cout << *p2 << endl;
p1.reset(); //引用计数减 1,p1为空指针
if (p1) {
cout << "p1 不为空" << endl;
} else {
cout << "p1 为空" << endl;
}
//以上操作,并不会影响 p2
cout << *p2 << endl;
//判断当前和 p2 同指向的智能指针有多少个
cout << p2.use_count() << endl;
return 0;
}

/* 程序运行结果:
10
p1 为空
10
1
*/

空指针 nullptr(原来NULL)

nullptr 是 nullptr_t 类型的右值常量,专用于初始化空类型指针。nullptr_t 是 C++11 新增加的数据类型,可称为“指针空值类型”。也就是说,nullptr 仅是该类型的一个实例对象(已经定义好,可以直接使用),如果需要我们完全定义出多个同 nullptr 完全一样的实例对象。值得一提的是,nullptr 可以被隐式转换成任意的指针类型。例如:

1
2
3
int * a1 = nullptr;
char * a2 = nullptr;
double * a3 = nullptr;

显然,不同类型的指针变量都可以使用 nullptr 来初始化,编译器分别将 nullptr 隐式转换成 int_、char_ 以及 double* 指针类型。另外,通过将指针初始化为 nullptr,可以很好地解决 NULL 遗留的问题,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;
void isnull(void *c) {
cout << "void*c" << endl;
}
void isnull(int n) {
cout << "int n" << endl;
}
int main() {
isnull(NULL); // NULL 指针是一个定义在标准库中的值为零的常量
isnull(nullptr);
return 0;
}
/* 程序运行结果:
int n
void*c
*/

基于范围的for循环

1
2
for (dataType rangeVariable : array)
statement;
1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <string>
using namespace std;
int main ()
{
string planets []= { "Mercury", "Venus", "Earth", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune", "Pluto(a dwarf planet)" };
// Display the values in the array
cout << "Here are the planets: \n ";
for (string val : planets)
cout << val << endl;
return 0;
}

右值引用和move语义

  1. 右值引用

    C++98/03 标准中就有引用,使用 “&” 表示。但此种引用方式有一个缺陷,即正常情况下只能操作 C++ 中的左值,无法对右值添加引用。举个例子:

    1
    2
    3
    int num = 10;
    int &b = num; //正确
    int &c = 10; //错误

    如上所示,编译器允许我们为 num 左值建立一个引用,但不可以为 10 这个右值建立引用。因此,C++98/03 标准中的引用又称为左值引用。

    注意,虽然 C++98/03 标准不支持为右值建立非常量左值引用,但允许使用常量左值引用操作右值。也就是说,常量左值引用既可以操作左值,也可以操作右值,例如:

    1
    2
    3
    int num = 10;
    const int &b = num;
    const int &c = 10;

    我们知道,右值往往是没有名称的,因此要使用它只能借助引用的方式。这就产生一个问题,实际开发中我们可能需要对右值进行修改(实现移动语义时就需要),显然左值引用的方式是行不通的。

    为此,C++11 标准新引入了另一种引用方式,称为右值引用,用 “&&” 表示。

    需要注意的,和声明左值引用一样,右值引用也必须立即进行初始化操作,且只能使用右值进行初始化,比如:

    1
    2
    3
    int num = 10;
    //int && a = num; //右值引用不能初始化为左值
    int && a = 10;

    和常量左值引用不同的是,右值引用还可以对右值进行修改。例如:

    1
    2
    3
    4
    5
    6
    int && a = 10;
    a = 100;
    cout << a << endl;
    /* 程序运行结果:
    100
    */

    另外值得一提的是,C++ 语法上是支持定义常量右值引用的,例如:

    1
    const int&& a = 10;//编译器不会报错

    但这种定义出来的右值引用并无实际用处。一方面,右值引用主要用于移动语义和完美转发,其中前者需要有修改右值的权限;其次,常量右值引用的作用就是引用一个不可修改的右值,这项工作完全可以交给常量左值引用完成。

  2. move语义

    move 本意为 “移动”,但该函数并不能移动任何数据,它的功能很简单,就是将某个左值强制转化为右值。基于 move() 函数特殊的功能,其常用于实现移动语义。move() 函数的用法也很简单,其语法格式如下:

    1
    move( arg ) //其中,arg 表示指定的左值对象。该函数会返回 arg 对象的右值形式。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    //程序实例
    #include <iostream>
    using namespace std;
    class first {
    public:
    first() : num(new int(0)) { cout << "construct!" << endl; }
    //移动构造函数
    first(first &&d) : num(d.num) {
    d.num = NULL;
    cout << "first move construct!" << endl;
    }

    public: //这里应该是 private,使用 public 是为了更方便说明问题
    int *num;
    };
    class second {
    public:
    second() : fir() {}
    //用 first 类的移动构造函数初始化 fir
    second(second &&sec) : fir(move(sec.fir)) {
    cout << "second move construct" << endl;
    }

    public: //这里也应该是 private,使用 public 是为了更方便说明问题
    first fir;
    };
    int main() {
    second oth;
    second oth2 = move(oth);
    // cout << *oth.fir.num << endl; //程序报运行时错误
    return 0;
    }

    /* 程序运行结果:
    construct!
    first move construct!
    second move construct
    */

C++ 右值引用与转移语义

  1. 右值引用

    一般来说,不能取地址的表达式,就是右值引用,能取地址的,就是左值。

1
2
3
class A { };
A & r = A(); //error,A()是无名变量,是右值
A && r = A(); //ok,r是右值引用
  1. 转移语义

    move 本意为 “移动”,但该函数并不能移动任何数据,它的功能很简单,就是将某个左值强制转化为右值。基于 move() 函数特殊的功能,其常用于实现移动语义。

无序容器(哈希表)

用法和功能同map一模一样,区别在于哈希表的效率更高。

(1) 无序容器具有以下 2 个特点:

a. 无序容器内部存储的键值对是无序的,各键值对的存储位置取决于该键值对中的键,

b. 和关联式容器相比,无序容器擅长通过指定键查找对应的值(平均时间复杂度为 O(1));但对于使用迭代器遍历容器中存储的元素,无序容器的执行效率则不如关联式容器。

(2) 和关联式容器一样,无序容器只是一类容器的统称,其包含有 4 个具体容器,分别为 unordered_map、unordered_multimap、unordered_set 以及 unordered_multiset。功能如下表:

无序容器功能
unordered_map存储键值对 <key, value> 类型的元素,其中各个键值对键的值不允许重复,且该容器中存储的键值对是无序的。
unordered_multimap和 unordered_map 唯一的区别在于,该容器允许存储多个键相同的键值对。
unordered_set不再以键值对的形式存储数据,而是直接存储数据元素本身(当然也可以理解为,该容器存储的全部都是键 key 和值 value 相等的键值对,正因为它们相等,因此只存储 value 即可)。另外,该容器存储的元素不能重复,且容器内部存储的元素也是无序的。
unordered_multiset和 unordered_set 唯一的区别在于,该容器允许存储值相同的元素。

正则表达式

可以认为正则表达式实质上是一个字符串,该字符串描述了一种特定模式的字符串。常用符号的意义如下:

符号意义
^匹配行的开头
$匹配行的结尾
.匹配任意单个字符
[…]匹配[]中的任意一个字符
(…)设定分组
\转义字符
\d匹配数字[0-9]
\D\d 取反
\w匹配字母[a-z],数字,下划线
\W\w 取反
\s匹配空格
\S\s 取反
+前面的元素重复1次或多次
*前面的元素重复任意次
?前面的元素重复0次或1次
{n}前面的元素重复n次
{n,}前面的元素重复至少n次
{n,m}前面的元素重复至少n次,至多m次
|逻辑或

Lambda匿名函数

所谓匿名函数,简单地理解就是没有名称的函数,又常被称为 lambda 函数或者 lambda 表达式。

我们可以忽略参数列表和返回类型,但必须永远包含捕获列表和函数体,不能有默认参数

定义

lambda 匿名函数很简单,可以套用如下的语法格式:

1
2
3
[外部变量访问方式说明符] (参数) mutable noexcept/throw() -> 返回值类型  {  
函数体;
};

其中各部分的含义分别为:

  1. [外部变量方位方式说明符]
    [ ] 方括号用于向编译器表明当前是一个 lambda 表达式,其不能被省略。在方括号内部,可以注明当前 lambda 函数的函数体中可以使用哪些“外部变量”。
    所谓外部变量,指的是和当前 lambda 表达式位于同一作用域内的所有局部变量。

  2. (参数)
    和普通函数的定义一样,lambda 匿名函数也可以接收外部传递的多个参数。和普通函数不同的是,如果不需要传递参数,可以连同 () 小括号一起省略;

  3. mutable

    image-20220222163614001

    此关键字可以省略,如果使用则之前的 () 小括号将不能省略(参数个数可以为 0)。默认情况下,对于以值传递方式引入的外部变量,不允许在 lambda 表达式内部修改它们的原来值,修改的是拷贝的值。而如果想修改它们,就必须使用 mutable 关键字。

    1
    2
    3
    4
    5
    6
    7
    8
    int main() {
    int v = 20;
    auto f = [v]() mutable { // 如果不加mutable,会报错:表达式必须是可修改的左值
    return ++v;
    };
    cout << f() << endl; // 21
    cout << v << endl; // 20
    }

    **注意:**对于以值传递方式引入的外部变量,lambda 表达式修改的是拷贝的那一份,并不会修改真正的外部变量;

    image-20220222163418189
  4. noexcept/throw()
    可以省略,如果使用,在之前的 () 小括号将不能省略(参数个数可以为 0)。默认情况下,lambda 函数的函数体中可以抛出任何类型的异常。而标注 noexcept 关键字,则表示函数体内不会抛出任何异常;使用 throw() 可以指定 lambda 函数内部可以抛出的异常类型。

  5. -> 返回值类型
    指明 lambda 匿名函数的返回值类型。值得一提的是,如果 lambda 函数体内只有一个 return 语句,或者该函数返回 void,则编译器可以自行推断出返回值类型,此情况下可以直接省略"-> 返回值类型"。

  6. 函数体
    和普通函数一样,lambda 匿名函数包含的内部代码都放置在函数体中。该函数体内除了可以使用指定传递进来的参数之外,还可以使用指定的外部变量以及全局范围内的所有全局变量。

C++ 中智能指针和指针的区别是什么?

  1. 智能指针

    如果在程序中使用 new 从堆(自由存储区)分配内存,等到不需要时,应使用 delete 将其释放。C++ 引用了智能指针 auto_ptr,以帮助自动完成这个过程。随后的编程体验(尤其是使用STL)表明,需要有更精致的机制。基于程序员的编程体验和BOOST库提供的解决方案,C++11 摒弃了 auto_ptr,并新增了三种智能指针:unique_ptr、shared_ptr 和 weak_ptr 。所有新增的智能指针都能与STL容器和移动语义协同工作。

  2. 指针

    C 语言规定所有变量在使用前必须先定义,指定其类型,并按此分配内存单元。指针变量不同于整型变量和其他类型的变量,它是专门用来存放地址的,所以必须将它定义为“指针类型”。

  3. 智能指针和普通指针的区别

    • 智能指针实际上是对普通指针加了一层封装机制

    • 它负责自动释放所指的对象,这样的一层封装机制的目的是为了使得智能指针可以方便的管理一个对象的生命期

    • 智能指针就是一个类,当超出了类的作用域时,类会自动调用析构函数,析构函数会自动释放资源

C++中的智能指针有哪些?分别解决的问题以及区别?

智能指针是为了解决动态内存分配时带来的内存泄漏以及多次释放同一块内存空间而提出的。C++11 中封装在了 <memory> 头文件中。

C++11 中智能指针包括以下三种:

  • 共享指针(shared_ptr):资源可以被多个指针共享,使用计数机制表明资源被几个指针共享。通过 use_count() 查看资源的所有者的个数,可以通过 unique_ptrweak_ptr 来构造,调用 release() 释放资源的所有权,计数减一,当计数减为 0 时,会自动释放内存空间,从而避免了内存泄漏。
  • 独占指针(unique_ptr):独享所有权的智能指针,资源只能被一个指针占有,该指针不能拷贝构造和赋值。但可以进行移动构造和移动赋值构造(调用 move() 函数),即一个 unique_ptr 对象赋值给另一个 unique_ptr 对象,可以通过该方法进行赋值。
  • 弱指针(weak_ptr):指向 share_ptr 指向的对象,能够解决由shared_ptr带来的循环引用问题。

智能指针的实现原理: 计数原理。

  1. C++中的智能指针有4种,分别为:shared_ptr、unique_ptr、weak_ptr、auto_ptr,其中auto_ptr被C++11弃用。

  2. 使用智能指针的原因

    申请的空间(即new出来的空间),在使用结束时,需要delete掉,否则会形成内存碎片。在程序运行期间,new 出来的对象,在析构函数中 delete 掉,但是这种方法不能解决所有问题,因为有时候 new 发生在某个全局函数里面,该方法会给程序员造成精神负担。此时,智能指针就派上了用场。

    使用智能指针可以很大程度上避免这个问题,因为智能指针就是一个类,当超出了类的作用域时,类会自动调用析构函数,析构函数会自动释放资源

    所以,智能指针的作用原理就是在函数结束时自动释放内存空间,避免了手动释放内存空间。

  3. 四种指针分别解决的问题以及各自特性如下:

    (1)auto_ptr(C++98的方案,C++11已经弃用)

    采用所有权模式。

    1
    2
    3
    auto_ptr<string> p1(new string("I reigned loney as a cloud."));
    auto_ptr<string> p2;
    p2=p1; //auto_ptr不会报错

    此时不会报错,p2剥夺了p1的所有权,但是当程序运行时访问p1将会报错。所以auto_ptr的缺点是:存在潜在的内存崩溃问题

    (2)unique_ptr(替换auto_ptr)

    unique_ptr实现独占式拥有或严格拥有概念,保证同一时间内只有一个智能指针可以指向该对象。它对于避免资源泄露,例如,以new创建对象后因为发生异常而忘记调用delete时的情形特别有用。

    采用所有权模式,和上面例子一样。

    1
    2
    3
    auto_ptr<string> p3(new string("I reigned loney as a cloud."));
    auto_ptr<string> p4;
    p4=p3; //此时不会报错

    编译器认为P4=P3非法,避免了p3不再指向有效数据的问题。因此,unique_ptr 比 auto_ptr 更安全。 另外 unique_ptr 还有更聪明的地方:当程序试图将一个 unique_ptr 赋值给另一个时,如果源 unique_ptr 是个临时右值,编译器允许这么做;如果源 unique_ptr 将存在一段时间,编译器将禁止这么做,比如:

    1
    2
    3
    4
    5
    unique_ptr<string> pu1(new string ("hello world"));
    unique_ptr<string> pu2;
    pu2 = pu1; // #1 not allowed
    unique_ptr<string> pu3;
    pu3 = unique_ptr<string>(new string ("You")); // #2 allowed

    其中#1留下悬挂的 unique_ptr(pu1),这可能导致危害。而#2不会留下悬挂的unique_ptr,因为它调用 unique_ptr 的构造函数,该构造函数创建的临时对象在其所有权让给 pu3 后就会被销毁。这种随情况而已的行为表明,unique_ptr 优于允许两种赋值的auto_ptr 。

    **注意:**如果确实想执行类似与#1的操作,要安全的重用这种指针,可给它赋新值。C++有一个标准库函数std::move(),让你能够将一个 unique_ptr 赋给另一个。例如:

    1
    2
    3
    4
    5
    unique_ptr<string> ps1, ps2;
    ps1 = demo("hello");
    ps2 = move(ps1);
    ps1 = demo("alexia");
    cout << *ps2 << *ps1 << endl;

    (3)shared_ptr(非常好使)

    shared_ptr实现共享式拥有概念。多个智能指针可以指向相同对象,该对象和其相关资源会在“最后一个引用被销毁”时候释放。从名字share就可以看出了资源可以被多个指针共享,它使用计数机制来表明资源被几个指针共享。可以通过成员函数use_count()来查看资源的所有者个数。除了可以通过new来构造,还可以通过传入auto_ptr , unique_ptr , weak_ptr 来构造。当我们调用 release() 时,当前指针会释放资源所有权,计数减一。当计数等于0时,资源会被释放。

    shared_ptr 是为了解决 auto_ptr 在对象所有权上的局限性( auto_ptr 是独占的), 在使用引用计数的机制上提供了可以共享所有权的智能指针。

    成员函数:

    • use_count 返回引用计数的个数

    • unique 返回是否是独占所有权( use_count 为 1)

    • swap 交换两个 shared_ptr 对象(即交换所拥有的对象)

    • reset 放弃内部对象的所有权或拥有对象的变更, 会引起原有对象的引用计数的减少

    • get 返回内部对象(指针), 由于已经重载了()方法, 因此和直接使用对象是一样的.如 shared_ptr sp(new int(1)); sp 与 sp.get()是等价的

    (4)weak_ptr

    weak_ptr 是一种不控制所指向对象生存期的智能指针,它指向由一个 shared_ptr 管理的对象。将一个 weak_ptr 绑定到一个 shared_ptr 不会改变 shared_ptr 的引用计数。 一旦最后一个指向对象的 shared_ptr 被销毁,对象就会被释放。 即使有 weak_ptr 指向对象,对象也还是会被释放。

    image-20220222165322539

    weak_ptr 是一种不控制对象生命周期的智能指针, 它指向一个 shared_ptr 管理的对象。进行该对象的内存管理的是那个强引用的 shared_ptr。weak_ptr只是提供了对管理对象的一个访问手段。weak_ptr 设计的目的是为配合 shared_ptr 而引入的一种智能指针来协助 shared_ptr 工作,它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造, 它的构造和析构不会引起引用记数的增加或减少。weak_ptr 是用来解决 shared_ptr 相互引用时的死锁问题,如果说两个 shared_ptr 相互引用,那么这两个指针的引用计数永远不可能下降为0, 资源永远不会释放。它是对对象的一种弱引用,不会增加对象的引用计数,和 shared_ptr 之间可以相互转化,shared_ptr 可以直接赋值给它,它可以通过调用 lock 函数来获得 shared_ptr。

    由于对象可能不存在,我们不能使用 weak_ptr 直接访问对象,而必须调用 lock 。此函数检查 weak_ptr 指向的对象是否仍存在。如果存在, lock 返回一个指向共享对象的 shared_ptr。与任何其他 shared_ptr 类似,只要此 shared_ptr 存在,它所指向的底层对象也就会一直存在。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    class B;
    class A {
    public:
    shared_ptr<B> pb_;
    ~A() { cout << "A delete\n"; }
    };
    class B {
    public:
    shared_ptr<A> pa_;
    ~B() { cout << "B delete\n"; }
    };
    void fun() {
    shared_ptr<B> pb(new B());
    shared_ptr<A> pa(new A());
    pb->pa_ = pa;
    pa->pb_ = pb;
    cout << pb.use_count() << endl;
    cout << pa.use_count() << endl;
    }
    int main() {
    fun();
    return 0;
    }

    可以看到fun函数中pa ,pb之间互相引用,两个资源的引用计数为2,当要跳出函数时,智能指针pa,pb析构时两个资源引用计数会减一,但是两者引用计数还是为1,导致跳出函数时资源没有被释放(A B的析构函数没有被调用),如果把其中一个改为weak_ptr就可以了,我们把类A里面的shared_ptr pb_; 改为weak_ptr pb; 这样的话,资源B的引用开始就只有1,当pb析构时,B的计数变为0,B得到释放,B释放的同时也会使A的计数减一,同时pa析构时使A的计数减一,那么A的计数为0,A得到释放。

    注意:我们不能通过 weak_ptr 直接访问对象的方法,比如 B 对象中有一个方法 print() , 我们不能这样访问,pa->pb_->print(); 英文pb_是一个weak_ptr,应该先把它转化为shared_ptr,如:shared_ptr p = pa->pb_.lock(); p->print();

简述 C++ 中智能指针的特点

  1. C++中的智能指针有4种,分别为:shared_ptr、unique_ptr、weak_ptr、auto_ptr,其中auto_ptr被C++11弃用。

  2. 为什么要使用智能指针:智能指针的作用是管理一个指针,因为存在申请的空间在函数结束时忘记释放,造成内存泄漏的情况。使用智能指针可以很大程度上避免这个问题,因为智能指针就是一个类,当超出了类的作用域时,类会自动调用析构函数,自动释放资源。

  3. 四种指针各自特性

    (1)auto_ptr

    auto指针存在的问题是,两个智能指针同时指向一块内存,就会两次释放同一块资源,自然报错。

    (2)unique_ptr

    unique指针规定一个智能指针独占一块内存资源。当两个智能指针同时指向一块内存,编译报错。

    实现原理: 将拷贝构造函数和赋值拷贝构造函数申明为 private 或 delete 。不允许拷贝构造函数和赋值操作符,但是支持移动构造函数,通过 std:move 把一个对象指针变成右值之后可以移动给另一个 unique_ptr

    (3)shared_ptr

    共享指针可以实现多个智能指针指向相同对象,该对象和其相关资源会在引用为0时被销毁释放。

    实现原理:有一个引用计数的指针类型变量,专门用于引用计数,使用拷贝构造函数和赋值拷贝构造函数时,引用计数加1,当引用计数为0时,释放资源。

**注意:**weak_ptr、shared_ptr 存在一个问题,当两个shared_ptr指针相互引用时,那么这两个指针的引用计数不会下降为0,资源得不到释放。因此引入weak_ptr,weak_ptr是弱引用,weak_ptr的构造和析构不会引起引用计数的增加或减少

weak_ptr 能不能知道对象计数为 0,为什么?

image-20220222172232898

可以调用函数查看与 weak_ptr 共享对象的 shared_ptr 的数量

weak_ptr 是一种不控制对象生命周期的智能指针,它指向一个shared_ptr管理的对象。进行该对象管理的是那个引用的shared_ptr。weak_ptr 只是提供了对管理对象的一个访问手段。weak_ptr 设计的目的只是为了配合 shared_ptr 而引入的一种智能指针,配合 shared_ptr 工作,它只可以从一个 shared_ptr 或者另一个 weak_ptr 对象构造,它的构造和析构不会引起计数的增加或减少

weak_ptr 如何解决 shared_ptr 的循环引用问题?

为了解决循环引用导致的内存泄漏,引入了弱指针 weak_ptr,weak_ptr 的构造函数不会修改引用计数的值,从而不会对对象的内存进行管理,其类似一个普通指针,但是不会指向引用计数的共享内存,但是可以检测到所管理的对象是否已经被释放,从而避免非法访问。

share_ptr 怎么知道跟它共享对象的指针释放了

多个 shared_ptr 对象可以同时托管一个指针,系统会维护一个引用计数。当无 shared_ptr 托管该指针时,delete 该指针。

shared_ptr 线程安全性,原理

多线程环境下,调用不同 shared_ptr 实例的成员函数是不需要额外的同步手段的,即使这些 shared_ptr 拥有的是同样的对象。

但是如果多线程访问(有写操作)同一个shared_ptr,则需要同步,否则就会有 race condition 发生。也可以使用 shared_ptr overloads of atomic functions 来防止 race condition 的发生。

多个线程同时读同一个 shared_ptr 对象是线程安全的,但是如果是多个线程对同一个 shared_ptr 对象进行读和写,则需要加锁。

多线程读写 shared_ptr 所指向的同一个对象,不管是相同的 shared_ptr 对象,还是不同的 shared_ptr 对象,也需要加锁保护。例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//程序实例
shared_ptr<long> global_instance = make_shared<long>(0);
std::mutex g_i_mutex;

void thread_fcn() {
// std::lock_guard<std::mutex> lock(g_i_mutex);
// shared_ptr<long> local = global_instance;
for (int i = 0; i < 100000000; i++) {
*global_instance = *global_instance + 1;
//*local = *local + 1;
}
}

int main(int argc, char** argv) {
thread thread1(thread_fcn);
thread thread2(thread_fcn);

thread1.join();
thread2.join();

cout << "*global_instance is " << *global_instance << endl;
return 0;
}

在线程函数thread_fcn的for循环中,2个线程同时对global_instance进行加1的操作。这就是典型的非线程安全的场景,最后的结果是未定的,运行结果如下:

*global_instance is 197240539

如果使用的是每个线程的局部shared_ptr对象local,因为这些local指向相同的对象,因此结果也是未定的,运行结果如下: *global_instance is 160285803

因此,这种情况下必须加锁,将thread_fcn中的第一行代码的注释去掉之后,不管是使用global_instance,还是使用local,得到的结果都是:

*global_instance is 200000000

一个 unique_ptr 怎么赋值给另一个 unique_ptr 对象?

借助 std::move() 可以实现将一个 unique_ptr 对象赋值给另一个 unique_ptr 对象,其目的是实现所有权的转移。

1
2
3
// A 作为一个类 
std::unique_ptr<A> ptr1(new A());
std::unique_ptr<A> ptr2 = std::move(ptr1);

智能指针有没有内存泄露的情况

智能指针有内存泄露的情况发生。

  1. 智能指针发生内存泄露的情况

    当两个对象同时使用一个 shared_ptr 成员变量指向对方,会造成循环引用,使引用计数失效,从而导致内存泄露。

  2. 智能指针的内存泄漏如何解决?
    为了解决循环引用导致的内存泄漏,引入了弱指针weak_ptr,weak_ptr的构造函数不会修改引用计数的值,从而不会对对象的内存进行管理,其类似一个普通指针,但是不会指向引用计数的共享内存,但是可以检测到所管理的对象是否已经被释放,从而避免非法访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//程序实例
#include <iostream>
#include <memory>
using namespace std;

class Child;
class Parent {
private:
std::shared_ptr<Child> ChildPtr;

public:
void setChild(std::shared_ptr<Child> child) { this->ChildPtr = child; }

void doSomething() {
if (this->ChildPtr.use_count()) {
}
}

~Parent() {}
};

class Child {
private:
std::shared_ptr<Parent> ParentPtr;

public:
void setPartent(std::shared_ptr<Parent> parent) { this->ParentPtr = parent; }
void doSomething() {
if (this->ParentPtr.use_count()) {
}
}
~Child() {}
};

int main() {
std::weak_ptr<Parent> wpp;
std::weak_ptr<Child> wpc;

{
std::shared_ptr<Parent> p(new Parent);
std::shared_ptr<Child> c(new Child);
p->setChild(c);
c->setPartent(p);
wpp = p;
wpc = c;
std::cout << p.use_count() << std::endl;
std::cout << c.use_count() << std::endl;
}
std::cout << wpp.use_count() << std::endl;
std::cout << wpc.use_count() << std::endl;
return 0;
}
/* 程序运行结果:
2
2
1
1
*/

上述代码中,parent有一个shared_ptr类型的成员指向孩子,而child也有一个shared_ptr类型的成员指向父亲。然后在创建孩子和父亲对象时也使用了智能指针c和p,随后将c和p分别又赋值给child的智能指针成员parent和parent的智能指针成员child。从而形成了一个循环引用。

循环引用的解决方法:weak_ptr

循环引用:该被调用的析构函数没有被调用,从而出现了内存泄漏。

  • weak_ptr 对被 shared_ptr 管理的对象存在 非拥有性(弱)引用,在访问所引用的对象前必须先转化为 shared_ptr
  • weak_ptr 用来打断 shared_ptr 所管理对象的循环引用问题,若这种环被孤立(没有指向环中的外部共享指针),shared_ptr 引用计数无法抵达 0,内存被泄露;令环中的指针之一为弱指针可以避免该情况;
  • weak_ptr 用来表达临时所有权的概念,当某个对象只有存在时才需要被访问,而且随时可能被他人删除,可以用 weak_ptr 跟踪该对象;需要获得所有权时将其转化为 shared_ptr,此时如果原来的 shared_ptr 被销毁,则该对象的生命期被延长至这个临时的 shared_ptr 同样被销毁。

简述一下 C++11 中四种类型转换

C++中四种类型转换分别为const_cast、static_cast、dynamic_cast、reinterpret_cast,四种转换功能分别如下:

  1. const_cast

    将 const 变量转为非 const

  2. static_cast

    最常用,可以用于各种隐式转换,比如非 const 转 const,static_cast 可以用于类向上转换,但向下转换能成功但是不安全。

  3. dynamic_cast

    只能用于含有虚函数的类转换,用于类向上和向下转换

    **向上转换:**指派生类类向基类转换。

    **向下转换:**指基类向派生类类转换。

    这两种转换,子类包含父类,当父类转换成子类时可能出现非法内存访问的问题。

    dynamic_cast 通过判断变量运行时类型和要转换的类型是否相同来判断是否能够进行向下转换。dynamic_cast 可以做类之间上下转换,转换的时候会进行类型检查,类型相等成功转换,类型不等转换失败。运用 RTTI 技术,RTTI 是 ”Runtime Type Information” 的缩写,意思是运行时类型信息,它提供了运行时确定对象类型的方法。在 c++ 层面主要体现在 dynamic_cast 和 typeid ,vs 中虚函数表的 -1 位置存放了指向 type_info 的指针,对于存在虚函数的类型,dynamic_cast 和 typeid 都会去查询 type_info。

  4. reinterpret_cast

    reinterpret_cast 可以做任何类型的转换,不过不对转换结果保证,容易出问题。

    **注意:**为什么不用 C 的强制转换:C 的强制转换表面上看起来功能强大什么都能转,但是转换不够明确,不能进行错误检查,容易出错

C++ 11 中 auto 的具体用法

auto用于定义变量,编译器可以自动判断变量的类型。auto主要有以下几种用法:

  1. auto的基本使用方法

    (1)基本使用语法如下

    1
    auto name = value; //name 是变量的名字,value 是变量的初始值

    注意: auto 仅仅是一个占位符,在编译器期间它会被真正的类型所替代。或者说,C++ 中的变量必须是有明确类型的,只是这个类型是由编译器自己推导出来的。

    (2)程序实例如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    auto n = 10; 
    auto f = 12.8;
    auto p = &n;
    auto url = "www.123.com";

    a. 第 1 行中,10 是一个整数,默认是 int 类型,所以推导出变量 n 的类型是 int
    b. 第 2 行中,12.8 是一个小数,默认是 double 类型,所以推导出变量 f 的类型是 double
    c. 第 3 行中,&n 的结果是一个 int* 类型的指针,所以推导出变量 f 的类型是 int*。
    d. 第 4 行中,由双引号`""`包围起来的字符串是 const char* 类型,所以推导出变量 url 的类型是 const char*,也即一个常量指针。
  2. auto和 const 的结合使用

    (1) auto 与 const 结合的用法

    a. 当类型不为引用时,auto 的推导结果将不保留表达式的 const 属性;

    b. 当类型为引用时,auto 的推导结果将保留表达式的 const 属性。

    (2)程序实例如下

    1
    2
    3
    4
    5
    int  x = 0;
    const auto n = x; //n 为 const int ,auto 被推导为 int
    auto f = n; //f 为 int,auto 被推导为 int(const 属性被抛弃)
    const auto &r1 = x; //r1 为 const int& 类型,auto 被推导为 int
    auto &r2 = r1; //r2 为 const int& 类型,auto 被推导为 const int 类型
  3. 使用 auto 定义迭代器

    在使用 stl 容器的时候,需要使用迭代器来遍历容器里面的元素;不同容器的迭代器有不同的类型,在定义迭代器时必须指明。而迭代器的类型有时候比较复杂,请看下面的例子:

    1
    2
    3
    4
    5
    6
    7
    8
    #include <vector>
    using namespace std;
    int main(){
    vector< vector<int> > v;
    //vector< vector<int> >::iterator i = v.begin();
    auto i = v.begin(); //使用 auto 代替具体的类型,该句比上一句简洁,根据表达式 v.begin() 的类型(begin() 函数的返回值类型)来推导出变量i的类型
    return 0;
    }
  4. 用于泛型编程

    auto 的另一个应用就是当我们不知道变量是什么类型,或者不希望指明具体类型的时候,比如泛型编程中。请看下面例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    #include <iostream>
    using namespace std;
    class A{
    public:
    static int get(void){
    return 100;
    }
    };
    class B{
    public:
    static const char* get(void){
    return "www.123.com";
    }
    };
    template <typename T>
    void func(void){
    auto val = T::get();
    cout << val << endl;
    }
    int main(void){
    func<A>();
    func<B>();
    return 0;
    }
    /* 运行结果:
    100
    www.123.com
    */

    本例中的模板函数 func() 会调用所有类的静态函数 get(),并对它的返回值做统一处理,但是 get() 的返回值类型并不一样,而且不能自动转换。这种要求在以前的 C++ 版本中实现起来非常的麻烦,需要额外增加一个模板参数,并在调用时手动给该模板参数赋值,用以指明变量 val 的类型。但是有了 auto 类型自动推导,编译器就根据 get() 的返回值自己推导出 val 变量的类型,就不用再增加一个模板参数了。

C++11 中的可变参数模板新特性

可变参数模板(variadic template)使得编程者能够创建这样的模板函数和模板类,即可接受可变数量的参数。例如要编写一个函数,它可接受任意数量的参数,参数的类型只需是cout能显示的即可,并将参数显示为用逗号分隔的列表。

1
2
3
4
5
6
7
8
9
10
int n = 14;
double x = 2.71828;
std::string mr = "Mr.String objects!";
show_list(n, x);
show_list(x*x, '!', 7, mr); //这里的目标是定义show_list()

/* 运行结果:
14, 2.71828
7.38905, !, 7, Mr.String objects!
*/

要创建可变参数模板,需要理解几个要点:

(1)模板参数包(parameter pack);

(2)函数参数包;

(3)展开(unpack)参数包;

(4)递归。

C++ 关键字、库函数

sizeof 和 strlen 的区别

  • strlen 是头文件 <cstring> 中的函数,sizeof 是 C++ 中的运算符。

  • strlen 测量的是字符串的实际长度(其源代码如下),以 \0 结束。而 sizeof 测量的是字符数组的分配大小。
    strlen 源代码:

    1
    2
    3
    4
    5
    6
    size_t strlen(const char *str) {
    size_t length = 0;
    while (*str++)
    ++length;
    return length;
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    #include <iostream>
    #include <cstring>

    using namespace std;

    int main() {
    char arr[10] = "hello";
    cout << strlen(arr) << endl; // 5
    cout << sizeof(arr) << endl; // 10
    return 0;
    }
  • 若字符数组 arr 作为函数的形参,sizeof(arr) 中 arr 被当作字符指针来处理,strlen(arr) 中 arr 依然是字符数组,从下述程序的运行结果中就可以看出。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include <iostream>
    #include <cstring>
    using namespace std;

    void size_of(char arr[]) {
    cout << sizeof(arr) << endl; // warning: 'sizeof' on array function parameter 'arr' will return size of 'char*' .
    cout << strlen(arr) << endl;
    }

    int main()
    {
    char arr[20] = "hello"; // 8
    size_of(arr); // 5
    return 0;
    }
  • strlen 本身是库函数,因此在程序运行过程中,计算长度;而 sizeof 在编译时,计算长度;

  • sizeof 的参数可以是类型,也可以是变量;strlen 的参数必须是 char* 类型的变量。

lambda 表达式(匿名函数)的具体应用和使用场景

  • lambda 表达式是一个可调度的代码单元,可以视为一个未命名的内部函数

  • lambda 函数是一个函数对象,编译器在编译时会生成一个 lambda 对象的类,然后再生成一个该命令未命名的对象

lambda 的形式如下:
[捕获列表] (参数列表) -> 返回类型 { 函数部分 }
[capture list] (parameter list) -> return type { function body }

  1. capture list 捕获列表是 lambda 函数所定义的函数的局部变量列表, 通常为空

    • 一个 lambda 只有在其捕获列表中捕获一个所在函数中的局部变量,才能在函数体中使用该变量。

    • 捕获列表只用于局部非 static 变量。 lambda 可以直接使用局部 static 变量 和在它所在函数之外的声明的名字。

    • 捕获列表的变量可以分为 值 或 引用传递。

    • 值传递: lambda 捕获的变量在 lambda 函数 创建时 就发生了拷贝而非调用时。

    • 隐式捕获:

      编译器可以根据 lambda 中的代码推导使用的变量,为指示编译器推断捕获列表,应该在捕获列表中写一个 & 或 =

      • & 告知编译器采用引用传递方式
      • = 告知编译器采用值传递方式
    • 当混合使用时,捕获列表第一个参数必须是 & 或 = ,此符号指定了默认捕获方式为引用或值。且显示捕获的变量必须和隐式捕获使用不同的传递方式。

      image-20220316212235865
  2. pameter list

    参数列表和普通函数类似,但是 lambda 不能有默认参数【lambda 实参和形参数目一定相等】

  3. return type

    • 与普通函数不同的是: lambda 必须使用 尾置返回 来指定返回类型。

    • 如果忽略返回类型,lambda 表达式会根据函数体中的代码推断出返回类型

    • 若函数体只有一个 return 语句, 则返回类型从返回表达式的类型推断而来,否则,若未指定返回类型,返回类型为 void

    • Note: 如果 lambda 的函数体包含任意单一 return 之外的内容,且未指定返回类型,则返回 void

    • 当需要为 lambda 定义返回类型时,必须使用尾置返回类型

  4. function body

    • 与常规函数类似

explicit 的作用(如何避免编译器进行隐式类型转换)

作用:

  • 用来声明类构造函数是显示调用的,而非隐式调用,可以阻止调用构造函数时进行隐式转换
  • 只可用于修饰单参构造函数,因为无参构造函数和多参构造函数本身就是显示调用的,再加上 explicit 关键字也没有什么意义。

隐式转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <cstring>
#include <iostream>
using namespace std;

class A {
public:
int var;
A(int tmp) { var = tmp; }
};
int main() {
A ex = 10; // 发生了隐式转换
return 0;
}

上述代码中,A ex = 10; 在编译时,进行了隐式转换,将 10 转换成 A 类型的对象,然后将该对象赋值给 ex,等同于如下操作:

1
2
A temp(10);
ex = temp;

为了避免隐式转换,可用 explicit 关键字进行声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <cstring>
#include <iostream>
using namespace std;

class A {
public:
int var;
explicit A(int tmp) {
var = tmp;
cout << var << endl;
}
};
int main() {
A ex(100);
A ex1 = 10; // error: conversion from 'int' to non-scalar type 'A' requested
return 0;
}

C 和 C++ static 的区别

C++ 中多了 静态成员变量和静态成员函数

  • 在 C 语言中,使用 static 可以定义局部静态变量、外部静态变量、静态函数
  • 在 C++ 中,使用 static 可以定义局部静态变量、外部静态变量、静态函数、静态成员变量和静态成员函数。因为 C++ 中有类的概念,静态成员变量、静态成员函数都是与类有关的概念。

static 的作用

static 定义静态变量,静态函数。

  • 保持变量内容持久static 作用于局部变量,改变了局部变量的生存周期,使得该变量存在于定义后直到程序运行结束的这段时间。static 修饰的变量只会被初始化一次

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    #include <iostream>
    using namespace std;
    int fun() {
    static int var = 1; // var 只在第一次进入这个函数的时初始化
    var += 1;
    return var;
    }
    int main() {
    for (int i = 0; i < 10; ++i) cout << fun() << " "; // 2 3 4 5 6 7 8 9 10 11
    return 0;
    }
  • 隐藏static 作用于全局变量和函数,改变了全局变量和函数的作用域,使得全局变量和函数只能在定义它的文件中使用,在其他的源文件中不具有全局可见性。(注:普通全局变量和函数具有全局可见性,即其他的源文件也可以使用。)

  • static 作用于类的成员变量和类的成员函数,使得类变量或者类成员函数和类有关,也就是说可以不定义类的对象就可以通过类访问这些静态成员。注意:类的静态成员函数中只能访问静态成员变量或者静态成员函数,不能访问非静态成员(因为没有 this 指针),不能将静态成员函数定义成虚函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include<iostream>
using namespace std;
class A {
private:
int var;
static int s_var; // 静态成员变量
public:
void show() { cout << s_var++ << endl; }
static void s_show() {
cout << s_var << endl;
// cout << var << endl; // error: invalid use of member 'A::a' in static member function. 静态成员函数不能调用非静态成员变量。无法使用 this.var
// show(); // error: cannot call member function 'void A::show()' without object. 静态成员函数不能调用非静态成员函数。无法使用 this.show()
}
};
int A::s_var = 1; // 静态成员变量在类外进行初始化赋值,默认初始化为 0

int main() {
// cout << A::sa << endl; // error: 'int A::sa' is private within this context
A ex;
ex.show();
A::s_show();
}
  1. static修饰的常规变量,从生命周期和作用域来来分析比较好:
  • 生命周期: 从定义该变量该开始,直到程序结束时
  • 作用域:
    若是局部变量,则作用域就是定义该变量的函数块范围
    若是全局变量,则是定义该变量的文件范围内部,也即是 static 修饰的变量具有文件作用域
  • static 定义变量的位置在静态变量区,超过其作用域该变量并不被释放,而是在函数结束时释放
  • static 修饰的变量只会被初始化一次
  1. static 修饰类:
    • static 修饰的成员变量要在类外初始化,属于类,为所有类对象共享,static 修饰的变量不占类的空间
    • static 修饰的函数,静态成员函数,属于类,为类的所有对象共享,不能访问类的非静态成员和外部函数。静态成员函数没有this指针,因此只能访问静态成员(静态成员变量和静态函数)

static 在类中使用的注意事项(定义、初始化和使用)

static 静态成员变量:

  1. 静态成员变量是在类内进行声明,在类外进行定义和初始化,在类外进行定义和初始化的时候不要出现 static 关键字和 privatepublicprotected 访问规则。

  2. 静态成员变量相当于类域中的全局变量,被类的所有对象所共享,包括派生类的对象。

  3. 静态成员变量可以作为成员函数的参数,而普通成员变量不可以。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #include <iostream>
    using namespace std;
    class A {
    public:
    static int s_var;
    int var;
    void fun1(int i = s_var); // 正确,静态成员变量可以作为成员函数的参数
    void fun2(int i = var); // error: invalid use of non-static data member 'A::var'
    };
    int main() {
    return 0;
    }
  4. 静态数据成员的类型可以是所属类的类型,而普通数据成员的类型只能是该类类型的指针或引用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    #include <iostream>
    using namespace std;
    class A {
    public:
    static A s_var; // 正确,静态数据成员
    A var; // error: field 'var' has incomplete type 'A'
    A *p; // 正确,指针
    A &var1; // 正确,引用
    };

    int main() {
    return 0;
    }

static 静态成员函数:

  1. 静态成员函数不能调用非静态成员变量或者非静态成员函数,因为静态成员函数没有 this 指针。静态成员函数做为类作用域的全局函数。
  2. 静态成员函数不能声明成虚函数(virtual)、const 函数和 volatile 函数

为何 static 成员函数不能为 const 函数

因为 static 成员函数没有 this 指针。

  • 当声明一个非静态成员函数为 const 时,对 this 指针会有影响。对于一个 Test 类中的 const 修饰的成员函数,this 指针相当于 Test const *, 而对于非 const 成员函数,this 指针相当于Test *.
  • 而 static 成员函数没有 this 指针,所以使用 const 来修饰 static 成员函数没有任何意义。
  • volatile 的道理也是如此。

为何 static 成员函数不能为 virtual

  1. static 成员不属于任何类对象或类实例,所以即使给此函数加上 virutal 也是没有任何意义的。
  2. 静态与非静态成员函数之间有一个主要的区别。那就是静态成员函数没有 this 指针。
  • 虚函数依靠 vptr 和 vtable 来处理。vptr 是一个指针,在类的构造函数中创建生成,并且只能用 this 指针来访问它,因为它是类的一个成员,并且 vptr 指向保存虚函数地址的 vtable.
  • 对于静态成员函数,它没有 this 指针,所以无法访问 vptr。 这就是为何 static 函数不能为 virtual.
  • 虚函数的调用关系:this->vptr->vtable->virtual function

static 全局变量和普通全局变量的异同

相同点:

  • 存储方式:普通全局变量和 static 全局变量都是静态存储方式。

不同点:

  • 作用域:
    • 普通全局变量的作用域是整个源程序,当一个源程序由多个源文件组成时,普通全局变量在各个源文件中都是有效的;
    • 静态全局变量则限制了其作用域,即只在定义该变量的源文件内有效,在同一源程序的其它源文件中不能使用它。
    • 由于静态全局变量的作用域限于一个源文件内,只能为该源文件内的函数公用,因此可以避免在其他源文件中引起错误。
  • 初始化:静态全局变量只初始化一次,防止在其他文件中使用。

const 作用及用法

作用

  • const 修饰成员变量,定义成 const 常量,相较于宏常量,可进行类型检查,节省内存空间,提高了效率。
  • const 修饰函数参数,使得传递过来的函数参数的值不能改变。
  • const 修饰成员函数,使得成员函数不能修改任何类型的成员变量(mutable 修饰的变量除外),也不能调用非 const 成员函数,因为非 const 成员函数可能会修改成员变量。

**存储位置:**const 变量的内存位于栈区或者静态存储区,不在符号表(常量表)中

  1. const 修饰的量不是常量,仅仅是个只读量。在编译的时候全部替换 const 变量被赋予的值(这点和 C 语言的宏相似),在运行的时候该 const 变量可通过内存进行修改:
  • 通过内存(指针)可以修改位于栈区的 const 变量,语法合乎规定,编译运行不会报错,但是在编译的时候所有用到该常量的地方全部被替换成了定义时所赋予的值,然后再运行的时候无法使用通过指针修改后的值。
  • 通过内存(指针)修改位于静态存储区的的 const 变量,语法上没有报错,编译不会出错,一旦运行就会报告异常。
  • 注:通过指针修改在全局区上的 const 变量,编译可通过,运行就会报异常。

在类中的用法

const 成员变量:

  1. const 成员变量只能在类内声明、定义,在构造函数初始化列表中初始化

  2. const 成员变量只在某个对象的生存周期内是常量,对于整个类而言却是可变的,因为类可以创建多个对象,不同对象的 const 成员变量的值是不同的。因此不能在类的声明中初始化 const 成员变量,类的对象还没有创建,编译器不知道他的值。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    struct A {
    const int a = 5; //之所以没报错是因为用了初始化列表
    A(int a) : a(a) {}
    };

    struct A {
    const int a;
    A(int a) { //没用初始化列表声明const成员变量,报错
    this->a = a;
    }
    };

    struct A {
    const int a; //这里给a赋值也没关系, 因为a会是第一次构造时的值, 后面再实例化对象, 也不会变
    A(int a) : a(a) {} //正确
    };

    int main() {
    A a(4);
    cout << a.a << endl;//4
    A b(5);
    cout << a.a << endl;//4
    return 0;
    }

const 成员函数:

  1. 不能修改成员变量的值,除非有 mutable 修饰;只能访问成员变量。
  2. 不能调用非常量成员函数,以防修改成员变量的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;

class A {
public:
int var;
A(int tmp) : var(tmp) {}
void c_fun(int tmp) const { // const 成员函数
var = tmp; // error: assignment of member 'A::var' in read-only object. 在 const 成员函数中,不能修改任何类成员变量。
fun(tmp); // error: passing 'const A' as 'this' argument discards qualifiers. const 成员函数不能调用非 const 成员函数,因为非 const 成员函数可能会修改成员变量。
}
void fun(int tmp) { var = tmp; }
};

int main() {
return 0;
}

define 和 const 的区别

区别:

  • 编译阶段:define 是在编译预处理阶段进行替换,const 是在编译阶段确定其值。
  • 安全性:define 定义的宏常量没有数据类型,只是进行简单的替换,不会进行类型安全的检查;const 定义的常量是有类型的,是要进行判断的,可以避免一些低级的错误。
  • 内存占用:define 定义的宏常量,在程序中使用多少次就会进行多少次替换,内存中有多个备份,占用的是代码段的空间;const 定义的全局常量占用静态存储区的空间,程序运行过程中只有一份(有的 const 局部变量,比如作为类的成员变量,是属于对象的,程序运行的时候内存中,并不是一份)
  • 调试:define 定义的宏常量不能调试,因为在预编译阶段就已经进行替换了;const 定义的常量可以进行调试。

const 的优点:

  • 有数据类型,在定义式可进行安全性检查。
  • 可以进行调试。
  • 占用较少的空间(全局变量)。

define 和 typedef 的区别

  • 原理:

    • #define 作为预处理指令,在编译预处理时进行替换操作,不作正确性检查,只有在编译已被展开的源程序时才会发现可能的错误并报错。
    • typedef 是关键字,在编译时处理,有类型检查功能,用来给一个已经存在的类型一个别名,但不能在一个函数定义里面使用 typedef
  • 功能:typedef 用来定义类型的别名,方便使用。#define 不仅可以为类型取别名,还可以定义常量、变量、编译开关等。

  • 作用域:

    • #define 没有作用域的限制,只要是之前预定义过的宏,在以后的程序中都可以使用,而 typedef 有自己的作用域。不管是在某个函数内,还是在所有函数之外,作用域都是从定义开始直到整个文件结尾。
    • typedef:有作用域的限制。如果放在所有函数之外,它的作用域就是从它定义开始直到文件尾;如果放在某个函数内,定义域就是从定义开始直到该函数结尾;
  • 指针的操作:typedef#define 在处理指针时不完全一样

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    #include <iostream>
    #define INTPTR1 int *
    typedef int * INTPTR2;

    using namespace std;

    int main() {
    INTPTR1 p1, p2; // p1: int *; p2: int 等价于:int *p1, p2;
    INTPTR2 p3, p4; // p3: int *; p4: int * 等价于:int *p1, *p2;

    int var = 1;
    const INTPTR1 p5 = &var; // 相当于 const int *p5; 常量指针,即不可以通过 p5 去修改 p5 指向的内容,但是 p5 可以指向其他内容。
    const INTPTR2 p6 = &var; // 相当于 int * const p6; 指针常量,不可使 p6 再指向其他内容。

    return 0;
    }

用宏实现比较大小,以及两个数中的最小值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#define MAX(X, Y) ((X)>(Y)?(X):(Y)) // 注意加括号!!!
#define MIN(X, Y) ((X)<(Y)?(X):(Y))
using namespace std;

int main () {
int var1 = 10, var2 = 100;
cout << MAX(var1, var2) << endl;
cout << MIN(var1, var2) << endl;
return 0;
}
/*
程序运行结果:
100
10
*/

inline 内联函数 作用及使用方法

作用
inline 是一个关键字,可以用于定义内联函数。内联函数,像普通函数一样被调用,但是在调用时并不通过函数调用的机制而是直接在调用点处展开,这样可以大大减少由函数调用带来的开销,从而提高程序的运行效率。

使用方法:

  1. 类内定义成员函数默认是内联函数

    在类内定义成员函数,可以不用在函数头部加 inline 关键字,因为编译器会自动将类内定义的函数(构造函数、析构函数、普通成员函数等)声明为内联函数,代码如下:

    1
    2
    3
    4
    5
    6
    class A{
    public:
    int var;
    A (int tmp) { var = tmp; }
    void fun(){ cout << var << endl; } // 类内定义成员函数,默认是内联函数
    };
  2. 类外定义成员函数,若想定义为内联函数,需用关键字声明

    当在类内声明函数,在类外定义函数时,如果想将该函数定义为内联函数,则可以在类内声明时不加 inline 关键字,而在类外定义函数时加上 inline 关键字。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    #include <iostream>
    using namespace std;

    class A {
    public:
    int var;
    A (int tmp) { var = tmp; }
    void fun();
    };

    inline void A::fun(){
    cout << var << endl;
    }

    int main() {
    return 0;
    }

另外,可以在声明函数和定义函数的同时加上 inline;也可以只在函数声明时加 inline,而定义函数时不加 inline。只要确保在调用该函数之前把 inline 的信息告知编译器即可。

内联函数的作用

  1. 消除函数调用的开销。

    在内联函数出现之前,程序员通常用 #define 定义一些“函数”来消除调用这些函数的开销。内联函数设计的目的之一,就是取代 #define 的这项功能(因为使用 #define 定义的那些“函数”,编译器不会检查其参数的正确性等,而使用 inline 定义的函数,和普通函数一样,可以被编译器检查,这样有利于尽早发现错误)。

  2. 去除函数只能定义一次的限制。

    内联函数可以在头文件中被定义,并被多个 .cpp 文件 include,而不会有重定义错误。这也是设计内联函数的主要目的之一。

关于减少函数调用的开销:

  1. 内联函数一定会被编译器在调用点展开吗?

    错,inline 只是对编译器的建议,而非命令。编译器可以选择忽视 inline。当程序员定义的 inline 函数包含复杂递归,或者 inlinie 函数本身比较长,编译器一般不会将其展开,而仍然会选择函数调用。

  2. “调用” 普通函数时,一定是调用吗?

    错,即使是普通函数,编译器也可以选择进行优化,将普通函数在“调用”点展开。

  3. 既然内联函数在编译阶段已经在调用点被展开,那么程序运行时,对应的内存中不包含内联函数的定义,对吗?

    错。

    首先,如第一点所言,编译器可以选择调用内联函数,而非展开内联函数。因此,内存中仍然需要一份内联函数的定义,以供调用。

    而且,一致性是所有语言都应该遵守的准则。普通函数可以有指向它的函数指针,那么,内联函数也可以有指向它的函数指针,因此,内存中需要一份内联函数的定义,使得这样的函数指针可以存在。

关于去除函数只能定义一次的限制:

  • 下述程序会报错:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 文件1
    #include <iostream>
    using namespace std;
    void myPrint() {
    cout << "function 1";
    }

    // 文件2
    #include <iostream>
    using namespace std;
    void myPrint() {
    cout << "function 2";
    }

    int main() {
    myPrint(); // error,会出现链接时错误, myPrint 函数被定义了两次。
    }
  • 而下述程序不会报错

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 文件1
    #include <iostream>
    using namespace std;

    inline void myPrint() {
    cout << "inline function 1";
    }

    // 文件2
    #include <iostream>
    using namespace std;

    inline void myPrint() {
    cout << "inline function 2";
    }

    int main() {
    myPrint()// 正常运行;
    }
  • 可见,内联函数可以在头文件中定义(即多个 .cpp 源文件可以定义函数名、参数都一样的内联函数,而不会有重定义错误)。

inline 函数工作原理

  • 内联函数不是在调用时发生控制转移关系,而是在编译阶段将函数体嵌入到每一个调用该函数的语句块中,编译器会将程序中出现内联函数的调用表达式用内联函数的函数体来替换。
  • 普通函数是将程序执行转移到被调用函数所存放的内存地址,当函数执行完后,返回到执行此函数前的地方。转移操作需要保护现场,被调函数执行完后,再恢复现场,该过程需要较大的资源开销。

宏定义(define)和内联函数(inline)的区别

  1. 内联函数是在编译时展开,而宏在编译预处理时展开;在编译的时候,内联函数直接被嵌入到目标代码中去,而宏只是一个简单的文本替换。
  2. 内联函数是真正的函数,和普通函数调用的方法一样,在调用点处直接展开,避免了函数的参数压栈操作,减少了调用的开销。而宏定义编写较为复杂,常需要增加一些括号来避免歧义。
  3. 宏定义只进行文本替换,不会对参数的类型、语句能否正常编译等进行检查。而内联函数是真正的函数,会对参数的类型、函数体内的语句编写是否正确等进行检查。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;

#define MAX(a, b) ((a) > (b) ? (a) : (b))

inline int fun_max(int a, int b) {
return a > b ? a : b;
}

int main() {
int var = 1;
cout << MAX(var, 5) << endl; // 5
cout << fun_max(var, 0) << endl; // 1
return 0;
}

new 和 malloc

new 的作用?

new 是 C++ 中的关键字,用来动态分配内存空间,实现方式如下:

1
int *p = new int[5]; 
new 和 malloc 如何判断是否申请到内存?
  • malloc :成功申请到内存,返回指向该内存的指针;分配失败,返回 NULL 指针。
  • new :内存分配成功,返回该对象类型的指针;分配失败,抛出 bac_alloc 异常。

注:可以使用std::nothrownew在申请内存失败时也同malloc一样返回NULL指针,而不是抛出std::bad_alloc异常。

1
2
3
4
5
A *a = new (std::nothrow) A();
if (a == nullptr) {
// add logs here
return false;
}

delete 实现原理?delete 和 delete[] 的区别?

delete 的实现原理:

  • 首先执行该对象所属类的析构函数
  • 进而通过调用 operator delete 的标准库函数来释放所占的内存空间。

deletedelete [] 的区别:

  • delete 用来释放单个对象所占的空间,只会调用一次析构函数;
  • delete [] 用来释放数组空间,会对数组中的每个成员都调用一次析构函数。
  • 对于像int/char/long/int*等等简单数据类型,由于对象没有析构函数,所以deletedelete []是一样的!都不会造成内存泄露! 但通常为了规范起见,new []都配套使用delete []。
  • 但是如果是C++自定义对象数组就不同了!由于delete p只调用了一次析构函数,剩余的对象不会调用析构函数,所以剩余对象中如果有申请了新的内存或者其他系统资源,那么这部分内存和资源就无法被释放掉了,因此会造成内存泄露或者更严重的问题

new 和 malloc 的区别,delete 和 free 的区别

image.png

在使用的时候 newdelete 搭配使用,mallocfree 搭配使用。

  • mallocfree 是库函数,而newdelete 是关键字。
    -new 申请空间时,无需指定分配空间的大小,编译器会根据类型自行计算;malloc 在申请空间时,需要确定所申请空间的大小。
  • new 申请空间时,返回的类型是对象的指针类型,无需强制类型转换,是类型安全的操作符;malloc 申请空间时,返回的是 void* 类型,需要进行强制类型的转换,转换为对象类型的指针。
  • new 分配失败时,会抛出 bad_alloc 异常,malloc 分配失败时返回空指针。
  • 对于自定义的类型,new 首先调用 operator new() 函数申请空间(底层通过 malloc 实现),然后调用构造函数进行初始化,最后返回自定义类型的指针;delete 首先调用析构函数,然后调用 operator delete() 释放空间(底层通过 free 实现)。mallocfree 无法进行自定义类型的对象的构造和析构。
  • new 操作符从自由存储区上为对象动态分配内存,而 malloc 函数从堆上动态分配内存。(自由存储区不等于堆)

堆区和自由存储区的区别与联系:
(1)malloc申请的内存在堆上,使用free释放。new申请的内存在自由存储区,用delete释放
(2)堆(heap)是c语言和操作系统的术语。堆是操作系统所维护的一块特殊内存,它提供了动态分配的功能,当程序运行时调用malloc()时就会从中分配,调用free可把内存交换。而自由存储区是C++中通过new和delete动态分配和释放对象的抽象概念,通过new来申请的内存区域可称为自由存储区。基本上,所有的C++编译器默认用堆来实现自由存储区,也即是缺省的全局运算符new和delete也许会按照malloc和free的方式来实现,这时由new运算符分配的对象,说它在堆上也对,说它在自由存储区也对。
记住:
(1)堆是c语言和操作系统的术语,是操作系统维护的一块内存。自由存储是 C++ 中通过 new 和 delete 动态分配和释放对象的抽象概念。
(2)new 所申请的内存区域在 C++ 中称为自由存储区,编译器用 malloc 和 free 实现 new 和 delete 操作符时,new 申请的内存可以说是在堆上。
(3)堆和自由存储区有相同之处,但并不等价。

malloc 的原理?malloc 的底层实现?

malloc 的原理:

  • 当开辟的空间小于 128K 时,调用 brk() 函数,通过移动 _enddata 来实现;
  • 当开辟空间大于 128K 时,调用 mmap() 函数,通过在虚拟地址空间中开辟一块内存空间来实现。

malloc 的底层实现:

  • brk() 函数实现原理:向高地址的方向移动指向数据段的高地址的指针 _enddata
  • mmap 内存映射原理:
    1. 进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域;
    2. 调用内核空间的系统调用函数 mmap(),实现文件物理地址和进程虚拟地址的一一映射关系;
    3. 进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝。

C 和 C++ struct 的区别?

  • 在 C 语言中 struct 是用户自定义数据类型;在 C++ 中 struct 是抽象数据类型,支持成员函数的定义。
  • C 语言中 struct 没有访问权限的设置,是一些变量的集合体,不能定义成员函数;C++ 中 struct 可以和类一样,有访问权限,并可以定义成员函数。
  • C 语言中 struct 定义的自定义数据类型,在定义该类型的变量时,需要加上 struct 关键字,例如:struct A var;,定义 A 类型的变量;而 C++ 中,不用加该关键字,例如:A var;

为什么有了 class 还保留 struct?

C++ 是在 C 语言的基础上发展起来的,为了与 C 语言兼容,C++ 中保留了 struct

struct 和 union 的区别

说明:union 是联合体,struct 是结构体。
区别:

  1. 联合体和结构体都是由若干个数据类型不同的数据成员组成。使用时,联合体只有一个有效的成员;而结构体所有的成员都有效。
  2. 对联合体的不同成员赋值,将会对覆盖其他成员的值,而对于结构体的对不同成员赋值时,相互不影响。
  3. 联合体的大小为其内部所有变量的最大值,按照最大类型的倍数进行分配大小;结构体分配内存的大小遵循内存对齐原则。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
using namespace std;

typedef union {
char c[10];
char cc1; // char 1 字节,按该类型的倍数分配大小
} u11;

typedef union {
char c[10];
int i; // int 4 字节,按该类型的倍数分配大小
} u22;

typedef union {
char c[10];
double d; // double 8 字节,按该类型的倍数分配大小
} u33;

typedef struct s1 {
char c; // 1 字节
double d; // 1(char)+ 7(内存对齐)+ 8(double)= 16 字节
} s11;

typedef struct s2 {
char c; // 1 字节
char cc; // 1(char)+ 1(char)= 2 字节
double d; // 2 + 6(内存对齐)+ 8(double)= 16 字节
} s22;

typedef struct s3 {
char c; // 1 字节
double d; // 1(char)+ 7(内存对齐)+ 8(double)= 16 字节
char cc; // 16 + 1(char)+ 7(内存对齐)= 24 字节
} s33;

int main() {
cout << sizeof(u11) << endl; // 10
cout << sizeof(u22) << endl; // 12
cout << sizeof(u33) << endl; // 16
cout << sizeof(s11) << endl; // 16
cout << sizeof(s22) << endl; // 16
cout << sizeof(s33) << endl; // 24

cout << sizeof(int) << endl; // 4
cout << sizeof(double) << endl; // 8
return 0;
}

class 和 struct 的异同

  • structclass 都可以自定义数据类型,也支持继承操作。
  • struct 中默认的访问级别是 public,默认的继承级别也是 publicclass 中默认的访问级别是 private,默认的继承级别也是 private
  • class 继承 struct 或者 struct 继承 class 时,默认的继承级别取决于 classstruct 本身, classprivate 继承),structpublic 继承),即取决于派生类的默认继承级别。
  • class 可以用于定义模板参数,struct 不能用于定义模板参数。

QQ图片20210408152636.png

volatile 的作用?是否具有原子性,对编译器有什么影响?

image-20220316231723297 image-20220316231404611
  • volatile 的作用:当对象的值可能在程序的控制或检测之外被改变时,应该将该对象声明为 volatile,告知编译器不应对这样的对象进行优化。
  • volatile 不具有原子性
  • volatile 对编译器的影响:使用该关键字后,编译器不会对相应的对象进行优化,即不会将变量从内存缓存到寄存器中,防止多个线程有可能使用内存中的变量,有可能使用寄存器中的变量,从而导致程序错误。
  1. volatile 中文意思是不稳定的,易变的。
  2. volatile 变量的作用和使用场景如下:
    • 在多线程编程中,多个线程访问同一个变量,而且有可能修改变量,如果不加 volatile,编译器有可能做优化,在寄存器中保存变量,CPU 直接访问寄存器,从而优化变量访问速度,加上 volatile 修饰,CPU 会直接访问内存,不会做寄存器的优化。这一条称为保证内存的可见性

      类似的情况在中断服务程序中访问变量,并行设备的硬件寄存器变量都是一个道理。

    • 另一个作用是禁止指令重排,也是针对编译器说的,保证缓存一致性

      volatile 保证了程序的正确性,损失了编译器的优化。

摘自《程序员的自我修养》中原话:

  • 我们可以使用 volatile 关键字试图阻止过度优化,volatile 基本可以做到两件事情:

    1. 阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回;(缓存一致性协议、轻量级同步)

    2. 阻止编译器调整操作 volatile 变量的指令排序。

    • 注意:即使volatile能够阻止编译器调整顺序,也无法阻止 CPU 动态调度换序。

      要保证线程安全,阻止CPU换序是必须的。遗憾的是,现在并不存在可移植的阻止换序的方法。
      通常情况下是调用CPU提供的一条指令,这条指令常常被称为 barrier。
      一条 barrier 指令会阻止 CPU 将该指令之前的指令交换到 barrier 之后。

对volatile的三点理解:

  1. 只能保证赋值原子性,复合操作不能保证;
  2. 告诉编译器不进行指令重排(JMM 中还会追加 CPU 内存屏障),以避免过度优化;
  3. 保证内存可见性。

参考《effective modern C++》:

  • 在 C++多线程中,volatile 不具有原子性;无法对代码重新排序实施限制。

  • 能干什么:告诉编译器不要在此内存上做任何优化。如果对内存有只写未读的等非常规操作,如

    1
    2
    x = 10;
    x = 20;

    编译器会优化

    1
    x = 20;

    volatile 就是阻止编译器进行此类优化。

什么情况下一定要用 volatile, 能否和 const 一起使用?

使用 volatile 关键字的场景:

  • 当多个线程都会用到某一变量,并且该变量的值有可能发生改变时,需要用 volatile 关键字对该变量进行修饰;
  • 中断服务程序中访问的变量或并行设备的硬件寄存器的变量,最好用 volatile 关键字修饰。

volatile 关键字和 const 关键字可以同时使用,某种类型可以既是 volatile 又是 const ,同时具有二者的属性。

返回函数中静态变量的地址会发生什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
using namespace std;

int * fun(int tmp) {
static int var = 10;
var *= tmp;
return &var;
}

int main() {
cout << *fun(5) << endl; // 50
return 0;
}

说明:上述代码中在函数 fun 中定义了静态局部变量 var,使得离开该函数的作用域后,该变量不会销毁,返回到主函数中,该变量依然存在,从而使程序得到正确的运行结果。但是,该静态局部变量直到程序运行结束后才销毁,浪费内存空间。

  • 全局变量(包括静态全局变量)是最先构造的,早于 main 函数,当然,析构函数也是执行的最晚,晚于 main 函数。
  • 静态局部变量是要等到执行该声明定义的表达式后,才开始执行构造的。当然,析构函数也是早于全局变量的。

extern C 的作用?

当 C++ 程序 需要调用 C 语言编写的函数,C++ 使用链接指示,即 extern "C" 指出任意非 C++ 函数所用的语言。

1
2
3
4
// 可能出现在 C++ 头文件<cstring>中的链接指示
extern "C"{
int strcmp(const char*, const char*);
}

C++ 和 C语言编译函数签名方式不一样, extern 关键字可以让两者保持统一,这样才能找到对应的函数

extern “C” 的作用是让 C++ 编译器将 extern “C” 声明的代码当作 C 语言代码处理,可以避免 C++ 因符号修饰导致代码不能和 C 语言库中的符号进行链接的问题。

c 和 c++ 对同一个函数经过编译后生成的函数名是不同的

  • C++ 支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;
  • C 语言并不支持函数重载,因此编译 C 语言代码的函数时不会带上函数的参数类型,一般只包括函数名。
  • 如果在 c++ 中调用一个使用 c 语言编写的模块中的某个函数,那么 c++ 是根据 c++ 的名称修饰方式来查找并链接这个函数,那么就会发生链接错误。

sizeof(1 == 1) 在 C 和 C++ 中分别是什么结果?

  • C: sizeof(1 == 1) 等价于 sizeof(1) 按照整数 int 处理,所以是 4 字节,这里也有可能是 8 字节(看操作系统)
  • C++:因为有 bool 类型 sizeof(1 == 1) 等价于sizeof(true) 按照 bool 类型处理,所以是 1 个字节

memmove 函数的底层原理?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void *memcpy(void *dst, const void *src, size_t size) {
char *psrc;
char *pdst;

if (NULL == dst || NULL == src) {
return NULL;
}

if ((src < dst) && (char *)src + size > (char *)dst) { // 出现地址重叠的情况,自后向前拷贝
psrc = (char *)src + size - 1;
pdst = (char *)dst + size - 1;
while (size--) {
*pdst-- = *psrc--;
}
}
else {
psrc = (char *)src;
pdst = (char *)dst;
while (size--) {
*pdst++ = *psrc++;
}
}
return dst;
}

memmove 和 memcopy 的区别

memcpy和memmove都是C语言的库函数,相比于strcpy和strncpy只能拷贝字符串数组,memcpy与memmove可以拷贝其它类型的数组,但是为什么要同时提供两种方法呢?本文主要就是介绍这两个函数的区别。

首先来看函数原型:

1
2
void *memcpy(void *restrict s1, const void *restrict s2, size_t n);
void *memmove(void *s1, const void *s2, size_t n);

这两个函数都是将s2指向位置的n字节数据拷贝到s1指向的位置,区别就在于关键字restrict, memcpy假定两块内存区域没有数据重叠,而memmove没有这个前提条件。如果复制的两个区域存在重叠时使用memcpy,其结果是不可预知的,有可能成功也有可能失败的,所以如果使用了memcpy,程序员自身必须确保两块内存没有重叠部分。

我们来看一组示例:

  1. 正常情况拷贝

    img

    正常情况下,即使内容有重叠,src的内容也可以正确地被拷贝到了dest指向的空间。

  2. 内存重叠的拷贝

    img

    这种情况下,src的地址小于dest的地址,拷贝前3个字节没问题,但是拷贝第4,5个字节时,原有的内容已经被src拷贝过来的字符覆盖了,所以已经丢失原来src的内容,这很明显就是问题所在。

memcpy的实现

一般来说,memcpy的实现非常简单,只需要顺序的循环,把字节一个一个从src拷贝到dest就行:

1
2
3
4
5
6
7
8
9
#include <stddef.h> /* size_t */
void *memcpy(void *dest, const void *src, size_t n)
{
char *dp = dest;
const char *sp = src;
while (n--)
*dp++ = *sp++;
return dest;
}

memmove的实现

memmove会对拷贝的数据作检查,确保内存没有覆盖,如果发现会覆盖数据,简单的实现是调转开始拷贝的位置,从尾部开始拷贝:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stddef.h> /* for size_t */
void *memmove(void *dest, const void *src, size_t n)
{
unsigned char *pd = dest;
const unsigned char *ps = src;
if (__np_anyptrlt(ps, pd))
for (pd += n, ps += n; n--;)
*--pd = *--ps;
else
while(n--)
*pd++ = *ps++;
return dest;
}

这里__np_anyptrlt是一个简单的宏,用于结合拷贝的长度检测dest与src的位置,如果dest和src指向同样的对象,且src比dest地址小,就需要从尾部开始拷贝。否则就和memcpy处理相同。
但是实际在C99实现中,是将内容拷贝到临时空间,再拷贝到目标地址中:

1
2
3
4
5
6
7
8
9
10
#include <stddef.h> /* for size_t */
#include <stdlib.h> /* for memcpy */

void *memmove(void *dest, const void *src, size_t n)
{
unsigned char tmp[n];
memcpy(tmp,src,n);
memcpy(dest,tmp,n);
return dest;
}

由此可见memcpy的速度比memmove快一点,如果使用者可以确定内存不会重叠,则可以选用memcpy,否则memmove更安全一些。另外一个提示是第三个参数是拷贝的长度,如果你是拷贝10个double类型的数值,要写成sizeof(double)*10,而不仅仅是10。

strcpy 函数有什么缺陷?

strcpy 函数的缺陷:strcpy 函数不检查目的缓冲区的大小边界,而是将源字符串逐一的全部赋值给目的字符串地址起始的一块连续的内存空间,同时加上字符串终止符,会导致其他变量被覆盖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <cstring>
using namespace std;

int main() {
int var = 0x11112222;
char arr[10];
cout << "Address : var " << &var << endl;
cout << "Address : arr " << &arr << endl;
strcpy(arr, "hello world!");
cout << "var:" << hex << var << endl; // 将变量 var 以 16 进制输出
cout << "arr:" << arr << endl;
return 0;
}
/*
Address : var 0x23fe4c
Address : arr 0x23fe42
var:11002164
arr:hello world!
*/

// strcpy 函数原型
char* strcpy(char* des,const char* source) {
char* r=des;
assert((des != NULL) && (source != NULL));
while((*r++ = *source++)!='\0');
return des;
}

说明:从上述代码中可以看出,变量 var 的后六位被字符串 "hello world!""d!\0" 这三个字符改变,这三个字符对应的 ascii 码的十六进制为:\0(0x00),!(0x21),d(0x64)。
原因:变量 arr 只分配的 10 个内存空间,通过上述程序中的地址可以看出 arrvar 在内存中是连续存放的,但是在调用 strcpy 函数进行拷贝时,源字符串 "hello world!" 所占的内存空间为 13,因此在拷贝的过程中会占用 var 的内存空间,导致 var的后六位被覆盖。

image.png

auto 类型推导的原理

auto 类型推导的原理:编译器根据初始值来推算变量的类型,要求用 auto 定义变量时必须有初始值。

编译器推断出来的 auto 类型有时和初始值类型并不完全一样,编译器会适当改变结果类型使其更符合初始化规则。

auto变量的规则是 “做函数模板需要做的事情”

1
auto my_new_variable = its_initial_value;

就像普通的赋值操作一样, my_new_variable 的基本类型和值与 its_initial_value 一样,但是 its_initial_value 的第二属性( 顶层 const , volatile, &/&&)不一定相同。例如 its_initial_valueconst 并不意味着 my_new_variableconst ,因此, my_new_variableits_initial_value 只是在基本类型上相同, 但不是完全相同的变量.

语言特性相关

左值和右值的区别?左值引用和右值引用的区别,如何将左值转换成右值?

左值:指表达式结束后依然存在的持久对象。

右值:表达式结束就不再存在的临时对象。

左值和右值的区别:左值持久,右值短暂

右值引用和左值引用的区别:

  • 左值引用不能绑定到要转换的表达式、字面常量或返回右值的表达式。右值引用恰好相反,可以绑定到这类表达式,但不能绑定到一个左值上。
  • 右值引用必须绑定到右值的引用,通过 && 获得。右值引用只能绑定到一个将要销毁的对象上,因此可以自由地移动其资源。

std::move 可以将一个左值强制转化为右值,继而可以通过右值引用使用该值,以用于移动语义。move 实际上它并不能移动任何东西,它唯一的功能是将一个左值强制转换为一个右值引用。如果是一些基本类型比如 int 和 char[10] 定长数组等类型,使用 move 的话仍然会发生拷贝(因为没有对应的移动构造函数)

T&& t 在发生自动类型推断的时候,它是未定的引用类型(universal references),如果被一个左值初始化,它就是一个左值;如果它被一个右值初始化,它就是一个右值,它是左值还是右值取决于它的初始化。需要注意的是,仅仅是当发生自动类型推导(如函数模板的类型自动推导,或auto关键字)的时候,T&& 才是 universal references

引用折叠:正是因为引入了右值引用,所以可能存在左值引用与右值引用和右值引用与右值引用的折叠,C++11 确定了引用折叠的规则,规则是这样的:

  • 所有的右值引用叠加到右值引用上仍然还是一个右值引用;
  • 所有的其他引用类型之间的叠加都将变成左值引用。

完美转发:C++11 引入了完美转发:在函数模板中,完全依照模板的参数的类型(即保持参数的左值、右值特征),将参数传递给函数模板中调用的另外一个函数。C++11中的 std::forward 正是做这个事情的,他会按照参数的实际类型进行转发

1
2
3
4
5
6
7
8
9
10
11
void processValue(int& a){ cout << "lvalue" << endl; }
void processValue(int&& a){ cout << "rvalue" << endl; }
template <typename T>
void forwardValue(T&& val) {
processValue(std::forward<T>(val)); //照参数本来的类型进行转发。
}
void Testdelcl() {
int i = 0;
forwardValue(i); //传入左值 lvaue
forwardValue(0);//传入右值 rvalue
}

右值引用 T&& 是一个 universal references,可以接受左值或者右值,正是这个特性让他适合作为一个参数的路由,然后再通过 std::forward 按照参数的实际类型去匹配对应的重载函数,最终实现完美转发。

std::move() 函数的实现原理

std::move() 函数原型:

1
2
3
4
template <typename T>
typename remove_reference<T>::type&& move(T&& t) {
return static_cast<typename remove_reference<T>::type &&>(t);
}

说明:引用折叠原理

  • 右值传递给上述函数的形参 T&& 依然是右值,即 T&& && 相当于 T&&
  • 左值传递给上述函数的形参 T&& 依然是左值,即 T&& & 相当于 T&

小结:通过引用折叠原理可以知道,move() 函数的形参既可以是左值也可以是右值。

remove_reference 具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//原始的,最通用的版本
template <typename T>
struct remove_reference {
typedef T type; //定义 T 的类型别名为 type
};

//部分版本特例化,将用于左值引用和右值引用
template <class T>
struct remove_reference<T&> //左值引用
{ typedef T type; }

template <class T>
struct remove_reference<T&&> //右值引用
{ typedef T type; }

//举例如下,下列定义的a、b、c三个变量都是int类型
int i;
remove_refrence<decltype(42)>::type a; //使用原版本,
remove_refrence<decltype(i)>::type b; //左值引用特例版本
remove_refrence<decltype(std::move(i))>::type b; //右值引用特例版本

举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
int var = 10; 

转化过程:
1. std::move(var) => std::move(int&& &) => 折叠后 std::move(int&)

2. 此时:T 的类型为 int&,typename remove_reference<T>::type 为 int,这里使用 remove_reference 的左值引用的特例化版本

3. 通过 static_castint& 强制转换为 int&&

整个std::move被实例化如下
string&& move(int& t) {
return static_cast<int&&>(t);
}

总结:

std::move() 实现原理:

  1. 利用引用折叠原理将右值经过 T&& 传递类型保持不变还是右值,而左值经过 T&& 变为普通的左值引用,以保证模板可以传递任意实参,且保持类型不变;
  2. 然后通过 remove_refrence 移除引用,得到具体的类型 T
  3. 最后通过 static_cast<> 进行强制类型转换,返回 T&& 右值引用。

什么是指针?指针的大小及用法?

指针: 指向另外一种类型的复合类型。

指针的大小: 在 32 位计算机中,指针占用 4 个字节空间;在 64 位计算机中,指针占 8 个字节空间。

1
2
3
4
5
6
7
8
int main() {
int *p = nullptr;
cout << sizeof(p) << endl; // 8

char *p1 = nullptr;
cout << sizeof(p1) << endl; // 8
return 0;
}

指针的用法:

  1. 指向普通对象的指针

  2. 指向常量对象的指针:常量指针

    1
    2
    3
    4
    5
    6
    int main(void) {
    const int c_var = 10;
    const int * p = &c_var;
    cout << *p << endl;
    return 0;
    }
  3. 指向函数的指针:函数指针

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    int add(int a, int b) {
    return a + b;
    }

    int main(void) {
    int (*fun_p)(int, int);
    fun_p = add;
    cout << fun_p(1, 6) << endl;
    return 0;
    }
  4. 指向对象成员的指针,包括指向对象成员函数的指针和指向对象成员变量的指针。
    特别注意:定义指向成员函数的指针时,要标明指针所属的类。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    class A {
    public:
    int var1, var2;
    int add(){ return var1 + var2; }
    };

    int main() {
    A ex;
    ex.var1 = 3;
    ex.var2 = 4;
    int *p1 = &ex.var1; // 指向对象成员变量的指针 3
    cout << *p1 << endl;
    int A::*p2 = &A::var2;
    cout << ex.*p2 << endl; // 4

    int (A::*fun_p)();
    fun_p = &A::add; // 指向对象成员函数的指针 fun_p 7
    cout << (ex.*fun_p)() << endl;
    return 0;
    }
  5. this 指针:指向类的当前对象的指针常量。

什么是野指针和悬空指针?

  • 悬空指针:

    若指针指向一块内存空间,当这块内存空间被释放后,该指针依然指向这块内存空间,此时,称该指针为“悬空指针”。

    1
    2
    3
    4
    5
    void *p = malloc(size);
    free(p);
    // 此时,p 指向的内存空间已释放, p 就是悬空指针。

    p = nullptr; // 现在 p 就不是悬空指针了
  • 野指针:

    “野指针” 是指不确定其指向的指针,未初始化的指针为“野指针”。

    1
    2
    void *p; 
    // 此时 p 是“野指针”。

C++ 11 nullptr 比 NULL 优势

  • NULL:预处理变量,是一个宏,它的值是 0,定义在头文件 <cstdlib> 中,即 #define NULL 0
  • nullptr:C++ 11 中的关键字,是一种特殊类型的字面值,nullptr 可以转换成任意其他指针类型。

nullptr 的优势:

  1. 有类型,类型是 typdef decltype(nullptr) nullptr_t;,使用 nullptr 提高代码的健壮性。
  2. 函数重载:因为 NULL 本质上是 0,在函数调用过程中,若出现函数重载并且传递的实参是 NULL,可能会出现,不知和哪一个函数匹配的情况;但是传递实参 nullptr 就不会出现这种情况,nullptr 本身是指针类型,不能转化为整数类型,否则还会在重载时出现二义性问题。
  • C++
1
2
3
4
5
6
7
8
void fun(char const *p) { cout << "fun(char const *p)" << endl; }
void fun(int tmp) { cout << "fun(int tmp)" << endl; }

int main() {
fun(nullptr); // fun(char const *p)
fun(NULL); // error: call of overloaded 'fun(NULL)' is ambiguous
return 0;
}

指针和引用的区别?

  • 指针所指向的内存空间在程序运行过程中可以改变,而引用所绑定的对象一旦绑定就不能改变绑定的对象。(是否可变)
  • 指针本身在内存中占有内存空间,引用相当于变量的别名,在内存中不占内存空间。(是否占内存)
  • 指针可以为空,但是引用必须绑定对象。(是否可为空)
  • 指针可以有多级,但是引用只能一级。(是否能为多级)

引用是否占内存,取决于编译器的实现。
如果编译器用指针实现引用,那么它占内存。
如果编译器直接将引用替换为其所指的对象,则其不占内存(毕竟,替换掉之后,该引用实际就不存在了)。

顺便一提,你无法用 sizeof 得到引用的大小,sizeof 作用于引用时,你得到的是它对应的对象的大小。

常量指针和指针常量的区别

常量指针:
常量指针本质上是个指针,只不过这个指针指向的对象是常量。
特点:const 的位置在指针声明运算符 * 的左侧。

只要 const 位于 * 的左侧,无论它在类型名的左边或右边,都表示指向常量的指针。(可以这样理解,* 左侧表示指针指向的对象,该对象为常量,那么该指针为常量指针。)

1
2
const int * p;
int const * p;

注意 1:指针指向的对象不能通过这个指针来修改,也就是说常量指针可以被赋值为变量的地址,之所以叫做常量指针,是限制了通过这个指针修改变量的值。
例如:

1
2
3
4
5
6
7
8
#include <iostream>
using namespace std;
int main() {
const int c_var = 8;
const int *p = &c_var;
*p = 6; // error: assignment of read-only location '* p'
return 0;
}

注意 2:虽然常量指针指向的对象不能变化,可是因为常量指针本身是一个变量,因此,可以被重新赋值。

1
2
3
4
5
6
7
8
9
10
#include <iostream>
using namespace std;

int main() {
const int c_var1 = 8;
const int c_var2 = 8;
const int *p = &c_var1;
p = &c_var2;
return 0;
}

指针常量:
指针常量的本质上是个常量,只不过这个常量的值是一个指针。
特点:const 位于指针声明操作符右侧,表明该对象本身是一个常量,* 左侧表示该指针指向的类型,即以 * 为分界线,其左侧表示指针指向的类型,右侧表示指针本身的性质。

1
2
const int var;
int * const c_p = &var;

注意 1:指针常量的值是指针,这个值因为是常量,所以指针本身不能改变。

1
2
3
4
5
6
7
8
9
#include <iostream>
using namespace std;

int main() {
int var, var1;
int * const c_p = &var;
c_p = &var1; // error: assignment of read-only variable 'c_p'
return 0;
}

注意 2:指针的内容可以改变。

1
2
3
4
5
6
7
8
9
#include <iostream>
using namespace std;

int main() {
int var = 3;
int * const c_p = &var;
*c_p = 12;
return 0;
}

函数指针和指针函数的区别

指针函数:
指针函数本质是一个函数,只不过该函数的返回值是一个指针。相对于普通函数而言,只是返回值是指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Type {
int var1;
int var2;
};

Type * fun(int tmp1, int tmp2){
Type * t = new Type();
t->var1 = tmp1;
t->var2 = tmp2;
return t;
}

int main() {
Type *p = fun(5, 6);
return 0;
}

函数指针:
函数指针本质是一个指针变量,只不过这个指针指向一个函数。函数指针即指向函数的指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int fun1(int tmp1, int tmp2) { return tmp1 * tmp2; }
int fun2(int tmp1, int tmp2) { return tmp1 / tmp2; }

int main() {
int (*fun)(int x, int y);
fun = fun1;
cout << fun(15, 5) << endl;
fun = fun2;
cout << fun(15, 5) << endl;
return 0;
}
/*
运行结果:
75
3
*/

函数指针和指针函数的区别:

  • 本质不同
    • 指针函数本质是一个函数,其返回值为指针。
    • 函数指针本质是一个指针变量,其指向一个函数。
  • 定义形式不同
    • 指针函数:int* fun(int tmp1, int tmp2); ,这里* 表示函数的返回值类型是指针类型。
    • 函数指针:int (*fun)(int tmp1, int tmp2);,这里* 表示变量本身是指针类型。
  • 用法不同

强制类型转换有哪几种?

关键字 说明
static_cast 用于良性转换,一般不会导致意外发生,风险很低。
const_cast 用于 const 与非 const、volatile 与非 volatile 之间的转换。
reinterpret_cast 高度危险的转换,这种转换仅仅是对二进制位的重新解释,不会借助已有的转换规则对数据进行调整,但是可以实现最灵活的 C++ 类型转换。
dynamic_cast 借助 RTTI(运行时类型识别),用于类型安全的向下转型(Downcasting)。
  • static_cast:用于数据的强制类型转换,强制将一种数据类型转换为另一种数据类型。

    1. 用于基本数据类型的转换。

    2. 用于类层次之间的基类和派生类之间 指针或者引用 的转换(不要求必须包含虚函数,但必须是有相互联系的类),进行上行转换(派生类的指针或引用转换成基类表示)是安全的;进行下行转换(基类的指针或引用转换成派生类表示)由于没有动态类型检查,所以是不安全的,最好用 dynamic_cast 进行下行转换。

    3. 可以将空指针转化成目标类型的空指针。

    4. 可以将任何类型的表达式转化成 void 类型。

    5. static_cast 只能用于良性转换,这样的转换风险较低,一般不会发生什么意外,例如:

      • 原有的自动类型转换,例如 short 转 int、int 转 double、const 转非 const、向上转型等;
      • void 指针和具体类型指针之间的转换,例如void *int *char *void *等;
      • 有转换构造函数或者类型转换函数的类与其它类型之间的转换,例如 double 转 Complex(调用转换构造函数)、Complex 转 double(调用类型转换函数)。

      需要注意的是,static_cast 不能用于无关类型之间的转换,因为这些转换都是有风险的,例如:

      • 两个具体类型指针之间的转换,例如int *double *Student *int *等。不同类型的数据存储格式不一样,长度也不一样,用 A 类型的指针指向 B 类型的数据后,会按照 A 类型的方式来处理数据:如果是读取操作,可能会得到一堆没有意义的值;如果是写入操作,可能会使 B 类型的数据遭到破坏,当再次以 B 类型的方式读取数据时会得到一堆没有意义的值。
      • int 和指针之间的转换。将一个具体的地址赋值给指针变量是非常危险的,因为该地址上的内存可能没有分配,也可能没有读写权限,恰好是可用内存反而是小概率事件。

      static_cast 也不能用来去掉表达式的 const 修饰和 volatile 修饰。换句话说,不能将 const/volatile 类型转换为非 const/volatile 类型。

      static_cast 是“静态转换”的意思,也就是在编译期间转换,转换失败的话会抛出一个编译错误。

  • const_cast:const_cast 比较好理解,它用来去掉表达式的 const 修饰或 volatile 修饰。换句话说,const_cast 就是用来将 const/volatile 类型转换为非 const/volatile 类型。

  • reinterpret_cast:reinterpret 是“重新解释”的意思,顾名思义,reinterpret_cast 这种转换仅仅是对二进制位的重新解释,不会借助已有的转换规则对数据进行调整,非常简单粗暴,所以风险很高。reinterpret_cast 可以认为是 static_cast 的一种补充,一些 static_cast 不能完成的转换,就可以用 reinterpret_cast 来完成,例如两个具体类型指针之间的转换、int 和指针之间的转换(有些编译器只允许 int 转指针,不允许反过来)。

  • dynamic_cast

    • dynamic_cast 用于在类的继承层次之间进行类型转换,它既允许向上转型(Upcasting),也允许向下转型(Downcasting)。向上转型是无条件的,不会进行任何检测,所以都能成功;向下转型的前提必须是安全的,要借助 RTTI 进行检测,所有只有一部分能成功。

    • 其他三种都是编译时完成的,动态类型转换是在程序运行时处理的,运行时会进行类型检查。

    • 只能用于带有虚函数的基类或派生类的指针或者引用对象的转换,转换成功返回指向类型的指针或引用,转换失败返回 NULL;不能用于基本数据类型的转换。

    • 在向上进行转换时,即派生类类的指针转换成基类类的指针和 static_cast 效果是一样的,(注意:这里只是改变了指针的类型,指针指向的对象的类型并未发生改变)。

    • 在下行转换时,基类的指针类型转化为派生类类的指针类型,只有当要转换的指针指向的对象类型是转化以后的对象类型及其后代,才会转化成功。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      #include <iostream>
      using namespace std;

      class A {
      public:
      virtual void func() const { cout << "Class A" << endl; }

      private:
      int m_a;
      };

      class B : public A {
      public:
      virtual void func() const { cout << "Class B" << endl; }

      private:
      int m_b;
      };

      class C : public B {
      public:
      virtual void func() const { cout << "Class C" << endl; }

      private:
      int m_c;
      };

      class D : public C {
      public:
      virtual void func() const { cout << "Class D" << endl; }

      private:
      int m_d;
      };

      int main() {
      A *pa = new A();
      B *pb;
      C *pc;

      //情况①
      pb = dynamic_cast<B *>(pa); //向下转型失败
      if (pb == NULL) {
      cout << "Downcasting failed: A* to B*" << endl;
      } else {
      cout << "Downcasting successfully: A* to B*" << endl;
      pb->func();
      }
      pc = dynamic_cast<C *>(pa); //向下转型失败
      if (pc == NULL) {
      cout << "Downcasting failed: A* to C*" << endl;
      } else {
      cout << "Downcasting successfully: A* to C*" << endl;
      pc->func();
      }

      cout << "-------------------------" << endl;

      //情况②
      pa = new D(); //向上转型都是允许的
      pb = dynamic_cast<B *>(pa); //向下转型成功
      if (pb == NULL) {
      cout << "Downcasting failed: A* to B*" << endl;
      } else {
      cout << "Downcasting successfully: A* to B*" << endl;
      pb->func();
      }
      pc = dynamic_cast<C *>(pa); //向下转型成功
      if (pc == NULL) {
      cout << "Downcasting failed: A* to C*" << endl;
      } else {
      cout << "Downcasting successfully: A* to C*" << endl;
      pc->func();
      }

      return 0;
      }

如何判断结构体是否相等?能否用 memcmp 函数判断结构体相等?

需要重载操作符 == 判断两个结构体是否相等,不能用函数 memcmp 来判断两个结构体是否相等,因为 memcmp 函数是逐个字节进行比较的,而结构体存在内存空间中保存时存在字节对齐,字节对齐时补的字节内容是随机的,会产生垃圾值,所以无法比较。

利用运算符重载来实现结构体对象的比较:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>

using namespace std;

struct A {
char c;
int val;
A(char c_tmp, int tmp) : c(c_tmp), val(tmp) {}

friend bool operator==(const A &tmp1, const A &tmp2); // 友元运算符重载函数
};

bool operator==(const A &tmp1, const A &tmp2) {
return (tmp1.c == tmp2.c && tmp1.val == tmp2.val);
}

int main() {
A ex1('a', 90), ex2('b', 80);
if (ex1 == ex2)
cout << "ex1 == ex2" << endl;
else
cout << "ex1 != ex2" << endl; // 输出
return 0;
}

关于重载 == 双目操作符!!!

操作符 == 为双目操作符,因此只接受两个参数。这时在类内定义或声明操作符时需要特别注意,因为类的成员函数会有隐含的 this 指针,如果定义为 bool operator==(const A &tmp1, const A &tmp2) ,实则有三个参数,在编译时会报错提示参数太多。

针对以上问题解决方法有以下三种:

  1. 如本示例一样,定义为友元函数,则不会有隐含的this指针。
  2. 在类外定义重载操作符,成为全局函数。
  3. 在类内定义重载操作符(非友元函数),但只含一个参数,加上隐含的this指针,共两个参数。

参数传递时,值传递、引用传递、指针传递的区别?

参数传递的三种方式:

  • 值传递:形参是实参的拷贝,函数对形参的所有操作不会影响实参。
  • 指针传递:本质上是值传递,只不过拷贝的是指针的值,拷贝之后,实参和形参是不同的指针,通过指针可以间接的访问指针所指向的对象,从而可以修改它所指对象的值。
  • 引用传递:当形参是引用类型时,我们说它对应的实参被引用传递。

什么是模板?如何实现?

模板:创建类或者函数的蓝图或者公式,分为函数模板和类模板。
实现方式:模板定义以关键字 template 开始,后跟一个模板参数列表。

  • 模板参数列表不能为空;
  • 模板类型参数前必须使用关键字 class 或者 typename,在模板参数列表中这两个关键字含义相同,可互换使用。
1
template <typename T, typename U, ...>
  1. 函数模板:通过定义一个函数模板,可以避免为每一种类型定义一个新函数。

    • 对于函数模板而言,模板类型参数可以用来指定返回类型或函数的参数类型,以及在函数体内用于变量声明或类型转换。
    • 函数模板实例化:当调用一个模板时,编译器用函数实参来推断模板实参,从而使用实参的类型来确定绑定到模板参数的类型
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    #include <iostream>
    using namespace std;

    template <typename T>
    T add_fun(const T& tmp1, const T& tmp2) {
    return tmp1 + tmp2;
    }

    int main() {
    int var1, var2;
    cin >> var1 >> var2;
    cout << add_fun(var1, var2);

    double var3, var4;
    cin >> var3 >> var4;
    cout << add_fun(var3, var4);
    return 0;
    }
  2. 类模板:类似函数模板,类模板以关键字 template 开始,后跟模板参数列表。但是,编译器不能为类模板推断模板参数类型,需要在使用该类模板时,在模板名后面的尖括号中指明类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    #include <iostream>
    using namespace std;
    template <typename T>
    class Complex {
    public:
    //构造函数
    Complex(T a, T b) {
    this->a = a;
    this->b = b;
    }

    //运算符重载
    Complex<T> operator+(Complex &c) {
    Complex<T> tmp(this->a + c.a, this->b + c.b);
    cout << tmp.a << " " << tmp.b << endl;
    return tmp;
    }

    private:
    T a;
    T b;
    };

    int main() {
    Complex<int> a(10, 20);
    Complex<int> b(20, 30);
    Complex<int> c = a + b;

    return 0;
    }

函数模板和类模板的区别?

  • 实例化方式不同:函数模板实例化由编译程序在处理函数调用时自动完成,类模板实例化需要在程序中显式指定。
  • 重载:函数模板是可以被重载的(类模板不能被重载),也就是说允许存在两个同名的函数模板,还可以对它们进行实例化,使它们具有相同的参数类型。
  • 默认参数:类模板在模板参数列表中可以有默认参数。在C++11 中函数模板也可以使用默认参数,且不用像类模板一样严格按照从右到左的顺序定义默认参数。
  • 特化:函数模板只能全特化;而类模板可以全特化,也可以偏特化。再次划重点 函数模板不能被偏特化
  • 调用方式不同:函数模板可以隐式调用,也可以显式调用;类模板只能显式调用。但在 C++17 中引入了 CTAD,类的模板参数也不一定需要显示指定了

函数模板重载、调用方式举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
using namespace std;

//函数模板
template <typename T>
int fun(T) {
return 1;
}
//函数模板的重载
template <typename T>
int fun(T*) {
return 2;
}

template <typename T>
T add_fun(const T& tmp1, const T& tmp2) {
return tmp1 + tmp2;
}

int main() {
// 函数模板重载示例
int a = 0;
int* p = &a;
cout << fun<int*>(p) << endl; // 1
cout << fun<int>(p) << endl; // 2

// 函数模板调用示例
int var1 = 1, var2 = 2;
double var3 = 1.1, var4 = 2.2;
cout << add_fun<int>(var1, var2) << endl; // 显式调用 3
cout << add_fun(var3, var4) << endl; // 隐式调用 3.3
return 0;
}

偏特例化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
using namespace std;

template <typename T1, typename T2>
class A {
public:
void function(T1 value1, T2 value2) {
cout << "value1 = " << value1 << endl;
cout << "value2 = " << value2 << endl;
}
};

template <typename T>
class A<T, double> { // 部分类型明确化,为偏特化类
public:
void function(T value1, double value2) {
cout << "Value = " << value1 << endl;
cout << "doubleValue = " << value2 << endl;
}
};

int main() {
A<char, double> a;
a.function('a', 12.3);
return 0;
}

什么是可变参数模板?

可变参数模板:接受可变数目参数的模板函数或模板类。将可变数目的参数被称为参数包,包括模板参数包和函数参数包。

  • 模板参数包:表示零个或多个模板参数;
  • 函数参数包:表示零个或多个函数参数。

用省略号来指出一个模板参数或函数参数表示一个包,在模板参数列表中,class...typename... 指出接下来的参数表示零个或多个类型的列表;一个类型名后面跟一个省略号表示零个或多个给定类型的非类型参数的列表。当需要知道包中有多少元素时,可以使用 sizeof... 运算符。

1
2
template <typename T, typename... Args> // Args 是模板参数包
void foo(const T &t, const Args&... rest); // 可变参数模板,rest 是函数参数包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#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", "world", "!");
return 0;
}
/*运行结果:
Hello world !
*/

说明:可变参数函数通常是递归的,第一个版本的 print_fun 负责终止递归并打印初始调用中的最后一个实参。第二个版本的 print_fun 是可变参数版本,打印绑定到 t 的实参,并用来调用自身来打印函数参数包中的剩余值。

什么是模板特化?为什么特化?

模板特化的原因:模板并非对任何模板实参都合适、都能实例化,某些情况下,通用模板的定义对特定类型不合适,可能会编译失败,或者得不到正确的结果。因此,当不希望使用模板版本时,可以定义类或者函数模板的一个特例化版本。

模板特化:模板参数在某种特定类型下的具体实现。分为函数模板特化和类模板特化

  • 函数模板特化:将函数模板中的全部类型进行特例化,称为函数模板特化。
  • 类模板特化:将类模板中的部分或全部类型进行特例化,称为类模板特化。

特化分为全特化和偏特化:

  • 全特化:模板中的模板参数全部特例化。
  • 偏特化:模板中的模板参数只确定了一部分,剩余部分需要在编译器编译时确定。

说明:要区分下函数重载与函数模板特化
定义函数模板的特化版本,本质上是接管了编译器的工作,为原函数模板定义了一个特殊实例,而不是函数重载,函数模板特化并不影响函数匹配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <cstring>
#include <iostream>

using namespace std;
//函数模板
template <class T>
bool compare(T t1, T t2) {
cout << "通用版本:";
return t1 == t2;
}

template <> //函数模板特化
bool compare(char *t1, char *t2) {
cout << "特化版本:";
return strcmp(t1, t2) == 0;
}

int main(int argc, char *argv[]) {
char arr1[] = "hello";
char arr2[] = "abc";
cout << compare(123, 123) << endl;
cout << compare(arr1, arr2) << endl;

return 0;
}
/*
运行结果:
通用版本:1
特化版本:0
*/

include " " 和 <> 的区别

include<文件名>#include"文件名" 的区别:

  • 查找文件的位置:include<文件名> 在标准库头文件所在的目录中查找,如果没有,编译器会终止查找,直接报错:No such file or directory.;#include"文件名" 在当前源文件所在目录中进行查找,如果没有;再到系统目录中查找。
  • 使用习惯:对于标准库中的头文件常用 include<文件名>,对于自己定义的头文件,常用 #include"文件名"

注意:虽然 #include"" 的查找范围更广,但是这并不意味着,不论是系统头文件,还是自定义头文件,一律用 #include""包含。因为 #include" " 的查找顺序存在先后关系,如果项目当前目录或者引用目录下存在和系统目录下重名的头文件,那么编译器在当前目录或者引用目录查找成功后,将不会继续查找,所以存在头文件覆盖的问题。另外,对于系统头文件,用 #include<>包含,查找时一步到位,程序编译时的效率也会相对更高。

switch 的 case 里定义变量细节

  1. 报错

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    #include <iostream>
    using namespace std;

    int main() {
    int var = 0;
    switch (var) {
    case 0:
    int a = 0;
    break;
    default:
    break;
    }
    }
    /*
    a.cpp:9:11: note: crosses initialization of 'int cnt'
    9 | int a = 0;
    | ^~~
    */
  2. 错误原因
    究其根本原因,是C++的一条规则:在任何作用域内,假如存在变量初始化语句,该初始化语句不可以被跳过,一定要执行!
    这里强调在作用域内的变量一旦初始化就不能跳过,但是可以跳过整个作用域!
    比如初始化语句 int a = 0;,就有虽然进入其所在的作用域,但是不被执行的风险,所以就报错了!

  3. 如何修改

    1. 把 int a; 移到 switch 和 case 之间:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      #include <iostream>
      using namespace std;

      int main() {
      int var = 0;

      switch (var) {
      int a;
      case 0:
      a = 0;
      break;
      default:
      break;
      }
      }
    2. 在case后+作用域符号{}

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      #include <iostream>
      using namespace std;

      int main() {
      int var = 0;

      switch (var) {
      case 0: {
      int a = 0;
      break;
      }
      default:
      break;
      }
      }

    这两种修改,都保证了只要进入a的作用域,都会执行a的初始化语句!

  4. 判断对错

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    #include <iostream>
    using namespace std;

    int main() {
    int var = 1;

    switch (var) {
    case 0:
    int a;
    break;
    case 1:
    a = 10;
    break;
    default:
    break;
    }
    }

    在C++中是正常编译和执行的,因为:int a 只是定义了 a,并没有初始化,没有违背上述规则!
    编译的时候,编译到case 0 的时候分配空间,运行到case 1的时候赋值,a 的作用域就是定义后开始到 switch 结束的整个作用域

  5. 关于声明、定义和初始化:
    ①声明变量不会分配内存空间;
    ②定义变量int a,编译的时候会分配内存,但是并不会产生任何可执行的代码,所以int a这句话只是在编译的时候有用,执行的时候跳过的时候也无所谓!
    ③初始化变量分配空间并初始化(编译时分配空间,运行时初始化赋值),假如存在,一定要执行!

迭代器的作用?

  • 迭代器:一种抽象的设计概念,在设计模式中有迭代器模式,即提供一种方法,使之能够依序寻访某个容器所含的各个元素,而无需暴露该容器的内部表述方式

  • 作用:在无需知道容器底层原理的情况下,遍历容器中的元素

泛型编程如何实现?

泛型编程实现的基础:模板。模板是创建类或者函数的蓝图或者说公式,当时用一个 vector 这样的泛型,或者 find 这样的泛型函数时,编译时会转化为特定的类或者函数。

标准模板库的六大组件:
1、容器
2、算法
3、迭代器
4、分配器
5、仿函数
6、适配器

什么是类型萃取?

类型萃取使用模板技术来萃取类型(包含自定义类型和内置类型)的某些特性,用以判断该类型是否含有某些特性,从而在泛型算法中来对该类型进行特殊的处理用来提高效率或者其他。

C++ 类型萃取一般用于模板中,当我们定义一个模板函数后,需要知道模板类型形参并加以运用时就可以用类型萃取。

  • 比如我们需要在函数中进行拷贝,通常我们可以用内置函数 memcpy 或者自己写一个 for 循环来进行拷贝。

C++ 设计模式

什么是单例模式?如何实现?应用场景?

单例模式保证类的实例化对象仅有一个,并且提供一个访问他的全局访问点

应用场景

  • 表示文件系统的类,一个操作系统一定是只有一个文件系统,因此文件系统的类的实例有且仅有一个。
  • 打印机打印程序的实例,一台计算机可以连接好几台打印机,但是计算机上的打印程序只有一个,就可以通过单例模式来避免两个打印作业同时输出到打印机。

实现方式:
单例模式可以通过全局或者静态变量的形式实现,这样比较简单,但是这样会影响封装性,难以保证别的代码不会对全局变量造成影响。

  • 默认的构造函数、拷贝构造函数、拷贝赋值运算符声明为私有的,这样禁止在类的外部创建该对象;
  • 全局访问点也要定义成 静态类型的成员函数,没有参数,返回该类的指针类型。因为使用实例化对象的时候是通过类直接调用该函数,并不是先创建一个该类的对象,通过对象调用。

不安全的实现方式:
原因:考虑当两个线程同时调用 getInstance 方法,并且同时检测到 instanceNULL,两个线程会同时实例化对象,不符合单例模式的要求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Singleton{
private:
static Singleton * instance;
Singleton(){}
Singleton(const Singleton& tmp){}
Singleton& operator=(const Singleton& tmp){}
public:
static Singleton* getInstance(){
if(instance == NULL){
instance = new Singleton();
}
return instance;
}
};
Singleton* Singleton::instance = NULL;

分类:

  • 懒汉模式:直到第一次用到类的实例时才去实例化,上面是懒汉实现。
  • 饿汉模式:类定义的时候就实例化。

线程安全的懒汉模式实现:
方法一:加锁
存在的问题:每次判断实例对象是否为空,都要被锁定,如果是多线程的话,就会造成大量线程阻塞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Singleton{
private:
static pthread_mutex_t mutex;
static Singleton * instance;
Singleton(){
pthread_mutex_init(&mutex, NULL);
}
Singleton(const Singleton& tmp){}
Singleton& operator=(const Singleton& tmp){}
public:
static Singleton* getInstance(){
pthread_mutex_lock(&mutex);
if(instance == NULL){
instance = new Singleton();
}
pthread_mutex_unlock(&mutex);
return instance;
}
};

Singleton* Singleton::instance = NULL;
pthread_mutex_t Singleton::mutex;

方法二:内部静态变量在全局访问点 getInstance 中定义静态实例。推荐!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Singleton{
private:
static pthread_mutex_t mutex;
Singleton(){
pthread_mutex_init(&mutex, NULL);
}
Singleton(const Singleton& temp){}
Singleton& operator=(const Singleton& temp){}
public:
static Singleton* getInstance(){
static Singleton instance;
return &instance;
}
};
pthread_mutex_t Singleton::mutex;

饿汉模式的实现:
饿汉模式本身就是线程安全的不用加锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Singleton{
private:
static Singleton* instance;
Singleton(const Singleton& temp){}
Singleton& operator=(const Singleton& temp){}
protected:
Singleton(){}
public:
static Singleton* getInstance(){
return instance;
}
};
Singleton* Singleton::instance = new Singleton();

懒汉式是以时间换空间,适应于访问量较小时;推荐使用内部静态变量的懒汉单例,代码量少。

饿汉式是以空间换时间,适应于访问量较大时,或者线程比较多的的情况。

什么是工厂模式?如何实现?应用场景?

工厂模式:包括简单工厂模式、抽象工厂模式、工厂方法模式

  • 简单工厂模式:主要用于创建对象。用一个工厂来根据输入的条件产生不同的类,然后根据不同类的虚函数得到不同的结果。
  • 工厂方法模式:修正了简单工厂模式中不遵守开放封闭原则。把选择判断移到了客户端去实现,如果想添加新功能就不用修改原来的类,直接修改客户端即可。
  • 抽象工厂模式:定义了一个创建一系列相关或相互依赖的接口,而无需指定他们的具体类。
  1. 简单工厂模式
    主要用于创建对象。用一个工厂来根据输入的条件产生不同的类,然后根据不同类的虚函数得到不同的结果。
    应用场景

    • 适用于针对不同情况创建不同类时,只需传入工厂类的参数即可,无需了解具体实现方法。例如:计算器中对于同样的输入,执行不同的操作:加、减、乘、除。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    #include <iostream>
    #include <vector>
    using namespace std;

    // Here is the product class
    class Operation {
    public:
    int var1, var2;
    virtual double GetResult() {
    double res = 0;
    return res;
    }
    };

    class Add_Operation : public Operation {
    public:
    virtual double GetResult() { return var1 + var2; }
    };

    class Sub_Operation : public Operation {
    public:
    virtual double GetResult() { return var1 - var2; }
    };

    class Mul_Operation : public Operation {
    public:
    virtual double GetResult() { return var1 * var2; }
    };

    class Div_Operation : public Operation {
    public:
    virtual double GetResult() { return var1 / var2; }
    };

    // Here is the Factory class
    class Factory {
    public:
    static Operation *CreateProduct(char op) {
    switch (op) {
    case '+':
    return new Add_Operation();

    case '-':
    return new Sub_Operation();

    case '*':
    return new Mul_Operation();

    case '/':
    return new Div_Operation();

    default:
    return new Add_Operation();
    }
    }
    };

    int main() {
    int a, b;
    cin >> a >> b;
    Operation *p = Factory::CreateProduct('+');
    p->var1 = a;
    p->var2 = b;
    cout << p->GetResult() << endl;

    p = Factory::CreateProduct('*');
    p->var1 = a;
    p->var2 = b;
    cout << p->GetResult() << endl;

    return 0;
    }
  2. 工厂方法模式
    修正了简单工厂模式中不遵守开放封闭原则。把选择判断移到了客户端去实现,如果想添加新功能就不用修改原来的类,直接修改客户端即可。
    应用场景

    • 一个类不知道它所需要的对象的类:在工厂方法模式中,客户端不需要知道具体产品类的类名,只需要知道所对应的工厂即可,具体的产品对象由具体工厂类创建;客户端需要知道创建具体产品的工厂类。

    • 一个类通过其派生类来指定创建哪个对象:在工厂方法模式中,对于抽象工厂类只需要提供一个创建产品的接口,而由其派生类来确定具体要创建的对象,利用面向对象的多态性和里氏代换原则,在程序运行时,派生类对象将覆盖父类对象,从而使得系统更容易扩展。

    • 将创建对象的任务委托给多个工厂派生类中的某一个,客户端在使用时可以无须关心是哪一个工厂派生类创建产品派生类,需要时再动态指定,可将具体工厂类的类名存储在配置文件或数据库中。

    • #include <iostream>
      #include <vector>
      using namespace std;
      
      // Here is the product class
      class Operation {
       public:
        int var1, var2;
        virtual double GetResult() {
          double res = 0;
          return res;
        }
      };
      
      class Add_Operation : public Operation {
       public:
        virtual double GetResult() { return var1 + var2; }
      };
      
      class Sub_Operation : public Operation {
       public:
        virtual double GetResult() { return var1 - var2; }
      };
      
      class Mul_Operation : public Operation {
       public:
        virtual double GetResult() { return var1 * var2; }
      };
      
      class Div_Operation : public Operation {
       public:
        virtual double GetResult() { return var1 / var2; }
      };
      
      class Factory {
       public:
        virtual Operation *CreateProduct() = 0;
      };
      
      class Add_Factory : public Factory {
       public:
        Operation *CreateProduct() { return new Add_Operation(); }
      };
      
      class Sub_Factory : public Factory {
       public:
        Operation *CreateProduct() { return new Sub_Operation(); }
      };
      
      class Mul_Factory : public Factory {
       public:
        Operation *CreateProduct() { return new Mul_Operation(); }
      };
      
      class Div_Factory : public Factory {
       public:
        Operation *CreateProduct() { return new Div_Operation(); }
      };
      
      int main() {
        int a, b;
        cin >> a >> b;
        Add_Factory *p_fac = new Add_Factory();
        Operation *p_pro = p_fac->CreateProduct();
        p_pro->var1 = a;
        p_pro->var2 = b;
        cout << p_pro->GetResult() << endl;
      
        Mul_Factory *p_fac1 = new Mul_Factory();
        Operation *p_pro1 = p_fac1->CreateProduct();
        p_pro1->var1 = a;
        p_pro1->var2 = b;
        cout << p_pro1->GetResult() << endl;
      
        return 0;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118
      119
      120
      121
      122
      123
      124
      125
      126
      127

      3. 抽象工厂模式
      定义了一个创建一系列相关或相互依赖的接口,而无需指定他们的具体类。
      **应用场景:**

      - 一个系统不应当依赖于产品类实例如何被创建、组合和表达的细节,这对于所有类型的工厂模式都是重要的。

      - 系统中有多于一个的产品族,而每次只使用其中某一产品族。

      - 属于同一个产品族的产品将在一起使用,这一约束必须在系统的设计中体现出来。

      - 产品等级结构稳定,设计完成之后,不会向系统中增加新的产品等级结构或者删除已有的
      产品等级结构。

      - ```cpp
      #include <iostream>
      #include <vector>
      using namespace std;

      // Here is the product class
      class Operation_Pos {
      public:
      int var1, var2;
      virtual double GetResult() {
      double res = 0;
      return res;
      }
      };

      class Add_Operation_Pos : public Operation_Pos {
      public:
      virtual double GetResult() { return var1 + var2; }
      };

      class Sub_Operation_Pos : public Operation_Pos {
      public:
      virtual double GetResult() { return var1 - var2; }
      };

      class Mul_Operation_Pos : public Operation_Pos {
      public:
      virtual double GetResult() { return var1 * var2; }
      };

      class Div_Operation_Pos : public Operation_Pos {
      public:
      virtual double GetResult() { return var1 / var2; }
      };
      /*********************************************************************************/
      class Operation_Neg {
      public:
      int var1, var2;
      virtual double GetResult() {
      double res = 0;
      return res;
      }
      };

      class Add_Operation_Neg : public Operation_Neg {
      public:
      virtual double GetResult() { return -(var1 + var2); }
      };

      class Sub_Operation_Neg : public Operation_Neg {
      public:
      virtual double GetResult() { return -(var1 - var2); }
      };

      class Mul_Operation_Neg : public Operation_Neg {
      public:
      virtual double GetResult() { return -(var1 * var2); }
      };

      class Div_Operation_Neg : public Operation_Neg {
      public:
      virtual double GetResult() { return -(var1 / var2); }
      };
      /*****************************************************************************************************/

      // Here is the Factory class
      class Factory {
      public:
      virtual Operation_Pos *CreateProduct_Pos() = 0;
      virtual Operation_Neg *CreateProduct_Neg() = 0;
      };

      class Add_Factory : public Factory {
      public:
      Operation_Pos *CreateProduct_Pos() { return new Add_Operation_Pos(); }
      Operation_Neg *CreateProduct_Neg() { return new Add_Operation_Neg(); }
      };

      class Sub_Factory : public Factory {
      public:
      Operation_Pos *CreateProduct_Pos() { return new Sub_Operation_Pos(); }
      Operation_Neg *CreateProduct_Neg() { return new Sub_Operation_Neg(); }
      };

      class Mul_Factory : public Factory {
      public:
      Operation_Pos *CreateProduct_Pos() { return new Mul_Operation_Pos(); }
      Operation_Neg *CreateProduct_Neg() { return new Mul_Operation_Neg(); }
      };

      class Div_Factory : public Factory {
      public:
      Operation_Pos *CreateProduct_Pos() { return new Div_Operation_Pos(); }
      Operation_Neg *CreateProduct_Neg() { return new Div_Operation_Neg(); }
      };

      int main() {
      int a, b;
      cin >> a >> b;
      Add_Factory *p_fac = new Add_Factory();
      Operation_Pos *p_pro = p_fac->CreateProduct_Pos();
      p_pro->var1 = a;
      p_pro->var2 = b;
      cout << p_pro->GetResult() << endl;

      Add_Factory *p_fac1 = new Add_Factory();
      Operation_Neg *p_pro1 = p_fac1->CreateProduct_Neg();
      p_pro1->var1 = a;
      p_pro1->var2 = b;
      cout << p_pro1->GetResult() << endl;

      return 0;
      }

什么是观察者模式?如何实现?应用场景?

观察者模式:定义一种一(被观察类)对多(观察类)的关系,让多个观察对象同时监听一个被观察对象,被观察对象状态发生变化时,会通知所有的观察对象,使他们能够更新自己的状态。

观察者模式中存在两种角色:

  • 观察者:内部包含被观察者对象,当被观察者对象的状态发生变化时,更新自己的状态。(接收通知更新状态)
  • 被观察者:内部包含了所有观察者对象,当状态发生变化时通知所有的观察者更新自己的状态。(发送通知)

应用场景

  • 当一个对象的改变需要同时改变其他对象,且不知道具体有多少对象有待改变时,应该考虑使用观察者模式;
  • 一个抽象模型有两个方面,其中一方面依赖于另一方面,这时可以用观察者模式将这两者封装在独立的对象中使它们各自独立地改变和复用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
#include <iostream>
#include <list>
#include <string>
using namespace std;

class Subject;
//观察者 基类 (内部实例化了被观察者的对象sub)
class Observer {
protected:
string name;
Subject *sub;

public:
Observer(string name, Subject *sub) {
this->name = name;
this->sub = sub;
}
virtual void update() = 0;
};

class StockObserver : public Observer {
public:
StockObserver(string name, Subject *sub) : Observer(name, sub) {}
void update();
};

class NBAObserver : public Observer {
public:
NBAObserver(string name, Subject *sub) : Observer(name, sub) {}
void update();
};
//被观察者 基类
//(内部存放了所有的观察者对象,以便状态发生变化时,给观察者发通知)
class Subject {
protected:
list<Observer *> observers;

public:
string action; //被观察者对象的状态
virtual void attach(Observer *) = 0;
virtual void detach(Observer *) = 0;
virtual void notify() = 0;
};

class Secretary : public Subject {
void attach(Observer *observer) { observers.push_back(observer); }
void detach(Observer *observer) {
list<Observer *>::iterator iter = observers.begin();
while (iter != observers.end()) {
if ((*iter) == observer) {
observers.erase(iter);
return;
}
++iter;
}
}
void notify() {
list<Observer *>::iterator iter = observers.begin();
while (iter != observers.end()) {
(*iter)->update();
++iter;
}
}
};

void StockObserver::update() {
cout << name << " 收到消息:" << sub->action << endl;
if (sub->action == "梁所长来了!") {
cout << "我马上关闭股票,装做很认真工作的样子!" << endl;
}
}

void NBAObserver::update() {
cout << name << " 收到消息:" << sub->action << endl;
if (sub->action == "梁所长来了!") {
cout << "我马上关闭NBA,装做很认真工作的样子!" << endl;
}
}

int main() {
Subject *dwq = new Secretary();
Observer *xs = new NBAObserver("xiaoshuai", dwq);
Observer *zy = new NBAObserver("zouyue", dwq);
Observer *lm = new StockObserver("limin", dwq);

dwq->attach(xs);
dwq->attach(zy);
dwq->attach(lm);

dwq->action = "去吃饭了!";
dwq->notify();
cout << endl;
dwq->action = "梁所长来了!";
dwq->notify();
return 0;
}

什么是单例设计模式,如何实现

  1. 单例模式定义

    保证一个类仅有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。

    那么我们就必须保证:

    (1)该类不能被复制。

    (2)该类不能被公开的创造。

    那么对于C++来说,它的构造函数,拷贝构造函数和赋值函数都不能被公开调用。

  2. 单例模式实现方式

    单例模式通常有两种模式,分别为懒汉式单例饿汉式单例。两种模式实现方式分别如下:

    (1)懒汉式设计模式实现方式(2种)

    a. 静态指针 + 用到时初始化

    b. 局部静态变量

    (2)饿汉式设计模式(2种)

    a. 直接定义静态对象

    b. 静态指针 + 类外初始化时new空间实现

解析

  1. 懒汉模式

    懒汉模式的特点是延迟加载,比如配置文件,采用懒汉式的方法,配置文件的实例直到用到的时候才会加载,不到万不得已就不会去实例化类,也就是说在第一次用到类实例的时候才会去实例化。以下是懒汉模式实现方式C++代码:

    (1)懒汉模式实现一:静态指针 + 用到时初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    //代码实例(线程不安全)
    template <typename T>
    class Singleton {
    public:
    static T& getInstance() {
    if (!value_) {
    value_ = new T();
    }
    return *value_;
    }

    private:
    Singleton();
    ~Singleton();
    static T* value_;
    };
    template <typename T>
    T* Singleton<T>::value_ = NULL;

    在单线程中,这样的写法是可以正确使用的,但是在多线程中就不行了,该方法是线程不安全的。
    a. 假如线程A和线程B, 这两个线程要访问getInstance函数,线程A进入getInstance函数,并检测if条件,由于是第一次进入,value为空,if条件成立,准备创建对象实例。
    b. 但是,线程A有可能被OS的调度器中断而挂起睡眠,而将控制权交给线程B。
    c. 线程B同样来到if条件,发现value还是为NULL,因为线程A还没来得及构造它就已经被中断了。此时假设线程B完成了对象的创建,并顺利的返回。
    d. 之后线程A被唤醒,继续执行new再次创建对象,这样一来,两个线程就构建两个对象实例,这就破坏了唯一性。
    另外,还存在内存泄漏的问题,new出来的东西始终没有释放,下面是一种饿汉式的一种改进。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    //代码实例(线程安全)
    template<typename T>
    class Singleton {
    public:
    static T& getInstance() {
    // 这里使用了两个 if判断语句的技术称为双检锁;好处是,只有判断指针为空的时候才加锁,
    // 避免每次调用 GetInstance的方法都加锁,锁的开销毕竟还是有点大的
    if (!value_) {
    std::unique_lock<std::mutex> lock(m_Mutex); // 加锁
    if (!value_) {
    value_ = new T();
    }
    }
    return *value_;
    }

    private:
    class CGarbo {
    public:
    ~CGarbo() {
    if (Singleton::value_) delete Singleton::value_;
    }
    };
    static CGarbo Garbo;
    Singleton();
    ~Singleton();
    static T* value_;
    static std::mutex m_Mutex;
    };

    template <typename T>
    T* Singleton<T>::value_ = NULL;

    在程序运行结束时,系统会调用Singleton的静态成员Garbo的析构函数,该析构函数会删除单例的唯一实例。使用这种方法释放单例对象有以下特征:

    a. 在单例类内部定义专有的嵌套类;

    b. 在单例类内定义私有的专门用于释放的静态成员;

    c. 利用程序在结束时析构全局变量的特性,选择最终的释放时机。

    (2)懒汉模式实现二:局部静态变量(C++ 11 线程安全)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    //代码实例(C++)
    template <typename T>
    class Singleton {
    public:
    static T& getInstance() {
    static T instance;
    return instance;
    }

    private:
    Singleton(){};
    Singleton(const Singleton&);
    Singleton& operator=(const Singleton&);
    };
  2. 饿汉模式

    单例类定义的时候就进行实例化。因为main函数执行之前,全局作用域的类成员静态变量m_Instance已经初始化,故没有多线程的问题

    (1)饿汉模式实现一:直接定义静态对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    //代码实例(线程安全)
    //.h文件
    class Singleton {
    public:
    static Singleton& GetInstance();

    private:
    Singleton() {}
    Singleton(const Singleton&);
    Singleton& operator=(const Singleton&);

    private:
    static Singleton m_Instance;
    };
    // CPP文件
    Singleton Singleton::m_Instance; //类外定义-不要忘记写
    Singleton& Singleton::GetInstance() { return m_Instance; }
    //函数调用
    Singleton& instance = Singleton::GetInstance();

    优点:

    实现简单,多线程安全。

    缺点:

    在程序开始时,就创建类的实例,如果Singleton对象产生很昂贵,而本身有很少使用,这种方式单从资源利用效率的角度来讲,比懒汉式单例类稍差些。但从反应时间角度来讲,则比懒汉式单例类稍好些。

    使用条件:

    想避免频繁加锁时的性能消耗

    (2)饿汉模式实现二:静态指针 + 类外初始化时new空间实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    //代码实例(线程安全)
    class Singleton {
    protected:
    Singleton() {}

    private:
    static Singleton* p;

    public:
    static Singleton* initance();
    };
    Singleton* Singleton::p = new Singleton;
    Singleton* singleton::initance() { return p; }

请说说工厂设计模式,如何实现,以及它的优点

  1. 工厂设计模式的定义

    定义一个创建对象的接口,让子类决定实例化哪个类,而对象的创建统一交由工厂去生产,有良好的封装性,既做到了解耦,也保证了最少知识原则。

  2. 工厂设计模式分类

    工厂模式属于创建型模式,大致可以分为三类,简单工厂模式、工厂方法模式、抽象工厂模式。听上去差不多,都是工厂模式。下面一个个介绍:

    (1)简单工厂模式

    它的主要特点是需要在工厂类中做判断,从而创造相应的产品。当增加新的产品时,就需要修改工厂类。

    **举例:**有一家生产处理器核的厂家,它只有一个工厂,能够生产两种型号的处理器核。客户需要什么样的处理器核,一定要显示地告诉生产工厂。下面给出一种实现方案:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    //程序实例(简单工厂模式)
    enum CTYPE { COREA, COREB };
    class SingleCore {
    public:
    virtual void Show() = 0;
    };
    //单核A
    class SingleCoreA : public SingleCore {
    public:
    void Show() { cout << "SingleCore A" << endl; }
    };
    //单核B
    class SingleCoreB : public SingleCore {
    public:
    void Show() { cout << "SingleCore B" << endl; }
    };
    //唯一的工厂,可以生产两种型号的处理器核,在内部判断
    class Factory {
    public:
    SingleCore* CreateSingleCore(enum CTYPE ctype) {
    if (ctype == COREA) //工厂内部判断
    return new SingleCoreA(); //生产核A
    else if (ctype == COREB)
    return new SingleCoreB(); //生产核B
    else
    return NULL;
    }
    };

    优点: 简单工厂模式可以根据需求,动态生成使用者所需类的对象,而使用者不用去知道怎么创建对象,使得各个模块各司其职,降低了系统的耦合性。

    **缺点:**就是要增加新的核类型时,就需要修改工厂类。这就违反了开放封闭原则:软件实体(类、模块、函数)可以扩展,但是不可修改。

    (2)工厂方法模式

    所谓工厂方法模式,是指定义一个用于创建对象的接口,让子类决定实例化哪一个类。Factory Method使一个类的实例化延迟到其子类。

    **举例:**这家生产处理器核的产家赚了不少钱,于是决定再开设一个工厂专门用来生产B型号的单核,而原来的工厂专门用来生产A型号的单核。这时,客户要做的是找好工厂,比如要A型号的核,就找A工厂要;否则找B工厂要,不再需要告诉工厂具体要什么型号的处理器核了。下面给出一个实现方案:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    //程序实例(工厂方法模式)
    class SingleCore {
    public:
    virtual void Show() = 0;
    };
    //单核A
    class SingleCoreA : public SingleCore {
    public:
    void Show() { cout << "SingleCore A" << endl; }
    };
    //单核B
    class SingleCoreB : public SingleCore {
    public:
    void Show() { cout << "SingleCore B" << endl; }
    };
    class Factory {
    public:
    virtual SingleCore* CreateSingleCore() = 0;
    };
    //生产A核的工厂
    class FactoryA : public Factory {
    public:
    SingleCoreA* CreateSingleCore() { return new SingleCoreA; }
    };
    //生产B核的工厂
    class FactoryB : public Factory {
    public:
    SingleCoreB* CreateSingleCore() { return new SingleCoreB; }
    };

    优点: 扩展性好,符合了开闭原则,新增一种产品时,只需增加改对应的产品类和对应的工厂子类即可。

    **缺点:**每增加一种产品,就需要增加一个对象的工厂。如果这家公司发展迅速,推出了很多新的处理器核,那么就要开设相应的新工厂。在C++实现中,就是要定义一个个的工厂类。显然,相比简单工厂模式,工厂方法模式需要更多的类定义。

    (3)抽象工厂模式

    **举例:**这家公司的技术不断进步,不仅可以生产单核处理器,也能生产多核处理器。现在简单工厂模式和工厂方法模式都鞭长莫及。抽象工厂模式登场了。它的定义为提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类。具体这样应用,这家公司还是开设两个工厂,一个专门用来生产A型号的单核多核处理器,而另一个工厂专门用来生产B型号的单核多核处理器,下面给出实现的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    //程序实例(抽象工厂模式)
    //单核
    class SingleCore {
    public:
    virtual void Show() = 0;
    };
    class SingleCoreA : public SingleCore {
    public:
    void Show() { cout << "Single Core A" << endl; }
    };
    class SingleCoreB : public SingleCore {
    public:
    void Show() { cout << "Single Core B" << endl; }
    };
    //多核
    class MultiCore {
    public:
    virtual void Show() = 0;
    };
    class MultiCoreA : public MultiCore {
    public:
    void Show() { cout << "Multi Core A" << endl; }
    };
    class MultiCoreB : public MultiCore {
    public:
    void Show() { cout << "Multi Core B" << endl; }
    };
    //工厂
    class CoreFactory {
    public:
    virtual SingleCore* CreateSingleCore() = 0;
    virtual MultiCore* CreateMultiCore() = 0;
    };
    //工厂A,专门用来生产A型号的处理器
    class FactoryA : public CoreFactory {
    public:
    SingleCore* CreateSingleCore() { return new SingleCoreA(); }
    MultiCore* CreateMultiCore() { return new MultiCoreA(); }
    };
    //工厂B,专门用来生产B型号的处理器
    class FactoryB : public CoreFactory {
    public:
    SingleCore* CreateSingleCore() { return new SingleCoreB(); }
    MultiCore* CreateMultiCore() { return new MultiCoreB(); }
    };

    优点: 工厂抽象类创建了多个类型的产品,当有需求时,可以创建相关产品子类和子工厂类来获取。

    缺点: 扩展新种类产品时困难。抽象工厂模式需要我们在工厂抽象类中提前确定了可能需要的产品种类,以满足不同型号的多种产品的需求。但是如果我们需要的产品种类并没有在工厂抽象类中提前确定,那我们就需要去修改工厂抽象类了,而一旦修改了工厂抽象类,那么所有的工厂子类也需要修改,这样显然扩展不方便。

答案解析

三种工厂模式的UML图如下:

  1. 简单工厂模式UML

    简单工厂

  2. 工厂方法的UML图

    工厂方法

  3. 抽象工厂模式的UML图

    抽象工厂模式

请说说装饰器计模式,以及它的优缺点

  1. 装饰器计模式的定义

    指在不改变现有对象结构的情况下,动态地给该对象增加一些职责(即增加其额外功能)的模式,它属于对象结构型模式。

  2. 优点

    (1)装饰器是继承的有力补充,比继承灵活,在不改变原有对象的情况下,动态的给一个对象扩展功能,即插即用;

    (2)通过使用不用装饰类及这些装饰类的排列组合,可以实现不同效果;

    (3)装饰器模式完全遵守开闭原则。

  3. 缺点

    装饰模式会增加许多子类,过度使用会增加程序得复杂性。

  4. 装饰模式的结构与实现

    通常情况下,扩展一个类的功能会使用继承方式来实现。但继承具有静态特征,耦合度高,并且随着扩展功能的增多,子类会很膨胀。如果使用组合关系来创建一个包装对象(即装饰对象)来包裹真实对象,并在保持真实对象的类结构不变的前提下,为其提供额外的功能,这就是装饰模式的目标。下面来分析其基本结构和实现方法。

    装饰模式主要包含以下角色:

    (1)抽象构件(Component)角色:定义一个抽象接口以规范准备接收附加责任的对象。

    (2)具体构件(ConcreteComponent)角色:实现抽象构件,通过装饰角色为其添加一些职责。

    (3)抽象装饰(Decorator)角色:继承抽象构件,并包含具体构件的实例,可以通过其子类扩展具体构件的功能。

    (4)具体装饰(ConcreteDecorator)角色:实现抽象装饰的相关方法,并给具体构件对象添加附加的责任。

装饰模式的结构图如下图所示:

装饰器模式

装饰模式结构图

装饰模式的实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <iostream>
#include <string>

//基础组件接口定义了可以被装饰器修改的操作
class Component {
public:
virtual ~Component() {}
virtual std::string Operation() const = 0;
};

//具体组件提供了操作的默认实现。这些类在程序中可能会有几个变体
class ConcreteComponent : public Component {
public:
std::string Operation() const override { return "ConcreteComponent"; }
};

//装饰器基类和其他组件遵循相同的接口。这个类的主要目的是为所有的具体装饰器定义封装接口。
//封装的默认实现代码中可能会包含一个保存被封装组件的成员变量,并且负责对齐进行初始化
class Decorator : public Component {
protected:
Component* component_;

public:
Decorator(Component* component) : component_(component) {}

//装饰器会将所有的工作分派给被封装的组件
std::string Operation() const override {
return this->component_->Operation();
}
};

//具体装饰器必须在被封装对象上调用方法,不过也可以自行在结果中添加一些内容。
class ConcreteDecoratorA : public Decorator {
//装饰器可以调用父类的是实现,来替代直接调用组件方法。
public:
ConcreteDecoratorA(Component* component) : Decorator(component) {}
std::string Operation() const override {
return "ConcreteDecoratorA(" + Decorator::Operation() + ")";
}
};

//装饰器可以在调用封装的组件对象的方法前后执行自己的方法
class ConcreteDecoratorB : public Decorator {
public:
ConcreteDecoratorB(Component* component) : Decorator(component) {}

std::string Operation() const override {
return "ConcreteDecoratorB(" + Decorator::Operation() + ")";
}
};

//客户端代码可以使用组件接口来操作所有的具体对象。这种方式可以使客户端和具体的实现类脱耦
void ClientCode(Component* component) {
// ...
std::cout << "RESULT: " << component->Operation();
// ...
}

int main() {
Component* simple = new ConcreteComponent;
std::cout << "Client: I've got a simple component:\n";
ClientCode(simple);
std::cout << "\n\n";

Component* decorator1 = new ConcreteDecoratorA(simple);
Component* decorator2 = new ConcreteDecoratorB(decorator1);
std::cout << "Client: Now I've got a decorated component:\n";
ClientCode(decorator2);
std::cout << "\n";

delete simple;
delete decorator1;
delete decorator2;

return 0;
}

请说说观察者设计模式,如何实现

  1. 观察者设计模式的定义

    指多个对象间存在一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。这种模式有时又称作发布-订阅模式、模型-视图模式,它是对象行为型模式。

  2. 优点

    (1)降低了目标与观察者之间的耦合关系,两者之间是抽象耦合关系。符合依赖倒置原则。

    (2)目标与观察者之间建立了一套触发机制。

  3. 缺点

    (1)目标与观察者之间的依赖关系并没有完全解除,而且有可能出现循环引用。

    (2)当观察者对象很多时,通知的发布会花费很多时间,影响程序的效率。

  4. 观察者设计模式的结构与实现

    观察者模式的主要角色如下:

    (1)抽象主题(Subject)角色:也叫抽象目标类,它提供了一个用于保存观察者对象的聚集类和增加、删除观察者对象的方法,以及通知所有观察者的抽象方法。

    (2)具体主题(Concrete Subject)角色:也叫具体目标类,它实现抽象目标中的通知方法,当具体主题的内部状态发生改变时,通知所有注册过的观察者对象。

    (3)抽象观察者(Observer)角色:它是一个抽象类或接口,它包含了一个更新自己的抽象方法,当接到具体主题的更改通知时被调用。

    (4)具体观察者(Concrete Observer)角色:实现抽象观察者中定义的抽象方法,以便在得到目标的更改通知时更新自身的状态。

可以举个博客订阅的例子,当博主发表新文章的时候,即博主状态发生了改变,那些订阅的读者就会收到通知,然后进行相应的动作,比如去看文章,或者收藏起来。博主与读者之间存在种一对多的依赖关系。下面给出相应的UML图设计:

观察者模式
观察者模式的结构图

可以看到博客类中有一个观察者链表(即订阅者),当博客的状态发生变化时,通过Notify成员函数通知所有的观察者,告诉他们博客的状态更新了。而观察者通过Update成员函数获取博客的状态信息。代码实现不难,下面给出C++的一种实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//观察者
class Observer {
public:
Observer() {}
virtual ~Observer() {}
virtual void Update() {}
};
//博客
class Blog {
public:
Blog() {}
virtual ~Blog() {}
void Attach(Observer *observer) {
m_observers.push_back(observer);
} //添加观察者
void Remove(Observer *observer) {
m_observers.remove(observer);
} //移除观察者
void Notify() //通知观察者
{
list<Observer *>::iterator iter = m_observers.begin();
for (; iter != m_observers.end(); iter++) (*iter)->Update();
}
virtual void SetStatus(string s) { m_status = s; } //设置状态
virtual string GetStatus() { return m_status; } //获得状态
private:
list<Observer *> m_observers; //观察者链表
protected:
string m_status; //状态
};

以上是观察者和博客的基类,定义了通用接口。博客类主要完成观察者的添加、移除、通知操作,设置和获得状态仅仅是一个默认实现。下面给出它们相应的子类实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//具体博客类
class BlogCSDN : public Blog {
private:
string m_name; //博主名称
public:
BlogCSDN(string name) : m_name(name) {}
~BlogCSDN() {}
void SetStatus(string s) {
m_status = "CSDN通知 : " + m_name + s;
} //具体设置状态信息
string GetStatus() { return m_status; }
};
//具体观察者
class ObserverBlog : public Observer {
private:
string m_name; //观察者名称
Blog *m_blog; //观察的博客,当然以链表形式更好,就可以观察多个博客
public:
ObserverBlog(string name, Blog *blog) : m_name(name), m_blog(blog) {}
~ObserverBlog() {}
void Update() //获得更新状态
{
string status = m_blog->GetStatus();
cout << m_name << "-------" << status << endl;
}
};

//测试案例
int main() {
Blog *blog = new BlogCSDN("wuzhekai1985");
Observer *observer1 = new ObserverBlog("tutupig", blog);
blog->Attach(observer1);
blog->SetStatus("发表设计模式C++实现(15)——观察者模式");
blog->Notify();
delete blog;
delete observer1;
return 0;
}