关于C语言中的结构体的语法,这是C++自定义类型的基础。 C++的类提供了非常丰富的功能,但是也会尽量兼容C语言的结构体。

简单示例

下面是针对学生信息的结构体的使用示例,包括了结构体对象的定义和读写,以及通过指针传递给函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <stdio.h>
#include <string.h>

struct Person {
char name[50];
int age;
double height;
};

void show(struct Person *person) {
printf("Name: %s\n", person->name);
printf("Age: %d\n", person->age);
printf("Height: %.2f\n", person->height);
}

int main() {
struct Person person1;

strcpy(person1.name, "John");
person1.age = 30;
person1.height = 6.1;

struct Person person2 = {"Alice", 25, 5.5};

printf("[Person 1]\n");
show(&person1);

printf("[Person 2]\n");
show(&person2);

printf("[Update Person 1]\n");
strcpy(person1.name, "David");
person1.age = 35;
person1.height = 6.2;

show(&person1);

return 0;
}

基本使用

结构体类型的定义

定义一种结构体类型的一般形式为

1
2
3
struct 结构名 {
成员列表
};

例如一种记录学生信息的结构体:

1
2
3
4
5
struct Person {
char name[50];
int age;
double height;
};

结构体的定义是不能重复出现在同一个编译单元的,会报错符号重定义。 但是与函数的定义不同,结构体的定义可以出现在头文件中,这不会报错类型重定义。

结构体类型的对象和指针

基于前面的结构体,我们可以定义对应的结构体对象

1
struct Person bob;

我们可以在定义的同时进行初始化,例如按照定义的顺序初始化

1
struct Person bob = {"Alice", 25, 5.5};

也可以指定成员进行初始化,此时可以不按照定义的顺序

1
struct Person bob = {.name = "Alice", .height = 5.5, .age = 25};

这两种写法都支持对某个成员缺省,不会报错,但是对应成员的值是随机的。

我们还可以用单独的语句对每一个分量分别赋值

1
2
3
strcpy(bob.name, "John");
bob.age = 30;
bob.height = 6.1;

我们可以定义指向某个结构体的指针,例如

1
2
struct Person bob = {"Alice", 25, 5.5};
struct Person *p = &bob;

我们还可以使用堆内存上的结构体对象,例如

1
struct Person *p = (struct Person *)malloc(sizeof(struct Person));

由于指针的定义不需要完整的类型信息,我们可以在结构体内部包含指向当前结构体的指针成员

1
2
3
struct Demo{
struct Demo *p;
};

这在语法上是允许的,在实践中也是非常重要的,例如据此实现链表的结点

1
2
3
4
struct Node{
double data;
struct Node* next;
};

使用.就可以访问结构体对象的成员,例如

1
2
3
printf("Name: %s\n", bob.name);
printf("Age: %d\n", bob.age);
printf("Height: %.2f\n", bob.height);

使用->就可以访问结构体指针对应的成员,例如

1
2
3
printf("Name: %s\n", p->name);
printf("Age: %d\n", p->age);
printf("Height: %.2f\n", p->height);

结构体类型的声明

除了提供完整的结构体类型定义之外,我们还可以提供如下不完整的结构体类型声明

1
struct Person;

在出现完整的结构体定义之前,我们都无法使用结构体对象(因为不知道结构体的成员信息)

1
2
3
4
5
6
7
8
9
10
11
struct Person; // 不完整类型声明

// 稍后提供的完整的结构体定义
struct Person {
char name[50];
int age;
double height;
};

// 此时可以使用完整的结构体定义了
struct Person person1;

但是在声明之后,我们就可以定义指向结构体的指针,这可以用来解决很多问题,例如循环依赖问题

1
2
3
4
5
6
7
8
9
10
11
struct B; // 不完整类型声明

struct A {
struct B *ptrB;
int data;
};

struct B {
struct A *ptrA;
double value;
};

同时定义结构体类型和变量

我们可以在定义结构体类型的同时定义对应类型的对象,指针或数组等,例如

1
2
3
4
5
struct Person {
char name[50];
int age;
double height;
} person1, *ptr, persons[10];

这等价于下面的代码,在定义了结构体之后分别进行定义

1
2
3
4
5
6
7
8
9
struct Person {
char name[50];
int age;
double height;
};

struct Person person1;
struct Person *ptr;
struct Person persons[10];

也支持加上初始化,例如

1
2
3
4
5
struct Person {
char name[50];
int age;
double height;
} person = {"Ada", 1, 2.1};

匿名结构体类型的使用

基于上面同时定义结构体类型和变量的写法,我们甚至可以省略结构体的名称

1
2
3
4
struct {
int x;
int y;
} tmp_point = {1, 2};

此时我们定义了一个匿名的结构体类型和对应的变量tmp_point。 因为这是匿名的,即使我们在别处定义了具有相同数据成员的结构体,编译器也会将它们视作不同的结构体类型。

匿名结构体可以用于多个变量的临时打包,例如

1
2
3
4
5
6
struct {
int x;
int y;
} tmp_point = {1, 2};

printf("x = %d, y = %d\n", tmp_point.x, tmp_point.y);

也可以用于嵌套结构体的定义,此时我们可以定义下面的结构体,对于嵌套的匿名结构体中的分量,在语法上支持直接访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Demo {
int x;
int y;

struct {
int z;
};
};

int main() {
struct Demo point = {1, 2, {3}};
printf("x = %d, y = %d, z = %d\n", point.x, point.y, point.z);
return 0;
}

不使用匿名结构体的等价实现如下,在语法上显得更加繁琐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Inside {
int z;
};

struct Demo {
int x;
int y;
struct Inside i;
};

int main() {
struct Demo point = {1, 2, {3}};
printf("x = %d, y = %d, z = %d\n", point.x, point.y, point.i.z);
return 0;
}

结构体类型的别名

关于结构体的语法还是太繁琐了,每次都要写struct Person,尤其是struct这个关键词, 我们可以通过使用typedef关键词给结构体类型起别名的方式省略它

1
2
3
4
5
6
7
struct Demo {
int x;
int y;
double z;
};

typedef struct Demo Demo;

此时下面的两个语句是完全一样的

1
2
struct Demo point = {1, 2, 3.1};
Demo point = {1, 2, 3.1};

当然起一个和结构体不一样的别名也是可以的,只是为了可读性要求并不建议这么做。

C语言在语法上还支持我们将结构体定义和起别名的语句合在一起

1
2
3
4
5
typedef struct Demo {
int x;
int y;
double z;
} Demo;

我们也可以给匿名结构体起别名,此时就不能使用struct Demo,只能使用Demo

1
2
3
4
5
typedef struct {
int x;
int y;
double z;
} Demo;

在Linux内核的编程风格中,使用typedefstruct消除掉反而是不建议的做法,因为这会降低代码的可读性。

进阶

成员偏移与内存对齐

一个结构体在内存中实际上就是将它的几个数据成员连续存储到一起,存储的顺序由成员在定义时的顺序决定, 但是在不同的数据成员之间并不是连续拼接的,可能存在几个字节的空隙,这是出于数据读写效率的考量,进行了内存对齐的设计。

例如下面的一个简单的结构体

1
2
3
4
5
struct Demo {
char x;
int y;
double z;
};

我们可以测试得到

1
2
3
4
sizeof(Demo) = 16
sizeof(char) = 1
sizeof(int) = 4
sizeof(double) = 8

结构体类型的字节数并不等于每一个数据成员的字节数之和!

对于指向结构体类型的指针Demo *p,通过它访问数据成员y的操作p->y,在实质上就是p指向的位置加上y相对于整个对象的地址偏移,利用这个特点,我们可以使用零指针来获取结构体成员在内存中的偏移量(通过几次类型转换)

1
unsigned long long offset_x = (unsigned long long)&(((struct Demo *)0)->y);

这甚至完全不需要定义一个结构体类型的对象,编译器在计算指针时就把偏移量告诉我们了。

我们通过下面的实验展示结构体成员的偏移量,并以此体现结构体中的内存对齐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>

struct Demo {
char x;
int y;
double z;
};

int main() {
struct Demo tmp;

printf("size = %llu\n", sizeof(struct Demo));

unsigned long long offset_x = (unsigned long long)&(((struct Demo *)0)->x);
printf("x: offset = %llu, size = %llu\n", offset_x, sizeof(tmp.x));

unsigned long long offset_y = (unsigned long long)&(((struct Demo *)0)->y);
printf("y: offset = %llu, size = %llu\n", offset_y, sizeof(tmp.y));

unsigned long long offset_z = (unsigned long long)&(((struct Demo *)0)->z);
printf("z: offset = %llu, size = %llu\n", offset_z, sizeof(tmp.z));

return 0;
}

输出结果为

1
2
3
4
size = 16
x: offset = 0, size = 1
y: offset = 4, size = 4
z: offset = 8, size = 8

输出表明在内存中的分布是这样的

  • 第一个字节:存储char类型的成员x
  • 三个字节的空隙
  • 第五个到第八个字节(共四个字节):存储int类型的成员y
  • 第九个到第十六个字节(共八个字节):存储double类型的成员z

如果我们改变struct Demo中成员的定义顺序,会得到不同的结果,例二

1
2
3
4
5
struct Demo {
double z;
int y;
char x;
};

输出

1
2
3
4
size = 16
z: offset = 0, size = 8
y: offset = 8, size = 4
x: offset = 12, size = 1

例三

1
2
3
4
5
struct Demo {
int y;
char x;
double z;
};

输出

1
2
3
4
size = 16
y: offset = 0, size = 4
x: offset = 4, size = 1
z: offset = 8, size = 8

例四,我们甚至可以得到更大的内存占用

1
2
3
4
5
struct Demo {
int y;
double z;
char x;
};

输出

1
2
3
4
size = 24
y: offset = 0, size = 4
z: offset = 8, size = 8
x: offset = 16, size = 1

内存对齐遵循如下原则:

  1. 对于结构体的各个成员,第一个成员的偏移量保证是0,后面每一个成员的偏移量必须是其类型大小的整数倍;
  2. 结构体内所有数据成员各自内存对齐后,结构体本身还要进行一次内存对齐,保证整个结构体占用内存大小是结构体内最大数据成员的最小整数倍;
  3. 如果程序存在#pragma pack(n)预编译指令,则所有成员的对齐以n字节为准(即偏移量是n的整数倍),不再 考虑当前类型以及结构体内的最大类型。

我们不考虑这里的具体细节,只是关注在考虑了内存对齐之后,我们应该怎么设计结构体中各个成员的定义顺序: 一个简单的原则是按照从大到小或者从小到大的顺序排列,避免大小交错。 此外,在具体情况下我们也可以调整顺序,使用小成员填充到大成员原本产生的空隙中。

offsetof宏和container_of宏

上面计算结构体中成员偏移的用法在Linux内核中被广泛使用(例如实现非侵入式的链表),可以定义下面的两个宏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 获取当前成员和结构体起始地址的差值
// type 结构体类型名称
// member 成员名称
#define offsetof(type, member) ((size_t) & ((type *)0)->member)

// 由结构体成员的地址来计算结构体的起始地址
// ptr 指向结构体成员的指针
// type 结构体类型名称
// member 成员名称
#define container_of(ptr, type, member) \
({ \
const typeof(((type *)0)->member) *__mptr = (ptr); \
(type *)((char *)__mptr - offsetof(type, member)); \
})

例如我们现在有结构体

1
2
3
4
struct Demo{
int x;
double y;
};

那么

1
2
3
4
5
6
size_t offset_x = offsetof(struct Demo, x); // 返回x的偏移量
size_t offset_y = offsetof(struct Demo, y); // 返回y成员的偏移量

struct Demo tmp;
int *ptr = &(tmp.x);
struct Demo *d1 = container_of(ptr, struct Demo, x); // 返回tmp的地址

完整的测试代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <stdio.h>

// 获取当前成员和结构体起始地址的差值
// type 结构体类型名称
// member 成员名称
#define offsetof(type, member) ((size_t) & ((type *)0)->member)

// 由结构体成员的地址来计算结构体的起始地址
// ptr 结构体成员指针
// type 结构体类型名称
// member 成员名称
#define container_of(ptr, type, member) \
({ \
const typeof(((type *)0)->member) *__mptr = (ptr); \
(type *)((char *)__mptr - offsetof(type, member)); \
})

struct Demo {
int x;
double y;
};

int main() {
size_t offset_x = offsetof(struct Demo, x); // 返回x的偏移量
size_t offset_y = offsetof(struct Demo, y); // 返回y成员的偏移量

struct Demo tmp;
int *ptr = &(tmp.x);
struct Demo *d1 = container_of(ptr, struct Demo, x); // 返回tmp的地址
struct Demo *d2 = &tmp;

printf("offset_x = %lld, offset_y = %lld\n", offset_x, offset_y);
printf("d1 = %lld, d2 = %lld\n", (size_t)d1, (size_t)d2);
return 0;
}

获取偏移量的offsetof宏其实在C语言或C++中的标准头文件stddef.h中直接定义了,MSVC的具体实现如下

1
2
3
4
5
6
7
8
9
#if defined _MSC_VER && !defined _CRT_USE_BUILTIN_OFFSETOF
#ifdef __cplusplus
#define offsetof(s,m) ((::size_t)&reinterpret_cast<char const volatile&>((((s*)0)->m)))
#else
#define offsetof(s,m) ((size_t)&(((s*)0)->m))
#endif
#else
#define offsetof(s,m) __builtin_offsetof(s,m)
#endif

可以发现它优先考虑使用编译器直接提供的偏移宏__builtin_offsetof,在不使用的情况下,还针对C语言和C++提供了两个版本。

直接对C++的自定义类型使用上述offsetof宏是存在问题的:C++11之后保证offsetof对于标准布局类型是正常使用的,但在复杂情况下仍存在未定义行为,特别是涉及到虚继承的情形。