【系统软件工程师面试】1. C++部分

2018/03/06 01:51
阅读数 214

C++分为写库(C run time,STL,还有比如那个gaylib)的和写应用程序的。绝大多数“精通C++”的需求都来自于写库。写库这种事不是绝大多数正常人类能够完成的,所以你也不用操心。对于写应用程序的来说,极多数的语言特性其实都不需要掌握。绝大多数时候你只需要:

  1. 知道坑在哪儿,不要掉坑(Effective C++系列)
  2. How to make your life easier,怎么写更清楚明白省事 (还是Effective C++系列)
  3. 你写下的code大致会被编译器怎么处理,以防止遇到奇怪的编译错误或者诡异的bug时懵逼(C++ Primer系列)
  4. 学会使用STL,有助于偷懒(《C++标准程序库》,加书名号表示这也是一本书!)
  5. 了解一些优秀的工程实践,这样写代码时候有个导航,少走弯路,错路(比如好好看看 那本Linux多线程编程实践)

Append:
A1. C++编译链接流程

程序的基本流程如图:

1.预处理: gcc  -E  test.c  -o  test.i

主要包括宏替换、头文件展开、条件编译的选择等。

2.编译:gcc  -S  test.i  -o  test.s

图片2

词法分析 -- 识别单词,确认词类;比如int i;知道int是一个类型,i是一个关键字以及判断i的名字是否合法

语法分析 -- 识别短语和句型的语法属性;

语义分析 -- 确认单词、短语和句型的语义特征;

代码优化 -- 

优化处理是编译系统中一项比较艰深的技术。它涉及到的问题不仅同编译技术本身有关,而且同机器的硬件环境也有很大的关系。优化一部分是对中间代码的优化。这种优化不依赖于具体的计算机。另一种优化则主要针对目标代码的生成而进行的。上图中,我们将优化阶段放在编译程序的后面,这是一种比较笼统的表示。

对于前一种优化,主要的工作是删除公共表达式、循环优化(代码外提、强度削弱、变换循环控制条件、已知量的合并等)、复写传播,以及无用赋值的删除,等等。

后一种类型的优化同机器的硬件结构密切相关,最主要的是考虑是如何充分利用机器的各个硬件寄存器存放的有关变量的值,以减少对于内存的访问次数。另外,如何根据机器硬件执行指令的特点(如流水线、RISC、CISC、VLIW等)而对指令进行一些调整使目标代码比较短,执行的效率比较高,也是一个重要的研究课题。 

经过优化得到的汇编代码必须经过汇编程序的汇编转换成相应的机器指令,方可能被机器执行。 

代码生成 -- 生成译文。

内联函数的替换就发生在这一阶段

3.汇编:gcc  test.s  -o  test.o

汇编过程实际上指把汇编语言代码翻译成目标机器指令的过程。对于被翻译系统处理的每一个C语言源程序,都将最终经过这一处理而得到相应的目标文件。目标文件中所存放的也就是与源程序等效的目标的机器语言代码。

 目标文件由段组成。通常一个目标文件中至少有两个段:

代码段  该段中所包含的主要是程序的指令。该段一般是可读和可执行的,但一般却不可写。  

数据段  主要存放程序中要用到的各种全局变量或静态的数据。一般数据段都是可读,可写,可执行的。

4.链接:gcc  test.o  -o  test

由汇编程序生成的目标文件并不能立即就被执行,其中可能还有许多没有解决的问题。例如,某个源文件中的函数可能引用了另一个源文件中定义的某个符号(如变量或者函数调用等);在程序中可能调用了某个库文件中的函数,等等。所有的这些问题,都需要经链接程序的处理方能得以解决。

链接程序的主要工作就是将有关的目标文件彼此相连接,也即将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体。

根据开发人员指定的同库函数的链接方式的不同,链接处理可分为两种: 

(1)静态链接  在这种链接方式下,函数的代码将从其所在地静态链接库中被拷贝到最终的可执行程序中。这样该程序在被执行时这些代码将被装入到该进程的虚拟地址空间中。静态链接库实际上是一个目标文件的集合,其中的每个文件含有库中的一个或者一组相关函数的代码。

 (2)动态链接  在此种方式下,函数的代码被放到称作是动态链接库或共享对象的某个目标文件中。链接程序此时所作的只是在最终的可执行程序中记录下共享对象的名字以及其它少量的登记信息。在此可执行文件被执行时,动态链接库的全部内容将被映射到运行时相应进程的虚地址空间。动态链接程序将根据可执行程序中记录的信息找到相应的函数代码。 

 

A2.单例模式

什么是单例模式?

单例模式是为确保一个类只有一个实例,并为整个系统提供一个全局访问点的一种模式方法。

单例特点:

1 在任何情况下,单例类永远只有一个实例存在。

2 单例需要有能力为整个系统提供这一唯一实例。

示例:打印机,任务管理器等。

实现一(单线程使用,多线程不安全)

#include <iostream>
using namespace std; class Singleton { private: Singleton(){} public: static Singleton* instance() { if(_instance == 0) _instance = new Singleton(); return _instance; } private: static Singleton* _instance; }; Singleton* Singleton::_instance = 0; 

上面这种实现在单线程环境下是没有问题的,可是多线程下就有问题了。

分析:

1 线程A进入函数instance执行判断语句,这句执行后就挂起了,这时线程A已经认为_instance为NULL,但是线程A还没有创建singleton对象。

2 又有一个线程B进入函数instance执行判断语句,此时同样认为_instance变量为null,因为A没有创建singleton对象。线程B继续执行,创建了一个singleton对象。

3 稍后,线程A接着执行,也创建了一个新的singleton对象。

4 创建了两个对象!

从上面分析可以看出,需要对_instance变量加上互斥锁:

实现二(多线程安全,加锁代价高)

#include <iostream>
#include <mutex> using namespace std; std::mutex mt; class Singleton { private: Singleton(){} public: static Singleton* instance() { mt.lock(); // 加锁  if(_instance == 0) _instance = new Singleton(); mt.unlock(); // 解锁  return _instance; } private: static Singleton* _instance; }; Singleton* Singleton::_instance = 0; 

上锁后是解决了线程安全问题,但是有些资源浪费。稍微分析一下:每次instance函数调用时候都需要请求加锁,其实并不需要,instance函数只需第一次调用的时候上锁就行了。这时可以用DCLP解决。

实现三(双检查锁,由于内存读写导致不安全)

Double-Checked Locking Pattern

#include <iostream>
#include <mutex> using namespace std; std::mutex mt; class Singleton { private: Singleton(){} public: static Singleton* instance() { if(_instance == 0) { mt.lock(); if(_instance == 0) _instance = new Singleton(); mt.unlock(); } return _instance; } private: static Singleton* _instance; public: int atestvalue; }; Singleton* Singleton::_instance = 0; 

这个版本很不错,又叫“双重检查”Double-Check。下面是说明:

  1. 第一个条件是说,如果实例创建了,那就不需要同步了,直接返回就好了。
  2. 不然,我们就开始同步线程。
  3. 第二个条件是说,如果被同步的线程中,有一个线程创建了对象,那么别的线程就不用再创建了。

分析

_instance = new Singleton();

为了执行这句代码,机器需要做三样事儿:

1.singleton对象分配空间。

2.在分配的空间中构造对象

3.使_instance指向分配的空间

遗憾的是编译器并不是严格按照上面的顺序来执行的。可以交换2和3.

将上面三个步骤标记到代码中就是这样:

Singleton* Singleton::instance() { if (_instance == 0) { mt.lock(); if (_instance == 0) { _instance = // Step 3  operator new(sizeof(Singleton)); // Step 1  new (_instance) Singleton; // Step 2  } mt.unlock(); } return _instance; } 
  • 线程A进入了instance函数,并且执行了step1和step3,然后挂起。这时的状态是:_instance不NULL,而_instance指向的内存区没有对象!
  • 线程B进入了instance函数,发现_instance不为null,就直接return _instance了。

实现四(C++ 11版本最简洁的跨平台方案)(推荐版本)

Meyers Singleton

局部静态变量不仅只会初始化一次,而且还是线程安全的。

#include <iostream>
using namespace std; class Singleton { public: // 注意返回的是引用  static Singleton& getInstance() { static Singleton value; //静态局部变量  return value; } private: Singleton() = default; Singleton(const Singleton& other) = delete; //禁止使用拷贝构造函数  Singleton& operator=(const Singleton&) = delete; //禁止使用拷贝赋值运算符 }; int main() { Singleton& s1 = Singleton::getInstance(); cout << &s1 << endl; Singleton& s2 = Singleton::getInstance(); cout << &s2 << endl; return 0; } 

这种单例被称为Meyers' Singleton。这种方法很简洁,也很完美,但是注意:

  1. gcc 4.0之后的编译器支持这种写法。
  2. C++11及以后的版本(如C++14)的多线程下,正确。
  3. C++11之前不能这么写。

实现五(通过C++11提供的call_once)

在C++11中提供一种方法,使得函数可以线程安全的只调用一次。即使用std::call_once和std::once_flag。实现代码如下:

#include <iostream>
#include <thread> #include <mutex> using namespace std; std::once_flag flag; class Singleton { public: static Singleton& getInstance() { std::call_once(flag, []() {instance_.reset(new Singleton()); }); return *instance_; } private: static std::unique_ptr<Singleton> instance_; private: Singleton() = default; Singleton(const Singleton& other) = delete; Singleton& operator=(const Singleton&) = delete; }; std::unique_ptr<Singleton> Singleton::instance_; void do_onceflag() { Singleton& s = Singleton::getInstance(); cout << &s << endl; } int main() { std::thread t1(do_onceflag); std::thread t2(do_onceflag); t1.join(); t2.join(); return 0; } 

总结

单例模式看着很简单,但实现上却很复杂,如何做到单例模式下多线程安全是个难点,多线程编程需要认真学习。

需要注意的一点是,上面讨论的线程安全指的是getInstance()是线程安全的,假如多个线程都获取类A的对象,如果只是只读操作,完全OK,但是如果有线程要修改,有线程要读取,那么类A自身的函数需要自己加锁防护,不是说线程安全的单例也能保证修改和读取该对象自身的资源也是线程安全的。

 

 

1. extern c

将让 C++ 中的函数名具备 C-linkage 性质,但是语法还是C++的语法,目的是让 C 代码在调用这个函数时,能正确的链接到具体的地址。

C调用C++,使用extern "C"则是告诉编译器依照C的方式来编译封装接口,当然接口函数里面的C++语法还是按C++方式编译。

而C++调用C,extern "C" 的作用是:让C++连接器找调用函数的符号时采用C的方式

函数的具体定义无关紧要,仍旧使用 C++ 编译

------------- 额外的废话

C++ 中函数有重载,使用函数名 + 参数信息作为链接时的唯一 ID。

C 中函数没有重载,只使用函数名作为链接时的唯一 ID。

C编译器编译代码生成的obj文件的符号表内,函数名称保持原样,比如int add(int,int)函数在符号表内就叫做add;C++编译器编译C++代码生成的obj文件符号表内,因为有overload的存在,函数名称的符号不再是原来的比如add,而是类似_Z3addii这样的(这是我的g++结果)。

那么,一个C程序需要使用某个C++库内的add函数时,C程序这边期望的是add,但C++库内是_Z3addii这样的,不匹配嘛对不对,所以链接阶段要报错,说找不到add这个函数。

同样,一个C++程序需要使用某个C库内的add函数,C++程序这边期望的是_Z3addii,但C库内是add这样的,同样不匹配,链接阶段也是报错,这次是说找不到_Z3addii。

extern "C"的意思,是让C++编译器(不是C编译器,而且是编译阶段,不是链接阶段)在编译C++代码时,为被extern “C”所修饰的函数在符号表中按C语言方式产生符号名(比如前面的add),而不是按C++那样的增加了参数类型和数目信息的名称(_Z3addii)。

展开来细说,就是:

如果是C调用C++函数,在C++一侧对函数声明加了extern "C"后符号表内就是add这样的名称,C程序就能正常找到add来调用;如果是C++调用C函数,在C++一侧在声明这个外部函数时,加上extern "C"后,C++产生的obj文件符号表内就也是标记为它需要一个名为add的外部函数,这样配合C库,就一切都好。

总结:

不管是C代码调用C++编译器生成的库函数,还是C++代码调用C编译器生成的库函数,都需要在C++代码一侧对相应的函数进行extern “C”申明。

复制代码 代码如下:

extern "C"  
{  
    int func(int);  
    int var;  
}  


它的意思就是告诉编译器将extern “C”后面的括号里的代码当做C代码来处理,当然我们也可以以单条语句来声明

复制代码 代码如下:

extern "C" int func(int);  
extern "C" int var;  

这样就声明了C类型的func和var。很多时候我们写一个头文件声明了一些C语言的函数,而这些函数可能被C和C++代码调用,当我们提供给C++代码调用时,需要在头文件里加extern “C”,否则C++编译的时候会找不到符号,而给C代码调用时又不能加extern “C”,因为C是不支持这样的语法的,常见的处理方式是这样的,我们以C的库函数memset为例

复制代码 代码如下:

#ifdef __cplusplus  
extern "C" {  
#endif  
  
void *memset(void*, int, size_t);  
  
#ifdef __cplusplus  
}  
#endif  


其中__cplusplus是C++编译器定义的一个宏,如果这份代码和C++一起编译,那么memset会在extern "C"里被声明,如果是和C代码一起编译则直接声明,由于__cplusplus没有被定义,所以也不会有语法错误。这样的技巧在系统头文件里经常被用到。

2. volatile/memory barriar

作者:Gomo Psivarh
链接:https://www.zhihu.com/question/31459750/answer/52069135
来源:知乎

C/C++多线程编程中不要使用volatile。
(注:这里的意思指的是指望volatile解决多线程竞争问题是有很大风险的,除非所用的环境系统不可靠才会为了保险加上volatile,或者是从极限效率考虑来实现很底层的接口。这要求编写者对程序逻辑走向很清楚才行,不然就会出错)

C++11标准中明确指出解决多线程的数据竞争问题应该使用原子操作或者互斥锁。
C和C++中的volatile并不是用来解决多线程竞争问题的,而是用来修饰一些因为程序不可控因素导致变化的变量,比如访问底层硬件设备的变量,以提醒编译器不要对该变量的访问擅自进行优化。

多线程场景下可以参考《Programming with POSIX threads》的作者Dave Butenhof对
Why don't I need to declare shared variables VOLATILE?
这个问题的解释:
comp.programming.threads FAQ

简单的来说,对访问共享数据的代码块加锁,已经足够保证数据访问的同步性,再加volatile完全是多此一举。
如果光对共享变量使用volatile修饰而在可能存在竞争的操作中不加锁或使用原子操作对解决多线程竞争没有任何卵用,因为volatile并不能保证操作的原子性,在读取、写入变量的过程中仍然可能被其他线程打断导致意外结果发生。

 

作者:Name5566
链接:https://www.zhihu.com/question/20228202/answer/24959876
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

首先需要明确的是,程序在运行起来,内存访问的顺序和程序员编写的顺序不一定一致,基于这个前提下,Memory barrier 就有存在的必要了。看一个例子:

x = r;
y = 1; 

这里,y = 1 在实际运行中可能先于 x = r 进行。实际上,在单线程环境中,这两句谁先执行谁后执行都没有任何关系,它们之间不存在依赖关系,但是如果在多线程中 x 和 y 的赋值存在隐式依赖时:

// thread 1
while (!x); // memory barrier assert(y == r); // thread 2 y = r; // memory barrier x = 1; 

此代码断言就可能失败。

Memory barrier 能够保证其之前的内存访问操作先于其后的完成。如果说到 Memory barrier 常用的地方,那么包括:

  1. 实现锁机制
  2. 用于驱动程序
  3. 编写无锁的代码

这里篇幅有限,如果你作为程序员,你可以从 https://www.kernel.org/doc/Documentation/memory-barriers.txt 一文入手研究,如果这个还不能满足你,可以进一步深入硬件来研究多 CPU 间内存乱序访问的问题:http://www.rdrop.com/users/paulmck/scalability/paper/whymb.2010.06.07c.pdf

我个人也对 Memory barrier 做了一点小研究,主要写了几个例子验证乱序的存在: http://b2e699b3.wiz03.com/share/s/2OVFCP1_wkXs20LtbT1nXNrj0EqwFC1zZAjT2bCeRi3Tzco2

 

3. dynamic cast

reinterpret_cast运算符是用来处理无关类型之间的转换;它会产生一个新的值,这个值会有与原始参数(expressoin)有完全相同的比特位

reinterpret_cast用在任意指针(或引用)类型之间的转换;以及指针与足够大的整数类型之间的转换;从整数类型(包括枚举类型)到指针类型,无视大小。

MSDN的Visual C++ Developer Center 给出了它的使用价值:用来辅助哈希函数。下边是MSNDN上的例子:

                // expre_reinterpret_cast_Operator.cpp
// compile with: /EHsc
#include <iostream>
// Returns a hash code based on an address unsigned short Hash( void *p ) { unsigned int val = reinterpret_cast<unsigned int>( p ); return ( unsigned short )( val ^ (val >> 16)); } using namespace std; int main() { int a[20]; for ( int i = 0; i < 20; i++ ) cout << Hash( a + i ) << endl; }

C++中的类型转换分为两种:

1.隐式类型转换;
2.显式类型转换。

而对于隐式变换,就是标准的转换,在很多时候,不经意间就发生了,比如int类型和float类型相加时,int类型就会被隐式的转换位float类型,然后再进行相加运算。而关于隐式转换不是今天总结的重点,重点是显式转换。在标准C++中有四个类型转换符:static_cast、dynamic_cast、const_cast和reinterpret_cast;下面将对它们一一的进行总结。

static_cast

static_cast的转换格式:static_cast <type-id> (expression)

将expression转换为type-id类型,主要用于非多态类型之间的转换,不提供运行时的检查来确保转换的安全性。主要在以下几种场合中使用:

1.用于类层次结构中,基类和子类之间指针和引用的转换;
当进行上行转换,也就是把子类的指针或引用转换成父类表示,这种转换是安全的;
当进行下行转换,也就是把父类的指针或引用转换成子类表示,这种转换是不安全的,也需要程序员来保证;

2.用于基本数据类型之间的转换,如把int转换成char,把int转换成enum等等,这种转换的安全性需要程序员来保证;

3.把void指针转换成目标类型的指针,是及其不安全的;

注:static_cast不能转换掉expression的const、volatile和__unaligned属性。

dynamic_cast

dynamic_cast的转换格式:dynamic_cast <type-id> (expression)

将expression转换为type-id类型,type-id必须是类的指针、类的引用或者是void *;如果type-id是指针类型,那么expression也必须是一个指针;如果type-id是一个引用,那么expression也必须是一个引用。

在C++的面对对象思想中,虚函数起到了很关键的作用,当一个类中拥有至少一个虚函数,那么编译器就会构建出一个虚函数表(virtual method table)来指示这些函数的地址,假如继承该类的子类定义并实现了一个同名并具有同样函数签名(function siguature)的方法重写了基类中的方法,那么虚函数表会将该函数指向新的地址。此时多态性就体现出来了:当我们将基类的指针或引用指向子类的对象的时候,调用方法时,就会顺着虚函数表找到对应子类的方法而非基类的方法。

当然虚函数表的存在对于效率上会有一定的影响,首先构建虚函数表需要时间,根据虚函数表寻到到函数也需要时间。

因为这个原因如果没有继承的需要,一般不必在类中定义虚函数。但是对于继承来说,虚函数就变得很重要了,这不仅仅是实现多态性的一个重要标志,同时也是dynamic_cast转换能够进行的前提条件。

假如去掉上个例子中Stranger类析构函数前的virtual,那么语句
Children* child_r = dynamic_cast<Children*> (stranger_r);

在编译期就会直接报出错误,具体原因不是很清楚,我猜测可能是因为当类没有虚函数表的时候,dynamic_cast就不能用RTTI来确定类的具体类型,于是就直接不通过编译。

对于从子类到基类的指针转换,static_cast和dynamic_cast都是成功并且正确的(所谓成功是说转换没有编译错误或者运行异常;所谓正确是指方法的调用和数据的访问输出是期望的结果),这是面向对象多态性的完美体现。

从基类到子类的转换,static_cast和dynamic_cast都是成功的,但是正确性方面,我对两者的结果都先进行了是否非空的判别:dynamic_cast的结果显示是空指针,而static_cast则是非空指针。但很显然,static_cast的结果应该算是错误的,子类指针实际所指的是基类的对象,而基类对象并不具有子类的Study()方法(除非妈妈又想去接受个"继续教育")。

对于没有关系的两个类之间的转换,输出结果表明,dynamic_cast依然是返回一个空指针以表示转换是不成立的;static_cast直接在编译期就拒绝了这种转换。

 

4、malloc/ new 

new的功能是在堆区新建一个对象,并返回该对象的指针。

所谓的【新建对象】的意思就是,将调用该类的构造函数,因为如果不构造的话,就不能称之为一个对象。

而malloc只是机械的分配一块内存,如果用mallco在堆区创建一个对象的话,是不会调用构造函数的

 

linux采用的是glibc中堆内存管理ptmalloc实现,虚拟内存的布局规定了malloc申请位置以及大小, malloc一次性能申请小内存(小于128KB),分配的是在堆区(heap),用sbrk()进行对齐生长,而 malloc一次性申请大内存(大于128KB时)分配到的是在映射区,而不是在堆区,采用的mmap()系统调用进行映射。当然虚拟地址只是规定了一种最理想的状态,实际分配还是要考虑到物理内存加交换内存总量的限制,因为每次分配,特别是大内存分配采用mmap()映射内存需要记录物理内存加交换内存地址,所有物理内存加交换内存限制了malloc实际分配。
malloc的实现与物理内存自然是无关的,内核为每个进程维护一张页表,页表存储进程空间内每页的虚拟地址,页表项中有的虚拟内存页对应着某个物理内存页面,也有的虚拟内存页没有实际的物理页面对应。无论malloc通过sbrk还是mmap实现, 分配到的内存只是虚拟内存,而且只是虚拟内存的页号,代表这块空间进程可以用,实际上还没有分配到实际的物理页面。等你的进程访问到这个 新分配的内存空间的时候,如果其还没有对应的物理页面分配,就会产生缺页中断,内核这个时候会给进程分配实际的物理页面,以与这个未被映射的虚拟页面对应起来

链接:https://www.zhihu.com/question/20220583/answer/28490955

5、内存对齐

  2.为什么要字节对齐  
  为什么呢?简单点说:为了提高存取效率。字节是内存空间分配的最小单位, 在程序中,我们定义的变量可以放在任何位置。其实不同架构 的CPU在访问特定类型变量时是有规律的,比如有的CPU访问int型变量时,会从偶数地址开始读取的,int类型占用4个字节(windows平台)。 0X0000,0X0004,0X0008.....这样只需要读一次就可以读出Int类型变量的值。相反地,则需要读取二次,再把高低字节相拼才能得到 int类型的值,这样子看的话,存取效率当然提高了。  通常写程序的时候,不需要考虑这些情况,编译都会为我们考虑这些情况,除非针对那些特别架构的 CPU编程的时候的则需要考虑 。当然用户也可以手工控制对齐方式。
 3.编译器对字节对齐的一些规则    

  我从下面三条说明了编译器对字节处理的一些原则。当然除了一些特殊的编译器在处理字节对齐的方式也不一样, 这些情况我未碰到过,就不作说明了。

  a. 关于数据类型自身的对齐值,不同类型会按不同的字节来对齐。
类型 对齐值(字节)
char 1
short 2
int 4
float 4
double 4
      b. 类、结构体的自身对齐字节值。对于结构体类型与类对象的对齐原则:使用成员当中最大的对齐字节来对齐。比如在Struct A中,int a的对齐字节为4,比char,short都大,所以A的对齐字节为4
     c. 指定对齐字节值。意思是指使用了宏 #pragma pack(n)来指定的对齐值

     d. 类、结构及成员的有效对齐字节值。有效对齐值=min(类/结构体/成员的自身对齐字节值,指定对齐字节值)。   有效对齐值决定了数据的存放方 式,sizeof 运算符就是根据有效对齐值来计算成员大小的。简单来说, 有效对齐其实就是要求数据成员存放的地址值能被有效对齐值整除,即:地址值%有效对齐值=0

 

6、stl

vector/set/map/unorderedmap, 
vector/list区别: 

1.vector数据结构
vector和数组类似,拥有一段连续的内存空间,并且起始地址不变。
因此能高效的进行随机存取,时间复杂度为o(1);
但因为内存空间是连续的,所以在进行插入和删除操作时,会造成内存块的拷贝,时间复杂度为o(n)。
另外,当数组中内存空间不够时,会重新申请一块内存空间并进行内存拷贝。

2.list数据结构
list是由双向链表实现的,因此内存空间是不连续的。
只能通过指针访问数据,所以list的随机存取非常没有效率,时间复杂度为o(n);
但由于链表的特点,能高效地进行插入和删除。

1.说说std::vector的底层(存储)机制。

 vector就是一个动态数组,里面有一个指针指向一片连续的内存空间,当空间不够装下数据时,会自动申请另一片更大的空间(一般是增加当前容量的100%),然后把原来的数据拷贝过去,接着释放原来的那片空间;当释放或者删除里面的数据时,其存储空间不释放,仅仅是清空了里面的数据。

2.std::vector的自增长机制。

当已经分配的空间不够装下数据时,分配双倍于当前容量的存储区,把当前的值拷贝到新分配的内存中,并释放原来的内存。

3.说说std::list的底层(存储)机制。

以结点为单位存放数据,结点的地址在内存中不一定连续,每次插入或删除一个元素,就配置或释放一个元素空间

4.什么情况下用vector,什么情况下用list。

vector可以随机存储元素(即可以通过公式直接计算出元素地址,而不需要挨个查找),但在非尾部插入删除数据时,效率很低,适合对象简单,对象数量变化不大,随机访问频繁。

list不支持随机存储,适用于对象大,对象数量变化频繁,插入和删除频繁。

 

说说std::map底层机制。

map以RB-TREE为底层机制。RB-TREE是一种平衡二叉搜索树,自动排序效果不错。

通过map的迭代器不能修改其键值,只能修改其实值。所以map的迭代器既不是const也不是mutable。

7、c++内存布局, 虚表

C语言的内存模型
 
C语言的内存模型

程序代码区(code area)

存放函数体的二进制代码

静态数据区(data area)

也称全局数据区,包含的数据类型比较多,如全局变量、静态变量、一般常量、字符串常量。其中:

  • 全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。
  • 常量数据(一般常量、字符串常量)存放在另一个区域。

注意:静态数据区的内存在程序结束后由操作系统释放。

堆区(heap area)

一般由程序员分配和释放,若程序员不释放,程序运行结束时由操作系统回收。malloc()、calloc()、free()等函数操作的就是这块内存。

注意:这里所说的堆区与数据结构中的堆不是一个概念,堆区的分配方式倒是类似于链表。

栈区(stack area)

由系统自动分配释放,存放函数的参数值、局部变量的值等。其操作方式类似于数据结构中的栈。

命令行参数区

存放命令行参数和环境变量的值,如通过main()函数传递的值。

 
C语句的个部分会出现在哪些段中

C++对象的内存布局

C++语言在C的基础上添加了面向对象的概念,引入了封装,继承,多态。而一个对象的内存布局就相对于C语言的结构体等在内存的布局要复杂的多。
在C++中,有两种数据成员(class data members):static 和nonstatic,以及三种类成员函数(class member functions):static、nonstatic和virtual:

非继承下的C++对象模型

概述:在此模型下,nonstatic 数据成员被置于每一个类对象中,而static数据成员被置于类对象之外。static与nonstatic函数也都放在类对象之外,而对于virtual 函数,则通过虚函数表+虚指针来支持,具体如下:

    • 每个类生成一个表格,称为虚表(virtual table,简称vtbl)。虚表中存放着一堆指针,这些指针指向该类每一个虚函数。虚表中的函数地址将按声明时的顺序排列,不过当子类有多个重载函数时例外,后面会讨论。
    • 每个类对象都拥有一个虚表指针(vptr),由编译器为其生成。虚表指针的设定与重置皆由类的复制控制(也即是构造函数、析构函数、赋值操作符)来完成。vptr的位置为编译器决定,传统上它被放在所有显示声明的成员之后,不过现在许多编译器把vptr放在一个类对象的最前端。关于数据成员布局的内容,在后面会详细分析。
      另外,虚函数表的前面设置了一个指向type_info的指针,用以支持RTTI(Run Time Type Identification,运行时类型识别)。RTTI是为多态而生成的信息,包括对象继承关系,对象本身的描述等,只有具有虚函数的对象在会生成。
 
C++数据成员及成员函数类型

现在我们有一个类Base,它包含了上面这5中类型的数据或函数:

class Base
{
    public:
    
    Base(int i) :baseI(i){}; int getI(){ return baseI; } static void countI(){}; virtual void print(void){ cout << "Base::print()"; } virtual ~Base(){} private: int baseI; static int baseS; }; 
 
Base类图
 
Base内存布局

可以看到,对一个C++对象来说,它的内存布局仅有虚表指针和非静态成员,而其他的静态成员,成员函数(静态,非静态),虚表等都是布局在类上的。


##################
A. tips

Aclass* ptra=new Bclass;
 98    int ** ptrvf=(int**)(ptra);
 99    RTTICompleteObjectLocator str=
100        *((RTTICompleteObjectLocator*)(*((int*)ptrvf[0]-1)));

可以明显看到,虚表地址减1之后才得到类型信息。

结论:vptr指向的第一个位置是第一个虚函数的地址,不是type_info。

B. tips

1. 空类
class A
{
};
 
void main()
{
    printf("sizeof(A): %d\n", sizeof(A));
    getchar();
}
 得到结果为:1。
 类的实例化就是给每个实例在内存中分配一块地址。空类被实例化时,会由编译器隐含的添加一个字节。所以空类的size为1。

2.虚函数
class A
{
    virtual void FuncA();<br>        virtual void FuncB(); 
};
 得到结果:4
当C++ 类中有虚函数的时候,会有一个指向虚函数表的指针(vptr),在32位系统分配指针大小为4字节。所以size为4.

3.静态数据成员
class A
{
  int a;
  static int b;
  virtual void FuncA();
};
 得到结果:8
静态数据成员被编译器放在程序的一个global data members中,它是类的一个数据成员.但是它不影响类的大小,不管这个类实际产生了多少实例,还是派生了多少新的类,静态成员数据在类中永远只有一个实体存在。

而类的非静态数据成员只有被实例化的时候,他们才存在.但是类的静态数据成员一旦被声明,无论类是否被实例化,它都已存在.可以这么说,类的静态数据成员是一种特殊的全局变量.
所以该类的size为:int a型4字节加上虚函数表指针4字节,等于8字节。

4.普通成员函数
class A
{
          void FuncA();
}
 结果:1
类的大小与它的构造函数、析构函数和其他成员函数无关,只已它的数据成员有关。

5.普通继承
class A
{
    int a;
};
class B
{
  int b;
};
class C : public A, public B
{
  int c;
};
 结果为:sizeof(C) =12.
可见普通的继承,就是基类的大小,加上派生类自身成员的大小。

6.虚拟继承

class C : virtual public A, virtual public B
{
  int c;
};
 结果:16.

当存在虚拟继承时,派生类中会有一个指向虚基类表的指针。所以其大小应为普通继承的大小(12字节),再加上虚基类表的指针大小(4个字节),共16字节。

###########################


作者:启发禅悟
链接:https://www.jianshu.com/p/0c10b662ef09
來源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

8、shared_ptr

When using unique_ptr, there can be at most one unique_ptr pointing at any one resource. When that unique_ptr is destroyed, the resource is automatically reclaimed. Because there can only be one unique_ptr to any resource, any attempt to make a copy of a unique_ptr will cause a compile-time error. For example, this code is illegal:

unique_ptr<T> myPtr(new T); // Okay unique_ptr<T> myOtherPtr = myPtr; // Error: Can't copy unique_ptr

However, unique_ptr can be moved using the new move semantics:

unique_ptr<T> myPtr(new T); // Okay unique_ptr<T> myOtherPtr = std::move(myPtr); // Okay, resource now stored in myOtherPtr

Similarly, you can do something like this:

unique_ptr<T> MyFunction() { unique_ptr<T> myPtr(/* ... */); /* ... */ return myPtr; }

This idiom means "I'm returning a managed resource to you. If you don't explicitly capture the return value, then the resource will be cleaned up. If you do, then you now have exclusive ownership of that resource." In this way, you can think of unique_ptr as a safer, better replacement for auto_ptr.

shared_ptr, on the other hand, allows for multiple pointers to point at a given resource. When the very last shared_ptr to a resource is destroyed, the resource will be deallocated. For example, this code is perfectly legal:

shared_ptr<T> myPtr(new T); // Okay shared_ptr<T> myOtherPtr = myPtr; // Sure! Now have two pointers to the resource.

Internally, shared_ptr uses reference counting to track how many pointers refer to a resource, so you need to be careful not to introduce any reference cycles.

In short:

  1. Use unique_ptr when you want a single pointer to an object that will be reclaimed when that single pointer is destroyed.
  2. Use shared_ptr when you want multiple pointers to the same resource.

unique_ptr

unique_ptr 这个类的关键点在于这个定义:

unique_ptr(const unique_ptr&) = delete;

它把拷贝构造函数干掉了,这样的话,就不能直接这样用了:

  unique_ptr<A> pa(new A());
    unique_ptr<A> pb = pa;

这样也挺好,既然auto_ptr是因为多个变量持有同一个指针引起的,那么我尽量避免这种拷贝就好了。

唉,但这是C++啊,不留点口子肯定不是C++的风格,所以unique_ptr还留下了move赋值这种东西,这个我们不去看了。只知道有这么一回事就行了。我们今天的重点是shared_ptr

shared_ptr

shared_ptr也是对auto_ptr的一种改进,它的思路是,使用引用计数来管理指针。如果一个指针被多次使用了,那么引用计数就加一,如果减少一次使用,引用计数就减一。当引用计数变为0,那就可以真正地删除指针了。先看一下基本用法:

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

class A { 
private:
    int a;
public:
    A() {
        cout << "create object of A" << endl;
        a = 1;
    }   

    ~A() {
        cout << "destroy an object A" << endl;
    }   

    void print() {
        cout << "a is " << a << endl;
    }   
};

int main() {
    shared_ptr<A> pa(new A());
    shared_ptr<A> pb = pa;
    return 0;
}

大家可以与上节课的auto_ptr比较一下,就发现它们的区别了,当然了,这样写还是不行:

int main() {
    A * a = new A(); shared_ptr<A> pa(a); shared_ptr<A> pb(a); return 0; } 

这种写法还是会让指针被 delete 两次。

它的基本原理是在智能指针中引入一个引用计数,在拷贝构造中对引用计数加一,在析构函数中,对引用计数减一。我写一个简单的例子模拟shared_ptr如下:

template <typename V>
class SmartPtr {
private:
    int * refcnt;
    V * v;
public:
    SmartPtr(V* ptr): v(ptr) {
        refcnt = new int(1);
    }   

    SmartPtr(const SmartPtr& ptr) {
        this->v = ptr.v;
        this->refcnt = ptr.refcnt;
        *refcnt += 1;
    }   

    ~SmartPtr() {
        cout << "to delete a smart pointer" << endl;
        *refcnt -= 1;

        if (*refcnt == 0) {
            delete v;
           delete refcnt;
       }
    }
};

int main() {
    A * ptrA = new A();
    SmartPtr<A> sp1(ptrA);
    SmartPtr<A> sp2 = sp1;

    return 0;
}

这个例子中中需要注意的点是引用计数是所有管理同一个指针的智能指针所共享的,所以在这个例子中,sp1和sp2的引用计数指向的是相同的一个整数。

我们看一下这个例子的输出:

# g++ -o smart myShare.cpp 
# ./smart 
create object of A
to delete a smart pointer
to delete a smart pointer
destroy an object A

可以看到,这个和shared_ptr一样可以正确地delete指针。

 

展开阅读全文
打赏
0
0 收藏
分享
加载中
更多评论
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部