目录
3.new和delete是如何实现的,new 与 malloc的异同处
5.C++、Java的联系与区别,包括语言特性、垃圾回收、应用场景等(java的垃圾回收机制)
7.define 和const的区别(编译阶段、安全性、内存占用等)
9.const和static在类中使用的注意事项(定义、初始化和使用)
10.C++中的const类成员函数(用法和意义),以及和非const成员函数的区别
18.STL中unordered_map和map的区别和应用场景
20.STL容器的几种迭代器以及对应的容器(输入迭代器,输出迭代器,前向迭代器,双向迭代器,随机访问迭代器)
22.vector使用的注意点及其原因,频繁对vector调用push_back()对性能的影响和原因。
35.构造函数的几种关键字(default delete 0)
51.成员初始化列表的概念,为什么用成员初始化列表会快一些(性能优势)?
55.C++中将临时变量作为返回值的时候的处理过程(栈上的内存分配、拷贝过程)
59.public,protected和private访问权限和继承
1.指针和引用的区别
(1)指针:指针是一个变量,只不过这个变量存储的是一个地址,指向内存的一个存储单元;而引用跟原来的变量实质上是同一个东西,只不过是原变量的一个别名而已。
(2)可以有const指针,但是没有const引用;
(3)指针可以有多级,但是引用只能是一级(int **p;合法 而 int &&a是不合法的)
(4)指针的值可以为空,但是引用的值不能为NULL,并且引用在定义的时候必须初始化;
(5)指针的值在初始化后可以改变,即指向其它的存储单元,而引用在进行初始化后就不会再改变了。
(6)"sizeof引用"得到的是所指向的变量(对象)的大小,而"sizeof指针"得到的是指针本身的大小;
(7)指针和引用的自增(++)运算意义不一样;
- 指针是对内存地址的自增
- 引用是对值的自增
(8)指针和引用作为函数参数进行传递时的区别。
- 用指针传递参数,可以实现对实参进行改变的目的,是因为传递过来的是实参的地址。
- 引用作为函数参数进行传递时,实质上传递的是实参本身,即传递进来的不是实参的一个拷贝,因此对形参的修改其实是对实参的修改,所以在用引用进行参数传递时,不仅节约时间,而且可以节约空间。
2.堆和栈的区别
(1)管理方式:堆中资源由程序员控制(通过malloc/free、new/delete,容易内存泄露),栈资源由编译器自动管理。
(2)系统响应:对于堆,系统有一个记录空闲内存地址的链表,当系统收到程序申请时,遍历该链表,寻找第一个大于所申请空间的空间的堆结点,删除空闲结点链表中的该结点,并将该结点空间分配给程序(大多数系统会在这块内存空间首地址记录本次分配的大小,这样delete才能正确释放本内存空间,另外,系统会将多余的部分重新放入空闲链表中)。对于栈,只要栈的剩余空间大于所申请空间,系统就会为程序分配内存,否则报异常出现栈空间溢出错误。
(3)空间大小:堆是不连续的内存区域(因为系统是用链表来存储空闲内存地址的,自然不是连续),堆的大小受限于计算机系统中有效的虚拟内存(32位机器上理论上是4G大小),所以堆的空间比较灵活,比较大。栈是一块连续的内存区域,大小是操作系统预定好的,windows下栈大小是2M(也有是1M,在编译时确定,VC中可设置)。
(4)碎片问题:对于堆,频繁的new/delete会造成大量内存碎片,降低程序效率。对于栈,它是一个先进后出(first-in-last-out)的结构,进出一一对应,不会产生碎片。
(5)生长方向:堆向上,向高地址方向增长;栈向下,向低地址方向增长。
(6)分配方式:堆是动态分配(没有静态分配的堆)。栈有静态分配和动态分配,静态分配由编译器完成(如函数局部变量),动态分配由alloca函数分配,但栈的动态分配资源由编译器自动释放,无需程序员实现。
(7)分配效率:堆由C/C++函数库提供,机制很复杂,因此堆的效率比栈低很多。栈是机器系统提供的数据结构,计算机在底层对栈提供支持,分配专门的寄存器存放栈地址,提供栈操作专门的指令。
3.new和delete是如何实现的,new 与 malloc的异同处
new的实现分为三步:
1.自动计算所需空间,调用operator new函数进行动态内存分配
2.调用构造函数,初始化对象
3.返回正确的指针
delete的实现也分为三步:
1.定位到指针所指向的内存空间,然后根据其类型,调用其自带的析构函数
2.然后释放其内存空间(将这块内存空间标志为可用,然后还给操作系统)
3.将指针标记为无效
注意:new的对象用完后必须用delete释放
new 与 malloc的异同处:
有了malloc/free为什么还要new/delete?
- new运算不需要进行强制类型转换,使用简单方便;
- new运算是通过调用构造函数初始化动态创建的对象,执行效率更高;
- 使用new能够进行异常处理,使用更安全
delete与delete []
- delete是回收new开辟出的单个对象指针指向的内存。
- delete[]是回收new [] 开辟出的对象数组指针指向的内存。
- new[]开辟数组空间要多出4个字节来存放数组大小。
- delete []要与new []要配套使用
4.C和C++的区别
C/C++的联系:
- C++是C的超集,兼容大部分C的语法的结构;
C/C++区别:
- C是面向过程的语言,而C++是面向对象的语言;
- C和C++动态管理内存的方法不一样,C是使用malloc/free函数,而C++除此之外还有new/delete关键字;
- C++的class类是C所没有的,但是C中的struct是可以在C++中正常使用的,并且C++对struct进行了进一步的扩展,使struct在C++中可以和class一样当做类使用,而唯一和class不同的地方在于struct的成员默认访问修饰符是public,而class默认的是private;
- C++支持函数重载,而C不支持函数重载,而C++支持重载的依仗就在于C++的名字修饰与C不同,例如在C++中函数int fun(int ,int)经过名字修饰之后变为 _fun_int_int ,而C是 _fun,一般是这样的,所以C++才会支持不同的参数调用不同的函数;
- C++中有引用,而C没有;
- C++全部变量的默认链接属性是外链接,而C是内连接;
- C 中用const修饰的变量不可以用在定义数组时的大小,但是C++用const修饰的变量可以(如果不进行&,解引用的操作的话,是存放在符号表的,不开辟内存);
5.C++、Java的联系与区别,包括语言特性、垃圾回收、应用场景等(java的垃圾回收机制)
语言特性:
- C++允许名字空间级别的常量,变量和函数. 而所有这样的 Java 声明必须在一个类或者接口当中.
- 在 C++ 的声明中,一个类名可以用来声明一个此类对象的值,在 Java 的声明中,一个类名声明的是对此类的一个对象的引用. 而在 C++ 里与之等价的做法是用 “*” 来声明一个指针
- 在 C++ 里,声明一个指向常量的指针(只读指针)是可能的,在 Java 里这是不可能做到的.
- C++保留了指针,而java的虚拟机为我们把这些东西都封装好了
- C++支持多重继承,Java不支持多重继承,但允许一个类继承多个接口
- 我们在c++中可以直接手动的释放内存,但是java做不到,它的垃圾是有虚拟机去回收的
- Java不支持操作符重载,C++支持
- Java不支持预处理功能。C/C++在编译过程中都有一个预编泽阶段,单java的import与预处理类似
- Java不支持缺省函数参数,而C++支持
- C和C++中有时出现数据类型的隐含转换,这就涉及了自动强制类型转换问题,Java不支持C++中的自动强制类型转换,如果需要,必须由程序显式进行强制类型转换。
应用场景:
- java:企业级应用开发,网站平台开发,移动领域等
- C++:游戏领域,办公软件,搜索引擎,关系型数据库等
垃圾回收 :
- C++的垃圾回收必须有程序员来完成,C++可以使用智能指针来管理内存(引用计数法)
- java可以进行自动的垃圾回收,java的垃圾回收机制是Java虚拟机(JVM)垃圾回收器提供的一种用于在空闲时间不定时回收无任何对象引用的对象占据的内存空间的一种机制。
6.结构体和类的区别(struct和class的区别)
主要是访问权限的区别:
- 默认继承权限:如果不明确指定,来自class的继承安置private继承处理,来自struct的继承安置public继承处理。
- 成员的默认访问权限:class的成员默认private权限,struct默认public权限
7.define 和const的区别(编译阶段、安全性、内存占用等)
什么是define: 宏定义,简单的理解就是替换,其实这也是本质。如果熟悉g++编译过程的话,会了解到一个概念叫做预处理,就是在编译之前做个处理。这个过程并不像编译那么复杂,就是简单的递归替换和删除。替换的就是宏定义和include文件,删除注释。
(1)编译器处理方式
- define – 在预处理阶段进行替换
- const – 在编译时确定其值
(2)类型检查
- define – 无类型,不进行类型安全检查,可能会产生意想不到的错误
- const – 有数据类型,编译时会进行类型检查
(3)内存空间
- define – 不分配内存,给出的是立即数,有多少次使用就进行多少次替换,在内存中会有多个拷贝,消耗内存大
- const – 在静态存储区中分配空间,在程序运行过程中内存中只有一个拷贝
(4)其他
- 在编译时, 编译器通常不为const常量分配存储空间,而是将它们保存在符号表中,这使得它成为一个编译期间的常量,没有了存储与读内存的操作,使得它的效率也很高。
- 宏替换只作替换,不做计算,不做表达式求解。
8.在C++中const和static的用法(定义,用途)
static:
- 经过static修饰的变量会作为类的属性而不是实体属性而存在
- static修饰的变量作为程序运行时的静态变量,存于内存的静态区,静态区数据的初始化在,操作系统加载完程序后执行main函数之前。
- static修饰的变量通过int ClassName::value=1这种方式初始化,不再需要static关键字
- static修饰局部变量,使得局部变量只初始化一次,且只有一份内存;static并不该变局部变量的作用域,仅仅改变局部变量的生命周期(程序结束,该变量才销毁)
- 类的static函数,只能访问该类的static变量或static函数,被static修饰的函数没有this指针,static函数不能申明为虚函数
- static必须在类的外部定义,不能在类内定义
- 在cpp文件的全局范围内声明static变量,则该变量在该cpp内有效,其他cpp不能访问;如果在头文件中声明static变量,则包含该头文件的cpp文件各自有自己独立的同名变量。
- 函数的实现不需要static修饰,可是如果希望该函数不能被其他cpp文件访问,函数的声明需要加static,不加的话其他cpp可能可以也可能不可以访问这个函数。
const:
- const定义的是一个常量,保护被修饰的量防止意外意外的修改
- const可以很方便的进行参数的调整和修改,起到和宏一样的作用
- const可以节省空间,避免不必要的内存分配
- const可以提高效率,编译器通常不为普通const常量分配存储空间,而是将它保存在符号表中,使得它成为一个编译期间的常量(const定义时不分配空间,第一次使用时才分配且只分配一次)
用法:
- int const i = 0; const int i = 0 本质上指const修饰类型为int的变量i的值是不可变的
- char* const p 指针本身是常量不可变;const char* p 指针所指向的内容是常量不可变;const char* const p 俩者均为常量,均不可变。
- void fun(const class& Var) 向函数传递const引用,传递到函数的是Var的地址,但是不能对地址的内容修改,它的效率比值传递高,同时防止修改。
- const修饰类内的成员变量表示成员常量,不能修改,且只能在初始化列表中赋值,class A { const int i};
- const 修饰类的成员函数,则该函数不能修改类的对象的任何数据成员,一般写在函数末尾;const成员函数能够访问对象的const成员,而其他成员函数不可以。class A { void fun() const };
- const修饰类的对象表示该对象为常量对象,该对象的任何成员不能被修改,任何非const成员函数不能被调用。
9.const和static在类中使用的注意事项(定义、初始化和使用)
static:
- static定义的变量,属于某个类,而不属于类的对象;其值的修改对所有类的对象可见。
- static数据成员必须在类的外部初始化,且不能标示为static。static const类型除了整型数可以在类内初始化,其他的只能在类外初始化
- static成员函数不与任意对象相关联,因此没有this指针,也无法访问类的非静态成员
- static成员函数不能被声明为const,virtual,volatile。
const:
- this指针是const的
- const修饰的成员函数,this指针指向的是一个const对象,因此const成员函数不能修改调用该函数的对象
- 非const对象,可以调用const成员函数也可以调用非const成员函数;const对象只能调用const成员函数。
- const 数据成员必须在构造函数的成员初始化列表中初始化,static const 成员除外
10.C++中的const类成员函数(用法和意义),以及和非const成员函数的区别
在设计类的时候,一个原则就是对于不改变数据成员的成员函数都要在后面加 const,而对于改变数据成员的成员函数不能加 const。所以 const 关键字对成员函数的行为作了更加明确的限定:
(1)有 const 修饰的成员函数(指 const 放在函数参数表的后面,而不是在函数前面或者参数表内),只能读取数据成员,不能改变数据成员;没有 const 修饰的成员函数,对数据成员则是可读可写的。
(2)除此之外,在类的成员函数后面加 const 还有什么好处呢?那就是常量(即 const)对象可以调用 const 成员函数,而不能调用非const修饰的函数。
11.C++的顶层const和底层const
顶层const表示指针本身是个常量;底层const表示指针所指的对象是一个常量。
对于顶层 const 与底层 const ,在运行对象拷贝时有着明显的不同:
(1)顶层 const 不受什么影响
(2)底层 const 的限制不能忽略, 要求拷出和拷入的对象有同样的底层 const 资格或者能转换为同样的数据类型,一般非常量可以向常量转换,反之则不行
12.final和override关键字
final
当不希望某个类被继承,或不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字,添加final关键字后被继承或重写,编译器会报错。
override
它指定了子类的这个虚函数是重写的父类的,如果名字不小心打错了的话,编译器不会编译通过。
13.拷贝初始化和直接初始化,初始化和赋值的区别
拷贝初始化和直接初始化
- 直接初始化直接调用与实参匹配的构造函数;
- 拷贝初始化总是调用拷贝构造函数。拷贝初始化首先使用指定构造函数创建一个临时对象,然后用拷贝构造函数将那个临时对象复制到正在创建的对象。所以当拷贝构造函数被声明为私有时,所有的拷贝初始化都不能使用。
初始化和赋值
- 赋值操作是在两个已经存在的对象间进行的
- 初始化是要创建一个新的对象,并且其初值来源于另一个已存在的对象。
- 编译器会区别这两种情况,赋值的时候调用重载的赋值运算符,初始化的时候调用拷贝构造函数。如果类中没有拷贝构造函数,则编译器会提供一个默认的。这个默认的拷贝构造函数只是简单地复制类中的每个成员。
14.extern "C"的用法
extern "C"主要是在c++代码中应用c的代码
可以有以下几种情况:
(1)在C的头文件中使用
#ifdef __cpluscplus
extern "C" {
#endif
//some code
#ifdef __cplusplus
}
#endif
这样,C++代码中可以直接包含此C头文件,然后引用函数
(2)在C的代码头文件中没有使用extern “c”
extern "C" {
#include "test_extern_c.h"
}
则需要在C++的代码中对C的头文件加上extern “c”,或直接在函数前面加入extern “c”
(3)如果在C中引用c++代码
在C中引用C++语言中的函数和变量时,C++的头文件需添加extern "C",但是在C语言中不能直接引用声明了extern "C"的该头文件,应该仅将C文件中将C++中定义的extern "C"函数声明为extern类型。
编写的C引用C++函数例子工程中包含的三个文件的源代码如下:
//C++头文件 cppExample.h
#ifndef CPP_EXAMPLE_H
#define CPP_EXAMPLE_H
extern "C" int add( int x, int y );
#endif
//C++实现文件 cppExample.cpp
#include "cppExample.h"
int add( int x, int y )
{
return x + y;
}
/* C实现文件 cFile.c
/* 这样会编译出错:#include "cExample.h" */
extern int add( int x, int y );
int main( int argc, char* argv[] )
{
add( 2, 3 );
return 0;
}
15.函数模板和类模板的特例化
引入原因:编写单一的模板,它能适应大众化,使每种类型都具有相同的功能,但对于某种特定类型,如果要实现其特有的功能,单一模板就无法做到,这时就需要模板特例化。
定义:是对单一模板提供的一个特殊实例,它将一个或多个模板参数绑定到特定的类型或值上。
- 函数模板特例化:
必须为原函数模板的每个模板参数都提供实参,且使用关键字template后跟一个空尖括号对<>,表明将原模板的所有模板参数提供实参。
template<typename T> //函数模板
int compare(const T &v1,const T &v2)
{
if(v1 > v2) return -1;
if(v2 > v1) return 1;
return 0;
}
//模板特例化,满足针对字符串特定的比较,要提供所有实参,这里只有一个T
template<>
int compare(const char* const &v1,const char* const &v2)
{
return strcmp(p1,p2);
}
特例化版本时,函数参数类型必须与先前声明的模板中对应的类型匹配,其中T为const char*。
本质:特例化的本质是实例化一个模板,而非重载它。特例化不影响参数匹配。参数匹配都以最佳匹配为原则。例如,此处如果是compare(3,5),则调用普通的模板,若为compare(“hi”,”haha”)则调用特例化版本(因为这个cosnt char*相对于T,更匹配实参类型),注意,二者函数体的语句不一样了,实现不同功能。
注意:普通作用于规则使用于特例化,即,模板及其特例化版本应该声明在同一个头文件中,且所有同名模板的声明应该放在前面,后面放特例化版本。
- 类模板特例化:
原理类似函数模板,不过在类中,我们可以对模板进行特例化,也可以对类进行部分特例化。对类进行特例化时,仍然用template<>表示是一个特例化版本,例如:
template<>
class hash<sales_data>
{
size_t operator()(sales_data&);
//里面所有T都换成特例化类型版本sales_data
};
按照最佳匹配原则,若T != sales_data,就用普通类模板,否则,就使用含有特定功能的特例化版本。
类模板的部分特例化:不必为所有模板参数提供实参,可以指定一部分而非所有模板参数,一个类模板的部分特例化本身仍是一个模板,使用它时还必须为其特例化版本中未指定的模板参数提供实参。此功能就用于STL源码剖析中的traits编程。
详见C++primer 628页的例子。(特例化时类名一定要和原来的模板相同,只是参数类型不同,按最佳匹配原则,那个最匹配,就用相应的模板)
特例化类中的部分成员:可以特例化类中的部分成员函数而不是整个类。
template<typename T>class Foo
{
void Bar();
void Barst(T a)();
};
template<>
void Foo<int>::Bar()
{
//进行int类型的特例化处理
}
Foo<string> fs;
Foo<int> fi;//使用特例化
fs.Bar();//使用的是普通模板,即Foo<string>::Bar()
fi.Bar();//特例化版本,执行Foo<int>::Bar()
//Foo<string>::Bar()和Foo<int>::Bar()功能不同
16.C++的STL源码
STL是对泛型编程思想的实现,从广义上分为三类:算法、容器、迭代器。其中算法部分主要有<algorithm>、<numeric>、<functional>组成,实现了常用的算法;容器主要实现了一些数据结构如向量、列表等,迭代器是一种允许程序员检查容器内元素,并实现元素遍历的数据类型;C++为每一种标准容器定义了一种迭代器类型,提供了比下标操作更加一般化的方法。
这个系列也很重要,建议侯捷老师的STL源码剖析书籍与视频,其中包括内存池机制,各种容器的底层实现机制,算法的实现原理等
17.STL源码中的hashtable的实现
- 哈希表是一种根据关键字直接访问内存的数据结构,通过哈希函数将键值映射为内存中的位置;哈希表可能遇到碰撞的情况,碰撞是指俩个不同的键值映射到了同一块内存区域;STL中使用开链法解决碰撞;当发生碰撞时,使用链表进行连接。
- STL哈希表的每一个哈希节点中有数据和 next 指针,哈希表定义时要指定数组大小 n,但是实际分配的数组长度是根据 n 计算的一个质数;在STL中存在一个计算好的质数表,只要找到第一个大于n的质数就可以;如果要放入哈希表的个数大于哈希表的长度,那么必须对哈希表的大小进行重新分配,每次重新分配的大小差不多是之前的二倍;如果发生碰撞,新的元素将放在链表中第一个元素的前面,成为链表中的第一个节点;
- STL中使用的哈希函数是简单的对数组的长度取余(M_hash(key)% n),其中M_hash 除了对字符串进行转换之外其它都返回原值。
18.STL中unordered_map和map的区别和应用场景
map:
map是STL内的一个关联容器,它提供一对一的数据处理能力,map内部自建一颗红黑树,该结构具有自动排序功能,因此map内的所有数据都是有序的,且map的查询、删除、插入的时间复杂度都是O(logN)。在使用时,map的key必须定义有operator < (<操作符)。
map的优点是时间复杂度好,效率高,且内部元素有序;缺点是空间占用率高。
unordered_map:
unordered_map与map类似,都是存储key-value的值,可以通过key快速找到value,不同的是unordered_map不会根据key的大小排序,存储时根据key的hash值判断元素是否相同。unordered_map的key需要定于hash_value并重载operator ==。
unordered_map的底层是一个防冗余的哈希表。
unordered_map的优点是查找速度快,缺点是建立哈希表比较耗时。
19.STL中vector的实现
vector采用的数据结构为线性连续的空间,它内部维护有三个迭代器,其中start指向连续空间中已经被使用的空间的开头,finish指向已经被使用空间的尾部,而end_of_storage指向整个连续空间的尾部;因此vector的大小size表示已经使用的空间大小(已有元素的个数),容量capacity表示vector本身连续空间的大小(最多可存储元素的个数),当size等于capacity时,若要再加入新的元素,要重新进行内存分配,整个vector数据都将移动到新内存,新分配的内存一般为原来大小的俩倍。所以程序员使用vector不需要提前知道内存大小。
一旦重新分配内存,指向原vector的迭代器会全部失效。
20.STL容器的几种迭代器以及对应的容器(输入迭代器,输出迭代器,前向迭代器,双向迭代器,随机访问迭代器)
- 顺序容器:vector,deque是随机访问迭代器;list是双向迭代器
- 容器适配器:stack,queue,priority_queue没有迭代器
- 关联容器:set,map,multiset,multimap是双向迭代器
- unordered_set,unordered_map,unordered_multiset,unordered_multimap是前向迭代器
21.STL中的traits技法
type_traits
iterator_traits
char traits
allocator_traits
pointer_traits
array_traits
22.vector使用的注意点及其原因,频繁对vector调用push_back()对性能的影响和原因。
- vector实现的关键点
(1)元素存储空间的增长方式,建议使用参考书籍的增长方式,每次增长的空间至少是原来空间的一半,即N=(N+N/2),注意存储空间利用率和当元素增长时程序的运行性能之间的平衡。
(2)实现时vector内的成员对象只有一个内存分配器对象和三个指向元素储存空间的指针(First、Last和End)。
(3)vector的特例化模板vector<bool>的存在主要是对于bool类型的元素存储和操作时在空间和时间上的优化。
(4)vector一般保留一个大小大于实际所需大小的数组空间,多余的存储空间在成为有效数组的一部分前保持为未构造状态。
(5)插入insert元素时需要移动元素的位置,移动时需要注意内存重叠,使元素的拷贝移动方向与元素的增长方向相反可解决内存重叠问题。
- vector使用注意事项:
(1)max_size函数返回的是vector中的内存分配器allocator能够分配的最大内存空间,即vector所能管控的最大序列长度,注意和capacity的区别。
(2)resize重新调整大小,既可以减小也可以增加size(数组的有效长度),但是内存并不一定减小。
(3)insert是在所指的元素之前进行插入,erase返回的迭代器指向被最后删除的元素的下一个元素。
(4)注意插入和删除元素后迭代器失效的问题。
(5)当预先知道所需的存储空间时,可以使用reserve预先分配内存。
(6)vector对象作为一个高效的栈使用时,应该让容器保持一定的预留存储空间,频繁的重新分配内存会影响栈的性能,可以使用reserve预分配内存,使用push_back、pop_back和back插入、删除和读取最后一个元素。
(7)clear只是保证了析构所有的元素,即size()=0,但并不保证释放所有的存储空间,即capacity不一定等于0,可以使用如下方式释放所有内存:
vec.swap(vector<T>());
- 频繁对vector调用push_back()对性能的影响和原因
在一个vector的尾部之外的任何位置添加元素,都需要重新移动元素。而且,向一个vector添加元素可能引起整个对象存储空间的重新分配。重新分配一个对象的存储空间需要分配新的内存,并将元素从旧的空间移到新的空间。一旦push_back引起空间重新配置,则指向原vector的所有迭代器失效。
23.C++中的重载和重写的区别
重载overload:在同一个类中,函数名相同,参数列表不同,编译器会根据这些函数的不同参数列表,将同名的函数名称做修饰,从而生成一些不同名称的预处理函数,未体现多态。
重写override:也叫覆盖,子类重新定义父类中有相同名称相同参数的虚函数,主要是在继承关系中出现的,被重写的函数必须是virtual的,重写函数的访问修饰符可以不同,尽管virtual是private的,子类中重写函数改为public,protected也可以,体现了多态。
24.C++内存管理,内存池技术(热门问题)
(1)内存管理:
- 内存分配机制及堆栈的区别(见问题2)
- new和delete(见问题3),注意C++允许对new和delete进行重载
- 常见的内存管理错误有
a>内存分配未成功却使用了它,如果所用的操作符不是类型安全的话,请使用assert(p != NULL)或者if(p != NULL)来判断。
b>内存分配成功但未初始化
c>内存分配成功并已初始化,但是操作超过了内存的边界
d>忘记释放内存,造成内存泄露,每申请一块内存必须保证它被释放,释放内存后立即将指针置为NULL
- 数组与指针的对比,数组要么在静态存储区被创建(如全局数组),要么在栈上被创建。数组名对应着(而不是指向)一块内存,其地址与容量在生命期内保持不变,只有数组的内容可以改变。
- 指针可以随时指向任意类型的内存块,它的特征是“可变”,所以我们常用指针来操作动态内存。指针远比数组灵活,但也更危险。
(2)内存池技术:
- 内存池简介
C/C++下内存管理是让几乎每一个程序员头疼的问题,分配足够的内存、追踪内存的分配、在不需要的时候释放内存——这个任务相当复杂。而直接使用系统调用malloc/free、new/delete进行内存分配和释放,有以下弊端:
- 调用malloc/new,系统需要根据“最先匹配”、“最优匹配”或其他算法在内存空闲块表中查找一块空闲内存,调用free/delete,系统可能需要合并空闲内存块,这些会产生额外开销
- 频繁使用时会产生大量内存碎片,从而降低程序运行效率
- 容易造成内存泄漏
- 内存池的优点
内存池则是在真正使用内存之前,预先申请分配一定数量、大小相等(一般情况下)的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块,若内存块不够再继续申请新的内存。这样做的一个显著优点是,使得内存分配效率得到提升。
- 内存池的分类
从线程安全的角度来分,内存池可以分为单线程内存池和多线程内存池。单线程内存池整个生命周期只被一个线程使用,因而不需要考虑互斥访问的问题;多线程内存池有可能被多个线程共享,因此需要在每次分配和释放内存时加锁。相对而言,单线程内存池性能更高,而多线程内存池适用范围更加广泛。
从内存池可分配内存单元大小来分,可以分为固定内存池和可变内存池。所谓固定内存池是指应用程序每次从内存池中分配出来的内存单元大小事先已经确定,是固定不变的;而可变内存池则每次分配的内存单元大小可以按需变化,应用范围更广,而性能比固定内存池要低。
(3)经典内存池的设计
- 经典内存池实现过程
a.先申请一块连续的内存空间,该段内存空间能够容纳一定数量的对象;
b.每个对象连同一个指向下一个对象的指针一起构成一个内存节点(Memory Node)。各个空闲的内存节点通过指针形成一个链表,链表的每一个内存节点都是一块可供分配的内存空间;
c.某个内存节点一旦分配出去,从空闲内存节点链表中去除;
d.一旦释放了某个内存节点的空间,又将该节点重新加入空闲内存节点链表;
e.如果一个内存块的所有内存节点分配完毕,若程序继续申请新的对象空间,则会再次申请一个内存块来容纳新的对象。新申请的内存块会加入内存块链表中。
经典内存池的实现过程大致如上面所述,其形象化的过程如下图所示:
如上图所示,申请的内存块存放三个可供分配的空闲节点。空闲节点由空闲节点链表管理,如果分配出去,将其从空闲节点链表删除,如果释放,将其重新插入到链表的头部。如果内存块中的空闲节点不够用,则重新申请内存块,申请的内存块由内存块链表来管理。
注意,本文涉及到的内存块链表和空闲内存节点链表的插入,为了省去遍历链表查找尾节点,便于操作,新节点的插入均是插入到链表的头部,而非尾部。当然也可以插入到尾部,读者可自行实现。
- 经典内存池数据结构设计
按照上面的过程设计,内存池类模板有这样几个成员。
两个指针变量:
内存块链表头指针:pMemBlockHeader;
空闲节点链表头指针:pFreeNodeHeader;
空闲节点结构体:
struct FreeNode
{
FreeNode* pNext;
char data[ObjectSize];
};
内存块结构体:
struct MemBlock
{
MemBlock *pNext;
FreeNode data[NumofObjects];
};
- 经典内存池的实现
根据以上经典内存池的设计,编码实现如下
#include <iostream>
using namespace std;
template<int ObjectSize, int NumofObjects = 20>
class MemPool
{
private:
//空闲节点结构体
struct FreeNode
{
FreeNode* pNext;
char data[ObjectSize];
};
//内存块结构体
struct MemBlock
{
MemBlock* pNext;
FreeNode data[NumofObjects];
};
FreeNode* freeNodeHeader;
MemBlock* memBlockHeader;
public:
MemPool()
{
freeNodeHeader = NULL;
memBlockHeader = NULL;
}
~MemPool()
{
MemBlock* ptr;
while (memBlockHeader)
{
ptr = memBlockHeader->pNext;
delete memBlockHeader;
memBlockHeader = ptr;
}
}
void* malloc();
void free(void*);
};
//分配空闲的节点
template<int ObjectSize, int NumofObjects>
void* MemPool<ObjectSize, NumofObjects>::malloc()
{
//无空闲节点,申请新内存块
if (freeNodeHeader == NULL)
{
MemBlock* newBlock = new MemBlock;
newBlock->pNext = NULL;
freeNodeHeader=&newBlock->data[0]; //设置内存块的第一个节点为空闲节点链表的首节点
//将内存块的其它节点串起来
for (int i = 1; i < NumofObjects; ++i)
{
newBlock->data[i - 1].pNext = &newBlock->data[i];
}
newBlock->data[NumofObjects - 1].pNext=NULL;
//首次申请内存块
if (memBlockHeader == NULL)
{
memBlockHeader = newBlock;
}
else
{
//将新内存块加入到内存块链表
newBlock->pNext = memBlockHeader;
memBlockHeader = newBlock;
}
}
//返回空节点闲链表的第一个节点
void* freeNode = freeNodeHeader;
freeNodeHeader = freeNodeHeader->pNext;
return freeNode;
}
//释放已经分配的节点
template<int ObjectSize, int NumofObjects>
void MemPool<ObjectSize, NumofObjects>::free(void* p)
{
FreeNode* pNode = (FreeNode*)p;
pNode->pNext = freeNodeHeader; //将释放的节点插入空闲节点头部
freeNodeHeader = pNode;
}
class ActualClass
{
static int count;
int No;
public:
ActualClass()
{
No = count;
count++;
}
void print()
{
cout << this << ": ";
cout << "the " << No << "th object" << endl;
}
void* operator new(size_t size);
void operator delete(void* p);
};
//定义内存池对象
MemPool<sizeof(ActualClass), 2> mp;
void* ActualClass::operator new(size_t size)
{
return mp.malloc();
}
void ActualClass::operator delete(void* p)
{
mp.free(p);
}
int ActualClass::count = 0;
int main()
{
ActualClass* p1 = new ActualClass;
p1->print();
ActualClass* p2 = new ActualClass;
p2->print();
delete p1;
p1 = new ActualClass;
p1->print();
ActualClass* p3 = new ActualClass;
p3->print();
delete p1;
delete p2;
delete p3;
}
程序运行结果:
004AA214: the 0th object
004AA21C: the 1th object
004AA214: the 2th object
004AB1A4: the 3th object
- 程序分析
阅读以上程序,应注意以下几点。
(1)对一种特定的类对象而言,内存池中内存块的大小是固定的,内存节点的大小也是固定的。内存块在申请之初就被划分为多个内存节点,每个Node的大小为ItemSize。刚开始,所有的内存节点都是空闲的,被串成链表。
(2)成员指针变量memBlockHeader是用来把所有申请的内存块连接成一个内存块链表,以便通过它可以释放所有申请的内存。freeNodeHeader变量则是把所有空闲内存节点串成一个链表。freeNodeHeader为空则表明没有可用的空闲内存节点,必须申请新的内存块。
(3)申请空间的过程如下。在空闲内存节点链表非空的情况下,malloc过程只是从链表中取下空闲内存节点链表的头一个节点,然后把链表头指针移动到下一个节点上去。否则,意味着需要一个新的内存块。这个过程需要申请新的内存块切割成多个内存节点,并把它们串起来,内存池技术的主要开销就在这里。
(4)释放对象的过程就是把被释放的内存节点重新插入到内存节点链表的开头。最后被释放的节点就是下一个即将被分配的节点。
(5)内存池技术申请/释放内存的速度很快,其内存分配过程多数情况下复杂度为O(1),主要开销在freeNodeHeader为空时需要生成新的内存块。内存节点释放过程复杂度为O(1)。
(6) 在上面的程序中,指针p1和p2连续两次申请空间,它们代表的地址之间的差值为8,正好为一个内存节点的大小(sizeof(FreeNode))。指针p1所指向的对象被释放后,再次申请空间,得到的地址与刚刚释放的地址正好相同。指针p3多代表的地址与前两个对象的地址相聚很远,原因是第一个内存块中的空闲内存节点已经分配完了,p3指向的对象位于第二个内存块中。
以上内存池方案并不完美,比如,只能单个单个申请对象空间,不能申请对象数组,内存池中内存块的个数只能增大不能减少,未考虑多线程安全等问题。现在,已经有很多改进的方案,请读者自行查阅相关资料。
注:与深入理解计算机系统(csapp)中几种内存分配方式对比学习加深理解
25.介绍面向对象的三大特性,并且举例说明每一个
- 封装
把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。
- 继承
继承是指这样一种能力:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。继承的过程,就是从一般到特殊的过程。要实现继承,可以通过“继承”(Inheritance)和“组合”(Composition)来实现。
在某些 OOP 语言中,一个子类可以继承多个基类。但是一般情况下,一个子类只能有一个基类,要实现多重继承,可以通过多级继承来实现。
继承概念的实现方式有三类:实现继承、接口继承和可视继承。
Ø 实现继承是指使用基类的属性和方法而无需额外编码的能力;
Ø 接口继承是指仅使用属性和方法的名称、但是子类必须提供实现的能力;
Ø 可视继承是指子窗体(类)使用基窗体(类)的外观和实现代码的能力。
在考虑使用继承时,有一点需要注意,那就是两个类之间的关系应该是“属于”关系。例如,Employee 是一个人,Manager 也是一个人,因此这两个类都可以继承 Person 类。但是 Leg 类却不能继承 Person 类,因为腿并不是一个人。
抽象类仅定义将由子类创建的一般属性和方法,创建抽象类时,请使用关键字 Interface 而不是 Class。
OO开发范式大致为:划分对象→抽象类→将类组织成为层次化结构(继承和合成) →用类与实例进行设计和实现几个阶段。
- 多态
关于多态,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”
C++多态性是通过虚函数来实现的,虚函数允许子类重新定义成员函数,而子类重新定义父类的做法称为覆盖(override),或者称为重写。(这里要补充,重写的话可以有两种,直接重写成员函数和重写虚函数,只有重写了虚函数的才能算作是体现了C++多态性)而重载则是允许有多个同名的函数,而这些函数的参数列表不同,允许参数个数不同,参数类型不同,或者两者都不同。编译器会根据这些函数的不同列表,将同名的函数的名称做修饰,从而生成一些不同名称的预处理函数,来实现同名函数调用时的重载问题。但这并没有体现多态性。
多态与非多态的实质区别就是函数地址是早绑定还是晚绑定。如果函数的调用,在编译器编译期间就可以确定函数的调用地址,并生产代码,是静态的,就是说地址是早绑定的。而如果函数调用的地址不能在编译器期间确定,需要在运行时才确定,这就属于晚绑定。
那么多态的作用是什么呢,封装可以使得代码模块化,继承可以扩展已存在的代码,他们的目的都是为了代码重用。而多态的目的则是为了接口重用。也就是说,不论传递过来的究竟是那个类的对象,函数都能够通过同一个接口调用到适应各自对象的实现方法。
最常见的用法就是声明基类的指针,利用该指针指向任意一个子类对象,调用相应的虚函数,可以根据指向的子类的不同而实现不同的方法。如果没有使用虚函数的话,即没有利用C++多态性,则利用基类指针调用相应的函数的时候,将总被限制在基类函数本身,而无法调用到子类中被重写过的函数。因为没有多态性,函数调用的地址将是一定的,而固定的地址将始终调用到同一个函数,这就无法实现一个接口,多种方法的目的了。
26.C++多态的实现
多态用虚函数来实现,结合动态绑定。
引用/指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在。
C++的多态性用一句话概括就是:在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。
27.C++虚函数相关
- 虚函数表,虚函数指针
存在虚函数的类都有一个一维的虚函数表叫做虚表,类的对象有一个指向虚表开始的虚指针。虚表是和类对应的,虚表指针是和对象对应的。
简化后就像这样:
注意 :
①每个虚表后面都有一个‘0’,它类似字符串的‘\0’,用来标识虚函数表的结尾。结束标识在不同的编译器下可能会有所不同。
②不难发现虚函数表的指针存在于对象实例中最前面的位置(这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下)这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。
多态实现利用到了虚函数表(虚表V-table)。它是一块虚函数的地址表,通过一块连续内存来存储虚函数的地址。这张表解决了继承、虚函数(重写)的问题。在有虚函数的对象实例中都存在一张虚函数表,虚函数表就像一张地图,指明了实际应该调用的虚函数函数。
- 为什么基类指针指向派生类对象时可以调用派生类成员函数
虚表指针一般放在首地址,如果父类有虚函数表,子类必定有;因为构造子类时先构造父类,所以使用父类的指针,编译器根据指针类型就能知道偏移多少就能找到父类的成员(包括虚函数指针),但是对于子类独有的成员,父类的指针无法提供偏移量,因此找不到。
- 基类的虚函数存放在内存的什么区
全局数据区(静态区)
- 虚表指针vptr的初始化时间
所有基类构造函数之后,但又在自身构造函数或初始化列表之前
- 编译器处理虚函数的方法是:
编译器为每个包含虚函数的类创建一个表,在表中编译器放置特定类的虚函数地址,在每个带有虚函数的类中,编译器为每个类对象放置一个指针(为每个类添加一个隐藏的成员),指向虚表。通过基类的指针或引用做虚函数调用时,编译器静态插入取得该指针,并在虚表中找到函数地址。注意基类和派生类的虚函数表是俩个东西,保存在不同的空间,但这俩个东西的内容可能一样。
28.虚函数实现原理(包括单一继承,多重继承等)
实现原理:虚函数表+虚表指针
每个虚函数都会有一个与之对应的虚函数表,该虚函数表的实质是一个指针数组,存放的是每一个对象的虚函数入口地址。对于一个派生类来说,他会继承基类的虚函数表同时增加自己的虚函数入口地址,如果派生类重写了基类的虚函数的话,那么继承过来的虚函数入口地址将被派生类的重写虚函数入口地址替代。那么在程序运行时会发生动态绑定,将父类指针绑定到实例化的对象实现多态。
- 单继承环境下的虚函数
假设存在下面的两个类Base和A,A类继承自Base类:
class Base
{
public:
// 虚函数func1
virtual void func1() { cout << "Base::func1()" << endl; }
// 虚函数func2
virtual void func2() { cout << "Base::func2()" << endl; }
// 虚函数func3
virtual void func3() { cout << "Base::func3()" << endl; }
int a;
};
class A : public Base
{
public:
// 重写父类虚函数func1
void func1() { cout << "A::func1()" << endl; }
void func2() { cout << "A::func2()" << endl; }
// 新增虚函数func4
virtual void func4() { cout << "A::func3()" << endl; }
};
利用Visual Studio提供的命令行工具查看一下这两个类的内存布局。
类Base的内存布局图:
类A的内存布局图:
通过两幅图片的对比,我们可以看到:
- 在单继承中,A类覆盖了Base类中的同名虚函数,在虚函数表中体现为对应位置被A类中的新函数替换,而没有被覆盖的函数则没有发生变化。
- 对于子类自己的虚函数,直接添加到虚函数表后面。
另外,我们注意到,类A和类Base中都只有一个vfptr指针,前面我们说过,该指针指向虚函数表,我们分别输出类A和类Base的vfptr:
int main()
{
typedef void(*pFunc)(void);
cout << "virtual function testing:" << endl;
Base b;
cout << "Base虚函数表地址:" << (int *)(&b) << endl;
A a;
cout << "A类虚函数表地址:" << (int *)(&a) << endl;
}
输出信息如下:
我们可以看到,类A和类B分别拥有自己的虚函数表指针vptr和虚函数表vtbl。到这里,你是否已经明白为什么指向子类实例的基类指针可以调用子类(虚)函数?每一个实例对象中都存在一个vptr指针,编译器会先取出vptr的值,这个值就是虚函数表vtbl的地址,再根据这个值来到vtbl中调用目标函数。所以,只要vptr不同,指向的虚函数表vtbl就不同,而不同的虚函数表中存放着对应类的虚函数地址,这样就实现了多态的”效果“。
最后,我们用一幅图来表示单继承下的虚函数实现:
- 多继承环境下的虚函数
假设存在下面这样的四个类:
class Base
{
public:
// 虚函数func1
virtual void func1() { cout << "Base::func1()" << endl; }
// 虚函数func2
virtual void func2() { cout << "Base::func2()" << endl; }
// 虚函数func3
virtual void func3() { cout << "Base::func3()" << endl; }
};
class A : public Base
{
public:
// 重写父类虚函数func1
void func1() { cout << "A::func1()" << endl; }
void func2() { cout << "A::func2()" << endl; }
};
class B : public Base
{
public:
void func1() { cout << "B::func1()" << endl; }
void func2() { cout << "B::func2()" << endl; }
};
class C : public A, public B
{
public:
void func1() { cout << "D::func1()" << endl; }
void func2() { cout << "D::func2()" << endl; }
};
类A和类B分别继承自类Base,类C继承了类B和类A,我们查看一下类C的内存布局:
可以看到,类C中拥有两个虚函数表指针vptr。类C中覆盖了类A的两个同名函数,在虚函数表中体现为对应位置替换为C中新函数;类C中覆盖了类B中的两个同名函数,在虚函数表中体现为对应位置替换为C中新函数(注意,这里使用跳转语句,而不是重复定义)。
类C的内存布局可以归纳为下图:
多重继承会有多个虚函数表,几重继承,就会有几个虚函数表。这些表按照派生的顺序依次排列,如果子类改写了父类的虚函数,那么就会用子类自己的虚函数覆盖虚函数表的相应的位置,如果子类有新的虚函数,那么就添加到第一个虚函数表的末尾。
29.C++中类的数据成员和成员函数内存分布情况
C++类成员所占内存总结:
(1)空类所占字节数为1
#include <iostream>
using namespace std;
class Parent
{
};
class Child:public Parent
{
public:
int b ;
};
int main(int argc, char* argv[])
{
Child b;
Parent a;
cout << "a.sizeof = " << sizeof(a) << endl;
cout << "b.sizeof = " << sizeof(b) << endl;
system("pause");
return 0;
}
打印结果为:
分析:
为了能够区分不同的对象,一个空类在内存中只占一个字节;
在子类继承父类后,如果子类仍然是空类,则子类也在内存中指针一个字节;
如果子类不是空类,则按照成员变量所占字节大小计算。
(2)类中的成员函数不占内存空间,虚函数除外;
#include <iostream>
using namespace std;
class Parent
{
public:
void func() {};
void func1() { int a; };
void func2() { int b; };
};
class Child:public Parent
{
public:
int b ;
};
int main(int argc, char* argv[])
{
Child b;
Parent a;
cout << "a.sizeof = " << sizeof(a) << endl;
cout << "b.sizeof = " << sizeof(b) << endl;
system("pause");
return 0;
}
输出结果如下:
分析:上述代码中父类,在内存中仍然只占有一个字节;原因就是因为函数在内存中不占字节;
但是,如果父类中如果有一个虚函数,则类所字节发生变化,如果是32位编译器,则占内存四个字节;
#include <iostream>
using namespace std;
class Parent
{
public:
virtual void func() {};
virtual void func1() { int a; };
void func2() { int b; };
};
class Child:public Parent
{
public:
int b ;
};
int main(int argc, char* argv[])
{
Child b;
Parent a;
cout << "a.sizeof = " << sizeof(a) << endl;
cout << "b.sizeof = " << sizeof(b) << endl;
system("pause");
return 0;
}
输出结果:
分析:
通过上述代码可见,编译器为32时,无论几个虚函数所占的字节数都为4;
而子类在内存中占的字节数为父类所占字节数+自身成员所占的字节数;
(3)和结构体一样,类中自身带有四字节对齐功能
#include <iostream>
using namespace std;
class Parent
{
public:
char a;
virtual void func() {};
virtual void func1() { int a; };
void func2() { int b; };
};
class Child:public Parent
{
public:
char c;
int b ;
};
int main(int argc, char* argv[])
{
Child b;
Parent a;
cout << "a.sizeof = " << sizeof(a) << endl;
cout << "b.sizeof = " << sizeof(b) << endl;
system("pause");
return 0;
}
输出结果:
分析:
Parent类中,char a;占一个字节,虚函数占有四个字节,由于类的字节对齐,所以总共父类占有8个字节;
子类中,char c 占有一个字节,int 占四个字节,由于字节对齐,本身共占有8字节,再加上父类的8字节,共占有16字节;
(4)类中的static静态成员变量不占内存,静态成员变量存储在静态区
#include <iostream>
using namespace std;
class G
{
public:
static int a;
};
int main(int argc, char * argv[])
{
cout << sizeof(G)<<endl;
system("pause");
return 0;
}
结果输出:
总结:
1.空类必须占一个字节;
2.函数指针不占字节;
3.虚函数根据编译器位数,占相应字节;
4.类具有4字节对齐功能;
5.类中的静态成员变量不占类的内存;并且静态成员变量的初始化必须在类外初始化;
30.this指针
用类去定义对象时,系统会为每一个对象分配存储空间。如果一个类包括了数据和函数,要分别为数据和函数的代码分配存储空间。按理说,如果用同一个类定义了10个对象,那么就需要分别为10个对象的数据和函数代码分配存储单元,如下图所示。
我们可以看出这样不仅麻烦而且特别浪费空间,因此经过分析我们可以知道是按以下方式来储存的。
只用一段空间来存放这个共同的函数代码段,在调用各对象的函数时,都去调用这个公用的函数代码。如下图所示。
显然,这样做会大大节约存储空间。C++编译系统正是这样做的,因此每个对象所占用的存储空间只是该对象的数据部分(虚函数指针和虚基类指针也属于数据部分)所占用的存储空间,而不包括函数代码所占用的存储空间。
那么问题来了在不同对象但是调用的的代码又相同的情况下,编译器是如何分辨且准确的调用到各自的函数???
在c++中专门设立了一个this指针,用来指向不同的对象,当调用对象t1的成员函数display1时,this指针就指向display1,当调用t2的成员函数display2,this指针就指向display2。。。。。以此类推来分辨准确的调用
31.析构函数一般写成虚函数的原因
按《Effective C++》中的观点其实是:只要一个类有可能会被其它类所继承, 析构函数就应该声明是虚析构函数。
那为什么定义成虚析构函数就能解决这个问题呢?
因为实现了多态。此时子类对象模型里父类析构函数被覆盖,(编译器依旧能知晓父类析构)当父类指针/引用指向父类对象时,调用的是父类的虚函数,指向子类对象时调用的是子类的虚函数;所以析构函数被定义为虚函数就不难理解了。
32.构造函数、拷贝构造函数和赋值操作符的区别
- 对象不存在,且没用别的对象来初始化,就是调用了构造函数;
- 对象不存在,且用别的对象来初始化,就是拷贝构造函数(上面说了三种用它的情况!)
- 对象存在,用别的对象来给它赋值,就是赋值函数。
33.构造函数声明为explicit
在C++中,explicit关键字用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换,只能以显示的方式进行类型转换。
explicit使用注意事项:
- explicit 关键字只能用于类内部的构造函数声明上。
- explicit 关键字作用于单个参数的构造函数。
- 在C++中,explicit关键字用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换
34.构造函数为什么一般不定义为虚函数
- 因为创建一个对象时需要确定对象的类型,而虚函数是在运行时确定其类型的。而在构造一个对象时,由于对象还未创建成功,编译器无法知道对象的实际类型,是类本身还是类的派生类等等
- 虚函数的调用需要虚函数表指针,而该指针存放在对象的内存空间中;若构造函数声明为虚函数,那么由于对象还未创建,还没有内存空间,更没有虚函数表地址用来调用虚函数即构造函数了
35.构造函数的几种关键字(default delete 0)
- = default:将拷贝控制成员定义为=default显式要求编译器生成合成的版本
- = delete:将拷贝构造函数和拷贝赋值运算符定义删除的函数,阻止拷贝(析构函数不能是删除的函数 C++Primer P450)
- = 0:将虚函数定义为纯虚函数(纯虚函数无需定义,= 0只能出现在类内部虚函数的声明语句处;当然,也可以为纯虚函数提供定义,不过函数体必须定义在类的外部)
36.构造函数或者析构函数中调用虚函数会怎样
先看一个例子:
#include <iostream>
using namespace std;
class A{
public:
A() {
show();
}
virtual void show(){
cout<<"in A"<<endl;
}
virtual ~A(){
show();
}
};
class B:public A{
public:
B() {
show();
}
void show(){
cout<<"in B"<<endl;
}
};
int main(){
A *a = new A;
delete a;
cout << "*****************" << endl;
A *b = new B;
delete b;
}
输出结果,可以看到没有预想的多态效果:
in A
in A
*****************
in A
in B
in A
结论:构造函数和析构函数调用虚函数时都不使用动态联编,如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本。
原因分析:
(1)不要在构造函数中调用虚函数的原因:因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化, 因此调用子类的虚函数是不安全的,故而C++不会进行动态联编。
(2)不要在析构函数中调用虚函数的原因:析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数。所以在调用基类的析构函数时,派生类对象的数据成员已经“销毁”,这个时再调用子类的虚函数已经没有意义了。
37.纯虚函数
在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该类的派生类去做(派生类必须要实现)。在基类中实现纯虚函数的方法是在函数原型后加“=0”。声明形式:virtual void fun()=0;
引入原因:1)为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。2)在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。
抽象类:含有一个或者多个纯虚函数的类是抽象类,抽象类不能创建对象。而只有被继承,并重写其虚函数后,才能使用
38.静态类型和动态类型,静态绑定和动态绑定的介绍
- 静态类型:对象在声明时采用的类型,在编译期既已确定;
- 动态类型:通常是指一个指针或引用目前所指对象的类型,是在运行期决定的;
- 静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;
- 动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;
39.引用是否能实现动态绑定,为什么引用可以实现
因为对象的类型是确定的,在编译期就确定了,指针或引用是在运行期根据他们绑定的具体对象确定。
40.深拷贝和浅拷贝的区别(举例说明深拷贝的安全性)
先考虑一种情况,对一个已知对象进行拷贝,编译系统会自动调用一种构造函数——拷贝构造函数,如果用户未定义拷贝构造函数,则会调用默认拷贝构造函数。
先看一个例子,有一个学生类,数据成员时学生的人数和名字:
#include <iostream>
using namespace std;
class Student
{
private:
int num;
char *name;
public:
Student();
~Student();
};
Student::Student()
{
name = new char(20);
cout << "Student" << endl;
}
Student::~Student()
{
cout << "~Student " << (int)name << endl;
delete name;
name = NULL;
}
int main()
{
{// 花括号让s1和s2变成局部对象,方便测试
Student s1;
Student s2(s1);// 复制对象
}
system("pause");
return 0;
}
执行结果:调用一次构造函数,调用两次析构函数,两个对象的指针成员所指内存相同,这会导致什么问题呢?name指针被分配一次内存,但是程序结束时该内存却被释放了两次,会导致崩溃!
这是由于编译系统在我们没有自己定义拷贝构造函数时,会在拷贝对象时调用默认拷贝构造函数,进行的是浅拷贝!即对指针name拷贝后会出现两个指针指向同一个内存空间。
所以,在对含有指针成员的对象进行拷贝时,必须要自己定义拷贝构造函数,使拷贝后的对象指针成员有自己的内存空间,即进行深拷贝,这样就避免了内存泄漏发生。
添加了自己定义拷贝构造函数的例子:
#include <iostream>
using namespace std;
class Student
{
private:
int num;
char *name;
public:
Student();
~Student();
Student(const Student &s);//拷贝构造函数,const防止对象被改变
};
Student::Student()
{
name = new char(20);
cout << "Student" << endl;
}
Student::~Student()
{
cout << "~Student " << (int)name << endl;
delete name;
name = NULL;
}
Student::Student(const Student &s)
{
name = new char(20);
memcpy(name, s.name, strlen(s.name));
cout << "copy Student" << endl;
}
int main()
{
{// 花括号让s1和s2变成局部对象,方便测试
Student s1;
Student s2(s1);// 复制对象
}
system("pause");
return 0;
}
运行结果:调用一次构造函数,一次自定义拷贝构造函数,两次析构函数。两个对象的指针成员所指内存不同。
总结:浅拷贝只是对指针的拷贝,拷贝后两个指针指向同一个内存空间,深拷贝不但对指针进行拷贝,而且对指针指向的内容进行拷贝,经深拷贝后的指针是指向两个不同地址的指针。
再说几句:
当对象中存在指针成员时,除了在复制对象时需要考虑自定义拷贝构造函数,还应该考虑以下两种情形:
- 当函数的参数为对象时,实参传递给形参的实际上是实参的一个拷贝对象,系统自动通过拷贝构造函数实现;
- 当函数的返回值为一个对象时,该对象实际上是函数内对象的一个拷贝,用于返回函数调用处。
- 浅拷贝带来问题的本质在于析构函数释放多次堆内存,使用std::shared_ptr,可以完美解决这个问题
41.对象复用的了解,零拷贝的了解
对象池:对象池通过对象复用的方式来避免重复创建对象,它会事先创建一定数量的对象放到池中,当用户需要创建对象的时候,直接从对象池中获取即可,用完对象之后再放回到对象池中,以便复用。
适用性:类的实例可重用。类的实例化过程开销较大。类的实例化的频率较高的时候可以使用对象复用。
零拷贝:避免CPU将数据从一快存储拷贝到另外一块存储的技术,比如emplace_back函数
42.介绍C++所有的构造函数
构造函数的名称与类名完全相同,且不反回任何类型。
- 默认的构造函数,默认的构造函数是没有任何参数的,如果类中没有声明构造函数,将由编译器提供,一旦声明了任何非默认构造函数,编译器将不再提供
- 复制(拷贝)构造函数,采用相同对象类型的引用作为输入,并创建一个它的副本,编译器会提供一个复制构造函数,但是是浅拷贝。
- 普通派生类的构造函数:会按继承时的声明顺序构造基类
- 含有虚继承的派生类构造函数,创建所有有参构造函数的基类,包括直接基类,间接基类
43.什么情况下会调用拷贝构造函数(三种情况)
- 直接初始化和拷贝初始化时(直接初始化就是赋值,最好使用初始化列表而不是直接初始化)
- 把一个对象作为实参传递给一个非引用非指针类型的形参
- 返回一个非引用非指针类型的对象
- 用花括号初始化列表初始化一个数组的元素或聚合类的成员
44.结构体内存对齐方式和为什么要进行内存对齐?
什么是结构体内存对齐?
结构体不像数组,结构体中可以存放不同类型的数据,它的大小也不是简单的各个数据成员大小之和,限于读取内存的要求,而是每个成员在内存中的存储都要按照一定偏移量来存储,根据类型的不同,每个成员都要按照一定的对齐数进行对齐存储,最后整个结构体的大小也要按照一定的对齐数进行对齐。
对齐规则:
- 第一个成员在与结构体变量偏移量为0的地址
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
- 对齐数=编译器默认的一个对齐数 与 该成员大小的较小值。
- Windows中默认对齐数为8,Linux中默认对齐数为4;
- 结构体总大小为最大对齐数的整数倍(每个成员变量除了第一个成员都有一个对齐数)
如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍,结构体的整体大小就是所有最大对齐数的整数倍(含嵌套结构体的对齐数)
特点 :
每个成员的偏移量%自己的对齐数=0;
结构体整体大小%所有成员最大对齐数=0;
结构体的对齐数是自己内部成员的对齐数中的最大对齐数
举例说明
//平台VS2013下(默认对齐数为8)
//练习一
struct S1
{
char c1;
int i;
short s2;
};
printf("%d\n", sizeof(struct S1));//12
//练习二
struct S2
{
char c1;
short s2;
int i;
};
printf("%d\n", sizeof(struct S2));//8
案例一分析
char 类型占1个字节,编译器默认对齐数为8,则该变量对齐数为1,实际偏移量为0
int 类型占4个字节,编译器默认对其数为8,则该变量对其数位4,偏移量应该为4的倍数,实际偏移量为4
short类型占2个字节,编译器默认对齐数为8,则该变量对其数2,偏移量应该为2的倍数,实际偏移量为8
结构体整体的对齐数为所有成员的对齐数中最大的一个,对齐数为4
结构体整体大小,按照上面数据占据空间大小,计算得结构体大小10字节。
按照对其规则,应该对齐到4的倍数,实际大小为12字节
案例二分析
char 类型占1个字节,编译器默认对齐数为8,则该变量对齐数为1,实际偏移量为0
short类型占2个字节,编译器默认对齐数为8,则该变量对其数2,偏移量应该为2的倍数,实际偏移量为2
int 类型占4个字节,编译器默认对其数为8,则该变量对其数位4,偏移量应该为4的倍数,实际偏移量为4
结构体整体的对齐数为所有成员的对齐数中最大的一个,对齐数为4
结构体整体大小,按照上面数据占据空间大小,计算得结构体大小8字节。
按照对其规则,应该对齐到4的倍数,实际大小为8字节
图形分析:
为什么存在内存对齐?
- 平台移植型好
不是所有的硬件平台都能访问任意地址上的数据;某些硬件平台只能只在某些地址访问某些特定类型的数据,否则抛出硬件异常,及遇到未对齐的边界直接就不进行读取数据了。
- cpu处理效率高
从上图可以看出,对应两种存储方式,若CPU的读取粒度为4字节,
(1)那么对于一个int 类型,若是按照内存对齐来存储,处理器只需要访存一次就可以读取完4个字节
(2)若没有按照内存对其来读取,如上图所示,就需要访问内存两次才能读取出一个完整的int 类型变量
(3)具体过程为,第一次拿出 4个字节,丢弃掉第一个字节,第二次拿出4个字节,丢弃最后的三个字节,然后拼凑出一个完整的 int 类型的数据。
其实结构体内存对齐是拿空间换取时间的做法。提高效率
45.内存泄露的定义,如何检测与避免?
(1)首先说到c++内存泄漏时要知道它的含义?
内存泄漏(memory leak)是指由于疏忽或错误造成了程序未能释放掉不再使用的内存的情况。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
(2)内存泄漏的后果?
最难捉摸也最难检测到的错误之一是内存泄漏,即未能正确释放以前分配的内存的 bug。 只发生一次的小的内存泄漏可能不会被注意,但泄漏大量内存的程序或泄漏日益增多的程序可能会表现出各种征兆:从性能不良(并且逐渐降低)到内存完全用尽。 更糟的是,泄漏的程序可能会用掉太多内存,以致另一个程序失败,而使用户无从查找问题的真正根源。 此外,即使无害的内存泄漏也可能是其他问题的征兆。
(3)对于C和C++这种没有垃圾回收机制的语言来讲,我们主要关注两种类型的内存泄漏:
- 堆内存泄漏 (Heap leak)。对内存指的是程序运行中根据需要分配通过malloc,realloc new等从堆中分配的一块内存,再是完成后必须通过调用对应的 free或者delete 删掉。如果程序的设计的错误导致这部分内存没有被释放,那么此后这块内存将不会被使用,就会产生Heap Leak.
- 系统资源泄露(Resource Leak).主要指程序使用系统分配的资源比如 Bitmap,handle ,SOCKET等没有使用相应的函数释放掉,导致系统资源的浪费,严重可导致系统效能降低,系统运行不稳定。
(4)使用C/C++语言开发的软件在运行时,出现内存泄漏。可以使用以下两种方式,进行检查排除:
- 使用工具软件BoundsChecker,BoundsChecker是一个运行时错误检测工具,它主要定位程序运行时期发生的各种错误。
- 调试运行DEBUG版程序,运用以下技术:CRT(C run-time libraries)、运行时函数调用堆栈、内存泄漏时提示的内存分配序号(集成开发环境OUTPUT窗口),综合分析内存泄漏的原因,排除内存泄漏。
(5)解决内存泄漏最有效的办法就是使用智能指针(Smart Pointer)。
使用智能指针就不用担心这个问题了,因为智能指针可以自动删除分配的内存。智能指针和普通指针类似,只是不需要手动释放指针,而是通过智能指针自己管理内存的释放,这样就不用担心内存泄漏的问题了。
46.手写智能指针的实现(shared_ptr和weak_ptr实现的区别)
weakptr的作为弱引用指针,其实现依赖于counter的计数器类和share_ptr的赋值,构造,所以需要把counter和share_ptr也简单实现一下。
counter对象的目地就是用来申请一个块内存来存引用基数,简单实现如下:
class Counter
{
public:
Counter():s(0),w(0){};
int s; //share_ptr的引用计数
int w; //weak_ptr的引用计数,当w为0时,删除Counter对象
};
share_ptr的简单实现如下:
template<class T> class WeakPtr;//为了用weak_ptr的lock(),来生成share_ptr用,需要拷贝构造用
template<class T>
class SharePtr
{
public:
SharePtr(T* p=0)
:_ptr(p){
cnt=new Counter();
if(p)
cnt->s=1;
cout<<"in construct "<<cnt->s<<endl;
}
~SharePtr()
{
release();
}
SharePtr(SharePtr<T> const &s)
{
cout<<"in copy con"<<endl;
_ptr=s._ptr;
(s.cnt)->s++;
cout<<"copy construct"<<(s.cnt)->s<<endl;
cnt=s.cnt;
}
SharePtr(WeakPtr<T> const &w)//为了用weak_ptr的lock(),来生成share_ptr用,需要拷贝构造用
{
cout<<"in w copy con "<<endl;
_ptr=w._ptr;
(w.cnt)->s++;
cout<<"copy w construct"<<(w.cnt)->s<<endl;
cnt=w.cnt;
}
SharePtr<T>& operator=(SharePtr<T> &s)
{
if(this != &s)
{
release();
(s.cnt)->s++;
cout<<"assign construct "<<(s.cnt)->s<<endl;
cnt=s.cnt;
_ptr=s._ptr;
}
return *this;
}
T& operator *()
{
return *_ptr;
}
T* operator ->()
{
return _ptr;
}
friend class WeakPtr<T>; //方便weak_ptr与share_ptr设置引用计数和赋值。
private:
void release()
{
cnt->s--;
cout<<"release "<<cnt->s<<endl;
if(cnt->s <1)
{
delete _ptr;
if(cnt->w <1)
{
delete cnt;
cnt=NULL;
}
}
}
T* _ptr;
Counter* cnt;
};
share_ptr的给出的函数接口为:构造,拷贝构造,赋值,解引用。通过release来在引用计数为0的时候删除_ptr和cnt的内存。
那么最后可以给出weak_ptr的实现,如下:
template<class T>
class WeakPtr
{
public://给出默认构造和拷贝构造,其中拷贝构造不能有从原始指针进行构造
WeakPtr()
{
_ptr=0;
cnt=0;
}
WeakPtr(SharePtr<T>& s):
_ptr(s._ptr),cnt(s.cnt)
{
cout<<"w con s"<<endl;
cnt->w++;
}
WeakPtr(WeakPtr<T>& w):
_ptr(w._ptr),cnt(w.cnt)
{
cnt->w++;
}
~WeakPtr()
{
release();
}
WeakPtr<T>& operator =(WeakPtr<T> & w)
{
if(this != &w)
{
release();
cnt=w.cnt;
cnt->w++;
_ptr=w._ptr;
}
return *this;
}
WeakPtr<T>& operator =(SharePtr<T> & s)
{
cout<<"w = s"<<endl;
release();
cnt=s.cnt;
cnt->w++;
_ptr=s._ptr;
return *this;
}
SharePtr<T> lock()
{
return SharePtr<T>(*this);
}
bool expired()
{
if(cnt)
{
if(cnt->s >0)
{
cout<<"empty "<<cnt->s<<endl;
return false;
}
}
return true;
}
friend class SharePtr<T>;//方便weak_ptr与share_ptr设置引用计数和赋值。
private:
void release()
{
if(cnt)
{
cnt->w--;
cout<<"weakptr release"<<cnt->w<<endl;
if(cnt->w <1&& cnt->s <1)
{
//delete cnt;
cnt=NULL;
}
}
}
T* _ptr;
Counter* cnt;
};
share_ptr的一般接口是,通过share_ptr来构造,通过expired函数检查原始指针是否为空,lock来转化为share_ptr。
测试代码如下:
class parent;
class child;
class parent
{
public:
// SharePtr<child> ch;
WeakPtr<child> ch;
};
class child
{
public:
SharePtr<parent> pt;
};
int main()
{
//SharePtr<parent> ft(new parent());
//SharePtr<child> son(new child());
//ft->ch=son;
//son->pt=ft;
//SharePtr<child> son2=(ft->ch).lock();
SharePtr<int> i;
WeakPtr<int> wi(i);
cout<<wi.expired()<<endl;
return 0;
}
通过打开注释,可以模拟share_ptr的经典的循环引用的案例,也可以检查指针是否为空。
47.智能指针的循环引用
(1)shared_ptr的使用
shared_ptr多个指针指向相同的对象。shared_ptr使用引用计数,每一个shared_ptr的拷贝都指向相同的内存。每使用他一次,内部的引用计数加1,每析构一次,内部的引用计数减1,减为0时,自动删除所指向的堆内存。shared_ptr内部的引用计数是线程安全的,但是对象的读取需要加锁。
- 初始化。智能指针是个模板类,可以指定类型,传入指针通过构造函数初始化。也可以使用make_shared函数初始化。不能将指针直接赋值给一个智能指针,一个是类,一个是指针。例如std::shared_ptr<int> p4 = new int(1);的写法是错误的
- 拷贝和赋值。拷贝使得对象的引用计数增加1,赋值使得原对象引用计数减1,当计数为0时,自动释放内存。后来指向的对象引用计数加1,指向后来的对象。
- get函数获取原始指针
- 注意不要用一个原始指针初始化多个shared_ptr,否则会造成二次释放同一内存
- 注意避免循环引用,shared_ptr的一个最大的陷阱是循环引用,循环,循环引用会导致堆内存无法正确释放,导致内存泄漏。循环引用在weak_ptr中介绍。
(2)unique_ptr的使用
unique_ptr“唯一”拥有其所指对象,同一时刻只能有一个unique_ptr指向给定对象(通过禁止拷贝语义、只有移动语义来实现)。相比与原始指针unique_ptr用于其RAII的特性,使得在出现异常的情况下,动态资源能得到释放。unique_ptr指针本身的生命周期:从unique_ptr指针创建时开始,直到离开作用域。离开作用域时,若其指向对象,则将其所指对象销毁(默认使用delete操作符,用户可指定其他操作)。unique_ptr指针与其所指对象的关系:在智能指针生命周期内,可以改变智能指针所指对象,如创建智能指针时通过构造函数指定、通过reset方法重新指定、通过release方法释放所有权、通过移动语义转移所有权。
(3) weak_ptr的使用
weak_ptr是为了配合shared_ptr而引入的一种智能指针,因为它不具有普通指针的行为,没有重载operator*和->,它的最大作用在于协助shared_ptr工作,像旁观者那样观测资源的使用情况。weak_ptr可以从一个shared_ptr或者另一个weak_ptr对象构造,获得资源的观测权。但weak_ptr没有共享资源,它的构造不会引起指针引用计数的增加。使用weak_ptr的成员函数use_count()可以观测资源的引用计数,另一个成员函数expired()的功能等价于use_count()==0,但更快,表示被观测的资源(也就是shared_ptr的管理的资源)已经不复存在。weak_ptr可以使用一个非常重要的成员函数lock()从被观测的shared_ptr获得一个可用的shared_ptr对象, 从而操作资源。但当expired()==true的时候,lock()函数将返回一个存储空指针的shared_ptr。
使用 weak_ptr 解决 shared_ptr 因循环引有不能释放资源的问题
使用 shared_ptr 时, shared_ptr 为强引用, 如果存在循环引用, 将导致内存泄露. 而 weak_ptr 为弱引用, 可以避免此问题, 其原理:
对于弱引用来说, 当引用的对象活着的时候弱引用不一定存在. 仅仅是当它存在的时候的一个引用, 弱引用并不修改该对象的引用计数, 这意味这弱引用它并不对对象的内存进行管理.
weak_ptr 在功能上类似于普通指针, 然而一个比较大的区别是, 弱引用能检测到所管理的对象是否已经被释放, 从而避免访问非法内存。
注意: 虽然通过弱引用指针可以有效的解除循环引用, 但这种方式必须在程序员能预见会出现循环引用的情况下才能使用, 也可以是说这个仅仅是一种编译期的解决方案, 如果程序在运行过程中出现了循环引用, 还是会造成内存泄漏.
48.遇到coredump要怎么调试
core dump的含义
core dump又叫核心转储。当程序运行过程中发生异常, 程序异常退出时, 由操作系统把程序当前的内存状况存储在一个core文件中, 叫core dump。
(1)ulimit -c unlimited命令设置coredump文件
(2)gdb a.out core命令运行程序(linux下)
(3)使用bt命令查看堆栈
49.内存检查工具的了解
工具 | 描述 |
---|---|
valgrind | 一个强大开源的程序检测工具 |
mtrace | GNU扩展, 用来跟踪malloc, mtrace为内存分配函数(malloc, realloc, memalign, free)安装hook函数 |
dmalloc | 用于检查C/C++内存泄露(leak)的工具,即检查是否存在直到程序运行结束还没有释放的内存,以一个运行库的方式发布 |
memwatch | 和dmalloc一样,它能检测未释放的内存、同一段内存被释放多次、位址存取错误及不当使用未分配之内存区域 |
mpatrol | 一个跨平台的 C++ 内存泄漏检测器 |
dbgmem | |
Electric Fence |
50.模板的用法与适用场景
51.成员初始化列表的概念,为什么用成员初始化列表会快一些(性能优势)?
成员初始化列表:
在类构造函数中,不在函数体内对变量赋值,而在参数列表后,跟一个冒号和初始化列表。
初始化和赋值对内置类型的成员没有什么大的区别,像上面的人一个构造函数都可以。对非内置类型成员变量,为了避免两次构造,推荐使用类构造函数初始化列表。
但是有时候必须使用带初始化列表的构造函数:
- 成员类型是没有默认构造函数的类。若没有提供显示初始化,则类创建对象时会调用默认构造函数,如果没有默认构造函数,则必须显示初始化。
- const成员或者引用类型的成员。因为const对象或者引用类型只能初始化,不能赋值。
- 子类初始化父类的私有成员
为什么成员初始化列表效率更高?
因为对于非内置类型,少了一次调用默认构造函数的过程。
类对象的构造顺序是这样的:
(1)分配内存,调用构造函数时,隐式/显示的初始化各数据成员;
(2)进入构造函数后在构造函数中执行一般赋值与计算。
类对象的构造顺序显示,进入构造函数体后,进行的是计算,是对成员变量的赋值操作,显然,赋值和初始化是不同的,这样就体现出了效率差异,如果不用成员初始化类表,那么类对自己的类成员分别进行的是一次隐式的默认构造函数的调用,和一次赋值操作符的调用,如果是类对象,这样做效率就得不到保障。
注意:构造函数需要初始化的数据成员,不论是否显示的出现在构造函数的成员初始化列表中,都会在该处完成初始化,并且初始化的顺序和其在类中声明时的顺序是一致的,与列表的先后顺序无关,所以要特别注意,保证两者顺序一致才能真正保证其效率和准确性。
为了说明清楚,假设有这样一个类:
class foo
{
private:
int a, b;
};
①、foo(){}和foo(int i = 0){}都被认为是默认构造函数,因为后者是默认参数。两者不能同时出现。
②构造函数列表的初始化方式不是按照列表的的顺序,而是按照变量声明的顺序。比如foo里面,a在b之前,那么会先构造a再构造b。所以无论foo():a(b + 1), b(2){}还是foo():b(2),a(b+1){}都不会让a得到期望的值。
③构造函数列表能够对const成员初始化。比如foo里面有一个int const c;则foo(int x) : c(x){}可以让c值赋成x。
不过需要注意的是,c必须在每个构造函数(如果有多个)都有值。
④在继承里面,只有初始化列表可以构造父类的private成员(通过显示调用父类的构造函数)。比如说:
class child : public foo{};
foo里面的构造函数是这样写的:
foo (int x)
{
a =x;
}
而在child里面写child(int x){ foo(x); }是通过不了编译的。
只有把子类构造函数写作child(int x) : foo(x){}才可以。
52.用过C++ 11吗,知道C++ 11哪些新特性?
- nullptr 专门代表空指针
- auto 自动进行类型推导
- 引入了基于范围的迭代写法for(auto &i : arr)
- 初始化列表
- 引入了外部模板,能够显式的告诉编译器何时进行模板的实例化
- 可以指定模板的默认参数
- 引入了委托构造的概念,这使得构造函数可以在同一个类中一个构造函数调用另一个构造函数
- 提供了一个匿名函数的特性
- 等等
53.C++的调用惯例(简单一点C++函数调用的压栈过程)
什么是调用惯例
调用惯例(Calling Conventions)指计算机程序执行时调用函数或过程的一些约定,包括:
- 函数的参数是通过栈还是寄存器传递?
- 如果通过栈传递,顺序是怎样的,是从左至右入栈还是相反。
- 谁负责清理栈,是调用者还是被调用者?
从清理栈的角度来讲,调用惯例可分为三类:函数的调用者清理,函数清理,混合清理(有时由调用者清理,有时由函数自己清理)。
调用者清理
著名的cdecl就是由函数调用者清理栈的调用惯例。 cdecl是基于c语言的调用惯例,也是x86机器上大多数C编译器采用的调用惯例。
函数的返回结果多通过EAX寄存器返回。 对于32位机器,EAX能容纳4个字节。 整数或内存地址(指针),通过EAX寄存器返回是没有问题的。 超过4个字节的结构体呢?如何返回?
通过阅读 http://en.wikipedia.org/wiki/X86_calling_conventions,我找到了答案。 对于较小的结构体或对象,可以通过EAX:EDX寄存器对返回。 对于超大的对象或结构体,caller在调用函数之前会分配出内存空间,然后把这个空间地址作为第一个参数隐式地传给函数。被调用的函数callee把结果写进这片内存空间,再pop空间地址,之后才返回。 对于浮点数的结果,似乎是通过 ST0 x87 register(浮点寄存器)返回的。
因为调用者知道为参数分配了多少栈空间,所以由调用者清理栈就有一个好处: 为参数分配的栈空间大小可以动态决定。 因此cdecl支持可变参数的函数的调用,例如printf
。
如果强迫某个函数使用cdecl调用惯例,可以在函数声明中加_cdecl
关键字,如:
void _cdecl funct();
函数自己清理
pascal,stdcall,fastcall都是由函数来清理栈。 通过阅读程序的汇编代码,可以很容易识别这类调用惯例。因为函数返回前会清理栈。
pascal是基于PASCAL编程语言的函数调用惯例。 参数按照从左到右的顺序压栈(和cdecl的入栈顺序相反)。 OS/2 1.x,Microsoft Windows 3.x 和 Borland Delphi 1.x中的16位API都使用这种函数调用惯例。
stdcall是从pascal调用惯例演变出来的,和pascal不同的是,stdcall以从右到左的顺序对参数压栈。 返回值存储在EAX寄存器中。Win32 API就是采用的这种调用惯例。
fastcall是混合使用寄存器和栈来存储函数的参数,比如把前两个参数存储在寄存器中,其余的参数入栈。 有Microsoft fastcall和Borland fastcall等不同的实现。
由函数自己清理栈的好处在于:调用者不需要每次调用函数之后都清理栈,从而节省了不少代码, 从而生成的二进制文件比较小。坏处在于,由于清理栈的代码是事先生成在函数体内, 所以不能支持可变参数的函数。
混合清理
混合清理的代表是thiscall,对C++中非静态成员函数使用的就是这种调用惯例。
对于gcc编译器来说,thiscall几乎和cdecl相同:函数调用者负责清理栈,参数按从右到左的顺序入栈。 不同的是,thiscall最后会把this
指针压栈,就好象它是函数的第一个参数。(其实也是的吧)
对于Microsoft VC++编译器,thiscall类似于Windows API的stdcall,函数的参数从右到左压栈,由参数来清理栈。和stdcall不同的是,thiscall会通过ECX寄存器来传递this
指针。因为由函数自己清理栈不支持可变参数的函数调用,所以对于可变参数的函数,则由函数的调用者来清理栈。这是thiscall的灵活之处。
总结
调用惯例 | 出栈方 | 参数传递 | 名字修饰 |
---|---|---|---|
cdecl | 函数调用方 | 从右至左的顺序压参数入栈 | 下划线+函数名 |
pascal | 函数本身 | 从左至右的顺序入栈 | 较为复杂,参见pascal文档 |
stdcall | 函数本身 | 从右至左的顺序压参数入栈 | 下划线+函数名+@+参数的字节数, 如函数 int func(int a, double b)的修饰名是 _func@12 |
fastcall | 函数本身 | 头两个 DWORD(4字节)类型或者更少字节的参数 被放入寄存器,其他剩下的参数按从右至左的顺序入栈 | @+函数名+@+参数的字节数 |
thiscall | 不一定 | 从右至左的顺序压参数入栈(有时会通过寄存器传递this指针) | 不详 |
54.C++的四种强制转换
- static_cast:完成静态基础类型转换、同一继承体系下的转换(上行安全,下行不安全)、任意类型与空指针void*(或void)之间的转换,主要执行非多态的转换操作,隐式转换都建议使用static_cast进行标明和替换
- dynamic_cast:只有在派生类之间转换时才使用dynamic_cast,转换参数必须是类指针,类引用或者void*。 使用时基类必须有虚函数,因为dynamic_cast是运行时类型检查,这个信息存在虚表当中,下行转换是安全的,如果不能转换,返回NULL
- const_cast:用于去除指针或引用的常量属性,或赋予常量属性,它是唯一可以对常量操作的转换符
- reinterpret_cast:不到万不得已,不要使用,从底层对数据进行重新解释,依赖具体的平台,可以把整形变成指针,也可以把指针变成数组,也可以在指针和引用之间随意转换,以转化任何内置的数据类型为其他任何的数据类型,实质是对二进制位的操作。
55.C++中将临时变量作为返回值的时候的处理过程(栈上的内存分配、拷贝过程)
56.C++的异常处理
57.volatile关键字
58.优化程序的几种方法
59.public,protected和private访问权限和继承
访问权限
- public 这类型成员可以被类本身函数访问,也可以被外部创建的类对象调用。子类对象与子类内部可以访问
- protected类型成员,只能被类本身函数访问。外部创建的类对象没有访问权限。子类对象没有访问权限,子类内部可以访问
- private类型成员,只能被类本身函数访问,外部创建的类对象没有访问权限。子类对象和子类内部都没有访问权限
继承关系的访问控制
- public继承,public继承使子类顺延父类的访问控制属性,即成员保持父类的控制属性,这样在子类中的成员访问控制同父类的一样
- protected继承,将父类public和protected属性的成员属性顺延到子类来后变成protected属性。protected属性是可以提供给子类在内部访问的。
- private继承。这种继承方式中断了后续子类对当前类的父类的所有访问权限,在该种继承方式下,会将父类public和protected属性顺延成private属性。这样,即使后面子类再次继承,都没有了对当前父类的成员的访问权限。
60.decltype()和auto
- auto
1.编译器通过分析表达式的类型来确定变量的类型,所以auto定义的变量必须有初始值。
auto i=10; //ok,i为整型
auto j; //error,定义时必须初始化。
j=2;
2.auto可以在一条语句中声明多个变量,但该语句中所有变量的初始值类型必须有一样。
auto i=0,*P=&i; //ok,i是整数,p是整型指针
auto a=2,b=3.14; //error,a和b类型不一致
3.auto会忽略掉顶层const,同时底层const则会保留下来
const int a=2,&b=a;
auto c=a; //c是int 型,而不是const int,即忽略了顶层const
auto d=&a; //d是一个指向const int 的指针,即保留了底层const
如果希望auto类型是一个顶层const ,需要明确指出:
const auto e=a; //e是const int 类型
4.当使用数组作为auto变量的初始值时,推断得到的变量类型是指针,而非数组
int a[10]={1,2,3,4,5,6,7,8,9,0}
auto b=a; //b是int *类型,指向数组的第一个元素
int c[2][3]={1}
auto d=c; //d是int(*d)[3]类型的数组指针
for(auto e:c) //e是int*类型,而不是int(*)[3]
for(auto &f:c) //f是int(&f)[3]
//**************************************************
decltype (a) c; //c是由10个整型数构成的数组,c[10]
- decltype
decltype和auto功能类型,但略有区别:
1.decltype根据表达式类型确定变量类型,但不要求定义时进行初始化
int a=2;
decltype (a) b; //b是int类型
b=3;
int &c=a;
decltype (c) d=a; //d为int &类型,因此定义时必须初始化
2.解引用指针操作将得到引用类型
int a=2,*b=a;
decltype (*b) c=a; //解引用,c是int &类型,因此必须初始化
3.decltype所用的表达式加()得到的是该类型的引用
int a=2;
decltype ((a)) b=a; //b是int&类型,而不是int类型,必须初始化
decltype (a) c; //c是int类型
4.decltype所用变量时数组时,得到的同类型的数组,而不是指针
int a[2]={1,2}
decltype (a) b={3,4} //int b[2]类型
5.decltype所用变量是函数时,得到的是函数类型,而不是函数指针
int fun(int a);
decltype(fun) *f(); //函数f返回的是 int(*)(int),即函数指针,而decltype(fun)是int(int)类型
61.inline和宏定义的区别
(1)内联函数在编译时展开,宏在预编译时展开;
(2)内联函数直接嵌入到目标代码中,宏是简单的做文本替换;
(3)内联函数有类型检测、语法判断等功能,而宏没有;
(4)inline函数是函数,宏不是;
(5)宏定义时要注意书写(参数要括起来)否则容易出现歧义,内联函数不会产生歧义;
62.C++和C的类型安全
类型安全很大程度上可以等价于内存安全,类型安全的代码不会试图访问自己没被授权的内存区域。绝对类型安全的编程语言暂时还没有。
C语言的类型安全
C只在局部上下文中表现出类型安全,比如试图从一种结构体的指针转换成另一种结构体的指针时,编译器将会报告错误,除非使用显式类型转换。然而,C中相当多的操作是不安全的。
如果C++使用得当,它将远比C更有类型安全性。相比于C,C++提供了一些新的机制保障类型安全:
(1)操作符new返回的指针类型严格与对象匹配,而不是void *;
(2)C中很多以void*为参数的函数可以改写为C++模板函数,而模板是支持类型检查的;
(3)引入const关键字代替#define constants,它是有类型、有作用域的,而#define constants只是简单的文本替换;
(4)一些#define宏可被改写为inline函数,结合函数的重载,可在类型安全的前提下支持多种类型,当然改写为模板也能保证类型安全;
(5)C++提供了dynamic_cast关键字,使得转换过程更加安全,因为dynamic_cast比static_cast涉及更多具体的类型检查。即便如此,C++也不是绝对类型安全的编程语言。如果使用不得当,同样无法保证类型安全。
63.结构体和类的区别