华为od 面试八股文_C++_03_含答案 浅浅的花香味﹌ 2024-04-20 14:13 86阅读 0赞 **目录** 1:解释C++中的友元函数和友元类,并解释其使用场景。 2:列举几个常见的设计原则,例如开闭原则、单一职责原则等,并解释其含义。 3:简述下C++程序的执行过程 4:了解malloc的底层实现吗? 5:面向对象和面向过程语言的区别在哪里? 6:了解左值和右值的区别吗? 7:说说虚函数工作原理 8:你觉得虚函数的性能怎么样? 9:什么情况下会调用拷贝构造函数? 10:构造函数析构函数是否能抛出异常? -------------------- #### 1:解释C++中的友元函数和友元类,并解释其使用场景。 #### 友元函数: 友元函数是在一个类内部声明并定义的独立函数,但不属于该类的成员。通过使用 `friend` 关键字声明,在类定义中指定某个函数为友元函数。作为友元函数,它可以直接访问该类的私有和保护成员。 **使用场景:** * 当两个类之间需要共享私有数据时,可以将一个类的成员函数声明为另一个类的友元函数。 * 当需要重载运算符时,通常将运算符重载函数声明为友元。 友元类: 友元类是指在一个类中声明另一个类为其友元。这意味着被声明为友元的类可以访问另一个类中的所有私有和保护成员。 **使用场景:** * 当两个或多个相关联的类需要彼此共享私有成员时,可以将其中一个类声明为另一个类的友元。 * 在设计模式中,例如代理模式、迭代器模式等,可能会用到友元关系来实现对私有信息进行封装和访问控制。 #### 2:列举几个常见的设计原则,例如开闭原则、单一职责原则等,并解释其含义。 #### 1. 开闭原则(Open-Closed Principle):软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。即在不修改现有代码的情况下,通过增加新功能来扩展系统。这样可以保证系统的稳定性和可维护性。 2. 单一职责原则(Single Responsibility Principle):一个类应该只有一个引起它变化的原因。每个类应该只负责一项职责或功能,这样可以提高类的内聚性和可读性,降低耦合度。 3. 里氏替换原则(Liskov Substitution Principle):子类必须能够替代其父类并完全符合父类所定义的行为。子类型应当透明地继承并遵循父类型所规定的约束和契约,确保程序正确性和稳定性。 4. 接口隔离原则(Interface Segregation Principle):客户端不应依赖于它不需要使用的接口。一个类不应强迫依赖于它不需要的接口,而是建立特定于客户端需求的小接口,避免接口臃肿和冗余。 5. 依赖倒置原则(Dependency Inversion Principle):高层模块不应依赖于低层模块,而是二者都应该依赖于抽象。通过定义抽象接口或基类,将高层模块与底层模块解耦,提高代码的灵活性和可维护性。 6. 迪米特法则(Law of Demeter):一个对象应该尽可能少地与其他对象发生相互作用。即每个对象只与其直接的朋友发生交互,避免暴露内部细节和繁琐的依赖关系。 #### 3:简述下C++程序的执行过程 #### * 预处理:根据以字符\#开头的命令修改原始的c程序,比如\#include<stdio.h>告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中,将\#define的变量替换等等,结果得到了另一个c程序,通常以.i作为文件扩展名。 * 编译:文本文件hello.i翻译成hello.s,它包含了一个汇编语言程序。汇编语言程序中的每条语句都以一种标准的文本格式确切的描述一条低级机器语言指令。 * 汇编:将汇编程序翻译为机器语言指令,把这些指令打包成可重定位目标程序(relocateble object program)的格式,并把结果保存在hello.o中。hello.o文件是二进制文件,因为他的字节编码是机器语言指令而不是ascii码。如果用文本编辑器打开hello.o会看到一堆乱码。 * 链接:比如hello中用到了printf函数,这是标准c库的函数,存在于一个名为printf.o的单独编译好的目标文件中,这个文件必须以某种方式合并到我们编译好的的目标文件中。链接器(ld)程序负责处理这种合并,结果得到hello文件,它是可执行目标文件,可以被加载到内存中,由系统执行。 ![70555f4411e8457dba69b4cccd898a2e.png][] #### 4:了解malloc的底层实现吗? #### Linux下: * 开辟空间小于128K时,通过**brk()函数** * 将数据段.data的最高地址指针**\_edata**向高地址移动,即**增加堆**的有效区域来申请内存空间 * brk分配的内存需要等到高地址内存释放以后才能释放,这也是内存碎片产生的原因 * 开辟空间大于128K时,通过**mmap()函数** * 利用mmap系统调用,在堆和栈之间**文件映射区域**申请一块虚拟内存 * 128K限制可由M\_MMAP\_THRESHOLD选项进行修改 * mmap分配的内存可以单独释放 * 以上只涉及虚拟内存的分配,直到进程第一次访问其地址时,才会通过缺页中断机制分配到物理页中 #### 5:面向对象和面向过程语言的区别在哪里? #### 首要要知道这两个都是一种编程思想 **面向过程**就是分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候一个一个依次调用就可以了。 **面向对象**是把构成问题事务分解成各个对象,建立对象的目的不是为了完成一个步骤,而是为了描叙某个事物在整个解决问题的步骤中的行为。 **举一个例子:** 例如五子棋,面向过程的设计思路就是首先分析问题的步骤:1、开始游戏,2、黑子先走,3、绘制画面,4、判断输赢,5、轮到白子,6、绘制画面,7、判断输赢,8、返回步骤2,9、输出最后结果。把上面每个步骤用分别的函数来实现,问题就解决了。 而面向对象的设计则是从另外的思路来解决问题。整个五子棋可以分为 1、黑白双方,这两方的行为是一模一样的,2、棋盘系统,负责绘制画面,3、规则系统,负责判定诸如犯规、输赢等。第一类对象(玩家对象)负责接受用户输入,并告知第二类对象(棋盘对象)棋子布局的变化,棋盘对象接收到了棋子的i变化就要负责在屏幕上面显示出这种变化,同时利用第三类对象(规则系统)来对棋局进行判定。 可以明显地看出,面向对象是以功能来划分问题,而不是步骤。同样是绘制棋局,这样的行为在面向过程的设计中分散在了总多步骤中,很可能出现不同的绘制版本,因为通常设计人员会考虑到实际情况进行各种各样的简化。而面向对象的设计中,绘图只可能在棋盘对象中出现,从而保证了绘图的统一。功能上的统一保证了面向对象设计的可扩展性。 **面向过程** 优点:性能比面向对象高,因为类调用时需要实例化,开销比较大,比较消耗资源;比如单片机、嵌入式开发、 Linux/Unix等一般采用面向过程开发,性能是最重要的因素。 缺点:没有面向对象易维护、易复用、易扩展 **面向对象** 优点:易维护、易复用、易扩展,由于面向对象有封装、继承、多态性的特性,可以设计出低耦合的系统,使系统 更加灵活、更加易于维护 缺点:性能比面向过程低 #### 6:了解左值和右值的区别吗? #### C语言中的表达式被分为`左值`和其它(函数和非对象值),其中左值被定义为标识一个对象的表达式,在C语言中lvalue是`locator value`的简写,因此lvalue对应了一块内存地址。 C++出来后,C++11之前,左值遵循了C语言的分类法,但与C不同的是,其将非左值表达式统称为右值,函数为左值,因为c++有引用这个东西 。并添加了引用能绑定到左值但唯有const的引用能绑定到右值的规则。 自C++11开始,对值类别又进行了详细分类,在原有左值的基础上增加了纯右值和消亡值,并对以上三种类型通过是否具名(identity)和可移动(moveable),又增加了glvalue和rvalue两种组合类型。 c++11后的值类别 自C++11开始,表达式的值分为`左值(lvalue, left value)`、`将亡值(xvalue, expiring value)`、`纯右值(pvalue, pure ravlue)`以及两种混合类别`泛左值(glvalue, generalized lvalue)`和`右值(rvalue, right value)`五种。我们只需要关注左值,纯右值和将亡值这三种即可。 #### #### lvalue 是“**loactor value**”的缩写,可意为存储在内存中、有明确存储地址(可寻址)的数据 左值是**可以取地址、位于赋值符号左边**的值,就记住,左值是表达式结束(不一定是赋值表达式)后依然存在的对象。 左值也是一个关联了名称的内存位置,允许程序的其他部分来访问它的值。 有以下特征: 1. 可通过取地址运算符获取其地址 2. 可修改的左值可用来赋值 3. 可以用来初始化左值引用 那些是左值? 1. 变量名、函数名以及数据成员名 2. 返回左值引用的函数调用 3. 由赋值运算符或复合赋值运算符连接的表达式,如(a=b, a-=b等) 4. 解引用表达式\*ptr 5. **前置自增和自减表达式(++a, ++b)** 6. 成员访问(点)运算符的结果 7. 由指针访问成员( `->` )运算符的结果 8. 下标运算符的结果(`[]`) 9. 字符串字面值("abc") rvalue 译为 "**read value**",指那些可以提供数据值的表达式(不一定可以寻址,例如存储于寄存器中的数据)。右值有可能在内存中也有可能在寄存器中。一般来说就是活不过一行就会消失的值。 那些是右值? 1. 字面值(字符串字面值除外),例如1,'a', true等 2. 返回值为非引用的函数调用或操作符重载,例如:str.substr(1, 2), str1 + str2, or it++ 3. 后置自增和自减表达式(a++, a--) 4. 算术表达式(x + y;) 5. 逻辑表达式 6. 比较表达式 7. 取地址表达式 8. lambda表达式`auto f = []{return 5;};` 左值和右值区分的一个点 从本质上理解,右值的创建和销毁由编译器幕后控制,程序员只能确保在本行代码有效的,就是右值(包括立即数);而用户创建的,通过作用域规则可知其生存期的,就是左值(包括函数返回的局部变量的引用以及const对象)。 右值又有纯右值和将亡值的说法。非引用返回的临时变量、运算表达式产生的临时变量、原始字面量和lambda表达式等都是纯右值。而将亡值是与右值引用相关的表达式,比如,将要被移动的对象、T&&函数返回值、std::move返回值和转换为T&&的类型的转换函数的返回值等。 #### 7:说说虚函数工作原理 #### c++没有强制规定虚函数的实现方式。**编译器中主要用虚表指针(vptr)和虚函数表(vtbl)来实现的** 先直接上图,然后再看一下虚函数的执行过程: ![f8bc270b2f214d63833e31461b0fec73.png][] 当调用一个对象对应的函数时,通过对象内存中的vptr找到一个虚函数表(注意这虚函数表既不在堆上,也不再栈上)。虚函数表内部是一个函数指针数组,记录的是该类各个虚函数的首地址。然后调用对象所拥有的函数。 #### 8:你觉得虚函数的性能怎么样? #### 第一步是通过对象的vptr找到该类的vtbl,因为虚函数表指针是编译器加上去的,通过vptr找到vtbl就是指针的寻址而已。 第二部就是找到对应vtbl中虚函数的指针,因为vtbl大部分是指针数组的形式实现的 在单继承的情况下调用虚函数所需的代价基本上和非虚函数效率一样,在大多数计算机上它多执行了很少的一些指令 在多继承的情况由于会根据多个父类生成多个vptr,在对象里为寻找 vptr 而进行的偏移量计算会变得复杂一些 空间层面为了实现运行时多态机制,编译器会给每一个包含虚函数或继承了虚函数的类自动建立一个虚函数表,所以虚函数的一个代价就是会增加类的体积。在虚函数接口较少的类中这个代价并不明显,虚函数表vtbl的体积相当于几个函数指针的体积,如果你有大量的类或者在每个类中有大量的虚函数,你会发现 vtbl 会占用大量的地址空间。但这并不是最主要的代价,主要的代价是发生在类的继承过程中,在上面的分析中,可以看到,当子类继承父类的虚函数时,子类会有自己的vtbl,如果子类只覆盖父类的一两个虚函数接口,子类vtbl的其余部分内容会与父类重复。**如果存在大量的子类继承,且重写父类的虚函数接口只占总数的一小部分的情况下,会造成大量地址空间浪费**。同时由于虚函数指针vptr的存在,虚函数也会增加该类的每个对象的体积。在单继承或没有继承的情况下,类的每个对象只会多一个vptr指针的体积,也就是4个字节;在多继承的情况下,类的每个对象会多N个(N=包含虚函数的父类个数)vptr的体积,也就是4N个字节。当一个类的对象体积较大时,这个代价不是很明显,但当一个类的对象很轻量的时候,如成员变量只有4个字节,那么再加上4(或4N)个字节的vptr,对象的体积相当于翻了1(或N)倍,这个代价是非常大的。 #### 9:什么情况下会调用拷贝构造函数? #### 1. 对象以值传递的方式进入函数体 2. 对象以值传递的方式从函数返回 3. 一个对象需要另外一个对象初始化 #### 10:构造函数析构函数是否能抛出异常? #### **构造函数可以抛出异常** 对象只有在构造函数执行完成之后才算构造妥当,c++只会析构已经完成的对象。因此如果构造函数中发生异常,控制权就需要转移出构造函数,执行异常处理函数。在这个过程中系统会认为对象没有构造成功,导致不会调用析构函数。在构造函数中抛出异常会导致当前函数执行流程终止,在构造函数流程前构造的成员对象会被释放,但是如果在构造函数中申请了内存操作,则会造成内存泄漏。另外,如果有继承关系,派生类中的构造函数抛出异常,那么基类的构造函数和析构函数可以照常执行的。 解决办法:用智能指针来管理内存就可以 **C++标准指明析构函数不能、也不应该抛出异常** C++异常处理模型最大的特点和优势就是对C++中的面向对象提供了最强大的无缝支持。那么如果对象在运行期间出现了异常,C++异常处理模型有责任清除那些由于出现异常所导致的已经失效了的对象(也即对象超出了它原来的作用域),并释放对象原来所分配的资源, 这就是调用这些对象的析构函数来完成释放资源的任务,所以从这个意义上说,析构函数已经变成了异常处理的一部分。 析构函数不能抛出异常原因有两个: 1. 如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。 2. 异常发生时,c++的异常处理机制在异常的传播过程中会进行栈展开(stack-unwinding)。在栈展开的过程中就会调用已经在栈构造好的对象的析构函数来释放资源,此时若其他析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃。 解决办法:把异常完全封装在析构函数内部,决不让异常抛出函数之外,代码如下: DBConn::~DBconn() { try { db.close(); } catch(...) { abort(); } } //如果close抛出异常就结束程序,通常调用abort完成: [70555f4411e8457dba69b4cccd898a2e.png]: https://image.dandelioncloud.cn/pgy_files/images/2024/04/20/dc6e3c54c64e41dab1295159eeed59d2.png [f8bc270b2f214d63833e31461b0fec73.png]: https://image.dandelioncloud.cn/pgy_files/images/2024/04/20/2b8dfbe738fc4fa1b3a698a8c77e92e6.png
相关 华为od 面试八股文_Java_03_含答案 为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648 到 2147483647,... 拼搏现实的明天。/ 2024年04月20日 14:14/ 0 赞/ 96 阅读
还没有评论,来说两句吧...