目录
1.指针和引用的区别
2.堆和栈的区别
3.new和delete是如何实现的,new 与 malloc的异同处
4.C和C++的区别
5.C++、Java的联系与区别,包括语言特性、垃圾回收、应用场景等(java的垃圾回收机制)
6.结构体和类的区别(struct和class的区别)
7.define 和const的区别(编译阶段、安全性、内存占用等)
8.在C++中const和static的用法(定义,用途)
9.const和static在类中使用的注意事项(定义、初始化和使用)
10.C++中的const类成员函数(用法和意义),以及和非const成员函数的区别
11.C++的顶层const和底层const
12.final和override关键字
13.拷贝初始化和直接初始化,初始化和赋值的区别
14.extern "C"的用法
15.函数模板和类模板的特例化
16.C++的STL源码
17.STL源码中的hashtable的实现
18.STL中unordered_map和map的区别和应用场景
19.STL中vector的实现
20.STL容器的几种迭代器以及对应的容器(输入迭代器,输出迭代器,前向迭代器,双向迭代器,随机访问迭代器)
21.STL中的traits技法
22.vector使用的注意点及其原因,频繁对vector调用push_back()对性能的影响和原因。
23.C++中的重载和重写的区别
24.C++内存管理,内存池技术(热门问题)
25.介绍面向对象的三大特性,并且举例说明每一个
26.C++多态的实现
27.C++虚函数相关
28.虚函数实现原理(包括单一继承,多重继承等)
29.C++中类的数据成员和成员函数内存分布情况
30.this指针
31.析构函数一般写成虚函数的原因
32.构造函数、拷贝构造函数和赋值操作符的区别
33.构造函数声明为explicit
34.构造函数为什么一般不定义为虚函数
35.构造函数的几种关键字(default delete 0)
36.构造函数或者析构函数中调用虚函数会怎样
37.纯虚函数
38.静态类型和动态类型,静态绑定和动态绑定的介绍
39.引用是否能实现动态绑定,为什么引用可以实现
40.深拷贝和浅拷贝的区别(举例说明深拷贝的安全性)
41.对象复用的了解,零拷贝的了解
44.结构体内存对齐方式和为什么要进行内存对齐?
45.内存泄露的定义,如何检测与避免?
47.智能指针的循环引用
48.遇到coredump要怎么调试
49.内存检查工具的了解
50.模板的用法与适用场景
51.成员初始化列表的概念,为什么用成员初始化列表会快一些(性能优势)?
52.用过C++ 11吗,知道C++ 11哪些新特性?
53.C++的调用惯例(简单一点C++函数调用的压栈过程)
54.C++的四种强制转换
55.C++中将临时变量作为返回值的时候的处理过程(栈上的内存分配、拷贝过程)
56.C++的异常处理
57.volatile关键字
58.优化程序的几种方法
59.public,protected和private访问权限和继承
60.decltype()和auto
61.inline和宏定义的区别
62.C++和C的类型安全
(1)指针:指针是一个变量,只不过这个变量存储的是一个地址,指向内存的一个存储单元;而引用跟原来的变量实质上是同一个东西,只不过是原变量的一个别名而已。
(2)可以有const指针,但是没有const引用;
(3)指针可以有多级,但是引用只能是一级(int **p;合法 而 int &&a是不合法的)
(4)指针的值可以为空,但是引用的值不能为NULL,并且引用在定义的时候必须初始化;
(5)指针的值在初始化后可以改变,即指向其它的存储单元,而引用在进行初始化后就不会再改变了。
(6)"sizeof引用"得到的是所指向的变量(对象)的大小,而"sizeof指针"得到的是指针本身的大小;
(7)指针和引用的自增(++)运算意义不一样;
(8)指针和引用作为函数参数进行传递时的区别。
(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++函数库提供,机制很复杂,因此堆的效率比栈低很多。栈是机器系统提供的数据结构,计算机在底层对栈提供支持,分配专门的寄存器存放栈地址,提供栈操作专门的指令。
new的实现分为三步:
1.自动计算所需空间,调用operator new函数进行动态内存分配
2.调用构造函数,初始化对象
3.返回正确的指针
delete的实现也分为三步:
1.定位到指针所指向的内存空间,然后根据其类型,调用其自带的析构函数
2.然后释放其内存空间(将这块内存空间标志为可用,然后还给操作系统)
3.将指针标记为无效
注意:new的对象用完后必须用delete释放
new 与 malloc的异同处:
有了malloc/free为什么还要new/delete?
delete与delete []
C/C++的联系:
C/C++区别:
语言特性:
应用场景:
垃圾回收 :
主要是访问权限的区别:
什么是define: 宏定义,简单的理解就是替换,其实这也是本质。如果熟悉g++编译过程的话,会了解到一个概念叫做预处理,就是在编译之前做个处理。这个过程并不像编译那么复杂,就是简单的递归替换和删除。替换的就是宏定义和include文件,删除注释。
(1)编译器处理方式
(2)类型检查
(3)内存空间
(4)其他
static:
const:
用法:
static:
const:
在设计类的时候,一个原则就是对于不改变数据成员的成员函数都要在后面加 const,而对于改变数据成员的成员函数不能加 const。所以 const 关键字对成员函数的行为作了更加明确的限定:
(1)有 const 修饰的成员函数(指 const 放在函数参数表的后面,而不是在函数前面或者参数表内),只能读取数据成员,不能改变数据成员;没有 const 修饰的成员函数,对数据成员则是可读可写的。
(2)除此之外,在类的成员函数后面加 const 还有什么好处呢?那就是常量(即 const)对象可以调用 const 成员函数,而不能调用非const修饰的函数。
顶层const表示指针本身是个常量;底层const表示指针所指的对象是一个常量。
对于顶层 const 与底层 const ,在运行对象拷贝时有着明显的不同:
(1)顶层 const 不受什么影响
(2)底层 const 的限制不能忽略, 要求拷出和拷入的对象有同样的底层 const 资格或者能转换为同样的数据类型,一般非常量可以向常量转换,反之则不行
final
当不希望某个类被继承,或不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字,添加final关键字后被继承或重写,编译器会报错。
override
它指定了子类的这个虚函数是重写的父类的,如果名字不小心打错了的话,编译器不会编译通过。
拷贝初始化和直接初始化
初始化和赋值
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;
}
引入原因:编写单一的模板,它能适应大众化,使每种类型都具有相同的功能,但对于某种特定类型,如果要实现其特有的功能,单一模板就无法做到,这时就需要模板特例化。
定义:是对单一模板提供的一个特殊实例,它将一个或多个模板参数绑定到特定的类型或值上。
必须为原函数模板的每个模板参数都提供实参,且使用关键字template后跟一个空尖括号对<>,表明将原模板的所有模板参数提供实参。
template //函数模板
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
{
size_t operator()(sales_data&);
//里面所有T都换成特例化类型版本sales_data
};
按照最佳匹配原则,若T != sales_data,就用普通类模板,否则,就使用含有特定功能的特例化版本。
类模板的部分特例化:不必为所有模板参数提供实参,可以指定一部分而非所有模板参数,一个类模板的部分特例化本身仍是一个模板,使用它时还必须为其特例化版本中未指定的模板参数提供实参。此功能就用于STL源码剖析中的traits编程。
详见C++primer 628页的例子。(特例化时类名一定要和原来的模板相同,只是参数类型不同,按最佳匹配原则,那个最匹配,就用相应的模板)
特例化类中的部分成员:可以特例化类中的部分成员函数而不是整个类。
templateclass Foo
{
void Bar();
void Barst(T a)();
};
template<>
void Foo::Bar()
{
//进行int类型的特例化处理
}
Foo fs;
Foo fi;//使用特例化
fs.Bar();//使用的是普通模板,即Foo::Bar()
fi.Bar();//特例化版本,执行Foo::Bar()
//Foo::Bar()和Foo::Bar()功能不同
STL是对泛型编程思想的实现,从广义上分为三类:算法、容器、迭代器。其中算法部分主要有
这个系列也很重要,建议侯捷老师的STL源码剖析书籍与视频,其中包括内存池机制,各种容器的底层实现机制,算法的实现原理等
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的优点是查找速度快,缺点是建立哈希表比较耗时。
vector采用的数据结构为线性连续的空间,它内部维护有三个迭代器,其中start指向连续空间中已经被使用的空间的开头,finish指向已经被使用空间的尾部,而end_of_storage指向整个连续空间的尾部;因此vector的大小size表示已经使用的空间大小(已有元素的个数),容量capacity表示vector本身连续空间的大小(最多可存储元素的个数),当size等于capacity时,若要再加入新的元素,要重新进行内存分配,整个vector数据都将移动到新内存,新分配的内存一般为原来大小的俩倍。所以程序员使用vector不需要提前知道内存大小。
一旦重新分配内存,指向原vector的迭代器会全部失效。
type_traits
iterator_traits
char traits
allocator_traits
pointer_traits
array_traits
(1)元素存储空间的增长方式,建议使用参考书籍的增长方式,每次增长的空间至少是原来空间的一半,即N=(N+N/2),注意存储空间利用率和当元素增长时程序的运行性能之间的平衡。
(2)实现时vector内的成员对象只有一个内存分配器对象和三个指向元素储存空间的指针(First、Last和End)。
(3)vector的特例化模板vector
(4)vector一般保留一个大小大于实际所需大小的数组空间,多余的存储空间在成为有效数组的一部分前保持为未构造状态。
(5)插入insert元素时需要移动元素的位置,移动时需要注意内存重叠,使元素的拷贝移动方向与元素的增长方向相反可解决内存重叠问题。
(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
在一个vector的尾部之外的任何位置添加元素,都需要重新移动元素。而且,向一个vector添加元素可能引起整个对象存储空间的重新分配。重新分配一个对象的存储空间需要分配新的内存,并将元素从旧的空间移到新的空间。一旦push_back引起空间重新配置,则指向原vector的所有迭代器失效。
重载overload:在同一个类中,函数名相同,参数列表不同,编译器会根据这些函数的不同参数列表,将同名的函数名称做修饰,从而生成一些不同名称的预处理函数,未体现多态。
重写override:也叫覆盖,子类重新定义父类中有相同名称相同参数的虚函数,主要是在继承关系中出现的,被重写的函数必须是virtual的,重写函数的访问修饰符可以不同,尽管virtual是private的,子类中重写函数改为public,protected也可以,体现了多态。
(1)内存管理:
a>内存分配未成功却使用了它,如果所用的操作符不是类型安全的话,请使用assert(p != NULL)或者if(p != NULL)来判断。
b>内存分配成功但未初始化
c>内存分配成功并已初始化,但是操作超过了内存的边界
d>忘记释放内存,造成内存泄露,每申请一块内存必须保证它被释放,释放内存后立即将指针置为NULL
(2)内存池技术:
C/C++下内存管理是让几乎每一个程序员头疼的问题,分配足够的内存、追踪内存的分配、在不需要的时候释放内存——这个任务相当复杂。而直接使用系统调用malloc/free、new/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
using namespace std;
template
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
void* MemPool::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
void MemPool::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 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)中几种内存分配方式对比学习加深理解
把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。
继承是指这样一种能力:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。继承的过程,就是从一般到特殊的过程。要实现继承,可以通过“继承”(Inheritance)和“组合”(Composition)来实现。
在某些 OOP 语言中,一个子类可以继承多个基类。但是一般情况下,一个子类只能有一个基类,要实现多重继承,可以通过多级继承来实现。
继承概念的实现方式有三类:实现继承、接口继承和可视继承。
Ø 实现继承是指使用基类的属性和方法而无需额外编码的能力;
Ø 接口继承是指仅使用属性和方法的名称、但是子类必须提供实现的能力;
Ø 可视继承是指子窗体(类)使用基窗体(类)的外观和实现代码的能力。
在考虑使用继承时,有一点需要注意,那就是两个类之间的关系应该是“属于”关系。例如,Employee 是一个人,Manager 也是一个人,因此这两个类都可以继承 Person 类。但是 Leg 类却不能继承 Person 类,因为腿并不是一个人。
抽象类仅定义将由子类创建的一般属性和方法,创建抽象类时,请使用关键字 Interface 而不是 Class。
OO开发范式大致为:划分对象→抽象类→将类组织成为层次化结构(继承和合成) →用类与实例进行设计和实现几个阶段。
关于多态,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”
C++多态性是通过虚函数来实现的,虚函数允许子类重新定义成员函数,而子类重新定义父类的做法称为覆盖(override),或者称为重写。(这里要补充,重写的话可以有两种,直接重写成员函数和重写虚函数,只有重写了虚函数的才能算作是体现了C++多态性)而重载则是允许有多个同名的函数,而这些函数的参数列表不同,允许参数个数不同,参数类型不同,或者两者都不同。编译器会根据这些函数的不同列表,将同名的函数的名称做修饰,从而生成一些不同名称的预处理函数,来实现同名函数调用时的重载问题。但这并没有体现多态性。
多态与非多态的实质区别就是函数地址是早绑定还是晚绑定。如果函数的调用,在编译器编译期间就可以确定函数的调用地址,并生产代码,是静态的,就是说地址是早绑定的。而如果函数调用的地址不能在编译器期间确定,需要在运行时才确定,这就属于晚绑定。
那么多态的作用是什么呢,封装可以使得代码模块化,继承可以扩展已存在的代码,他们的目的都是为了代码重用。而多态的目的则是为了接口重用。也就是说,不论传递过来的究竟是那个类的对象,函数都能够通过同一个接口调用到适应各自对象的实现方法。
最常见的用法就是声明基类的指针,利用该指针指向任意一个子类对象,调用相应的虚函数,可以根据指向的子类的不同而实现不同的方法。如果没有使用虚函数的话,即没有利用C++多态性,则利用基类指针调用相应的函数的时候,将总被限制在基类函数本身,而无法调用到子类中被重写过的函数。因为没有多态性,函数调用的地址将是一定的,而固定的地址将始终调用到同一个函数,这就无法实现一个接口,多种方法的目的了。
多态用虚函数来实现,结合动态绑定。
引用/指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在。
C++的多态性用一句话概括就是:在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。
存在虚函数的类都有一个一维的虚函数表叫做虚表,类的对象有一个指向虚表开始的虚指针。虚表是和类对应的,虚表指针是和对象对应的。
简化后就像这样:
注意 :
①每个虚表后面都有一个‘0’,它类似字符串的‘\0’,用来标识虚函数表的结尾。结束标识在不同的编译器下可能会有所不同。
②不难发现虚函数表的指针存在于对象实例中最前面的位置(这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下)这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。
多态实现利用到了虚函数表(虚表V-table)。它是一块虚函数的地址表,通过一块连续内存来存储虚函数的地址。这张表解决了继承、虚函数(重写)的问题。在有虚函数的对象实例中都存在一张虚函数表,虚函数表就像一张地图,指明了实际应该调用的虚函数函数。
虚表指针一般放在首地址,如果父类有虚函数表,子类必定有;因为构造子类时先构造父类,所以使用父类的指针,编译器根据指针类型就能知道偏移多少就能找到父类的成员(包括虚函数指针),但是对于子类独有的成员,父类的指针无法提供偏移量,因此找不到。
全局数据区(静态区)
所有基类构造函数之后,但又在自身构造函数或初始化列表之前
编译器为每个包含虚函数的类创建一个表,在表中编译器放置特定类的虚函数地址,在每个带有虚函数的类中,编译器为每个类对象放置一个指针(为每个类添加一个隐藏的成员),指向虚表。通过基类的指针或引用做虚函数调用时,编译器静态插入取得该指针,并在虚表中找到函数地址。注意基类和派生类的虚函数表是俩个东西,保存在不同的空间,但这俩个东西的内容可能一样。
实现原理:虚函数表+虚表指针
每个虚函数都会有一个与之对应的虚函数表,该虚函数表的实质是一个指针数组,存放的是每一个对象的虚函数入口地址。对于一个派生类来说,他会继承基类的虚函数表同时增加自己的虚函数入口地址,如果派生类重写了基类的虚函数的话,那么继承过来的虚函数入口地址将被派生类的重写虚函数入口地址替代。那么在程序运行时会发生动态绑定,将父类指针绑定到实例化的对象实现多态。
假设存在下面的两个类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中都只有一个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的内存布局可以归纳为下图:
多重继承会有多个虚函数表,几重继承,就会有几个虚函数表。这些表按照派生的顺序依次排列,如果子类改写了父类的虚函数,那么就会用子类自己的虚函数覆盖虚函数表的相应的位置,如果子类有新的虚函数,那么就添加到第一个虚函数表的末尾。
C++类成员所占内存总结:
(1)空类所占字节数为1
#include
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
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
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
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
using namespace std;
class G
{
public:
static int a;
};
int main(int argc, char * argv[])
{
cout << sizeof(G)<
结果输出:
总结:
1.空类必须占一个字节;
2.函数指针不占字节;
3.虚函数根据编译器位数,占相应字节;
4.类具有4字节对齐功能;
5.类中的静态成员变量不占类的内存;并且静态成员变量的初始化必须在类外初始化;
用类去定义对象时,系统会为每一个对象分配存储空间。如果一个类包括了数据和函数,要分别为数据和函数的代码分配存储空间。按理说,如果用同一个类定义了10个对象,那么就需要分别为10个对象的数据和函数代码分配存储单元,如下图所示。
我们可以看出这样不仅麻烦而且特别浪费空间,因此经过分析我们可以知道是按以下方式来储存的。
只用一段空间来存放这个共同的函数代码段,在调用各对象的函数时,都去调用这个公用的函数代码。如下图所示。
显然,这样做会大大节约存储空间。C++编译系统正是这样做的,因此每个对象所占用的存储空间只是该对象的数据部分(虚函数指针和虚基类指针也属于数据部分)所占用的存储空间,而不包括函数代码所占用的存储空间。
那么问题来了在不同对象但是调用的的代码又相同的情况下,编译器是如何分辨且准确的调用到各自的函数???
在c++中专门设立了一个this指针,用来指向不同的对象,当调用对象t1的成员函数display1时,this指针就指向display1,当调用t2的成员函数display2,this指针就指向display2。。。。。以此类推来分辨准确的调用
按《Effective C++》中的观点其实是:只要一个类有可能会被其它类所继承, 析构函数就应该声明是虚析构函数。
那为什么定义成虚析构函数就能解决这个问题呢?
因为实现了多态。此时子类对象模型里父类析构函数被覆盖,(编译器依旧能知晓父类析构)当父类指针/引用指向父类对象时,调用的是父类的虚函数,指向子类对象时调用的是子类的虚函数;所以析构函数被定义为虚函数就不难理解了。
在C++中,explicit关键字用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换,只能以显示的方式进行类型转换。
explicit使用注意事项:
先看一个例子:
#include
using namespace std;
class A{
public:
A() {
show();
}
virtual void show(){
cout<<"in A"<
输出结果,可以看到没有预想的多态效果:
in A
in A
*****************
in A
in B
in A
结论:构造函数和析构函数调用虚函数时都不使用动态联编,如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本。
原因分析:
(1)不要在构造函数中调用虚函数的原因:因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化, 因此调用子类的虚函数是不安全的,故而C++不会进行动态联编。
(2)不要在析构函数中调用虚函数的原因:析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数。所以在调用基类的析构函数时,派生类对象的数据成员已经“销毁”,这个时再调用子类的虚函数已经没有意义了。
在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该类的派生类去做(派生类必须要实现)。在基类中实现纯虚函数的方法是在函数原型后加“=0”。声明形式:virtual void fun()=0;
引入原因:1)为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。2)在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。
抽象类:含有一个或者多个纯虚函数的类是抽象类,抽象类不能创建对象。而只有被继承,并重写其虚函数后,才能使用
因为对象的类型是确定的,在编译期就确定了,指针或引用是在运行期根据他们绑定的具体对象确定。
先考虑一种情况,对一个已知对象进行拷贝,编译系统会自动调用一种构造函数——拷贝构造函数,如果用户未定义拷贝构造函数,则会调用默认拷贝构造函数。
先看一个例子,有一个学生类,数据成员时学生的人数和名字:
#include
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
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;
}
运行结果:调用一次构造函数,一次自定义拷贝构造函数,两次析构函数。两个对象的指针成员所指内存不同。
总结:浅拷贝只是对指针的拷贝,拷贝后两个指针指向同一个内存空间,深拷贝不但对指针进行拷贝,而且对指针指向的内容进行拷贝,经深拷贝后的指针是指向两个不同地址的指针。
再说几句:
当对象中存在指针成员时,除了在复制对象时需要考虑自定义拷贝构造函数,还应该考虑以下两种情形:
对象池:对象池通过对象复用的方式来避免重复创建对象,它会事先创建一定数量的对象放到池中,当用户需要创建对象的时候,直接从对象池中获取即可,用完对象之后再放回到对象池中,以便复用。
适用性:类的实例可重用。类的实例化过程开销较大。类的实例化的频率较高的时候可以使用对象复用。
零拷贝:避免CPU将数据从一快存储拷贝到另外一块存储的技术,比如emplace_back函数
42.介绍C++所有的构造函数
构造函数的名称与类名完全相同,且不反回任何类型。
43.什么情况下会调用拷贝构造函数(三种情况)
什么是结构体内存对齐?
结构体不像数组,结构体中可以存放不同类型的数据,它的大小也不是简单的各个数据成员大小之和,限于读取内存的要求,而是每个成员在内存中的存储都要按照一定偏移量来存储,根据类型的不同,每个成员都要按照一定的对齐数进行对齐存储,最后整个结构体的大小也要按照一定的对齐数进行对齐。
对齐规则:
如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍,结构体的整体大小就是所有最大对齐数的整数倍(含嵌套结构体的对齐数)
特点 :
每个成员的偏移量%自己的对齐数=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的读取粒度为4字节,
(1)那么对于一个int 类型,若是按照内存对齐来存储,处理器只需要访存一次就可以读取完4个字节
(2)若没有按照内存对其来读取,如上图所示,就需要访问内存两次才能读取出一个完整的int 类型变量
(3)具体过程为,第一次拿出 4个字节,丢弃掉第一个字节,第二次拿出4个字节,丢弃最后的三个字节,然后拼凑出一个完整的 int 类型的数据。
其实结构体内存对齐是拿空间换取时间的做法。提高效率
(1)首先说到c++内存泄漏时要知道它的含义?
内存泄漏(memory leak)是指由于疏忽或错误造成了程序未能释放掉不再使用的内存的情况。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
(2)内存泄漏的后果?
最难捉摸也最难检测到的错误之一是内存泄漏,即未能正确释放以前分配的内存的 bug。 只发生一次的小的内存泄漏可能不会被注意,但泄漏大量内存的程序或泄漏日益增多的程序可能会表现出各种征兆:从性能不良(并且逐渐降低)到内存完全用尽。 更糟的是,泄漏的程序可能会用掉太多内存,以致另一个程序失败,而使用户无从查找问题的真正根源。 此外,即使无害的内存泄漏也可能是其他问题的征兆。
(3)对于C和C++这种没有垃圾回收机制的语言来讲,我们主要关注两种类型的内存泄漏:
(4)使用C/C++语言开发的软件在运行时,出现内存泄漏。可以使用以下两种方式,进行检查排除:
(5)解决内存泄漏最有效的办法就是使用智能指针(Smart Pointer)。
使用智能指针就不用担心这个问题了,因为智能指针可以自动删除分配的内存。智能指针和普通指针类似,只是不需要手动释放指针,而是通过智能指针自己管理内存的释放,这样就不用担心内存泄漏的问题了。
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 WeakPtr;//为了用weak_ptr的lock(),来生成share_ptr用,需要拷贝构造用
template
class SharePtr
{
public:
SharePtr(T* p=0)
:_ptr(p){
cnt=new Counter();
if(p)
cnt->s=1;
cout<<"in construct "<s< const &s)
{
cout<<"in copy con"<s++;
cout<<"copy construct"<<(s.cnt)->s< const &w)//为了用weak_ptr的lock(),来生成share_ptr用,需要拷贝构造用
{
cout<<"in w copy con "<s++;
cout<<"copy w construct"<<(w.cnt)->s<& operator=(SharePtr &s)
{
if(this != &s)
{
release();
(s.cnt)->s++;
cout<<"assign construct "<<(s.cnt)->s<()
{
return _ptr;
}
friend class WeakPtr; //方便weak_ptr与share_ptr设置引用计数和赋值。
private:
void release()
{
cnt->s--;
cout<<"release "<s<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 WeakPtr
{
public://给出默认构造和拷贝构造,其中拷贝构造不能有从原始指针进行构造
WeakPtr()
{
_ptr=0;
cnt=0;
}
WeakPtr(SharePtr& s):
_ptr(s._ptr),cnt(s.cnt)
{
cout<<"w con s"<w++;
}
WeakPtr(WeakPtr& w):
_ptr(w._ptr),cnt(w.cnt)
{
cnt->w++;
}
~WeakPtr()
{
release();
}
WeakPtr& operator =(WeakPtr & w)
{
if(this != &w)
{
release();
cnt=w.cnt;
cnt->w++;
_ptr=w._ptr;
}
return *this;
}
WeakPtr& operator =(SharePtr & s)
{
cout<<"w = s"<w++;
_ptr=s._ptr;
return *this;
}
SharePtr lock()
{
return SharePtr(*this);
}
bool expired()
{
if(cnt)
{
if(cnt->s >0)
{
cout<<"empty "<s<;//方便weak_ptr与share_ptr设置引用计数和赋值。
private:
void release()
{
if(cnt)
{
cnt->w--;
cout<<"weakptr release"<w<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 ch;
WeakPtr ch;
};
class child
{
public:
SharePtr pt;
};
int main()
{
//SharePtr ft(new parent());
//SharePtr son(new child());
//ft->ch=son;
//son->pt=ft;
//SharePtr son2=(ft->ch).lock();
SharePtr i;
WeakPtr wi(i);
cout<
通过打开注释,可以模拟share_ptr的经典的循环引用的案例,也可以检查指针是否为空。
(1)shared_ptr的使用
shared_ptr多个指针指向相同的对象。shared_ptr使用引用计数,每一个shared_ptr的拷贝都指向相同的内存。每使用他一次,内部的引用计数加1,每析构一次,内部的引用计数减1,减为0时,自动删除所指向的堆内存。shared_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 在功能上类似于普通指针, 然而一个比较大的区别是, 弱引用能检测到所管理的对象是否已经被释放, 从而避免访问非法内存。
注意: 虽然通过弱引用指针可以有效的解除循环引用, 但这种方式必须在程序员能预见会出现循环引用的情况下才能使用, 也可以是说这个仅仅是一种编译期的解决方案, 如果程序在运行过程中出现了循环引用, 还是会造成内存泄漏.
core dump的含义
core dump又叫核心转储。当程序运行过程中发生异常, 程序异常退出时, 由操作系统把程序当前的内存状况存储在一个core文件中, 叫core dump。
(1)ulimit -c unlimited命令设置coredump文件
(2)gdb a.out core命令运行程序(linux下)
(3)使用bt命令查看堆栈
工具 | 描述 |
---|---|
valgrind | 一个强大开源的程序检测工具 |
mtrace | GNU扩展, 用来跟踪malloc, mtrace为内存分配函数(malloc, realloc, memalign, free)安装hook函数 |
dmalloc | 用于检查C/C++内存泄露(leak)的工具,即检查是否存在直到程序运行结束还没有释放的内存,以一个运行库的方式发布 |
memwatch | 和dmalloc一样,它能检测未释放的内存、同一段内存被释放多次、位址存取错误及不当使用未分配之内存区域 |
mpatrol | 一个跨平台的 C++ 内存泄漏检测器 |
dbgmem | |
Electric Fence |
成员初始化列表:
在类构造函数中,不在函数体内对变量赋值,而在参数列表后,跟一个冒号和初始化列表。
初始化和赋值对内置类型的成员没有什么大的区别,像上面的人一个构造函数都可以。对非内置类型成员变量,为了避免两次构造,推荐使用类构造函数初始化列表。
但是有时候必须使用带初始化列表的构造函数:
为什么成员初始化列表效率更高?
因为对于非内置类型,少了一次调用默认构造函数的过程。
类对象的构造顺序是这样的:
(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){}才可以。
什么是调用惯例
调用惯例(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指针) | 不详 |
访问权限
继承关系的访问控制
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和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)类型
(1)内联函数在编译时展开,宏在预编译时展开;
(2)内联函数直接嵌入到目标代码中,宏是简单的做文本替换;
(3)内联函数有类型检测、语法判断等功能,而宏没有;
(4)inline函数是函数,宏不是;
(5)宏定义时要注意书写(参数要括起来)否则容易出现歧义,内联函数不会产生歧义;
类型安全很大程度上可以等价于内存安全,类型安全的代码不会试图访问自己没被授权的内存区域。绝对类型安全的编程语言暂时还没有。
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++也不是绝对类型安全的编程语言。如果使用不得当,同样无法保证类型安全。