top of page
作家相片Lingheng Tao

C++ Programming #5 Functions

已更新:8月25日


这篇笔记主要是关于 C++ 中常见的函数以及相关的知识点。

基本知识


要使用一个 C++ 函数,必须完成以下三个工作:

  • 提供函数的定义(Definition)

  • 提供函数的原型(Prototype)

  • 调用该函数(Reference / Call)


定义函数


没有返回值的函数被称为 void 函数,可以没有return,所以有时也会被称为过程或者子程序。有返回值的函数则必须要有 return。

void FunctionName (paramList) {
    statements;
    return; // optional
}

int FunctionNameAnother (paramList) {
    statements;
    return 0; // must return an int
}

参数列表 paramList 中,每个参数都必须要指明其类型。


通常,函数通过将返回值复制到指定的 CPU 寄存器(Register)或内存单元中来将其返回。


函数原型


C++ 要求给函数提供它的原型。因此,我们要了解这背后的原因,以及如何提供原型。


原因


原型将函数返回值的类型和参数的类型、数量告诉了编译器。如果让编译器在每次使用函数的时候就全文查找,可能效率不高,因为它必须从 main() 中跳出。而且函数可能根本不在文件内,C++ 允许将一个程序放在多个文件中,单独编译这些文件然后再将它们组合起来。在这种情况下,编译器在编译main() 时,可能还无权访问这些函数代码,所以我们会希望有函数原型提供这些信息。


语法


基本上,只要将函数实现的花括号部分整个删除改成分号即可。在函数原型中,参数的名字不重要,甚至可以不写。

// prototype
double Volume(int h, int w, int d);
// or double volume(int, int, int);

// definition
double Volume(int h, int w, int d) {
    return h * w * d;
}

按值传递


C++ 通常按值传递(Passed by values),意思是在调用函数时,实际传递给函数的是实参的”副本“,而不是原始数据本身。这样,传进来的参数本身不会受到函数语句的影响。


其中,实参(实际参数,argument)和形参(形式参数,parameter)是两个很重要的概念:

  1. 实际参数:指的是函数调用时传递给函数的具体值或者变量。它是函数调用时在调用方使用的实际数据。

  2. 形式参数:指的是调用函数时写在函数名后面的小括号内、作为函数输入的变量。


例如,

void add(int x, int y) { // parameter x and y
	int sum = x + y;
	std::cout << "Sum:" << sum << std::endl;
}

int main() {
	int a = 5;
	int b = 10;
	add(a,b); // arguments a and b
	return 0;
}


按值传递的影响


  1. 性能:按值传递会创建副本,如果传递的是大型对象或者结构体,副本的创建和销毁可能会导致较高的性能开销。

  2. 数据安全:按值传递的好处是原始数据不会被函数内部的操作意外修改,这在某些情况下可以提高代码的安全性和稳定性。


按引用传递


与按值传递不同,按引用传递(Passed by References)意味着函数参数是传递的实参的引用。函数内部对形参的任何修改都会直接影响形参。


语法上,在参数类型后面加上&表示按引用传递。


例如,

void modifyValue1(int &x) { x = 10; }
void modifyValue2(int x) { x = 10; }

int main() {
	int a = 5;
	int b = 5;
	modifyValue1(a); // a is now 10
	modifyValue2(b); // b is still 5
}

按指针传递


按指针传递(Passed by Pointers)意味着函数参数传递的是实参的地址。通过指针,函数可以访问和修改实参的内容。


语法上,在参数类型前加上*表示按指针传递。在调用函数时,需要传递变量的地址。


例如,

void modifyValue3(int *x) { *x = 10;}

int main() {
	int a = 5;
	modifyValue3(&a); // pass the address of a
    // a is now 10
	return 0;
}

函数指针


函数指针是 C++ 中的一种特殊指针,它可以指向函数,并且通过它可以调用函数。函数指针的概念在某些编程场景下非常有用,例如实现回调函数、动态函数调用或者函数作为参数传递等。


例如,假设有一个函数如下,

int add(int a, int b) {
	return a + b;
}

要定义一个指向这个函数的函数指针,可以这样写,

int (*funcPtr)(int, int);

其中,

  • int 是函数的返回类型

  • (*funcPtr) 是函数指针的名称,其中 * 表示它是一个指针。

  • (int, int) 是函数的参数列表。


使用函数指针


定义函数指针后,可以将一个函数的地址赋值给它,例如

funcPtr = add;

或者在定义时直接初始化:

int (*funcPtr)(int, int) = add;

然后,我们可以通过函数指针调用函数,语法类似于普通的函数调用:

int result = funcPtr(2,3);

在这个例子中,funcPtr(2,3) 实际上调用了 add(2,3), result 的值为5.


应用场景


  1. 回调函数:函数指针作为参数传递给另一个函数,实现回调机制。例如,在事件驱动编程中,可以通过函数指针指定在事件发生时要执行的回调函数。

  2. 动态函数调用:在某些情况下,可以根据条件动态选择调用哪个函数。例如,模拟菜单选项的选择时,可以使用函数指针调用不同的处理函数。

  3. 函数数组:可以定义一个函数指针数组,用于存储一组功能相似 的函数,通过索引来调用不同的函数。


注意:

  • 函数指针指向的是代码段中的函数地址,而不是数据。

  • 与普通指针类似,函数指针也可以是空指针,因此在使用函数指针之前需要确保它已经被正确初始化,避免空指针调用导致的程序崩溃。


内联函数


内联函数(inline fucntion)是 C++ 的一种特殊函数。它的主要目的是通过消除函数调用的开销来提高程序的执行效率。内联函数必须定义在头文件(.h 或 .hpp)而不是实现文件(.cpp文件)中,它们本质是机器码的直接替代。


在 C++ 中,我们可以通过 inline 关键字来声明一个内联函数。例如,

inline int add(int a, int b) {
	return a + b;
}

在这个例子中,add 函数被声明为内联函数,编译器会尝试在每次调用 add 函数时,将 add 函数的代码直接插入到调用点。


内联函数也有其优缺点。


优点:

  1. 性能:内联函数是机器码的直接替代,避免了函数调用的开销,适合简单且频繁调用的小函数。

  2. 编译优化:内联函数的代码可以与调用点的上下文更紧密地结合,提供了更多的优化机会。


缺点:

  1. 代码膨胀:如果内联函数被多次调用,每次调用都插入函数代码,可能最终导致生成的可执行文件变大,尤其是当函数体积较大时。

  2. 编译器行为不可控:即使使用了 inline 关键字,编译器也可能根据具体情况选择不内联函数,尤其是在函数体积较大、递归调用等场景下。相反,编译器也可能在没有 inline 关键字的情况下自行决定内联某些函数。

  3. 调试困难:内联函数的展开可能会使调试变得困难,因为在调试器中跟踪每个函数调用可能不再简单直接。


头文件与实现文件


上面提到,内联函数需要定义在头文件中,而不是实现文件中。头文件和实现文件在 C++ 编程中扮演着不同的角色,它们本质的区别在于代码的组织和用途。理解这两者的区别对于编写和维护 C++ 程序至关重要。


头文件(header file)主要用于声明接口,包括函数的声明、类的定义、宏定义、常量定义、模板定义等。头文件中通常不包含具体的实现细节,除了内联函数必须在头文件中提供实现。


实现文件(implementation file)包含函数和类方法的具体实现。实现文件通常与一个或多个头文件配对,用于定义头文件中声明的函数和方法。头文件通常会通过 #include 指令被包含在实现文件中,而实现文件时编译单元的主体,编译器会将其编译为目标文件。



重载


函数重载(function overload)是 C++ 中的一项重要的特性,它允许在同一个作用域内定义多个同名的函数,只要这些函数的参数列表不同。函数重载提高了代码的可读性和灵活性,使得同一个函数名可以根据不同的输入参数执行不同的操作。


为了成功重载一个函数,重载函数必须满足以下的条件之一:

  • 参数类型不同:函数的参数类型不同,例如一个函数接受 int 参数,另一个则接受 float 参数。

  • 参数数量不同:函数的参数数量不同,例如一个函数接受两个参数,另一个则接受三个参数。

  • 参数顺序不同:当参数类型不同且顺序不同,也可以实现函数重载。

需要注意的是:

  • 返回类型不参与重载决策。如果两个函数只有返回类型不同,则编译器会报错。

  • 使用默认参数时,需要小心避免歧义。如果两个函数的重载导致编译器无法确定调用哪个函数,则会引发编译错误。例如,

void display(int x, int y = 10);
void display(int x); // 错误:在调用 display(5) 时编译器无法确定使用哪个函数
  • 当两个重载函数的参数列表足够接近,可能会导致编译器无法明确选择,进而引发编译错误,例如,

void show(double d);
void show(float f);

show(5.0f); // 模糊调用,编译器可能不确定调用哪个版本

模板


函数模板(function template)是指用来创建通用函数的蓝图或者模板,这种函数可以接受不同类型的参数。使用模板时,编译器会根据传递的实际参数类型自动生成对应的函数实例。


模板使用 template 关键字定义,后跟一个模板参数列表。模板参数通常使用 typename 或者 class 关键字表示。


例如,

template <typename T>
T add(T a, T b) {
	return a + b;
}

然后,我们就可以在其它地方自动地使用这个函数。

int main() {
	int x = 5, y = 10;
	double p = 3.14, q = 2.71;
	
	cout << add(x,y) << endl; // int add(int, int)
	cout << add(p,q) << endl; // double add(double, double) 
	return 0;
}

在这里,T 是一个模板参数,可以代表任何类型。编译器在调用 add 的时候会根据传入的参数类型生成对应的函数实例。在大多数情况下, 编译器都可以自动推导出模板参数的类型,但如果有必要,你也可以显式地指定模板参数的类型:

cout << add<int>(5,10) << endl;

但是需要知道,编译器必须要能正确推导出 T 所指代的类型。例如,add(3, 2.5) 就会导致编译器报错,因为在 3 出现的时候编译器认为 T 是 int,而在处理到 2.5 时,它又发现 T 应该是 double,这种情况下编译器就会报错。当然,解决这个问题可以让模板接受多个参数,使用不同的模板参数即可:

template <typename T, typename U> 
T multiply(T a, U b) {
	return a * b;
}

int main () {
	cout << multiply(5, 2.5) << endl; 
}

特化


模板特化允许你为特定类型提供特殊的实现。通常情况下,特化是针对模板的特定实例提供不同的实现。


例如,全特化是指对模板的所有参数类型都提供特定的实现。

template<>
const char* add(const char* a, const char* b) {
	return strcat(a, b);
}

在这个例子中,add 函数针对 const char* 类型提供了一个特化版本,用于字符串拼接。


模板的优点


  • 代码复用:模板使得你可以编写一次代码,然后在多个类型上复用这些代码,而无需为每种类型单独编写函数。

  • 类型安全:与传统的 C 宏相比,模板提供了类型安全性,避免了在不同类型之间转换时可能发生的错误。

  • 简化代码:通过模板,可以避免为类似功能不同类型编写多个函数,减少冗余。


模板的局限性


  • 编译时间增加:由于模板在编译时实例化,模板的使用会导致编译时间的增加。

  • 代码膨胀:模板可能会导致代码膨胀,特别是当模板在许多不同的类型上实例化时,编译器会为每个类型生成一份代码。

  • 调试困难:模板代码的错误信息有时较为复杂, 特别是在模板嵌套和类型推导失败时,调试可能比较困难。


性能分析


函数内存占用


变量


  1. 自动变量:在函数中声明的变量是私有的。函数被调用时,计算机将为这些变量分配内存,在函数结束时,计算机将释放这些变量使用的内存。

  2. 静态变量:在函数中变量如果被声明为 static,它的内存将在全局内存区,也就是数据段或者 BSS 段中分配。这使得它在程序的整个生命周期中保持存在。

  3. 动态内存分配:在函数中如果使用了 new 或者 malloc 来分配内存,内存会在堆上分配内存块。程序员必须负责释放这部分内存, 否则就会发生内存泄漏。并没有必要在同一函数内释放在该函数内申请的内存,但是程序员必须知道哪部分的内存被申请了,接下来在其它地方要如何去释放这一部分的内存。


函数指针


函数指针本质上是指向函数代码段中某个地址的指针。函数指针本身占据的内存与其他指针相同,通常是 4 字节或者 8 字节,取决于系统架构。函数指针指向的实际代码位于代码段 (.text 段),而指针本身存储在栈、堆或者全局区,具体取决于其声明位置。


函数模板


函数模板本身不占据内存,只有在模板实例化为具体类型的函数时,才会生成对应的代码。


如果有多个不同类型的实例化,每个实例化的函数都会占用独立的内存空间。由于模板在编译时生成实际代码,编译器会为每种类型生成单独的函数代码,这些代码存储在代码段。


函数重载


函数重载本质上是定义了多个具有相同名称但参数不同的函数。 每个重载函数都是独立的,编译器会为每个重载函数生成独立的代码,这些代码同样存储在代码段。因此,函数重载会占用多个函数实例的内存空间。


函数递归


每次递归调用都会在栈上分配新的栈帧来存储函数的局部变量和返回地址。递归调用的深度越深,栈上占用的内存就越多。如果递归深度过大,可能导致栈溢出。



39 次查看0 則留言

留言


bottom of page