【C++基础】第一百一十三课:[特殊工具与技术]固有的不可移植的特性

不可移植特性,位域,volatile限定符,链接指示,extern "C"

Posted by x-jeff on September 23, 2024

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.固有的不可移植的特性

为了支持低层编程,C++定义了一些固有的不可移植(nonportable)的特性。所谓不可移植的特性是指因机器而异的特性,当我们将含有不可移植特性的程序从一台机器转移到另一台机器上时,通常需要重新编写该程序。算术类型的大小在不同机器上不一样,这是我们使用过的不可移植特性的一个典型示例。

本文将介绍C++从C语言继承而来的另外两种不可移植的特性:位域和volatile限定符。此外,我们还将介绍链接指示,它是C++新增的一种不可移植的特性。

2.位域

类可以将其(非静态)数据成员定义成位域(bit-field),在一个位域中含有一定数量的二进制位。当一个程序需要向其他程序或硬件设备传递二进制数据时,通常会用到位域。

位域在内存中的布局是与机器相关的。

位域的类型必须是整型或枚举类型。因为带符号位域的行为是由具体实现确定的,所以在通常情况下我们使用无符号类型保存一个位域。位域的声明形式是在成员名字之后紧跟一个冒号以及一个常量表达式,该表达式用于指定成员所占的二进制位数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef unsigned int Bit;
class File {
    Bit mode : 2; //mode占2位
    Bit modified : 1; //modified占1位
    Bit prot_owner : 3; //prot_owner占3位
    Bit prot_group : 3; //prot_group占3位
    Bit prot_world : 3; //prot_world占3位
    //File的操作和数据成员
public:
    //文件类型以八进制的形式表示
    enum modes { READ = 01, WRITE = 02, EXECUTE = 03 };
    File &open(modes);
    void close();
    void write();
    bool isRead() const;
    void setWrite();
};

mode位域占2个二进制位,modified只占1个,其他成员则各占3个。如果可能的话,在类的内部连续定义的位域压缩在同一整数的相邻位,从而提供存储压缩。例如在之前的声明中,五个位域可能会存储在同一个unsigned int中。这些二进制位是否能压缩到一个整数中以及如何压缩是与机器相关的。

取地址运算符(&)不能作用于位域,因此任何指针都无法指向类的位域。

2.1.使用位域

访问位域的方式与访问类的其他数据成员的方式非常相似:

1
2
3
4
5
6
7
8
9
10
void File::write()
{
    modified = 1;
    //...
}
void File::close()
{
    if (modified)
        //......保存内容
}

通常使用内置的位运算符操作超过1位的位域:

1
2
3
4
5
6
7
8
File &File::open(File::modes m)
{
    mode |= READ; //按默认方式设置READ
    //其他处理
    if (m & WRITE) //如果打开了READ和WRITE
    //按照读/写方式打开文件
    return *this;
}

如果一个类定义了位域成员,则它通常也会定义一组内联的成员函数以检验或设置位域的值:

1
2
inline bool File::isRead() const { return mode & READ; }
inline void File::setWrite() { mode |= WRITE; }

3.volatile限定符

volatile的确切含义与机器有关,只能通过阅读编译器文档来理解。要想让使用了volatile的程序在移植到新机器或新编译器后仍然有效,通常需要对该程序进行某些改变。

直接处理硬件的程序常常包含这样的数据元素,它们的值由程序直接控制之外的过程控制。例如,程序可能包含一个由系统时钟定时更新的变量。当对象的值可能在程序的控制或检测之外被改变时,应该将该对象声明为volatile。关键字volatile告诉编译器不应对这样的对象进行优化。

个人注解:

在C++中,volatile关键字用于告诉编译器,某个变量的值可能在程序的其他地方或由外部事件(例如硬件中断、多个线程并发访问等)改变,因此不应该对这个变量进行优化。

通常情况下,编译器为了提高程序性能,可能会对变量的读取和写入进行优化。例如,它可能会将变量的值保存在寄存器中,而不是每次都从内存中读取。对于普通的程序变量,这样做通常没有问题。但是当变量的值可能随时被外部因素改变时,这种优化可能会导致程序行为异常。为了解决这个问题,可以用volatile来告诉编译器不要对该变量的访问进行优化。

volatile限定符的用法和const很相似,它起到对类型额外修饰的作用:

1
2
3
4
volatile int display_register; //该int值可能发生改变
volatile Task *curr_task; //curr_task指向一个volatile对象
volatile int iax[max_size]; //iax的每个元素都是volatile
volatile Screen bitmapBuf; //bitmapBuf的每个成员都是volatile

const和volatile限定符互相没什么影响,某种类型可能既是const的也是volatile的,此时它同时具有二者的属性。

就像一个类可以定义const成员函数一样,它也可以将成员函数定义成volatile的。只有volatile的成员函数才能被volatile的对象调用。

我们可以声明volatile指针、指向volatile对象的指针以及指向volatile对象的volatile指针:

1
2
3
4
5
6
7
volatile int v; //v是一个volatile int
int *volatile vip; //vip是一个volatile指针,它指向int
volatile int *ivp; //ivp是一个指针,它指向一个volatile int
volatile int *volatile vivp; //vivp是一个volatile指针,它指向一个volatile int
int *ip = &v; //错误:必须使用指向volatile的指针
ivp = &v; //正确:ivp是一个指向volatile的指针
vivp = &v; //正确:vivp是一个指向volatile的volatile指针

和const一样,我们只能将一个volatile对象的地址(或者拷贝一个指向volatile类型的指针)赋给一个指向volatile的指针。同时,只有当某个引用是volatile的时,我们才能使用一个volatile对象初始化该引用。

3.1.合成的拷贝对volatile对象无效

const和volatile的一个重要区别是我们不能使用合成的拷贝/移动构造函数及赋值运算符初始化volatile对象或从volatile对象赋值。合成的成员接受的形参类型是(非volatile)常量引用,显然我们不能把一个非volatile引用绑定到一个volatile对象上。

如果一个类希望拷贝、移动或赋值它的volatile对象,则该类必须自定义拷贝或移动操作。例如,我们可以将形参类型指定为const volatile引用,这样我们就能利用任意类型的Foo进行拷贝或赋值操作了:

1
2
3
4
5
6
7
8
9
class Foo {
public:
    Foo(const volatile Foo&); //从一个volatile对象进行拷贝
    //将一个volatile对象赋值给一个非volatile对象
    Foo& operator=(volatile const Foo&);
    //将一个volatile对象赋值给一个volatile对象
    Foo& operator=(volatile const Foo&) volatile;
    //Foo类的剩余部分
};

4.链接指示:extern “C”

C++程序有时需要调用其他语言编写的函数,最常见的是调用C语言编写的函数。像所有其他名字一样,其他语言中的函数名字也必须在C++中进行声明,并且该声明必须指定返回类型和形参列表。对于其他语言编写的函数来说,编译器检查其调用的方式与处理普通C++函数的方式相同,但是生成的代码有所区别。C++使用链接指示(linkage directive)指出任意非C++函数所用的语言。

要想把C++代码和其他语言(包括C语言)编写的代码放在一起使用,要求我们必须有权访问该语言的编译器,并且这个编译器与当前的C++编译器是兼容的。

4.1.声明一个非C++的函数

链接指示可以有两种形式:单个的或复合的。链接指示不能出现在类定义或函数定义的内部。同样的链接指示必须在函数的每个声明中都出现。

举个例子,接下来的声明显示了cstring头文件的某些C函数是如何声明的:

1
2
3
4
5
6
7
8
//可能出现在C++头文件<cstring>中的链接指示
//单语句链接指示
extern "C" size_t strlen(const char *);
//复合语句链接指示
extern "C" {
    int strcmp(const char*, const char*);
    char *strcat(char*, const char*);
}

链接指示的第一种形式包含一个关键字extern,后面是一个字符串字面值常量以及一个“普通的”函数声明。

其中的字符串字面值常量指出了编写函数所用的语言。编译器应该支持对C语言的链接指示。此外,编译器也可能会支持其他语言的链接指示,如extern “Ada”、extern “FORTRAN”等。

4.2.链接指示与头文件

我们可以令链接指示后面跟上花括号括起来的若干函数的声明,从而一次性建立多个链接。花括号的作用是将适用于该链接指示的多个声明聚合在一起,否则花括号就会被忽略,花括号中声明的函数名字就是可见的,就好像在花括号之外声明的一样。

多重声明的形式可以应用于整个头文件。例如,C++的cstring头文件可能形如:

1
2
3
4
//复合语句链接指示
extern "C" {
#include <string.h> //操作C风格字符串的C函数
}

当一个#include指示被放置在复合链接指示的花括号中时,头文件中的所有普通函数声明都被认为是由链接指示的语言编写的。链接指示可以嵌套,因此如果头文件包含带自带链接指示的函数,则该函数的链接不受影响。

C++从C语言继承的标准库函数可以定义成C函数,但并非必须:决定使用C还是C++实现C标准库,是每个C++实现的事情。

4.3.指向extern “C”函数的指针

编写函数所用的语言是函数类型的一部分。因此,对于使用链接指示定义的函数来说,它的每个声明都必须使用相同的链接指示。而且,指向其他语言编写的函数的指针必须与函数本身使用相同的链接指示:

1
2
//pf指向一个C函数,该函数接受一个int返回void
extern "C" void (*pf)(int);

当我们使用pf调用函数时,编译器认定当前调用的是一个C函数。

指向C函数的指针与指向C++函数的指针是不一样的类型。一个指向C函数的指针不能用在执行初始化或赋值操作后指向C++函数,反之亦然。就像其他类型不匹配的问题一样,如果我们试图在两个链接指示不同的指针之间进行赋值操作,则程序将发生错误:

1
2
3
void (*pf1)(int); //指向一个C++函数
extern "C" void (*pf2)(int); //指向一个C函数
pf1 = pf2; //错误:pf1和pf2的类型不同

有的C++编译器会接受之前的这种赋值操作并将其作为对语言的扩展,尽管从严格意义上来看它是非法的。

4.4.链接指示对整个声明都有效

当我们使用链接指示时,它不仅对函数有效,而且对作为返回类型或形参类型的函数指针也有效:

1
2
//f1是一个C函数,它的形参是一个指向C函数的指针
extern "C" void f1(void(*)(int));

这条声明语句指出f1是一个不返回任何值的C函数。它有一个类型是函数指针的形参,其中的函数接受一个int形参返回为空。这个链接指示不仅对f1有效,对函数指针同样有效。当我们调用f1时,必须传给它一个C函数的名字或者指向C函数的指针。

因为链接指示同时作用于声明语句中的所有函数,所以如果我们希望给C++函数传入一个指向C函数的指针,则必须使用类型别名

1
2
3
4
//FC是一个指向C函数的指针
extern "C" typedef void FC(int);
//f2是一个C++函数,该函数的形参是指向C函数的指针
void f2(FC *);

4.5.导出C++函数到其他语言

通过使用链接指示对函数进行定义,我们可以令一个C++函数在其他语言编写的程序中可用:

1
2
//calc函数可以被C程序调用
extern "C" double calc(double dparm) { /*...*/ }

编译器将为该函数生成适合于指定语言的代码。

值得注意的是,可被多种语言共享的函数的返回类型或形参类型受到很多限制。例如,我们不太可能把一个C++类的对象传给C程序,因为C程序根本无法理解构造函数、析构函数以及其他类特有的操作。

对链接到C的预处理器的支持:

有时需要在C和C++中编译同一个源文件,为了实现这一目的,在编译C++版本的程序时预处理器定义__cplusplus(两个下划线)。利用这个变量,我们可以在编译C++程序的时候有条件地包含进来一些代码:

1
2
3
4
5
#ifdef __cplusplus
//正确:我们正在编译C++程序
extern "C"
#endif
int strcmp(const char*, const char*);

4.6.重载函数与链接指示

链接指示与重载函数的相互作用依赖于目标语言。如果目标语言支持重载函数,则为该语言实现链接指示的编译器很可能也支持重载这些C++的函数。

C语言不支持函数重载,因此也就不难理解为什么一个C链接指示只能用于说明一组重载函数中的某一个了:

1
2
3
//错误:两个extern "C"函数的名字相同
extern "C" void print(const char*);
extern "C" void print(int);

如果在一组重载函数中有一个是C函数,则其余的必定都是C++函数:

1
2
3
4
5
6
7
class SmallInt { /*...*/ };
class BigNum { /*...*/ };
//C函数可以在C或C++程序中调用
//C++函数重载了该函数,可以在C++程序中调用
extern "C" double calc(double);
extern SmallInt calc(const SmallInt&);
extern BigNum calc(const BigNum&);

C版本的calc函数可以在C或C++程序中调用,而使用了类类型形参的C++函数只能在C++程序中调用。上述性质与声明的顺序无关。