C++面经记录
const
问:了解const么?哪些时候用到const?与宏定义有什么差异?
当然了解const
。const
是C++中的一个类型修饰符,它用于指定一个变量为常量,即其值在初始化后不能被修改。使用const
可以提高代码的可读性和可维护性,同时也有助于编译器进行更好的优化。
在以下情况中,我们通常会使用const
:
- 定义常量值:当我们需要定义一些在程序运行过程中不会改变的值时,可以使用
const
。
const int MAX_SIZE = 100;
- 指针与
const
:- 指向常量的指针:指针指向的内容不能被修改。
const int *p = &someInt; // p指向的内容是常量 *p = 5; // 错误,不能修改p指向的内容
- 常量指针:指针本身不能被修改,即不能指向其他地址。
int *const q = &someInt; // q是常量指针 q = &anotherInt; // 错误,不能修改q的值
- 指向常量的常量指针:指针本身和指向的内容都不能被修改。
const int *const r = &someInt; *r = 5; // 错误 r = &anotherInt; // 错误
- 函数参数:当不希望函数内部修改传入的参数时,可以使用
const
修饰函数参数。
void foo(const int ¶m) {
// param不能被修改
}
- 函数返回值:当函数返回一个对象,但不希望调用者修改这个返回的对象时,可以在函数声明中使用
const
。
const MyClass& getMyClass() const;
- 类成员:在类中,可以使用
const
来修饰成员变量或成员函数,表示它们不可被修改。
class MyClass {
public:
const int myConstVar;
void myConstFunc() const;
};
const
与宏定义的主要差异在于:
- 作用域和生命周期:
const
定义的常量具有明确的作用域和生命周期,它们遵循C++的作用域规则。而宏定义(通过#define
)在预处理阶段就被替换,没有作用域和生命周期的概念,可能导致意外的名称冲突和难以追踪的错误。 - 类型安全:
const
定义的常量是类型安全的,编译器会检查其使用是否符合类型规则。而宏定义只是简单的文本替换,不进行类型检查,容易引发类型错误。 - 调试和可维护性:由于
const
是C++语言的一部分,它支持调试器和其他工具进行更好的分析和错误检查。宏定义则可能导致调试困难,因为它们在编译前就被替换,不会在调试信息中反映出来。 - 存储方式:
const
定义的常量通常会被分配到程序的静态存储区,而宏定义只是文本替换,不占用存储空间。
总的来说,const
提供了一种更安全、更易于维护的方式来定义和使用常量,而宏定义则更多地用于简单的文本替换和条件编译等场景。在现代C++编程中,通常推荐使用const
和constexpr
来定义常量,而不是使用宏定义。
问:请再解释下这句话 const定义的常量具有明确的作用域和生命周期,它们遵循C++的作用域规则。而宏定义(通过#define)在预处理阶段就被替换,没有作用域和生命周期的概念,可能导致意外的名称冲突和难以追踪的错误。
好的,我会进一步解释这句话的含义。
首先,让我们谈谈const
定义的常量。在C++中,当你使用const
关键字定义一个常量时,这个常量会有一个明确的作用域,即它在哪里是可见的。这通常是由它声明的位置(比如在函数内部、类内部、全局作用域等)决定的。同时,常量也有一个生命周期,它通常与包含它的代码块的生命周期相关联。一旦超出这个作用域或生命周期,该常量就不再可访问。
例如:
void someFunction() {
const int localConst = 10; // 局部常量,作用域和生命周期限于这个函数
}
int main() {
const int globalConst = 20; // 全局常量,作用域是整个程序,生命周期通常也是整个程序执行期间
return 0;
}
在这个例子中,localConst
的作用域和生命周期仅限于someFunction
函数内部,而globalConst
的作用域是整个程序,生命周期也是整个程序执行期间。
然而,宏定义是通过预处理器指令#define
来创建的,它们在编译过程的预处理阶段就被文本替换。这意味着宏没有作用域和生命周期的概念,它们在代码中被替换的位置取决于宏的名字在源代码中出现的位置,而不是定义的位置。
例如:
#define PI 3.14159
void usePi() {
double area = PI * radius * radius; // PI在这里被替换为3.14159
}
int main() {
double circumference = 2 * PI * radius; // PI同样在这里被替换
return 0;
}
在这个例子中,不论PI
在哪里被使用,预处理器都会将其替换为3.14159
。因此,如果你在不同的头文件中或不同的地方定义了同名的宏,就可能导致名称冲突,因为预处理器只是简单地替换文本,而不考虑作用域或生命周期。
此外,由于宏只是文本替换,它们不会受到C++的类型系统的约束。这可能导致难以追踪的错误,因为编译器不会检查宏的使用是否符合预期的类型。
总结来说,const
定义的常量具有明确的作用域和生命周期,遵循C++的作用域规则,提供了类型安全和更好的调试支持。而宏定义在预处理阶段就被替换,没有作用域和生命周期的概念,可能导致意外的名称冲突和难以追踪的类型错误。因此,在现代C++编程中,推荐使用const
和constexpr
来定义常量,而不是使用宏定义,除非在特定情况下宏定义确实提供了必要的灵活性或性能优势。
问:顶层const和底层const的区别
只需要记住,在拷贝时,既要判断指针本身的const转换是否合法,又要判断指针所指对象的const转换是否合法。
- 对于指针本身是否是常量、其所指对象是否是常量,按照从右往左的读法来判断:
int a = 0;
const int *const p= a; //第二个const其实是const p,表示p这个指针本身是常量,即其中存放的地址不能变。
//第一个const,其实是 const *p, 表示*p不变,即其指向的对象不变。
- 一般来说,非常量可以转换成常量,反之则不行
基于这两点,就能解决关于const拷贝是否合法的问题了。
- 先看c++ primer里的原文:
如前所述,指针本身是一个对象,它又可以指向另外一个对象。因此,指针本身是不是常量以及指针所指的是不是一个常量就是两个相互独立的问题。用名词顶层 const 表示指针本身是个常量,而用名词底层 const(low-level const)表示指针所指的对象是一个常量。
更一般的,顶层 const 可以表示任意的对象是常量,这一点对任何数据类型都适用,如算术类型、类、指针等。底层 const 则与指针和引用等复合类型的基本类型部分有关。比较特殊的是,指针类型既可以是顶层 const 也可以是底层const,这一点和其他类型相比区别明显∶
对于指针本身是否是常量、其所指对象是否是常量,按照从右往左的读法来判断:
int a = 0;
const int *const p= a; //第二个const其实是const p,表示p这个指针本身是常量,即其中存放的地址不能变。
//第一个const,其实是 const *p, 表示*p不变,即其指向的对象不变。
所以可以认为在第一个const是底层const,第二个const是顶层const.
2.书中定义顶层和底层,主要用来判断拷贝过程是否合法:
当执行对象的拷贝操作时,常量是顶层const还是底层const 区别明显。
其中,顶层const不受什么影响∶
另一方面,底层 const 的限制却不能忽视。当执行对象的拷贝操作时,拷入和拷出的对象必须具有相同的底层 const 资格,或者两个对象的数据类型必须能够转换。
一般来说,非常量可以转换成常量,反之则不行∶
可以解释为,顶层const在拷贝的时候,因为它本身就是常量,只需要考虑它本身拷出和拷入对象之间类型是否相同;而底层const只是代表它所指对象时常量,所以在拷贝它的时候还要考虑它指向的对象是否是相同类型。
所以我认为顶层、底层的概念是用于判断在拷贝时应该考虑哪个层次类型保持一直的问题。
而解决这个问题的本质,其实就是判断是常量指针,还是常量的指针。
int i = 0;
const int ci = 42; //这是一个顶层const,基于顶层const定义+“顶层 const 可以表示任意的对象是常量”
const int *p2 = &ci; //这是一个底层const
const int *const p3 = p2; //靠右的是顶层,靠左的是底层
i = ci; //正确,ci是一个顶层,只需要考虑它本身和i类型是否对的上
p2 = p3; //正确,对于底层部分,考虑它们所指的对象,p2指向常量,p3指向非常量。对于顶层部分,p3是顶层,所以不影响。
int *p = p3; //错误,底层部分,p3有底层而p没有,p3指向常量,p指向非常量,那么程序可以试图通过p去改变一个常量,所以是错误的。
p2 = p3; //正确, 两个都是底层,即指向的都是常量。
p2 = &i; //正确,底层部分:p2指向常量,但是int i是可以转换成常量的。
int &r = ci; //错误,因为非常量的引用r会试图改变常量ci的值。
const int &r2 =i; //正确,用于声明引用的都是底层const,int i可以转换成常量。
reference和pointer
问:reference和pointer的区别?哪些情况使用pointer?
首先,它们的主要功能都是用来间接访问另一个对象的值。但是,reference(引用)在声明时必须被初始化,一旦初始化后就不能再指向其他对象,可以将其看作是对象的别名。而pointer(指针)则可以在任何时候指向不同的对象,而且在声明时可以选择不初始化,即可以为空。
其次,从内存存储形式来看,引用不需要解引用操作,可以直接使用,而指针则需要使用星号操作符进行解引用。
再者,从使用场景来看,引用主要用于作为函数的参数和返回值,而指针则更多地用于动态内存分配、链表等数据结构以及某些需要灵活改变指向对象的场景。
至于哪些情况使用pointer,我认为主要有以下几种情况:
- 当需要动态分配内存时,通常会使用指针来指向动态分配的内存空间。
- 在处理链表、树等复杂数据结构时,指针可以方便地指向链表节点或树的节点,实现数据的遍历和操作。
- 当需要灵活改变指向对象时,比如交换两个变量的值,或者实现函数的多返回值功能时,可以使用指针。
需要注意的是,在使用指针时需要特别小心,避免出现野指针、空指针等问题。
此外,我认为理解reference和pointer的底层实现机制也很重要,这有助于我们更好地使用它们。比如,引用在底层是通过指针来实现的,但是它们在使用方式和语义上有很大的区别。
inline
问:inline的优劣
当然可以。下面我将通过一个具体的例子来解释inline函数的优劣。
假设我们有一个简单的类Calculator
,它有一个成员函数add
用于计算两个整数的和。
不使用inline的情况:
class Calculator {
public:
int add(int a, int b) {
return a + b;
}
};
// 在某个地方使用这个类
Calculator calc;
int sum = calc.add(2, 3);
在这个例子中,每次调用add
函数时,都会发生函数调用开销,包括将参数压入栈中、跳转到函数实现代码、执行函数代码、返回结果并恢复栈状态。如果add
函数被频繁调用,这些开销可能会变得显著。
使用inline的情况:
class Calculator {
public:
inline int add(int a, int b) {
return a + b;
}
};
// 在某个地方使用这个类
Calculator calc;
int sum = calc.add(2, 3);
通过将add
函数声明为inline,我们请求编译器在调用add
函数的地方直接插入函数的代码,而不是进行常规的函数调用。这样,编译器可能会将add
函数的代码直接嵌入到调用它的地方,从而消除了函数调用的开销。
优势:
- 由于减少了函数调用的开销,
add
函数的执行可能会更快,尤其是在该函数被频繁调用的情况下。 - inline函数通常比宏更安全,因为它们会进行类型检查,并且遵循作用域规则。
劣势:
- 如果inline函数的实现非常复杂或者函数体很大,那么将其代码直接插入到每个调用点可能会导致生成的代码体积显著增加,即代码膨胀。
- inline函数可能会增加编译器的复杂性,因为编译器需要决定哪些函数应该内联,哪些不应该。这可能会增加编译时间。
请注意,虽然我们在函数声明中使用了inline
关键字,但最终是否内联以及内联的程度仍然取决于编译器的优化策略。编译器可能会忽略我们的请求,特别是当函数体很大或者内联不会带来性能提升时。
此外,值得注意的是,inline函数并不是总是能够提高性能。在某些情况下,由于代码膨胀或指令缓存未命中等原因,内联函数甚至可能导致性能下降。因此,在决定是否使用inline函数时,应该根据具体情况进行权衡和测试。
final和override
问:final和override的作用,以及使用场合
final
和override
是两种常用于Java和C++等面向对象编程语言的关键字,它们各自具有特定的作用和使用场合。
final的作用:
final
关键字主要用来修饰类、方法和变量。
- 修饰类:当一个类被声明为
final
时,表示这个类不能被继承。这有助于保护类的完整性和安全性,防止其他类扩展或修改这个类的行为。例如,Java中的String
类就是一个final
类。 - 修饰方法:
final
方法不能被子类重写。这确保了方法的实现不会被修改,从而保持方法的稳定性和一致性。在Java中,许多核心类的方法都被声明为final
,以防止它们被子类意外修改。 - 修饰变量:
final
变量是一个常量,一旦赋值后就不能再修改。这有助于确保变量的值在程序的整个生命周期中保持不变。
override的作用:
override
关键字用于标示一个方法重写了父类中的同名方法。这主要出现在子类中,当子类需要改变或扩展父类的某个方法的行为时。
- 明确重写关系:
override
标记使得代码更加清晰和易于理解,它明确告诉阅读代码的人这个方法是一个重写的方法,而不是一个新的方法或重载的方法。 - 提高代码可读性:使用
override
可以提高代码的可读性,使得代码更容易被他人理解和维护。 - 防止错误:通过显式地使用
override
标记,编译器可以在编译时检查是否确实重写了父类的方法,从而避免因为方法签名不匹配或方法不存在而导致的错误。
使用场合:
- final的使用场合
:
- 当你不希望一个类被其他类继承时,可以将这个类声明为
final
。 - 当一个方法是核心逻辑的一部分,并且你不希望子类改变其行为时,可以将这个方法声明为
final
。 - 当你需要一个变量在整个程序中保持不变时,可以使用
final
来修饰这个变量。
- 当你不希望一个类被其他类继承时,可以将这个类声明为
- override的使用场合
:
- 当子类需要改变或扩展父类的某个方法的行为时,可以使用
override
来重写这个方法。 - 在实现接口或继承抽象类时,必须实现或重写其中的抽象方法,此时可以使用
override
来明确标识这些方法的重写关系。
- 当子类需要改变或扩展父类的某个方法的行为时,可以使用
总的来说,final
和override
是面向对象编程中非常重要的两个关键字,它们各自在保护类的完整性、确保方法的稳定性和一致性、以及实现多态行为等方面发挥着重要作用。在适当的使用场合下,它们可以帮助我们编写更加健壮、清晰和易于维护的代码。
Delete数组
问:Delete数组的一部分会发生什么?为什么出现异常?
删除数组的一部分可能会导致多种结果和异常,具体取决于所使用的编程语言、上下文环境以及删除操作的方式。以下是可能发生的情况及异常原因:
- 数组首地址变更:如果无意中更改了数组的首地址,如通过递增操作(
a++
),那么尝试访问或删除该数组的部分元素可能会导致问题,因为你现在可能指向了一个错误的内存位置。 - 作用域问题:如果在一个函数体内分配了数组,却在另一个函数体内尝试删除它,可能会因为作用域的问题而导致错误。当数组的作用域结束时,它会被自动释放,此时尝试删除它可能会引发异常。
- 重复删除:如果已经删除过数组的一部分,再次尝试删除相同的部分会导致问题,因为这部分内存可能已经被释放或标记为不可用。
- 内存访问越界:当删除数组的一部分时,如果不小心修改了数组边界外的内存(如管理单元或内存边界标志),那么在下次访问或释放数组时可能会出错。例如,在VC分配的内存中,除了用户请求的数据空间外,还会分配一些额外的管理内存。如果修改了这些管理信息,那么在释放数组时就会出错。
- 原生操作符与特定框架/库的行为差异:在某些编程语言或框架中,使用原生操作符(如JavaScript中的
delete
)删除数组元素时,可能不会改变数组的长度,而是将元素标记为undefined
。然而,某些框架或库(如Vue.js中的Vue.delete
)可能会更新数组长度并完全移除元素。这种差异可能导致在使用特定框架或库时出现问题。 - 底层内存管理问题:直接操作内存(如使用C或C++的指针)时,如果不正确地管理内存(如未初始化指针、释放后继续使用等),可能导致程序崩溃或异常。
为了避免这些问题,建议在删除数组部分之前确保:
-
- 数组的首地址未被更改。
- 在正确的作用域内操作数组。
- 避免重复删除同一部分。
- 确保不越界访问或修改数组及其周围的内存。
- 理解并遵循所使用的编程语言、框架或库的最佳实践和规则。
指针越界
问:系统是如何知道指针越界的?
_CrtMemBlockHeader :这个结构体,存放了动态申请得到的内存块的各种信息,并且返回到你的指针上面。具体内容如下:
typedef struct _CrtMemBlockHeader
{
// 指向下一块数据块的指针
struct _CrtMemBlockHeader *pBlockHeaderNext;
// 指向前一块数据块的指针
struct _CrtMemBlockHeader *pBlockHeaderPrev;
// File name:请求内存分配操作的那行代码所在的文件的路径和名称,但实际上是空指针
char *szFileName;
// Line number:行号,请求内存分配操作的那行代码的行号
int nLine;
// 请求分配的大小 size_t nDataSize;
// Type of block 类型
int nBlockUse;
// 请求号
long lRequest;
// 这个gap,正是cpp中对于指针的界限
unsigned char gap[nNoMansLandSize];
} _CrtMemBlockHeader;
VC下有一个结构体_CrtMemBlockHeader,里面有一个Gap属性,这个Gap数组放在你的指针数据的后面,默认为0xFD,当检测到你的数据后不是0xFD的时候就说明的你的数据越界了。
这段代码中有几个问题,首先,我们来逐一解释这些问题,然后再总结。
-
-
- 分配内存和越界访问:
-
int* p = (int*)malloc(sizeof(int));
这里你分配了足够的内存来存储一个int
类型的变量。p
现在指向这块内存的起始地址。
*(p+1) = 1;
这里你试图访问p
指向的内存块后面的内存,并将值1
存储在那里。这是越界访问,因为它超出了你分配的内存范围。这样的操作可能导致未定义行为,包括程序崩溃或数据损坏。
-
-
- 错误的
free
调用:
- 错误的
-
free(p);
当你调用free(p)
时,你告诉系统你已经完成了对p
指向的内存块的使用,并可以将其释放回系统。然而,由于之前的越界访问,这个操作可能无法正常工作,或者即使可以,后续对p
的解引用也是非法的。
-
-
- 解引用已释放的内存:
-
*(p-1) = 1;
在调用free(p)
之后,你试图访问p
指向的内存(或它之前的内存),这是非法的。p
指向的内存已经被释放,你不能再访问或修改它。这样的操作可能导致程序崩溃或其他未定义行为。
-
-
- 重复释放同一块内存:
-
free(p); // 依旧崩溃
尝试再次释放同一块内存是另一个错误。free
应该只被调用一次针对同一块内存。重复调用free
可能导致未定义行为,通常会导致程序崩溃。
优化
C++编译器有哪些常见的优化?听说过RVO(NRVO)么?
C++编译器为了生成更高效的代码,通常会进行一系列的优化。这些优化涉及代码的多个方面,包括循环、函数调用、内存管理等。以下是一些C++编译器常见的优化技术:
-
-
- 内联函数(Inline Functions):对于函数体较小且频繁调用的函数,编译器可能会将其内联展开,即将函数调用替换为函数体本身,以减少函数调用的开销。
- 循环展开(Loop Unrolling):编译器可能会将循环中的多次迭代展开成多个重复的代码块,减少循环控制的开销,从而提高程序的执行速度。
- 代码复制(Code Duplication):为了避免函数调用的开销,编译器可能会将重复出现的代码块复制到多个位置。
- 递归优化(Recursive Optimization):对于递归函数,编译器可能会进行优化,如尾递归优化或递归展开,以减少递归调用的开销。
- 常量传播(Constant Propagation):编译器会将常量值直接传播到使用该常量的地方,减少变量的读取操作。
- 数据流分析(Data Flow Analysis):通过对程序的数据流进行分析,编译器可以优化变量的使用方式,如寄存器分配、循环不变量外提等。
-
至于你提到的RVO(返回值优化)和NRVO(命名返回值优化),它们确实是C++编译器用于优化代码的重要技术。
返回值优化(RVO):当函数返回一个对象时,编译器可能会直接在调用者的上下文中构造该对象,避免不必要的复制或移动操作。这在函数返回一个临时“temp”对象时尤其有用。
命名返回值优化(NRVO):比RVO进一步优化。对于RVO,如果函数在返回前创建了一个临时变量,这个临时变量还是会被构造的,参考下面代码
Point3d Factory()
{
Point3d po(1,2, 3);
return po;
}
//RVO优化后
void Factory(Point3d &_result)
{
Point3d po(1,2,3);
_result.Point3d::Point3d(po);
return;
}
//NRVO优化后
void Factory(Point3d &_result)
{
_result.Point3d::Point3d(1, 2, 3);
return;
}
NRVO则直接跳过临时对象的构造。
还有一个小的技巧,static变量不会被优化
关于static的知识
在C++中,static
关键字具有多种用途,它可以在不同的上下文中改变变量的生命周期、可见性或链接性。下面我们将详细讨论static
在C++中的几种主要用途:
-
-
- 局部静态变量:
-
当static
用于函数内部声明的变量时,该变量具有静态存储期。这意味着该变量只会被初始化一次,并且它的生命周期是整个程序的执行期间,而不是仅在定义它的函数被调用时。
void foo() {
static int count = 0; // 局部静态变量
count++;
std::cout << count << std::endl;
}
int main() {
foo(); // 输出 1
foo(); // 输出 2
// ... 每次调用foo(),count都会递增
}
在上面的例子中,count
是一个局部静态变量,它在foo
函数第一次被调用时初始化,并在后续的调用中保持其值。
-
-
- 类静态成员:
-
当static
用于类的成员变量或成员函数时,该成员属于类本身,而不是类的任何特定对象。所有对象共享同一个静态成员。静态成员变量必须在类定义外部进行定义和初始化。
class MyClass {
public:
static int count; // 静态成员变量声明
void increment() {
count++; // 访问静态成员变量
}
static void printCount() { // 静态成员函数
std::cout << count << std::endl;
}
};
// 静态成员变量定义和初始化
int MyClass::count = 0;
int main() {
MyClass obj1, obj2;
obj1.increment(); // count 变为 1
obj2.printCount(); // 输出 1,因为obj1和obj2共享同一个count
}
静态成员函数只能访问静态成员变量或其他静态成员函数,因为它们不依赖于类的任何特定对象。
-
-
- 静态全局变量和静态函数:
-
在文件作用域(即全局作用域,但不在任何函数或类内部)中,static
用于声明静态全局变量或静态函数。这样的变量或函数只对其定义的文件可见,对其他文件是不可见的,即使它们使用了extern
关键字来声明。这提供了一种封装机制,用于限制变量或函数的可见性。
// file1.cpp
static int myStaticGlobal = 42; // 只对file1.cpp可见
static void myStaticFunction() {
// ...
}
// file2.cpp
extern int myStaticGlobal; // 错误!myStaticGlobal在file2.cpp中不可见
extern void myStaticFunction(); // 错误!myStaticFunction在file2.cpp中不可见
-
-
- 静态类内部变量(C++11及以后):
-
在C++11中,可以在类内部定义静态常量整型成员,并给它一个初始值。这样的成员具有内部链接性,并且其值在编译时就是已知的。
class MyClass {
public:
static const int kConstant = 42; // C++11及以后允许的静态常量整型成员
};
int main() {
int a = MyClass::kConstant; // 正确,kConstant的值是42
}
需要注意的是,虽然static
关键字在C++中有多种用途,但它不应该被随意使用。过度使用static
可能会导致代码难以理解和维护,特别是当它在不同上下文中改变变量的行为时。因此,在使用static
时应该仔细考虑其影响,并确保它符合代码的设计目标和可读性要求。
函数重载
听说过mangling么?
C++函数重载底层原理是基于编译器的 name mangling
机制。
编译器需要为C++中的所有函数,在符号表中生成唯一的标识符,来区分不同的函数。而对于同名不同参的函数,编译器在进行name mangling
操作时,会通过函数名和其参数类型生成唯一标识符,来支持函数重载。
注意:name mangling
后得到的函数标识符与返回值类型是无关的,因此函数重载与返回值类型无关。
比如,下面的几个同名函数func
:
int func(int i) { return 0; }
float func(int i, float f) { return i + f; }
double func(int i, double d) { return i+d; }
在经过编译中的name mangling
操作后,得到的符号表中和func
有关的如下:
$ g++ main.cc -o main.o && objdump -t main.o
main.o: file format elf64-x86-64
SYMBOL TABLE:
0000000000001157 g F .text 000000000000001c _Z4funcid
000000000000113b g F .text 000000000000001c _Z4funcif
0000000000001129 g F .text 0000000000000012 _Z4funci
0000000000001173 g F .text 0000000000000016 main
...
其中, 前缀 _z
是GCC的规定,4
是函数名func
的字符个数,i
表示第一个函数的参数类型int
,f
是第二个函数的参数类型float
,而d
表示参数类型是double
。经过 name mangling
后可以发现,函数重载与返回值类型无关,仅与函数名和函数参数类型相关。
注意:不必过于关注name mangling
本身,只需要知道name mangling
这个机制是一套命名规则,为每个函数生成唯一的标识符即可,不必研究规则本身,是怎么命名的,每个单词是啥缩写,大致了解即可,不同的编译器规则都可能不同。
相信,到此,你应该明白了函数重载怎么回事:只依赖于函数名及其参数类型,与返回值类型无关!!!
成员函数指针
问:成员函数指针了解么?可以转换为Void*么?为什么?
成员函数指针是指向成员函数的指针。由于成员函数指针在调用时需要提供一个对象的地址,因此其类型与普通函数指针不同。在C++中,使用成员函数指针时需要使用指向对象的指针或引用。
成员函数指针可以转换为void*,但是这个转换是不安全的。void是一种通用指针类型,可以指向任意类型的数据,而成员函数指针是一种特定类型的指针,其指向的是特定的成员函数。因此,将成员函数指针转换为void可能会导致类型错误或访问违规。
在进行指针类型转换时,应该尽量避免使用void*,而是使用更具体的类型来保证类型安全。如果需要将成员函数指针作为参数传递或返回值,可以考虑使用函数模板或std::function来封装成员函数指针。
编译过程
问:描述一下C/C++代码的编译过程?
C/C++代码的编译过程是一个相对复杂的过程,涉及多个阶段。以下是C/C++代码编译过程的主要步骤:
-
-
- 预处理(Preprocessing)
:
- 编译器首先读取源代码文件(如
.c
或.cpp
文件)。 - 预处理器(preprocessor)处理源代码中的预处理指令,如
#include
、#define
等。 #include
指令告诉预处理器将另一个文件的内容包含在当前文件中。这通常用于包含标准库或用户自定义的头文件。#define
指令定义宏,预处理器会在编译前替换所有宏定义。- 预处理后的代码通常保存为一个新的文件,通常以
.i
或.ii
作为文件扩展名。
- 编译器首先读取源代码文件(如
- 编译(Compilation)
:
- 经过预处理后的代码进入编译阶段。
- 编译器将预处理后的代码转换为汇编语言代码。这一步骤涉及到词法分析、语法分析、语义分析、中间代码生成和目标代码生成等子步骤。
- 词法分析将源代码分解为一系列的标记(tokens)。
- 语法分析检查这些标记是否符合语言的语法规则,并构建抽象语法树(AST)。
- 语义分析检查AST的语义,确保它们是有效的。
- 中间代码生成将AST转换为中间表示形式(IR)。
- 目标代码生成将IR转换为特定于平台的汇编语言代码。
- 编译后的汇编代码通常保存为
.s
文件。
- 汇编(Assembly)
:
- 汇编器(assembler)将汇编代码转换为机器代码(也称为目标代码)。
- 这一步骤将汇编指令转换为机器可以执行的二进制指令。
- 汇编后的机器代码通常保存为
.o
或.obj
文件,称为目标文件。
- 链接(Linking)
:
- 链接器(linker)将多个目标文件以及必要的库文件组合成一个可执行文件或共享库。
- 如果代码中有对其他函数或变量的引用(例如,来自标准库或其他用户定义的文件),链接器会找到这些引用的定义,并将它们包含在最终的可执行文件中。
- 如果找不到某些引用的定义,链接器会报错。
- 链接完成后,生成一个可执行文件(如
.out
、.exe
)或共享库(如.so
、.dll
)。
- 加载和执行(Loading and Execution)
:
- 当用户运行可执行文件时,操作系统会将其加载到内存中,并设置必要的执行环境。
- CPU开始执行程序,从
main
函数开始。
- 预处理(Preprocessing)
-
问:了解静态库与动态库么?说说静态链接与动态链接的实现思路
静态链接与动态链接
静态链接:
-
-
-
- 静态链接是指将静态库中的代码直接链接到目标程序中,生成一个完整的可执行文件。
- 静态链接的实现思路是在编译时,将静态库中的代码和目标程序的代码一起打包成一个可执行文件。
- 这种链接方式生成的程序在运行时不依赖外部库文件,但是体积较大。
-
-
动态链接:
-
-
-
- 动态链接是指在程序运行时,将动态库中的代码加载到内存中,并与目标程序的代码进行链接。
- 动态链接的实现思路是在编译时,只在目标程序中记录下所需动态库的名称和位置,并不实际包含库中的代码。程序在运行时,操作系统会根据这些记录去查找和加载动态库。
- 这种链接方式生成的程序体积较小,但需要依赖外部库文件。
-
-
静态链接:编译器和汇编器将多个文件(模块)生成多个可重定位的目标文件,静态链接器在链接时将多个可重定位目标文件链接成可执行的文件(exe,.out文件 ELF格式)
静态库文件可以在静态链接时和其他可重定位目标文件一同链接成可执行目标文件。
动态链接:动态链接器需要在链接时先通过静态连接器传入一些重定位和符号信息,后续在可执行文件加载或者运行的时候先加载动态链接器(.interp节中和包含动态连接器的路径,他本身就是一个共享库),随后根据重定位等信息将目标动态库文件加载到内存中。
静态库:
-
-
-
- 静态库在程序编译时就会被链接到目标程序中,成为目标程序的一部分。
- 静态库中的代码在程序运行时会被完全加载到内存中。
- 静态库文件通常具有
.a
(在Unix-like系统)或.lib
(在Windows)的扩展名。 - 优点:不需要在运行时查找库文件,程序运行稳定,不会受到库文件更新的影响。
- 缺点:生成的程序体积较大,如果多个程序都使用了同一个静态库,那么每个程序都会包含一份库代码,造成空间浪费。
-
-
动态库:
-
-
-
- 动态库在程序运行时才被加载,而不是在编译时。
- 动态库中的代码只有在程序需要时才会被加载到内存中。
- 动态库文件通常具有
.so
(在Unix-like系统)或.dll
(在Windows)的扩展名。 - 优点:程序体积较小,多个程序可以共享同一个动态库,节省空间。库文件更新后,所有使用该库的程序都能立即享受到更新带来的好处。
- 缺点:运行时需要查找库文件,如果库文件缺失或版本不匹配,程序可能无法正常运行。
-
-
问:知道内部链接与外部链接么?
在编译原理中,内部链接和外部链接主要涉及到程序中的符号或定义在链接过程中的可见性和可访问性。
内部链接:
内部链接意味着一个定义或符号的访问受到当前编译单元的限制。也就是说,这个定义或符号对于任何其他编译单元都是不可见的。在链接过程中,其他编译单元无法链接到这个定义或符号。这通常适用于静态(static)全局变量的定义、静态自由函数的定义、静态友元函数的定义、类的定义、内联函数定义、Union共同体定义、const常量定义、枚举类型定义等。内部链接的一个典型例子是类的定义,因为类的描述通常是一个定义,并且它只在定义它的编译单元内部可见。
外部链接:
外部链接则是指一个定义或符号在链接时可以被其他编译单元访问。也就是说,这个定义或符号不是局部的,它可以和别的编译单元交互。如果一个名称在连接时可以和其他的编译单元交互,则这个名称具有外部连接。这通常用于全局变量或函数的定义,它们在整个程序中都是可见的,可以被其他编译单元引用。
链接器在链接过程中,会根据这些链接属性来确定如何将各个编译单元中的符号链接起来,生成最终的可执行文件。内部链接的符号在链接时不会被导出到其他编译单元,而外部链接的符号则会被导出,以便其他编译单元可以引用它们。
extern和static
问:extern与static
在C语言中,extern
和static
都是非常重要的关键字,它们与变量的链接性和可见性紧密相关。
extern
extern
关键字用于声明全局变量或函数的外部链接。其主要作用是扩展变量和函数的可见性。具体来说,当你在一个源文件中声明一个全局变量或函数为extern
时,你实际上是在告诉编译器:“这个变量或函数是在其他地方定义的,我要在这里引用它”。这样,编译器就知道在链接时需要查找这个变量或函数的定义。
例如,假设你有两个源文件:main.c
和global.c
。如果你在global.c
中定义了一个全局变量globalVar
,然后在main.c
中使用extern
来声明它,那么编译器就知道globalVar
在global.c
中定义,并在链接时将其链接到main.c
。
同样的,你也可以用extern
来声明外部函数。这意味着你可以在一个文件中定义函数,然后在其他文件中声明并使用它。
需要注意的是,extern
声明并不是定义,也就是说,它并不分配存储空间。它只是告诉编译器这个变量或函数在其他地方定义,并需要链接。
static
static
关键字在C语言中有多种用法,它可以用来修饰局部变量、全局变量和函数。
-
-
-
- 当
static
修饰局部变量时,该变量的生命周期被延长至整个程序的执行期间,但其可见性仍限于定义它的代码块。这意味着该变量只会在首次进入其作用域时被初始化一次。 - 当
static
修饰全局变量时,该变量的可见性被限制在定义它的源文件中。也就是说,这个全局变量只能被本文件中的函数访问,其他源文件无法直接访问它。即使你在其他源文件中使用extern
来声明它,编译器也会报错,因为它在其他文件中是不可见的。 - 当
static
修饰函数时,情况与修饰全局变量类似,即该函数的作用域被限制在定义它的源文件中。
- 当
-
-
delegate
问:delegate是什么?实现思路?与event的区别?
代理简单来说就是让对象B去代理A执行A本身的操作,本质上就是通过指向其他成员函数或者全局函数的函数指针去代理执行。而函数指针有两种,成员函数指针与普通的函数指针,我们一般就是通过对这两种指针的封装来实现代理的效果。常见的实现方式有两种,一种是通过多态接口,另一种是通过宏。代理也分为单播代理与多播代理,单播就是一个调用只代理执行一个函数功能,多播代理就是一个调用可以绑定多个代理函数,可以触发多个代理的函数操作。
Event是一种特殊的多播delegate,只有声明事件的类可以调用事件的触发操作。最常见的也容易理解的就是MFC里面的按钮的鼠标点击事件了,他的调用只能在Button里面去执行。
Template
问:使用过模板么?了解哪些特性?
模板分为函数模板与类模板,其根本目的是将类型“参数化”,实现编译时的“动态化”,避免重复代码的书写。另一种运行时的“动态化”就是多态。
模板使用常见的特性有“特化”,“偏特化”,“非类型模板参数”,“设置模板参数默认类型”,“模板中的typename的使用”,“双重模板参数Template Template Parameters”,“成员模板Member Template”,理解这些内容我们就基本上可以看STL标准库了。
当然可以,以下是关于这些模板特性的具体例子:
-
-
-
- 特化 (Specialization)
-
-
完全特化:
假设我们有一个通用的模板类template<typename T> class MyArray
,但有时我们想为int
类型提供特定的实现。
template<typename T>
class MyArray {
// 通用实现
};
template<>
class MyArray<int> {
// 专为int类型实现的版本
};
部分特化:
当我们想对模板参数中的某些类型进行特化时,可以使用部分特化。
template<typename T1, typename T2>
class MyPair {
// 通用实现
};
template<typename T>
class MyPair<T*, int> {
// 专为T*和int组合的特化版本
};
-
-
-
- 非类型模板参数 (Non-Type Template Parameters)
-
-
template<typename T, int N>
class StaticArray {
public:
T array[N];
void fill(T value) {
for(int i = 0; i < N; ++i) {
array[i] = value;
}
}
};
int main() {
StaticArray<int, 10> arr;
arr.fill(5);
// ...
}
-
-
-
- 设置模板参数默认类型 (Default Template Parameters)
-
-
template<typename T = int>
class DefaultTypeClass {
T value;
// ...
};
int main() {
DefaultTypeClass<> obj1; // T默认为int
DefaultTypeClass<double> obj2; // 明确指定T为double
// ...
}
-
-
-
- 模板中的typename的使用
-
-
template<typename T>
class MyClass {
typename T::NestedType nestedObj; // 使用typename表明NestedType是一个类型
// ...
};
-
-
-
- 双重模板参数 (Template Template Parameters)
-
-
template<template<typename> class Container, typename T>
class Wrapper {
Container<T> data;
// ...
};
int main() {
Wrapper<std::vector, int> wrapper; // 使用std::vector<int>作为内部容器
// ...
}
-
-
-
- 成员模板 (Member Templates)
-
-
class MyClass {
public:
template<typename T>
void setValue(T value) {
// 基于T类型的处理
}
// ...
};
int main() {
MyClass obj;
obj.setValue(5); // 调用setValue<int>(int)
obj.setValue(3.14); // 调用setValue<double>(double)
// ...
}
问:模板代码如何组织?模板的编译(以及实例化)过程
一般来说,模板类的声明与定义不像普通类那样拆分成.h和cpp,而是要全部放在头文件里面(或者定义放在使用到模板的.cpp里),否则会发生编译错误。为什么?因为模板函数所在的cpp不能直接编译成相应的二进制代码,他并不知道模板参数是什么,所以需要一个“实例化”的过程。简单来说,C++标准规定,如果一个cpp里面没有任何显示调用过模板函数(或者使用类模板)的语句,就不会生成真正的拥有确切类型的类的定义,进而就不会生成任何二进制代码,所以其他cpp也无法链接到只包含定义的.cpp文件。下面的例子就会编译报错,除了把template.cpp放到头文件里面,这里放到main.cpp也是可以的
//-------------template.h----------------//
template<typename T>
class TemTest
{
public:
void TestFun();
};
//------------template.cpp-------------//
#include “template.h”
template<class T>
void TemTest <T>::TestFun() //定义,但是不会生成二进制文件
{
.....
}
//---------------main.cpp---------------//
#include “template.h”
int main()
{
TemTest<int> t;
t. TestFun();
}
你的解释非常准确,关于模板类和模板函数的编译和链接过程,确实与普通的类和函数有所不同。下面我将详细解释原因:
模板的编译特性
在C++中,模板类和模板函数并不是真正的代码,而是代码生成器。当你编写一个模板时,你实际上是在告诉编译器如何根据提供的类型参数来生成具体的类或函数。然而,在编译模板定义的文件时(例如.cpp
文件),编译器并不会生成任何实际的代码,因为它并不知道将会用哪些类型来实例化这个模板。
实例化过程
只有当模板在其他地方被实例化(即,使用特定的类型参数来创建模板的实例)时,编译器才会根据这些类型参数来生成具体的代码。这个实例化过程通常发生在包含模板定义的头文件被其他源文件包含,并且这些源文件试图使用特定类型的模板实例时。
分割声明和定义的问题
如果你将模板的声明放在头文件中,而将定义放在.cpp
文件中,那么当你尝试在其他源文件中使用这个模板时,就会遇到问题。因为当编译器编译这个源文件时,它只能看到模板的声明,而看不到定义。由于定义不在同一个编译单元中,编译器就无法生成特定类型的模板实例代码。链接器也无法链接到这些代码,因为它根本不存在。
你的例子
在你的例子中,template.cpp
中的模板成员函数定义不会被编译成二进制代码,因为它是一个模板定义,而不是一个具体的函数定义。只有当main.cpp
中包含template.h
并尝试使用TemTest<int>
时,编译器才会根据int
类型来实例化TemTest
类及其成员函数TestFun
。
如果你将template.cpp
中的定义移到template.h
中,或者直接在main.cpp
中定义TestFun
,那么当你编译main.cpp
时,编译器就能够看到完整的模板定义,并生成TemTest<int>
的实例代码。
转发构造
问:听说过转发构造么?
std::move
就是左值变右值节省空间,std::forward
就是左值依旧为左值,右值依旧为右值防止隐式转换
std::move和std::forward这两个API主要服务于左值引用和右值引用的转化和转发,因此再了解这两个API之前,需要先弄清楚这几个概念。
-
-
-
- 左值:一般指的是在内存中有对应的存储单元的值,最常见的就是程序中创建的变量
- 右值:和左值相反,一般指的是没有对应存储单元的值(寄存器中的立即数,中间结果等),例如一个常量,或者表达式计算的临时变量
-
-
int x = 10
int y = 20
int z = x + y
//x, y , z 是左值
//10 , 20,x + y 是右值,因为它们在完成赋值操作后即消失,没有占用任何资源
-
-
-
- 左值引用:C++中采用 &对变量进行引用,这种常规的引用就是左值引用
- 右值引用:这个概念实际上不是说对上述的右值进行引用(因为右值本身也没有对应的存储单元),右值引用实际上只是一个逻辑上的概念,最大的作用就是让一个左值达到类似右值的效果(下面程序举例),让变量之间的转移更符合“语义上的转移”,以减少转移之间多次拷贝的开销。右值引用符号是&&。
-
-
例如,对于以下程序,我们要将字符串放到vector中,且我们后续的代码中不再用到x:
std::vector<std::string> vec;
std::string x = "abcd";
vec.push_back(x);
std::cout<<"x: "<<x<<"\n";
std::cout<<"vector: "<< vec[0]<<"\n";
//-------------output------------------
// x: abcd
// vector: abcd
该程序在真正执行的过程中,实际上是复制了一份字符串x,将其放在vector中,这其中多了一个拷贝的开销和内存上的开销。但如果x以及没有作用了,我们希望做到的是 真正的转移,即x指向的字符串移动到vector中,不需要额外的内存开销和拷贝开销。因此我们希望让变量 x传入到push_back 表现的像一个右值 ,这个时候就体现右值引用的作用,只需要将x
的右值引用传入就可以。
std::move
前面提到了右值引用的主要作用是减少不必要的拷贝开销和内存开销。而std::move的作用就是进行无条件转化,任何的左值/右值通过std::move都转化为右值引用。将上面的程序改写成右值引用的方式
std::vector<std::string> vec;
std::string x = "abcd";
vec.push_back(std::move(x));
std::cout<<"x: "<<x<<"\n";
std::cout<<"vector: "<< vec[0]<<"\n";
//-------------output------------------
// x:
// vector: abcd
可以看到,完成push_back
后x
是空的。
std::forward
std::forward的作用是完美转发,如果传递的是左值转发的就是左值引用,传递的是右值转发的就是右值引用。
在具体介绍std::forward之前,需要先了解C++的引用折叠规则,对于一个值引用的引用最终都会被折叠成左值引用或者右值引用。
-
-
-
- T& & -> T& (对左值引用的左值引用是左值引用)
- T& && -> T& (对左值引用的右值引用是左值引用)
- T&& & ->T& (对右值引用的左值引用是左值引用)
- T&& && ->T&& (对右值引用的右值引用是右值引用)
-
-
只有对于右值引用的右值引用折叠完还是右值引用,其他都会被折叠成左值引用,根据折叠规则,可以构造出一个通用引用。
#include<iostream>
template <typename T>
void foo(T&& param){
if(std::is_rvalue_reference<decltype(param)>::value)
std::cout<<"rvalue reference\n";
else std::cout<<"lvalue reference\n";
}
int main(){
int a = 0;
foo(a);
foo(std::move(a));
return 0;
}
//------------output----------
// lvalue reference
// rvalue reference
-
-
-
foo(a)
,T就是int &,则param的类型为T&&->int & &&->int &foo(std::move(a))
,std::move转成右值引用,那么T就是int&&,则param的类型为T &&->int && &&->int &&
-
-
前面提到的std::move可以减少不必要的拷贝开销,可以提高程序的效率,但是std::forward的作用是转发,左值引用转发成左值引用,右值引用还是右值引用,刚开始一直想不通这个API的意义到底是什么?
原来是在程序的执行过程中,对于引用的传递实际上会有额外的隐式的转化,一个右值引用参数经过函数的调用转发可能会转化成左值引用,但这就不是我们希望看到的结果。
在上面的程序上进行修改
#include <iostream>
#include <vector>
#include <string>
class A {
public:
A(){}
A(size_t size): size(size), array((int*) malloc(size)) {
std::cout
<< "create Array,memory at: "
<< array << std::endl;
}
~A() {
free(array);
}
A(A &&a) : array(a.array), size(a.size) {
a.array = nullptr;
std::cout
<< "Array moved, memory at: "
<< array
<< std::endl;
}
A(A &a) : size(a.size) {
array = (int*) malloc(a.size);
for(int i = 0;i < a.size;i++)
array[i] = a.array[i];
std::cout
<< "Array copied, memory at: "
<< array << std::endl;
}
size_t size;
int *array;
};
template<typename T>
void warp(T&& param) {
if(std::is_rvalue_reference<decltype(param)>::value){
std::cout<<"param is rvalue reference\n";
}
else std::cout<<"param is lvalue reference\n";
A y = A(param); //这里param被当作左值处理
A z = A(std::forward<T>(param));
}
int main(){
A a = A(100);
warp(std::move(a));
return 0;
}
//----------------output----------------
// create Array,memory at: 0x600002e60000 //main函数中,A a = A(100);调用构造函数
// param is rvalue reference //使用了std::move,根据引用折叠规则,param是一个右值引用
// Array copied, memory at: 0x600002e60070 // A y = A(param); 可以看到调用的是拷贝的构造函数
// Array moved, memory at: 0x600002e60000。// A z = A(std::forward<T>(param)); 调用了移动构造函数
从程序的输出就可以看到,当一个右值引用再进行转发的时候,没使用std::forward进行二次转发的时候,实际上是会被隐式的转换,转发成一个左值引用,从而调用不符合期待的构造函数,带来额外的开销,所以std::forward的一个重要作用就是完美转发,确保转发过程中引用的类型不发生任何改变,左值引用转发后一定还是左值引用,右值引用转发后一定还是右值引用!
函数调用1
问:描述一下函数调用过程中栈的变化
栈帧(stack frame):机器用栈来传递过程参数,存储返回信息,保存寄存器用于以后恢复,以及本地存储。为单个过程(函数调用)分配的那部分栈称为栈帧。栈帧其实是两个指针寄存器,寄存器ebp为帧指针(指向该栈帧的最底部),而寄存器esp为栈指针(指向该栈帧的最顶部)。
然后我们再简单描述一下函数调用的机制,每个函数有自己的函数调用地址,里面会有各种指令操作(这端内存位于“代码段”部分),函数的参数与局部变量会被创建并压缩到“栈”的里面,并由两个指针分别指向当前帧栈顶和帧栈尾。当进入另一个子函数时候,当前函数的相关数据会被保存到栈里面,并压入当前的返回地址。子函数执行时也会有自己的“栈帧”,这个过程中会调用CPU的寄存机进行计算,计算后再弹出“栈帧”相关数据,通过“栈”里面之前保存的返回地址再回到原来的位置执行前面的函数。
首先,什么是栈帧?引用百度百科:C语言中,每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量。从这句话中,可以提炼以下几点信息:
-
-
-
- 栈帧是一块因函数运行而临时开辟的空间。
- 每调用一次函数便会创建一个独立栈帧。
- 栈帧中存放的是函数中的必要信息,如局部变量、函数传参、返回值等。
- 当函数运行完毕栈帧将会销毁。
-
-
常用寄存器及简单汇编指令
寄存器 | 用途 |
---|---|
EAX | 累加寄存器:用于乘除法、函数返回值 |
EBX | 用于存放内存数据指针 |
ECX | 计数器 |
EDX | 用于乘除法、IO指针 |
ESI | 源索引寄存器,存放源字符串指针 |
EDI | 目标索引寄存器,存放目标字符串指针EAX |
ESP | 存放栈顶指针 |
EBP | 存放栈底指针 |
汇编指令 | 用途 |
---|---|
mov | mov A,B 将数据B移动到A |
push | 压栈 |
pop | 出栈 |
call | 函数调用 |
add | 加法 |
sub | 减法 |
rep | 重复 |
lea | 加载有效地址 |
main函数栈帧创建动态演示 
008B1410 push ebp
008B1411 mov ebp,esp
008B1413 sub esp,0E4h
008B1419 push ebx
008B141A push esi
008B141B push edi
008B141C lea edi,[ebp-0E4h]
008B1422 mov ecx,39h
008B1427 mov eax,0CCCCCCCCh
008B142C rep stos dword ptr es:[edi] //dword 为 4个字节
-
-
-
- 在__tmainCRTStartup()函数顶部压入ebp,如图所示esp指向ebp,ebp成功压入栈中。
- esp值传递给ebp。
- esp减去0E4h:由于栈先使用高地址后使用低地址,减去一个值意味着esp指针向低地址移动了0E4h个地址,此处便开辟了main函数的栈帧。
- 压入ebx,esp指向ebx顶部。
- 压入esi,esp指向esi顶部。
- 压入edi,esp指向edi顶部。
- 将edi向下39h个空间全部改为0xCCCCCCCC。
-
-
局部变量创建
int a = 10;
00AA142E mov dword ptr [ebp-8],0Ah
int b = 20;
00AA1435 mov dword ptr [ebp-14h],14h
int ret = 0;
00AA143C mov dword ptr [ebp-20h],0
-
-
-
- 将十六进制整数:0Ah(DEC 10)放入ebp 向低地址移动8个字节。
- 将十六进制整数:14h(DEC 20)放入ebp 向低地址移动20个字节。
- 将十六进制整数:0(DEC 0)放入ebp 向低地址移动32个字节。
-
-
函数传参和调用
ret = Add(a, b);
00AA1443 mov eax,dword ptr [ebp-14h]
00AA1446 push eax
00AA1447 mov ecx,dword ptr [ebp-8]
00AA144A push ecx
00AA144B call 00AA10E1
00AA1450 add esp,8
00AA1453 mov dword ptr [ebp-20h],eax
从以上汇编代码可知函数是先传参后调用,函数传参顺序是从右往左。
-
-
-
- ebp – 14h 的地址传给eax,即eax中实际存放了20。
- eax 压栈。
- ebp – 8 的地址传给ecx,即ecx中实际存放了10。
- ecx 压栈。
-
-
在函数栈帧、局部变量创建完毕后,进行Add()函数运算过程:
c = a + b;
00AA13E5 mov eax,dword ptr [ebp+8]
00AA13E8 add eax,dword ptr [ebp+0Ch]
00AA13EB mov dword ptr [ebp-8],eax
-
-
-
- 将(ebp + 8)的值传递给eax,此时的ebp存放Add函数的栈底指针,(ebp + 8) 的位置即函数传参时创建的ecx的地址,其内部存放的正是10。
- eax寄存器中执行求和指令,加上(ebp + 0ch) 中的值,同理可以得知(ebp + 0ch)中的值是20。
- 将eax的经过求和的结果,传递到(ebp – 8)的位置 。
-
-
函数返回
return c;
00AA13EE mov eax,dword ptr [ebp-8]
将返回值传递至寄存器eax中,因此在函数调用结束函数栈帧被销毁时,返回值并不会销毁。在函数拿到返回值后,开始出栈:
00AA13F1 pop edi
00AA13F2 pop esi
00AA13F3 pop ebx
00AA13F4 mov esp,ebp
00AA13F6 pop ebp
00AA13F7 ret
从低位置到高位置依次弹出edi,esi,ebx,随后将ebp赋给esp并弹出ebp,最后执行ret指令返回到调用Add函数的call指令的下一地址,在执行ret指令时实际已弹出After call,以执行指令 add esp,8,此时esp向高地址移动8字节,esp,ebp重新维护main函数,eax中存放的返回值将被传递给地址(ebp – 20h)即ret的地址。至此,Add函数返回完毕。main函数栈帧销毁过程与前述过程类似。
函数调用2
问:cdecl/stdcall是什么意思
__cdecl
和 __stdcall
是两种常见的调用约定(Calling Conventions)在 x86 架构的 Windows 平台上。这些调用约定定义了函数如何接收参数、返回结果以及如何清理栈。下面是它们之间的主要区别:
__cdecl
__cdecl
(C declaration)是 C 语言默认的调用约定。它的特点如下:
-
-
-
- 参数从右到左入栈。
- 调用者负责清理栈(即将参数从栈中弹出)。
- 返回值通常放在 EAX 寄存器中(对于整数)或者通过指针参数返回(对于大型数据结构)。
- 可以支持可变数量的参数(如
printf
函数)。
-
-
由于调用者负责清理栈,__cdecl
在函数返回后,栈指针会指向调用者之前的状态,因此它提供了很大的灵活性,但也可能导致栈的不平衡,如果函数和调用者之间的约定不一致。
__stdcall
__stdcall
(Standard Call)是 Windows API 函数经常使用的调用约定。它的特点如下:
-
-
-
- 参数从右到左入栈。
- 被调用者(即函数本身)负责清理栈。
- 返回值通常放在 EAX 寄存器中(对于整数)。
- 不支持可变数量的参数。
-
-
由于被调用者负责清理栈,__stdcall
保证了栈的平衡,即使在发生异常的情况下。这使得它非常适合于编写库函数和 API,因为这些函数通常会被许多不同的调用者使用,而调用者不需要关心栈的清理。
选择哪种调用约定?
-
-
-
- 如果你正在编写一个库或 API,并且想要确保栈的平衡和减少调用者的负担,那么
__stdcall
可能是更好的选择。 - 如果你正在编写一个应用程序,并且需要更大的灵活性(例如,支持可变数量的参数),那么
__cdecl
可能更适合你。
- 如果你正在编写一个库或 API,并且想要确保栈的平衡和减少调用者的负担,那么
-
-
在编写跨平台或可移植代码时,还需要考虑其他平台和编译器可能不支持这些特定的调用约定。在这种情况下,使用标准的 C 或 C++ 调用约定(通常等同于 __cdecl
)可能是更好的选择。
四种Cast
问:C++中四种Cast的使用场景是什么?
1. static_cast
-
-
-
- 功能:用于基础数据类型之间的转换(如int转double),执行非多态类型的上下转换(upcasting和downcasting),以及执行空指针和空指针类型的转换。
- 安全性:通常认为是安全的,因为它在编译时会检查转换的合法性。
- 使用场景:当你需要在基础数据类型之间转换,或者需要在具有继承关系的类之间转换时(只要这种转换是安全的)。
-
-
2. dynamic_cast
-
-
-
- 功能:主要用于执行安全的向下和侧向类型转换,会在运行时检查转换是否有效。
- 安全性:是最安全的转换,因为它会在运行时进行类型检查。
- 使用场景:当你需要在运行时确定一个对象是否属于特定的类,并需要安全地执行向下或侧向转换时。
-
-
3. reinterpret_cast
-
-
-
- 功能:提供最低级别的类型转换,只是简单地重新解释给定的位模式。
- 安全性:是最不安全的转换,因为它不会进行任何类型检查或调整。
- 使用场景:在与硬件直接交互、处理低级数据结构或需要执行非常规的转换时使用。
-
-
4. const_cast
-
-
-
- 功能:用于添加或删除类型的const或volatile限定符,从而允许修改本应为const的对象。
- 安全性:使用时需要谨慎,因为它可能破坏数据的完整性。
- 使用场景:当你确实需要修改一个本应为const的对象时(尽管这通常是不推荐的)。
-
-
设计模式
问:用过或很熟悉的设计模式有哪些?
编码
问:编码了解么?unicode和utf-8的区别
编码是将信息、数据等按照一定规则转换成特定的符号或代码,以便于传输、存储和处理。它是计算机科学、通信技术、信息安全等领域中的一项重要技术。编码的主要目的是将复杂的信息转换为计算机可以处理的简单的数字编码,从而使信息更加高效地传输和存储,同时保护信息的安全性和完整性。
Unicode和UTF-8都是与编码相关的概念,但它们之间存在明显的区别:
-
-
-
- 定义与用途:
- Unicode:是一种字符集,为全球范围内的每个字符分配一个唯一的数字代码。这意味着它可以表示世界上大部分的字符。
- UTF-8:是一种对Unicode字符集进行编码的方式,它允许使用可变长度的字节来表示字符。
-
- 编码方式:
- Unicode:可以通过多种编码方式来实现,例如UTF-8和UTF-16。
- UTF-8:是Unicode的一种具体编码实现。
-
- 字符范围与大小:
- Unicode:字符大小固定或可变,具体取决于其编码方式。
- UTF-8:字符的大小是可变的。例如,英文字符通常使用1个字节表示,而更复杂的字符可能需要更多字节。
-
- 兼容性:
- UTF-8:兼容ASCII编码,因此它常被用于网页和文件存储。
- Unicode:作为一个广泛接受的标准,它可以表示大量的字符,适用于需要统一和国际化的字符处理场景。
-
- 应用场景:
- UTF-8:由于其可变长度和与ASCII的兼容性,它通常更节省存储空间,因此适用于需要节省存储空间并保持兼容性的场合。
- Unicode:其广泛的应用范围使其适用于各种需要统一字符处理的场景。
-
-
怎么突然开始卷了??(╯‵□′)╯︵┴─┴
因为爱情❤️
别这么卷,害怕了😰
嘻嘻 就卷就卷ヾ(≧∇≦*)ゝ