Skip to content

Latest commit

 

History

History
340 lines (273 loc) · 10.4 KB

l_15.md

File metadata and controls

340 lines (273 loc) · 10.4 KB

Lesson15 函数高级



1. 内联函数

看如下场景

class A {
  friend A operator+(const A&, const A&);

  int _x;
  int _y;
};

A operator+(const A& lhs, const A& rhs) {
  A new_a;

  new_a._x = lhs._x + rhs._x;
  new_a._y = lhs._y + rhs._y;

  return new_a;
}

理论上,一个比较优雅的做法是使用内联函数来完成 setget 函数

void A::x(int new_x) { _x = new_x; }
int A::x() { return _x; }

new_a._x(lhs._x + rhs._x);

由于我们首先只能在上述两个函数中对 _x 直接存取,因此也就将稍后可能会发生的成员变量的改变所带来的冲击(如在继承体系中的上移或下移)最小化了。如果把这些存取函数声明为 inline ,我们就可以继续保持直接存取members的那种高效率。此外,重载加法运算符不再需要被声明为友元。

实际上,我们并不能够强迫将任何函数都变为 inline 。只有在编译器认为内联函数的执行成本比一般的函数调用及返回机制所带来的符合低时,才会将函数变为内联(优秀的编译器甚至不需要函数声明为 inline )。

cfront有一套复杂的测试法,通常时用来计算赋值,函数调用,虚函数调用等操作的次数,每个表达式种类有一个权值,而内联函数的复杂度就以这些操作的总和来决定。

一般而言,处理一个内联函数,有两个阶段:

  1. 分析函数定义,以决定函数的 本质内联能力 (intrinsic inline ability) ,如果函数因其复杂度或构建问题被判定为不可内联,它会被转化为一个 static 函数,并在该编译单元内产生对应的函数定义。
  2. 真正的内联函数拓展操作在调用时发生,这会带来参数求值操作和临时对象的管理。

然而,不同的编译器的决议方式不同,函数到底有没有发生内联,必须通过反汇编来查看。

形式参数

内联拓展期间,到底发生的什么事呢?

每一个形参都会被实参取代。但是其实并不是简单的一一替换实参。如果实参是一个常量表达式,我们需要在替换前完成其求值操作并由一个临时变量接收。

举个例子,我们有如下内联函数:

inline int min(int i, int j) {
  return i < j ? i : j;
}

通过三种调用方式

inline int bar() {
  int minVal;
  int val1 = 10;
  int val2 = 20;

  // 1
  minVal = min(val1, val2);

  // 2
  minVal = min(10, 20);

  // 3
  minVal = min(foo(), bar() + 1);

  return minVal;
}

1 会被拓展为

minVal = val1 < val2 ? val1 : val2;

2 会被拓展为

minVal = 10;

3 如果直接带入会引发多次函数求值,它需要一个临时对象接管常量表达式的值,即拓展为

int t1, t2;

minVal = (t1 = foo()), 
         (t2 = bar() +1),
         t1 < t2 ? t1 : t2;

局部变量

如果我们在 inline 函数定义中加入一个局部变量,会怎么样?

inline int min(int i, int j) {
  int minVal = i < j ? i : j;
  return minVal;
}

int main() {
  int l_var;
  int minVal;

  ...
  minVal = min(val1, val2);
}

内联展开后,为了维护其局部变量,可能会变成这个样子

int l_var;
int minVal;

// 将内联函数的局部变量mangling
int __min_lv_minVal;
minVal = 
  (__min_lv_minVal = val1 < val2 ? val1 : val2),
  __min_val_minVal;

一般而言,内联函数的每一个局部变量都必须被放在函数调用的一个封闭区段内,拥有一个独一无二的名称。

参数的副作用

inline 函数对于封装提供了一种必要的支持,可以有效存取封装于类中的nonpublic数据。它同时也是C中大量使用宏 #define 的一个安全替代品。然而一个内联函数如果被调用太多次的话,会产生大量拓展代码,使程序大小暴涨。

例如:

minVal = min(val1, val2) + min(foo(), foo() + 1);

可能被拓展为

// mangling
int __min_lv_minVal_00;
int __min_lv_minVal_01;

// tmp
int t1;
int t2;

minVal = 
  ((__min_lv_minVal_00 = val1 < val2 ? val1 : val2),
  __min_lv_minVal_00)
  +
  ((__min_lv_minVal_01 = (t1 = foo()),
  (t2 = foo() + 1),
  t1 < t2 ? t1 : t2),
  __min_lv_minVal_01);

参数带有的副作用,或是以一个单一表达式做多重调用,或是在内联函数中有多个局部变量,都会产生临时对象,编译器也许能将他们移除,也许不能。

此外,嵌套的inline,即 inline 中再有 inline 可能会使一个内联函数因其连锁复杂度无法拓展开来。

构造函数

为了保证效率,创建类时调用的构造函数会被编译器处理为内联函数,如果因为太复杂无法展开,则会处理为静态函数。


2. 常量表达式

C++11中,引入常量表达式关键字 constexpr,它可以修饰函数,变量,类等数据。

在了解 constexpr 的特点之前,我们先来了解下我们的老朋友 const

常值变量修饰符 const

正如标题那样,const#define 的不同之处正是在于, const 修饰的仍然是在内存中的变量,在整个程序中只有一份数据,只是编译器让它不可以被修改,在编译期仅分配内存,运行期赋值 ,所以被其修饰的变量也被称为“常值变量”。
#define 宏定义则是将常量放在内存中的只读区域,在预编译期执行替换操作,编译期确定数值,并且在整个程序中存在多份数据(如果被调用多次的话)。

举个例子

#define N 10
const int n = 10;

int arr[N];
int arr[n];   // error

尽管在最新的编译器中,定义数组时数组长度为变量 n 的行为被优化,成为可执行语句,但它仍然是一个非法行为。

即使数组长度是一个常值变量,仍然是非法行为。但是使用宏常量定义时是合法的,因为它只是完成替换,与内存中的变量无关。

当然,数组长度也可以是一些常量表达式

int arr[10 + 5];

但是,我们仍然无法这样使用

const int n = 10 + 5;
int arr[n];           // error

常量表达式修饰符 constexpr

constexpr(常量表达式):是指值不会改变并且在编译过程就能得到计算结果的表达式。

常量表达式的优点是将计算过程转移到编译时期,那么运行期就不再需要计算了,程序性能也就提升了。

  • 修饰变量

那么我们将上述代码改为

constexpr int n = 10;
int arr[n];

这样就合法了

引用变量可声明为 constexpr

static constexpr const int& x = 42;// 到 const int 对象的 constexpr 引用
                                   // (该对象拥有静态存储期,因为静态引用延长了生存期)
  • 同样,它也可以 修饰函数

注意,这里修饰的是函数的返回值

constexpr int Length_Constexpr() {
    return 5;
}
​
char arr_2[Length_Constexpr() + 1];

当然,为了保证函数能够产生一个常量表达式,函数必须满足以下条件:

  1. 修饰的函数 只能包括 return 语句(允许出现 using,typedef,static_assert
  2. 修饰的函数 只能引用全局不变常量
  3. 修饰的函数 只能调用其他 constexpr 修饰的函数(C++23开始允许)
  4. 函数必须有返回值且 不能为 void 类型

constexpr 修饰的函数是可以实现递归的,同时它本身自带 inline 属性

如求斐波那契数列的第n项

constexpr int fibonacci(const int n) {
    return n == 1 || n == 2 ? 1 : fibonacci(n - 1) + fibonacci(n - 2);
}

在C++11中,被 constexpr 修饰的函数 有且只能有一个 return 语句 。而在C++14中,这个要求被放宽,我们可以这样写

constexpr int fibonacci(const int n) {
    if (n == 1) return 1;
    if (n == 2) return 1;
    return fibonacci_2(n - 1) + fibonacci_2(n - 2);
}

注意,C++11中 constexpr 不支持修饰被 virtual 修饰的成员函数

  • 修饰构造函数

对象会在编译器被初始化,同时构造函数需要满足如下条件:

  1. 对于类或结构体的构造函数,每个子对象和每个非变体非静态数据成员必须被初始化。如果类是联合体式的类,那么对于它的每个非空匿名联合体成员,必须恰好有一个变体成员被初始化
  2. 对于非空联合体的构造函数,恰好有一个非静态数据成员被初始化
class Rectangle { 
 private:
  int _h, _w; 
 public: 
  // 修饰构造函数
	constexpr Rectangle (int h, int w) 
    : _h(h), _w(w) {} 
	// 修饰一个函数,_h, _w为全局,并且在实例化时就已经是初始化后的常量了
	constexpr int getArea () const {  // 这里要加const修饰来声明函数体内变量不被修改
    return _h * _w; 
  } 
}; 
 
int main() { 
	// 对象在编译时就已经初始化了
	constexpr Rectangle obj(10, 20); 
	cout << obj.getArea(); 
	return 0; 
}
  • 修饰析构函数

C++20前,constexpr 不允许修饰析构函数,不做讨论

最后,对于 constexpr 是否成功修饰,仍然要看编译器是否允许(就像 inline 一样),并不是加上就一定是常量表达式的


3. 弃置函数

当我们不希望一个类被拷贝时,在C++98中我们可以这样写

class X {
 public: 
   X();
 private:
   X(const X&);
};

但是这样的写法还是不够优雅。于是,C++11新增弃置函数,让我们可以这样写。

class X {
 public:
  X();
  X(const X&) = delete;
};

当函数被写为弃置函数后,函数为非良构,即编译器不会编译此函数。

弃置函数的用法还不止于此,我们还可以禁止某些数值向函数传参。

void f(int) {}
void f(double) = delete;
f(1);
f(1.1);   // error

这种方法对成员函数和构造函数同样有效

class X {
 public:
  X() {}
  void* operator new(std::size_t) = delete;
  void* operator new[](std::size_t) = delete;
};

void test() {
  X* x = new X;     // error
  X* x = new X[];   // error
}

edit Serein