精华内容
下载资源
问答
  • malloc函数实现原理

    万次阅读 多人点赞 2016-12-03 12:37:54
    任何一个用过或学过C的人malloc都不会陌生。大家都知道malloc可以分配一...实际上,malloc只是C的标准库中提供的一个普通函数,而且实现malloc的基本思想并不复杂,任何一个C和操作系统有些许了解的程序员都可以很

    任何一个用过或学过C的人对malloc都不会陌生。大家都知道malloc可以分配一段连续的内存空间,并且在不再使用时可以通过free释放掉。但是,许多程序员对malloc背后的事情并不熟悉,许多人甚至把malloc当做操作系统所提供的系统调用或C的关键字。实际上,malloc只是C的标准库中提供的一个普通函数,而且实现malloc的基本思想并不复杂,任何一个对C和操作系统有些许了解的程序员都可以很容易理解。

    这篇文章通过实现一个简单的malloc来描述malloc背后的机制。当然与现有C的标准库实现(例如glibc)相比,我们实现的malloc并不是特别高效,但是这个实现比目前真实的malloc实现要简单很多,因此易于理解。重要的是,这个实现和真实实现在基本原理上是一致的。

    这篇文章将首先介绍一些所需的基本知识,如操作系统对进程的内存管理以及相关的系统调用,然后逐步实现一个简单的malloc。为了简单起见,这篇文章将只考虑x86_64体系结构,操作系统为Linux。

    1 什么是malloc

    在实现malloc之前,先要相对正式地对malloc做一个定义。

    根据标准C库函数的定义,malloc具有如下原型:

    这个函数要实现的功能是在系统中分配一段连续的可用的内存,具体有如下要求:

    • malloc分配的内存大小至少为size参数所指定的字节数
    • malloc的返回值是一个指针,指向一段可用内存的起始地址
    • 多次调用malloc所分配的地址不能有重叠部分,除非某次malloc所分配的地址被释放掉
    • malloc应该尽快完成内存分配并返回(不能使用NP-hard的内存分配算法)
    • 实现malloc时应同时实现内存大小调整和内存释放函数(即realloc和free)

    对于malloc更多的说明可以在命令行中键入以下命令查看:

    2 预备知识

    在实现malloc之前,需要先解释一些Linux系统内存相关的知识。

    2.1 Linux内存管理

    2.1.1 虚拟内存地址与物理内存地址

    为了简单,现代操作系统在处理内存地址时,普遍采用虚拟内存地址技术。即在汇编程序(或机器语言)层面,当涉及内存地址时,都是使用虚拟内存地址。采用这种技术时,每个进程仿佛自己独享一片2N字节的内存,其中N是机器位数。例如在64位CPU和64位操作系统下,每个进程的虚拟地址空间为264Byte。

    这种虚拟地址空间的作用主要是简化程序的编写及方便操作系统对进程间内存的隔离管理,真实中的进程不太可能(也用不到)如此大的内存空间,实际能用到的内存取决于物理内存大小。

    由于在机器语言层面都是采用虚拟地址,当实际的机器码程序涉及到内存操作时,需要根据当前进程运行的实际上下文将虚拟地址转换为物理内存地址,才能实现对真实内存数据的操作。这个转换一般由一个叫MMU(Memory Management Unit)的硬件完成。

    2.1.2 页与地址构成

    在现代操作系统中,不论是虚拟内存还是物理内存,都不是以字节为单位进行管理的,而是以页(Page)为单位。一个内存页是一段固定大小的连续内存地址的总称,具体到Linux中,典型的内存页大小为4096Byte(4K)。

    所以内存地址可以分为页号和页内偏移量。下面以64位机器,4G物理内存,4K页大小为例,虚拟内存地址和物理内存地址的组成如下:

    上面是虚拟内存地址,下面是物理内存地址。由于页大小都是4K,所以页内便宜都是用低12位表示,而剩下的高地址表示页号。

    MMU映射单位并不是字节,而是页,这个映射通过查一个常驻内存的数据结构页表来实现。现在计算机具体的内存地址映射比较复杂,为了加快速度会引入一系列缓存和优化,例如TLB等机制。下面给出一个经过简化的内存地址翻译示意图,虽然经过了简化,但是基本原理与现代计算机真实的情况的一致的。

    2.1.3 内存页与磁盘页

    我们知道一般将内存看做磁盘的的缓存,有时MMU在工作时,会发现页表表明某个内存页不在物理内存中,此时会触发一个缺页异常(Page Fault),此时系统会到磁盘中相应的地方将磁盘页载入到内存中,然后重新执行由于缺页而失败的机器指令。关于这部分,因为可以看做对malloc实现是透明的,所以不再详细讲述,有兴趣的可以参考《深入理解计算机系统》相关章节。

    最后附上一张在维基百科找到的更加符合真实地址翻译的流程供大家参考,这张图加入了TLB和缺页异常的流程(图片来源页)。

    2.2 Linux进程级内存管理

    2.2.1 内存排布

    明白了虚拟内存和物理内存的关系及相关的映射机制,下面看一下具体在一个进程内是如何排布内存的。

    以Linux 64位系统为例。理论上,64bit内存地址可用空间为0x0000000000000000 ~ 0xFFFFFFFFFFFFFFFF,这是个相当庞大的空间,Linux实际上只用了其中一小部分(256T)。

    根据Linux内核相关文档描述,Linux64位操作系统仅使用低47位,高17位做扩展(只能是全0或全1)。所以,实际用到的地址为空间为0x0000000000000000 ~ 0x00007FFFFFFFFFFF和0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF,其中前面为用户空间(User Space),后者为内核空间(Kernel Space)。图示如下:

    对用户来说,主要关注的空间是User Space。将User Space放大后,可以看到里面主要分为如下几段:

    • Code:这是整个用户空间的最低地址部分,存放的是指令(也就是程序所编译成的可执行机器码)
    • Data:这里存放的是初始化过的全局变量
    • BSS:这里存放的是未初始化的全局变量
    • Heap:堆,这是我们本文重点关注的地方,堆自低地址向高地址增长,后面要讲到的brk相关的系统调用就是从这里分配内存
    • Mapping Area:这里是与mmap系统调用相关的区域。大多数实际的malloc实现会考虑通过mmap分配较大块的内存区域,本文不讨论这种情况。这个区域自高地址向低地址增长
    • Stack:这是栈区域,自高地址向低地址增长

    下面我们主要关注Heap区域的操作。对整个Linux内存排布有兴趣的同学可以参考其它资料。

    2.2.2 Heap内存模型

    一般来说,malloc所申请的内存主要从Heap区域分配(本文不考虑通过mmap申请大块内存的情况)。

    由上文知道,进程所面对的虚拟内存地址空间,只有按页映射到物理内存地址,才能真正使用。受物理存储容量限制,整个堆虚拟内存空间不可能全部映射到实际的物理内存。Linux对堆的管理示意如下:

    Linux维护一个break指针,这个指针指向堆空间的某个地址。从堆起始地址到break之间的地址空间为映射好的,可以供进程访问;而从break往上,是未映射的地址空间,如果访问这段空间则程序会报错。

    2.2.3 brk与sbrk

    由上文知道,要增加一个进程实际的可用堆大小,就需要将break指针向高地址移动。Linux通过brk和sbrk系统调用操作break指针。两个系统调用的原型如下:

    brk将break指针直接设置为某个地址,而sbrk将break从当前位置移动increment所指定的增量。brk在执行成功时返回0,否则返回-1并设置errno为ENOMEM;sbrk成功时返回break移动之前所指向的地址,否则返回(void *)-1。

    一个小技巧是,如果将increment设置为0,则可以获得当前break的地址。

    另外需要注意的是,由于Linux是按页进行内存映射的,所以如果break被设置为没有按页大小对齐,则系统实际上会在最后映射一个完整的页,从而实际已映射的内存空间比break指向的地方要大一些。但是使用break之后的地址是很危险的(尽管也许break之后确实有一小块可用内存地址)。

    2.2.4 资源限制与rlimit

    系统对每一个进程所分配的资源不是无限的,包括可映射的内存空间,因此每个进程有一个rlimit表示当前进程可用的资源上限。这个限制可以通过getrlimit系统调用得到,下面代码获取当前进程虚拟内存空间的rlimit:

    其中rlimit是一个结构体:

    每种资源有软限制和硬限制,并且可以通过setrlimit对rlimit进行有条件设置。其中硬限制作为软限制的上限,非特权进程只能设置软限制,且不能超过硬限制。

    3 实现malloc

    3.1 玩具实现

    在正式开始讨论malloc的实现前,我们可以利用上述知识实现一个简单但几乎没法用于真实的玩具malloc,权当对上面知识的复习:

    这个malloc每次都在当前break的基础上增加size所指定的字节数,并将之前break的地址返回。这个malloc由于对所分配的内存缺乏记录,不便于内存释放,所以无法用于真实场景。

    3.2 正式实现

    下面严肃点讨论malloc的实现方案。

    3.2.1 数据结构

    首先我们要确定所采用的数据结构。一个简单可行方案是将堆内存空间以块(Block)的形式组织起来,每个块由meta区和数据区组成,meta区记录数据块的元信息(数据区大小、空闲标志位、指针等等),数据区是真实分配的内存区域,并且数据区的第一个字节地址即为malloc返回的地址。

    可以用如下结构体定义一个block:

    由于我们只考虑64位机器,为了方便,我们在结构体最后填充一个int,使得结构体本身的长度为8的倍数,以便内存对齐。示意图如下:

    3.2.2 寻找合适的block

    现在考虑如何在block链中查找合适的block。一般来说有两种查找算法:

    • First fit:从头开始,使用第一个数据区大小大于要求size的块所谓此次分配的块
    • Best fit:从头开始,遍历所有块,使用数据区大小大于size且差值最小的块作为此次分配的块

    两种方法各有千秋,best fit具有较高的内存使用率(payload较高),而first fit具有更好的运行效率。这里我们采用first fit算法。

    find_block从frist_block开始,查找第一个符合要求的block并返回block起始地址,如果找不到这返回NULL。这里在遍历时会更新一个叫last的指针,这个指针始终指向当前遍历的block。这是为了如果找不到合适的block而开辟新block使用的,具体会在接下来的一节用到。

    3.2.3 开辟新的block

    如果现有block都不能满足size的要求,则需要在链表最后开辟一个新的block。这里关键是如何只使用sbrk创建一个struct:

    3.2.4 分裂block

    First fit有一个比较致命的缺点,就是可能会让很小的size占据很大的一块block,此时,为了提高payload,应该在剩余数据区足够大的情况下,将其分裂为一个新的block,示意如下:

    实现代码:

    3.2.5 malloc的实现

    有了上面的代码,我们可以利用它们整合成一个简单但初步可用的malloc。注意首先我们要定义个block链表的头first_block,初始化为NULL;另外,我们需要剩余空间至少有BLOCK_SIZE + 8才执行分裂操作。

    由于我们希望malloc分配的数据区是按8字节对齐,所以在size不为8的倍数时,我们需要将size调整为大于size的最小的8的倍数:

    3.2.6 calloc的实现

    有了malloc,实现calloc只要两步:

    1. malloc一段内存
    2. 将数据区内容置为0

    由于我们的数据区是按8字节对齐的,所以为了提高效率,我们可以每8字节一组置0,而不是一个一个字节设置。我们可以通过新建一个size_t指针,将内存区域强制看做size_t类型来实现。

    3.2.7 free的实现

    free的实现并不像看上去那么简单,这里我们要解决两个关键问题:

    1. 如何验证所传入的地址是有效地址,即确实是通过malloc方式分配的数据区首地址
    2. 如何解决碎片问题

    首先我们要保证传入free的地址是有效的,这个有效包括两方面:

    • 地址应该在之前malloc所分配的区域内,即在first_block和当前break指针范围内
    • 这个地址确实是之前通过我们自己的malloc分配的

    第一个问题比较好解决,只要进行地址比较就可以了,关键是第二个问题。这里有两种解决方案:一是在结构体内埋一个magic number字段,free之前通过相对偏移检查特定位置的值是否为我们设置的magic number,另一种方法是在结构体内增加一个magic pointer,这个指针指向数据区的第一个字节(也就是在合法时free时传入的地址),我们在free前检查magic pointer是否指向参数所指地址。这里我们采用第二种方案:

    首先我们在结构体中增加magic pointer(同时要修改BLOCK_SIZE):

    然后我们定义检查地址合法性的函数:

    当多次malloc和free后,整个内存池可能会产生很多碎片block,这些block很小,经常无法使用,甚至出现许多碎片连在一起,虽然总体能满足某此malloc要求,但是由于分割成了多个小block而无法fit,这就是碎片问题。

    一个简单的解决方式时当free某个block时,如果发现它相邻的block也是free的,则将block和相邻block合并。为了满足这个实现,需要将s_block改为双向链表。修改后的block结构如下:

    合并方法如下:

    有了上述方法,free的实现思路就比较清晰了:首先检查参数地址的合法性,如果不合法则不做任何事;否则,将此block的free标为1,并且在可以的情况下与后面的block进行合并。如果当前是最后一个block,则回退break指针释放进程内存,如果当前block是最后一个block,则回退break指针并设置first_block为NULL。实现如下:

    3.2.8 realloc的实现

    为了实现realloc,我们首先要实现一个内存复制方法。如同calloc一样,为了效率,我们以8字节为单位进行复制:

    然后我们开始实现realloc。一个简单(但是低效)的方法是malloc一段内存,然后将数据复制过去。但是我们可以做的更高效,具体可以考虑以下几个方面:

    • 如果当前block的数据区大于等于realloc所要求的size,则不做任何操作
    • 如果新的size变小了,考虑split
    • 如果当前block的数据区不能满足size,但是其后继block是free的,并且合并后可以满足,则考虑做合并

    下面是realloc的实现:

    3.3 遗留问题和优化

    以上是一个较为简陋,但是初步可用的malloc实现。还有很多遗留的可能优化点,例如:

    • 在分配较大快内存时,考虑使用mmap而非sbrk,这通常更高效
    • 可以考虑维护多个链表而非单个,每个链表中的block大小均为一个范围内,例如8字节链表、16字节链表、24-32字节链表等等。此时可以根据size到对应链表中做分配,可以有效减少碎片,并提高查询block的速度
    • 可以考虑链表中只存放free的block,而不存放已分配的block,可以减少查找block的次数,提高效率

    还有很多可能的优化,这里不一一赘述。下面附上一些参考文献,有兴趣的同学可以更深入研究。

    4 其它参考

    1. 这篇文章大量参考了A malloc Tutorial,其中一些图片和代码直接引用了文中的内容,这里特别指出
    2. Computer Systems: A Programmer’s Perspective, 2/E一书有许多值得参考的地方
    3. 关于Linux的虚拟内存模型,Anatomy of a Program in Memory是很好的参考资料,另外作者还有一篇How the Kernel Manages Your Memory对于Linux内核中虚拟内存管理的部分有很好的讲解
    4. 对于真实世界的malloc实现,可以参考glic的实现
    展开全文
  • C++函数编译原理和成员函数实现

    千次阅读 2017-07-25 15:34:54
    对象的内存中只保留了成员变量,除此之外没有任何其他信息,程序运行时不知道 stu 的类型为 Student,也不知道它还有四个成员函数 setname()、setage()、setscore()、show(),C++ 究竟是如何通过对象调用成员函数...

    【学习于C语言中文网,请勿转载】

    对象的内存中只保留了成员变量,除此之外没有任何其他信息,程序运行时不知道 stu 的类型为 Student,也不知道它还有四个成员函数 setname()、setage()、setscore()、show(),C++ 究竟是如何通过对象调用成员函数的呢?

    C++函数的编译

    C++和C语言的编译方式不同。C语言中的函数在编译时名字不变,或者只是简单的加一个下划线_(不同的编译器有不同的实现),例如,func() 编译后为 func() 或 _func()。

    而C++中的函数在编译时会根据它所在的命名空间、它所属的类、以及它的参数列表(也叫参数签名)等信息进行重新命名,形成一个新的函数名。这个新的函数名只有编译器知道,对用户是不可见的。对函数重命名的过程叫做名字编码(Name Mangling),是通过一种特殊的算法来实现的。

    Name Mangling 的算法是可逆的,既可以通过现有函数名计算出新函数名,也可以通过新函数名逆向推演出原有函数名。Name Mangling 可以确保新函数名的唯一性,只要函数所在的命名空间、所属的类、包含的参数列表等有一个不同,最后产生的新函数名也不同。

    如果你希望看到经 Name Mangling 产生的新函数名,可以只声明而不定义函数,这样调用函数时就会产生链接错误,从报错信息中就可以看到新函数名。请看下面的代码:
    1. #include <iostream>
    2. using namespace std;
    3. void display();
    4. void display(int);
    5. namespace ns{
    6. void display();
    7. }
    8. class Demo{
    9. public:
    10. void display();
    11. };
    12. int main(){
    13. display();
    14. display(1);
    15. ns::display();
    16. Demo obj;
    17. obj.display();
    18. return 0;
    19. }
    该例中声明了四个同名函数,包括两个具有重载关系的全局函数,一个位于命名空间 ns 下的函数,以及一个属于类 Demo 的函数。它们都是只声明而未定义的函数。

    在 VS 下编译源代码可以看到类似下面的错误信息:


    小括号中就是经 Name Mangling 产生的新函数名,它们都以?开始,以区别C语言中的_

    上图是 VS2010 产生的错误信息,不同的编译器有不同的 Name Mangling 算法,产生的函数名也不一样。
    __thiscall、cdecl 是函数调用惯例,有兴趣的读者可以猛击《函数调用惯例》一文深入了解。
    除了函数,某些变量也会经 Name Mangling 算法产生新名字,这里不再赘述。

    成员函数的调用

    从上图可以看出,成员函数最终被编译成与对象无关的全局函数,如果函数体中没有成员变量,那问题就很简单,不用对函数做任何处理,直接调用即可。

    如果成员函数中使用到了成员变量该怎么办呢?成员变量的作用域不是全局,不经任何处理就无法在函数内部访问。

    C++规定,编译成员函数时要额外添加一个参数,把当前对象的指针传递进去,通过指针来访问成员变量。

    假设 Demo 类有两个 int 型的成员变量,分别是 a 和 b,并且在成员函数 display() 中使用到了,如下所示:
    1. void Demo::display(){
    2. cout<<a<<endl;
    3. cout<<b<<endl;
    4. }
    那么编译后的代码类似于:
    1. void new_function_name(Demo * const p){
    2. //通过指针p来访问a、b
    3. cout<<p->a<<endl;
    4. cout<<p->b<<endl;
    5. }
    使用obj.display()调用函数时,也会被编译成类似下面的形式:
    new_function_name(&obj);
    这样通过传递对象指针就完成了成员函数和成员变量的关联。这与我们从表明上看到的刚好相反,通过对象调用成员函数时,不是通过对象找函数,而是通过函数找对象。

    这一切都是隐式完成的,对程序员来说完全透明,就好像这个额外的参数不存在一样。

    最后需要提醒的是,Demo * const p中的 const 表示指针不能被修改,p 只能指向当前对象,不能指向其他对象。读者可以猛击《C语言const:禁止修改变量的值》了解更多关系 const 的信息。

    展开全文
  • 函数实现原理

    万次阅读 多人点赞 2018-09-02 10:28:21
    关于多态,简而言之就是用父类型别的指针指向子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来...

    前言

    C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来实现可变的算法。比如:模板技术,RTTI技术,虚函数技术,要么是试图做到在编译时决议,要么试图做到运行时决议。

    关于虚函数的使用方法,我在这里不做过多的阐述。大家可以看看相关的C++的书籍。在这篇文章中,我只想从虚函数的实现机制上面为大家一个清晰的剖析。

    当然,相同的文章在网上也出现过一些了,但我总感觉这些文章不是很容易阅读,大段大段的代码,没有图片,没有详细的说明,没有比较,没有举一反三。不利于学习和阅读,所以这是我想写下这篇文章的原因。也希望大家多给我提意见。

    言归正传,让我们一起进入虚函数的世界。

    虚函数表

    对C++ 了解的人都应该知道虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。这样,在有虚函数的类的实例中这个表被分配在了这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。

    这里我们着重看一下这张虚函数表。在C++的标准规格说明书中说到,编译器必需要保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证正确取到虚函数的偏移量)。这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。

    听我扯了那么多,我可以感觉出来你现在可能比以前更加晕头转向了。没关系,下面就是实际的例子,相信聪明的你一看就明白了。

    假设我们有这样的一个类:

    class Base {

    public:

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

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

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

     

    };

    按照上面的说法,我们可以通过Base的实例来得到虚函数表。下面是实际例程:

    typedef void(*Fun)(void);

     

    Base b;

     

    Fun pFun = NULL;

     

    cout << "虚函数表地址:" << (int*)(&b) << endl;

    cout << "虚函数表 — 第一个函数地址:" << (int*)*(int*)(&b) << endl;

     

    // Invoke the first virtual function

    pFun = (Fun)*((int*)*(int*)(&b));

    pFun();

    实际运行经果如下:(Windows XP+VS2003, Linux 2.6.22 + GCC 4.1.3)

    虚函数表地址:0012FED4

    虚函数表 — 第一个函数地址:0044F148

    Base::f

    通过这个示例,我们可以看到,我们可以通过强行把&b转成int *,取得虚函数表的地址,然后,再次取址就可以得到第一个虚函数的地址了,也就是Base::f(),这在上面的程序中得到了验证(把int* 强制转成了函数指针)。通过这个示例,我们就可以知道如果要调用Base::g()和Base::h(),其代码如下:

    (Fun)*((int*)*(int*)(&b)+0); // Base::f()

    (Fun)*((int*)*(int*)(&b)+1); // Base::g()

    (Fun)*((int*)*(int*)(&b)+2); // Base::h()

    这个时候你应该懂了吧。什么?还是有点晕。也是,这样的代码看着太乱了。没问题,让我画个图解释一下。如下所示:

     

     

    注意:在上面这个图中,我在虚函数表的最后多加了一个结点,这是虚函数表的结束结点,就像字符串的结束符“\0”一样,其标志了虚函数表的结束。这个结束标志的值在不同的编译器下是不同的。在WinXP+VS2003下,这个值是NULL。而在Ubuntu 7.10 + Linux 2.6.22 + GCC 4.1.3下,这个值是如果1,表示还有下一个虚函数表,如果值是0,表示是最后一个虚函数表。

    下面,我将分别说明“无覆盖”和“有覆盖”时的虚函数表的样子。没有覆盖父类的虚函数是毫无意义的。我之所以要讲述没有覆盖的情况,主要目的是为了给一个对比。在比较之下,我们可以更加清楚地知道其内部的具体实现。

    一般继承(无虚函数覆盖)

    下面,再让我们来看看继承时的虚函数表是什么样的。假设有如下所示的一个继承关系:

    请注意,在这个继承关系中,子类没有重载任何父类的函数。那么,在派生类的实例中,其虚函数表如下所示:

     

     

    对于实例:Derive d; 的虚函数表如下:

     

    我们可以看到下面几点:

    1)虚函数按照其声明顺序放于表中。

    2)父类的虚函数在子类的虚函数前面。

    我相信聪明的你一定可以参考前面的那个程序,来编写一段程序来验证。

    一般继承(有虚函数覆盖)

    覆盖父类的虚函数是很显然的事情,不然,虚函数就变得毫无意义。下面,我们来看一下,如果子类中有虚函数重载了父类的虚函数,会是一个什么样子?假设,我们有下面这样的一个继承关系。

    为了让大家看到被继承过后的效果,在这个类的设计中,我只覆盖了父类的一个函数:f()。那么,对于派生类的实例,其虚函数表会是下面的一个样子:

    我们从表中可以看到下面几点,

    1)覆盖的f()函数被放到了虚表中原来父类虚函数的位置。

    2)没有被覆盖的函数依旧。

    这样,我们就可以看到对于下面这样的程序,

     

    Base *b = new Derive();

     

    b->f();

    由b所指的内存中的虚函数表的f()的位置已经被Derive::f()函数地址所取代,于是在实际调用发生时,是Derive::f()被调用了。这就实现了多态。

    多重继承(无虚函数覆盖)

    下面,再让我们来看看多重继承中的情况,假设有下面这样一个类的继承关系。注意:子类并没有覆盖父类的函数。

    对于子类实例中的虚函数表,是下面这个样子:

     

    我们可以看到:

    1) 每个父类都有自己的虚表。

    2) 子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的)

    这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数。

    多重继承(有虚函数覆盖)

    下面我们再来看看,如果发生虚函数覆盖的情况。

    下图中,我们在子类中覆盖了父类的f()函数。

    下面是对于子类实例中的虚函数表的图:

    我们可以看见,三个父类虚函数表中的f()的位置被替换成了子类的函数指针。这样,我们就可以任一静态类型的父类来指向子类,并调用子类的f()了。如:

     

    Derive d;

    Base1 *b1 = &d;

    Base2 *b2 = &d;

    Base3 *b3 = &d;

    b1->f(); //Derive::f()

    b2->f(); //Derive::f()

    b3->f(); //Derive::f()

    b1->g(); //Base1::g()

    b2->g(); //Base2::g()

    b3->g(); //Base3::g()

    安全性

    每次写C++的文章,总免不了要批判一下C++。这篇文章也不例外。通过上面的讲述,相信我们对虚函数表有一个比较细致的了解了。水可载舟,亦可覆舟。下面,让我们来看看我们可以用虚函数表来干点什么坏事吧。

    一、通过父类型的指针访问子类自己的虚函数

    我们知道,子类没有重载父类的虚函数是一件毫无意义的事情。因为多态也是要基于函数重载的。虽然在上面的图中我们可以看到Base1的虚表中有Derive的虚函数,但我们根本不可能使用下面的语句来调用子类的自有虚函数:

     

    Base1 *b1 = new Derive();

    b1->f1(); //编译出错

    任何妄图使用父类指针想调用子类中的未覆盖父类的成员函数的行为都会被编译器视为非法,所以,这样的程序根本无法编译通过。但在运行时,我们可以通过指针的方式访问虚函数表来达到违反C++语义的行为。(关于这方面的尝试,通过阅读后面附录的代码,相信你可以做到这一点)

    二、访问non-public的虚函数

    另外,如果父类的虚函数是private或是protected的,但这些非public的虚函数同样会存在于虚函数表中,所以,我们同样可以使用访问虚函数表的方式来访问这些non-public的虚函数,这是很容易做到的。

    如:

    class Base {

    private:

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

     

    };

     

    class Derive : public Base{

    };

    typedef void(*Fun)(void);

     

    void main() {

    Derive d;

    Fun pFun = (Fun)*((int*)*(int*)(&d)+0);

    pFun();

    }

     

     

    结束语

    C++这门语言是一门Magic的语言,对于程序员来说,我们似乎永远摸不清楚这门语言背着我们在干了什么。需要熟悉这门语言,我们就必需要了解C++里面的那些东西,需要去了解C++中那些危险的东西。不然,这是一种搬起石头砸自己脚的编程语言。

    在文章束之前还是介绍一下自己吧。我从事软件研发有十个年头了,目前是软件开发技术主管,技术方面,主攻Unix/C/C++,比较喜欢网络上的技术,比如分布式计算,网格计算,P2P,Ajax等一切和互联网相关的东西。管理方面比较擅长于团队建设,技术趋势分析,项目管理。欢迎大家和我交流,我的MSN和Email是:haoel@hotmail.com

    附录一:VC中查看虚函数表

    我们可以在VC的IDE环境中的Debug状态下展开类的实例就可以看到虚函数表了(并不是很完整的)

     

    附录 二:例程

    下面是一个关于多重继承的虚函数表访问的例程:

    #include <iostream>

    using namespace std;

     

    class Base1 {

    public:

    virtual void f() { cout << "Base1::f" << endl; }

    virtual void g() { cout << "Base1::g" << endl; }

    virtual void h() { cout << "Base1::h" << endl; }

     

    };

     

    class Base2 {

    public:

    virtual void f() { cout << "Base2::f" << endl; }

    virtual void g() { cout << "Base2::g" << endl; }

    virtual void h() { cout << "Base2::h" << endl; }

    };

     

    class Base3 {

    public:

    virtual void f() { cout << "Base3::f" << endl; }

    virtual void g() { cout << "Base3::g" << endl; }

    virtual void h() { cout << "Base3::h" << endl; }

    };

     

     

    class Derive : public Base1, public Base2, public Base3 {

    public:

    virtual void f() { cout << "Derive::f" << endl; }

    virtual void g1() { cout << "Derive::g1" << endl; }

    };

     

     

    typedef void(*Fun)(void);

     

    int main()

    {

    Fun pFun = NULL;

     

    Derive d;

    int** pVtab = (int**)&d;

    //Base1's vtable

    //pFun = (Fun)*((int*)*(int*)((int*)&d+0)+0);

    pFun = (Fun)pVtab[0][0];

    pFun();

     

    //pFun = (Fun)*((int*)*(int*)((int*)&d+0)+1);

    pFun = (Fun)pVtab[0][1];

    pFun();

     

    //pFun = (Fun)*((int*)*(int*)((int*)&d+0)+2);

    pFun = (Fun)pVtab[0][2];

    pFun();

     

    //Derive's vtable

    //pFun = (Fun)*((int*)*(int*)((int*)&d+0)+3);

    pFun = (Fun)pVtab[0][3];

    pFun();

     

    //The tail of the vtable

    pFun = (Fun)pVtab[0][4];

    cout<<pFun<<endl;

     

     

    //Base2's vtable

    //pFun = (Fun)*((int*)*(int*)((int*)&d+1)+0);

    pFun = (Fun)pVtab[1][0];

    pFun();

     

    //pFun = (Fun)*((int*)*(int*)((int*)&d+1)+1);

    pFun = (Fun)pVtab[1][1];

    pFun();

     

    pFun = (Fun)pVtab[1][2];

    pFun();

     

    //The tail of the vtable

    pFun = (Fun)pVtab[1][3];

    cout<<pFun<<endl;

     

     

     

    //Base3's vtable

    //pFun = (Fun)*((int*)*(int*)((int*)&d+1)+0);

    pFun = (Fun)pVtab[2][0];

    pFun();

     

    //pFun = (Fun)*((int*)*(int*)((int*)&d+1)+1);

    pFun = (Fun)pVtab[2][1];

    pFun();

     

    pFun = (Fun)pVtab[2][2];

    pFun();

     

    //The tail of the vtable

    pFun = (Fun)pVtab[2][3];

    cout<<pFun<<endl;

     

    return 0;

    }

    展开全文
  • 内部实现原理

    千次阅读 2018-02-18 23:21:18
    关于内部类的定义和使用,这里不做过多介绍,不...通过javac和javap去了解内部类的大体实现过程。先附上代码public class Use { private String name; private Integer age; public Integer getAge() { return...

    关于内部类的定义和使用,这里不做过多介绍,不太清楚的请先行了解。

    自己写了一个类,里面有内部类和局部内部类。通过javac和javap去了解内部类的大体实现过程。


    先附上代码

    public class Use {
        private String name;
    
        private Integer age;
    
        public Integer getAge() {
            return age;
        }
    
        public void setAge(Integer age) {
            this.age = age;
        }
    
        public void setName(String name) {
            this.name = name;
        }
    
        public String getName() {
            return name;
        }
    
        public class UseInner {
            private String innerName;
    
            public String getInnerName() {
                return innerName;
            }
    
            public void setInnerName(String innerName) {
                this.innerName = innerName;
            }
    
            public void testInner() {
                System.out.println("out:" + name + "-" + age + "-" + innerName);
            }
        }
    
        public String getSendChanenl() {
            class Channel {
                private Integer code;
                private String name;
                private UseInner useInner;
    
                public Integer getCode() {
                    return code;
                }
    
                public void setCode(Integer code) {
                    this.code = code;
                }
    
                public String getName() {
                    return name;
                }
    
                public void setName(String name) {
                    this.name = name;
                }
    
                public UseInner getUseInner() {
                    return useInner;
                }
    
                public void setUseInner(UseInner useInner) {
                    this.useInner = useInner;
                }
            }
    
            Channel channel = new Channel();
            channel.setCode(1);
            channel.setName("XXX");
    
            return channel.getCode() + channel.getName() + channel.getUseInner().getInnerName();
        }
    }



    通过javac Use.java编译后,产生了3个class文件,他们分别是Use.class、Use$1Channel.class和Use$UseInner.class。

    分别使用javap命令查看class文件信息。

    javap -p Use

    Compiled from "Use.java"
    public class Use {
      private java.lang.String name;
      private java.lang.Integer age;
      public Use();
      public java.lang.Integer getAge();
      public void setAge(java.lang.Integer);
      public void setName(java.lang.String);
      public java.lang.String getName();
      public java.lang.String getSendChanenl();
      static java.lang.String access$000(Use);
      static java.lang.Integer access$100(Use);
    }

    上面有两个方法值得我们注意:

    static java.lang.String access$000(Use);  为内部类提供外围类name属性的访问

    static java.lang.Integer access$100(Use); 为内部类提供外围类age属性的访问

    默认修饰符加static是当前方法只能在同一个包下访问。而内部类真是通过编译器生成一个同一个包下的类,来实现内部类。


    javap -p Use\$1Channel.class

    Compiled from "Use.java"
    class Use$1Channel {
      private java.lang.Integer code;
      private java.lang.String name;
      private Use$UseInner useInner;
      final Use this$0;
      Use$1Channel(Use);
      public java.lang.Integer getCode();
      public void setCode(java.lang.Integer);
      public java.lang.String getName();
      public void setName(java.lang.String);
      public Use$UseInner getUseInner();
      public void setUseInner(Use$UseInner);
    }

    这里把Use对象通过构造函数构造进来,就是为了能够访问外围类,且这里是final修饰的,对应唯一。


    javap -p Use\$UseInner.class

    Compiled from "Use.java"
    public class Use$UseInner {
      private java.lang.String innerName;
      final Use this$0;
      public Use$UseInner(Use);
      public java.lang.String getInnerName();
      public void setInnerName(java.lang.String);
      public void testInner();
    }

    这个和上面的类似,也是把Use构造进来。


    如果您有疑问,欢迎留言评论!

    展开全文
  • Epoll的本质(内部实现原理

    万次阅读 多人点赞 2019-05-06 11:11:26
    epoll作为linux下高性能网络服务器的必备技术至关重要,nginx、redis、skynet和大部分游戏服务器都使用到这一多路复用技术。 因为epoll的重要性,不少游戏公司(如某某九九)在招聘服务端同学时,可能会问及epoll...
  • c++虚函数实现原理

    千次阅读 2018-08-04 09:09:24
    关于多态,简而言之就是用父类型别的指针指向子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来...
  • jquery内部实现原理分析

    千次阅读 2014-05-22 18:47:13
    我们经常用jquery的选择器,甚至为jquery写插件,今天来讨论一下jquery内部实现原理。 一、关于window.$和window.jQuery 其实在jquery中这两个变量就是一个函数对象,即如下的形式 window.$=window.jQuery=...
  • C语言 回调函数原理实现

    千次阅读 2019-08-30 18:32:38
    最近需要实现处理AWSIOT传...这里就发现了原来回调函数使用还存在一定的误区,这里特地整理一篇文章以供查阅。实质上就是传入一个函数指针 内部去调用。 可以参考Linux内核callback调用方式。 第一节主要展示什...
  • printf 函数实现原理

    万次阅读 多人点赞 2010-12-13 13:36:00
    /* * ===================================================================================== * * Filename: printf.c * * Description: printf 函数实现 * * Version: 1.0 * Created:...
  • [转]PHP函数实现原理及性能分析

    万次阅读 2010-08-26 11:50:00
    在任何语言中,函数都是最基本的组成单元。对于php的函数,它具有...本文将从原理出发进行分析结合实际的性能测试尝试这些问题进行回答,在了解实现的同时更好的编写php程序。同时也会一些常见的php函数进行介绍。
  • C++常见面试题:虚函数实现原理

    千次阅读 2017-08-15 17:04:52
    关于多态,简而言之就是用父类型别的指针指向子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来...
  • C++虚函数实现原理分析

    千次阅读 2012-11-13 10:05:52
    函数的定义要遵循以下重要规则:  1.如果虚函数在基类与派生类中出现,仅仅是名字相同,而形式参数不同,或者是返回类型不同,那么即使加上了virtual关键字,也是不会进行滞后联编的。 2.只有类的成员...
  • 函数实现原理(转)

    万次阅读 多人点赞 2012-06-18 21:06:49
    关于多态,简而言之就是用父类型别的指针指向子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来...
  • 在阅读本博客之前先阅读: ...本系列博客主要说一下一个函数从定义到调用到解析到执行的过程,以便于更好的理解后续介绍的闭包、this等概念。先介绍内部原理,然后通过一个实例说明一下这个原理。然后是一些这个原
  • 深入理解Java并发之synchronized实现原理

    万次阅读 多人点赞 2017-06-04 17:44:44
    了解完synchronized的基本含义及其使用方式后,下面我们将进一步深入理解synchronized的底层实现原理。 synchronized底层语义原理 Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象...
  • [转]PHP函数实现原理及性能分析 .

    千次阅读 2012-03-27 18:49:59
    本文将从原理出发进行分析结合实际的性能测试尝试这些问题进行回答,在了解实现的同时更好的编写php程序。同时也会一些常见的php函数进行介绍。 php函数的分类 在php中,横向划分的话,函数分为两大类: user ...
  • 本文主要介绍了Java内部类的基本原理使用方法和各种细节。 有关内部实现回调,事件驱动和委托机制的文章将在后面发布。 具体代码在我的GitHub中可以找到 https://github.com/h2pl/MyTech 文章首发于我的个人...
  • ucenter整合同步登录的内部实现原理

    千次阅读 2013-07-18 15:42:29
    以前简单看过DISCUZ、今天工作遇到了UCENTER同步通信的问题、找了程序好久发现没问题、于是网上找了篇通信原理, 进而来思索是哪里出现的问题…… 我遇到的问题其实很简单,就是在...内部实现原理如下: 1、用
  • JAVA多线程实现方式主要有三种:继承Thread类、实现Runnable接口、使用ExecutorService、Callable、Future实现有返回结果的多线程。其中前两种方式线程执行完后都没有返回值,只有最后一种是带返回值的。 1、继承...
  • 文章目录前言什么是缓冲穿透问题?I、如何判断一个数据是否在有大量数据的池子里?...2.2.1.1 BloomFilterV18.0 的定义2.2.2 Bloom Filter构造2.2.3 哈希函数2.2.4 位数组具体实现2.3 添加元素的实现步骤2
  • C++虚函数实现

    千次阅读 2019-05-09 16:41:17
    http://blog.kongfy.com/2015/08/探索c虚函数在g中的实现/?utm_source=tuicool&utm_medium=referral https://blog.csdn.net/haoel/article/details/1948051 目录 一、虚函数表解析 前言 虚函数表 一般继承...
  • AOP如何实现及实现原理

    万次阅读 多人点赞 2018-11-24 10:41:03
    最近在开发中遇到了一个刚好可以用AOP实现的例子,就顺便研究了AOP的实现原理,把学习到的东西进行一个总结。文章中用到的编程语言为kotlin,需要的可以在IDEA中直接转为java。 这篇文章将会按照如下目录展开: AOP...
  • C++ virtual函数 实现机制

    千次阅读 2016-07-23 10:16:10
    关于多态,简而言之就是用父类型别的指针指向子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来...
  • 简单了解Python装饰器实现原理

    千次阅读 2018-06-12 11:27:11
    有过开发经验得朋友队装饰模式这个词应该不陌生,装饰装饰,顾名思义就是指我们原来有得东西进行装饰,比如我们买了新房,那么我们毛坯房的装修,就是我们房子进行拓展,让它更加完善!同样得对于代码也是如此...
  • 事务通常由高级数据库操作语言或编程语言(如 SQL,C++ 或 Java)书写的用户程序的执行所引起,并用形如`begin transaction`和`end transaction`语句(或函数调用)来界定。事务由事务开始(`begin transaction`)和...
  • Vue底层实现原理

    千次阅读 2018-09-30 21:31:40
    1、了解vue的双向数据绑定原理以及核心代码模块 2、缓解好奇心的同时了解如何实现双向绑定 为了便于说明原理实现,本文相关代码主要摘自vue源码, 并进行了简化改造,相对较简陋,并未考虑到数组的处理、数据的循环...
  • 一、Spring Cloud Feign概述与工作原理解读 (一)服务间调用的几种方式 (二)Feign 概述 二、FeignClent注解剖析+Spring Cloud Feign基本功能配置解读 (一)@FeignClient 注解剖析 (二)Spring Cloud Feign...
  • 前言:JMM基础-计算机原理 1、物理内存模型带来的问题 2、伪共享 3、Java内存模型(JMM) 4、Java内存模型带来的问题 4.1 可见性问题 4.2 竞争问题 4.3 重排序 5、volatile详解 5.1 volatile特性 5.2 ...
  • 今天在牛客网讨论区看到了一个题目:如何实现一个函数,可以使用new操作符创建对象,也可以直接使用创建对象? 当时有点懵,回头翻了翻《JS高程》,发现里面的工厂模式和寄生构造函数模式的代码不就是这个题目的...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 158,556
精华内容 63,422
关键字:

对函数的使用必须了解其内部实现原理