当前位置: C语言 -- 基础 -- 声明

声明(二十一)

6、函数类型的标准属性

函数类型属性用于函数声明符或者具有函数类型的类型说明符。对应属性是函数类型属性。ISO/IEC 9899:2024标准支持两个函数类型的标准属性:unsequenced属性和reproducible属性,其语法格式分别如下所示:

[[unsequenced]]
[[reproducible]]

函数类型属性应出现在形参类型列表)符号后,可用于函数声明和函数指针声明。

typedef int Func(int) [[reproducible]];
Func f1;
Func *p1;

int f2(int) [[reproducible]];
int (*p2)(int) [[reproducible]];

如果同一函数或者函数指针的多个声明可见,无论属性是存在于多个声明,还是存在于其中一个声明,该属性都适用于对应的函数定义、函数指针或者函数指针值的类型。


函数类型属性(function type properties and attributes)的主要目的是向编译器提供关于函数访问对象的信息,以便推导函数调用的某些特性。这些属性区分读操作(无状态、独立。)、写操作(无副作用、幂等、可重现。)以及两者组合(无序。),并帮助编译器优化代码、检查潜在问题。

尽管属性在语义上与函数类型相关联,但所描述的属性并非此类函数原型的组成部分,且丢弃此类属性的重新声明和转换都是有效的,并构成兼容类型;但如果一个不具备所声明属性的定义被具有该属性的函数声明或者函数指针访问,其行为未定义的。也就是说,一个函数是否具有这些属性,通常不仅取决于其所在编译单元,其它编译单元以及特定执行条件也会影响这些属性的可能实现。

//两个func函数是兼容类型。
int func(int) [[reproducible]];	//合法。
int func(int);				//合法。

int (*p)(int) = func;			//合法。

//func定义可以没有[[reproducible]]。
int func(int i)
{
  return i;
}

为允许函数调用重排,对于生命周期在函数调用前开始或者函数调用后结束的对象的访问必须加以限制;函数调用过程中所有访问对象的影响(effects)都限制在与调用函数相同的线程中;并且基于指针形参与左值模型之间的关系(the based-on relation between pointer parameters and lvalues models),对象在函数调用期间不会无意改变。

int global = 0;

int f1(int * ptr)
{
  *ptr = 1;

  return *ptr;
}

int f2(void)
{
  int local = 0;

  ...
  f1(&local);   //local无需限制。
  ...
  f1(&global);  //global需要限制。
}

如果某一操作发生在函数调用开始后和函数调用结束前之间,该操作称为在函数调用期间排序。形参初始化在函数调用期间排序。

int i = 0;  //操作在函数调用前。

int func(void)
{
  int i = 1;  //操作在函数调用期间。

  return i;
}

int main(void)
{
  func();
  
  int i = 5;  //操作在函数调用后。

  return 0;
}

对于在函数调用期间排序的操作,编译器不能将其重排到函数调用之前或者函数调用之后,除非能证明这样的操作是无副作用的。


如果某对象在函数func中定义,在没有调用函数func的情况下,仍然可以访问该对象,这称为对象逃逸(object escape)。如果对象生命周期在函数func调用期间开始并结束,或者如果对象在函数func内定义,并且未发生对象逃逸,该对象对于函数func的调用是局部的。

int *func(void)
{
  int i = 0;	//对象i是函数func的局部对象。
  static double *p1 = NULL;
  static int *p2 = NULL;
  
  p1 = malloc(sizeof(double));	//对象malloc(sizeof(double))是函数func的局部对象。
  free(p1);

  p2 = malloc(sizeof(int));	//对象malloc(sizeof(int))会发生对象逃逸。

  return p2;
}

int main(void)
{
  int *ptr = NULL;
  ptr = func();

  *ptr = 5; //在函数func调用外,访问malloc(sizeof(int))对象。
  printf("%d\n", *ptr); //在函数func调用外,访问malloc(sizeof(int))对象。

  return 0;
}

对象与函数调用同步意味着:对于该对象的所有访问,如果这些访问不在函数调用内部顺序执行,那么这些访问必须在函数调用开始前完成,或者在函数调用结束后开始。这样可以确保函数调用期间对该对象的访问不会被外部的并发访问干扰,从而避免数据竞争。

对于本章节属性而言,执行状态(例如:浮点环境、转换状态、区域设置(locale)、输入/输出流、外部文件、errno。)视为对象;访问执行状态的操作(即使间接访问。)视为左值转换(lvalue conversions);修改执行状态的操作视为存储操作(store operations)。


如果函数func内部或者函数func调用的任何函数内部所有具有静态存储期限和线程存储期限的对象都使用const类型限定符限定,且未使用volatile类型限定符限定,则函数func的定义是无状态的(stateless)。

//函数f1是无状态的。
int f1(int i)
{
  const static int csi = 3;
  ...
  return (csi+i);
}

//函数f2是有状态的。
int f2(int i)
{
  static int si = 5;
  ...
  return (si+i);
}

如果某对象被函数调用观察到,应满足以下所有条件:

① 该对象与函数调用之间存在同步关系;

② 该对象对于函数调用不是局部的;

③ 该对象的生命周期在函数调用之前开始;

④ 函数调用过程中对该对象进行了访问。

函数调用观察到的对象值是函数调用前最后存储到该对象的值。

atomic_int aglobal = 0;
int global = 0;

void func(void)
{
  int i1 = atomic_load(&aglobal);
  int i2 = global;
  ...
}

int main(void)
{
  atomic_store(&aglobal, 3);
  global = 5;

  func();
  ...
}

原子变量aglobalfunc函数调用存在同步关系,原子变量aglobal能够被func函数调用观察到;变量globalfunc函数调用不存在同步关系,变量global能否被func函数调用观察到取决于实现。


如果函数指针值f是独立(independent)的,应满足以下两个条件之一:

-- 如果对象X能够被f函数调用观察到,并且对象X不是通过参数传递的,在整个程序执行期间所有对f的调用中所有对对象X的访问都必须观察到相同值。

也就是说,如果函数读取外部变量,外部变量在函数调用期间是不变的。

const int cglobal = 1;
int global = 1;

//函数f1是独立函数。
int f1(void)
{
  return cglobal; //cglobal值不会发生改变。
}

//函数f2不是独立函数。
int f2(void)
{
  return global++;  //global值会发生改变。
}

-- 否则如果访问是基于指针参数,必须存在唯一的指针参数P,对X的任何访问都基于指针参数P

const int g1 = 1;
int g2 = 2;

const int * restrict p1 = &g1;
int *p21 = &g2;
int *p22 = &g2;

//函数f1是独立函数。
int f1(const int * restrict p)
{
  ...
}

//函数f2不是独立函数。
int f2(int *p1, int *p2)
{
  ...
}
...
f1(p1);
f2(p21, p22);

如果函数定义派生出的函数指针是独立的,则该函数定义是独立的。

函数定义的独立性意味着:如果函数调用访问任何全局变量或者静态变量,这些变量在程序执行期间对所有该函数调用保持值不变;如果函数调用通过指针访问数据,必须明确使用唯一指针,不能混用不同指针参数访问同一对象。函数独立性的目的是为了确保一个函数可以安全地并发调用,而不会导致数据竞争。


某对象的存储操作(store operation)在函数调用期间排序,如果该操作是可见的,应满足以下条件:

① 该存储操作与函数调用之间存在同步关系;

② 该对象对于函数调用不是局部的,即该对象的作用域不局限于当前函数调用;

③ 该对象的生命周期在函数调用后仍然持续;

④存储操作的存储值不同于函数调用观察到的值(如果存在。);

:编译器优化通常会消除冗余存储(dead store),因此写入相同值通常不会产生任何可见副作用。)

⑤ 存储操作的存储值是函数调用终止前最后写入的值。

//线程一
atomic_int aglobal = 0;

int f1(atomic_int * restrict ap)
{
    while(*ap==0)
        ;
    atomic_load(ap);  //A
    ...  
}

int main(void)
{
    ...
    f1(&aglobal);
    ...
}

//线程二
int f2(void *arg)
{
    atomic_store(&aglobal, 5);  //B
    ...
}

B处的存储操作与A处的加载操作同步;aglobal是全局变量,在函数f1调用结束后,aglobal生命周期仍然持续;函数调用开始时,aglobal值为0B处的存储值是5,两者不同;A处加载操作后aglobal值为5;所以B处的存储操作对f1函数调用可见。


如果函数调用过程中任何排序的存储操作都是对与该函数调用同步对象的修改,则对该函数调用的评估是无副作用的(effectless);如果该存储操作同时是可见的,该函数应存在一个唯一的指针参数,所有对该对象的访问都是基于该指针参数。如果调用函数指针值f的任何函数调用评估为无副作用,则函数指针值f是无副作用的。如果派生函数指针值是无副作用的,则函数定义也是无副作用的。

ISO标准建议:对于独立或者无副作用的函数,其指针形参使用restrict类型限定符。

上述f1函数是无副作用的;下面的func函数是有副作用的。

int global = 0;

int func(void)
{
    return global++;
}

func函数调用时存储操作不是针对同步对象的修改,因此func函数是有副作用的。

综上所述,函数调用有无副作用就是看函数执行过程中被修改对象是否通过同步机制管理;如果通过同步机制管理,则函数调用无副作用;如果未通过同步机制管理,则函数调用有副作用。


如果对表达式E的第二次评估紧跟在原始评估后进行,并且不改变表达式E的结果值(如果存在。)或者执行过程中的可见状态,则表达式E是幂等的(idempotent)。如果对调用函数指针值f的任何函数调用评估都是幂等的,则函数指针值f是幂等的。如果派生函数指针值是幂等的,则函数定义是幂等的。

int global = 0;
const int ci = 0;

//f1不是幂等的。
int f1(void)
{
    return global++;
}

//f2是幂等的。
int f2(void)
{
    return ci;
}

如果一个函数无副作用,并且是幂等的,那么该函数是可重现的(reproducible);如果一个函数无状态、无副作用、幂等、并且是独立的,那么该函数是无序的(unsequenced)。

int global = 0;
const int ci = 0;

//f1是可重现的;但依赖于全局变量global。
//如果其它线程或者函数修改global,其行为会发生变化。
int f1(void)
{
    return global;
}

//f2是无序的。
int f2(void)
{
    return ci;
}

无序函数的调用最早可在函数指针值、参数值以及通过这些参数可访问的所有对象值,以及所有全局可访问状态的值确定后执行,最晚可在参数及其可能指向的对象未发生变化,并且其返回值或者修改后的被指向实参访问时执行。


对于任何被访问对象X,同步要求(synchronization requirements)为函数的独立性提供了约束条件。这些约束条件规定了函数调用可以安全重排的边界,只要在边界内重排就不会改变程序语义。

如果对象X使用const类型限定符限定,且未使用volatile类型限定符限定,重排不受限制。如果对象X是初始化阶段的条件对象,对于单线程程序,通过之前排序关系(sequenced before relation)提供同步,因此重排原则上可以将函数调用移至初始化后。对于多线程程序,同步保证可以通过调用头文件中的同步函数,或者在初始化阶段结束时适当地调用atomic_thread_fence函数来实现。

如果已知某个函数是独立的或者无副作用的,给所有指针形参声明添加restrict类型限定符并不会改变函数调用语义;同样在调用此类函数时,将所有原子操作的内存顺序设置为memory_order_relaxed可保持语义。


一般而言,标准头文件提供的库函数不具备上述函数特性;许多库函数遇到错误时会修改浮点状态或者errno,因此具有可见的副作用;而大多数库函数结果依赖全局执行状态,例如:舍入模式,因此它们不是独立的。特定C库函数是否可重现或者无序,通常还取决于实现,例如:某些错误条件下的实现定义行为。