【C 语言】操作符详解(下):逗号、下标、结构体、类型转换与表达式求值

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者。

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。


🔥 本文定位 :接续上篇的位运算,读懂逗号表达式、[]、函数调用 ()、结构成员访问 . 与 ->,再解决更核心的问题:表达式被怎样分组,并不等于各部分按什么时间顺序执行。

💡 学习目标:会计算逗号表达式;能正确访问数组与结构体成员;理解优先级、结合性、整数提升和通常算术转换;识别未定义行为与未指定求值顺序,并把复杂表达式改成可预测的代码。

📌 阅读说明:根据课程 PDF《第10讲:操作符详解》第 6~11 节整理。上篇已解释进制、补码、移位与位运算。本篇按 C11 讨论规则;对讲义中的示例做了必要的语法修正和边界说明。


文章目录


一、单目操作符:一个操作数,多种角色

"单目"只表示它处理一个操作数 ,并不表示只有一种用途。课程列出 !、++、--、&、*、一元 +、一元 -、~、sizeof 和类型转换 (类型) 等。

写法 含义 例子
!x 逻辑非,零变真、非零变假 !0 为 1
++x / x++ 增加 1,返回值时机不同 前置返回更新后的值;后置返回原值
--x / x-- 减少 1,返回值时机不同 同上
&x 获取对象地址 int *p = &x;
*p 通过指针访问对象 *p = 10;
~x 按位取反 ~0U
sizeof x 求类型或表达式结果类型的大小 结果类型为 size_t
(double)x 显式转换类型 让除法按浮点数计算

例如:

c 复制代码
int x = 3;
int a = ++x;   /* x=4,a=4 */
int b = x++;   /* b=4,之后 x=5 */

这里的前置和后置区别只说明表达式的值 。不要据此推断它与同一个大表达式里其他子表达式的执行先后,更不要把 ++x + ++x 当成普通算术题。

sizeof 也有一个常见陷阱:它通常不会求值其操作数表达式(可变长数组的相关情况是例外),但会根据类型 决定字节数。sizeof array 对真实数组得到整个数组大小;数组作为函数形参时会调整为指针类型,不能再用 sizeof 参数 得到原数组长度。


二、逗号表达式:明确规定的从左到右

逗号操作符把两个表达式连接起来:先求左边并完成其副作用,再求右边;整个逗号表达式的值是右边表达式的值。多个逗号连续出现时,按左结合分组,可以按从左到右跟踪。

c 复制代码
int a = 1;
int b = 2;
int c = (a > b, a = b + 10, a, b = a + 1);

逐项计算:

步骤 表达式 状态或结果
1 a > b 1 > 2 为假;该结果被丢弃
2 a = b + 10 a = 12
3 a 值为 12,仍被丢弃
4 b = a + 1 b = 13,也是整个表达式的值

因此最后 a == 12、b == 13、c == 13。括号不可随意去掉,因为逗号操作符的优先级很低。

2.1 有逗号,不一定就是逗号操作符

c 复制代码
printf("%d %d\n", a, b);
int numbers[] = {1, 2, 3};

函数实参之间、初始化列表项之间的逗号是分隔符 。它们不享有逗号操作符"左边完成后才求右边"的规则。尤其是函数 f(left(), right()):两个实参表达式的求值先后没有由书写顺序指定。

若确实需要强制先后,写成独立语句更直接:

c 复制代码
int left_value = left();
int right_value = right();
f(left_value, right_value);

讲义还把读取、计数与判断压进一个 while 条件里。语法上可用逗号表达式实现,但把副作用拆开,通常更容易调试:

c 复制代码
for (;;)
{
    int value = get_val();
    count_val(value);
    if (value <= 0)
        break;

    /* 处理 value */
}

这段写法仍需按实际业务定义处理结束标记或错误返回值,但每步执行时机一眼可见。


三、下标访问与函数调用:符号相近,规则不同

3.1 []:从起点找到一个元素

c 复制代码
int arr[10] = {0};
arr[9] = 10;
printf("%d\n", arr[9]);

下标表达式 arr[9] 可按 *(arr + 9) 理解:从 arr 指向的首元素位置移动 9 个元素,再访问它。合法下标是 0 到 9。越界不是"读到下一个格子也许能用",而是未定义行为。

c 复制代码
int arr[10] = {0};
/* arr[10] = 1;  错误:最后一个合法元素是 arr[9] */

下标里也可以是计算表达式,如 arr[i + 1],但要确保计算出的最终下标仍在有效范围内。

3.2 ():调用函数,实参个数可为零

c 复制代码
#include <stdio.h>

void greet(void)
{
    puts("hello");
}

void print_number(int value)
{
    printf("%d\n", value);
}

int main(void)
{
    greet();
    print_number(10);
    return 0;
}

greet() 的实参个数是 0 ,这也是合法的函数调用。函数调用前会先准备实参,然后进入函数体;但多个实参之间的求值顺序没有由它们在括号中的左右位置规定。需要先后关系时,分别算好再传入。


四、结构成员访问:. 与 -> 怎么选

一个 int 不够描述一名学生:还可能需要姓名、年龄、学号等。结构体把这些不同类型的成员组织成一个新类型。

c 复制代码
struct Student
{
    char name[20];
    int age;
    unsigned int id;
};  /* 结构类型声明末尾的分号不能漏 */

声明并初始化一个变量:

c 复制代码
struct Student student = {"Zhang", 20, 1001U};
printf("%s %d %u\n", student.name, student.age, student.id);

4.1 有结构体对象,用 .

student.age 访问的是 student 这个结构体对象 里的成员 age。C11 还支持指定初始化器:

c 复制代码
struct Student student = {.id = 1001U, .age = 20, .name = "Zhang"};

这样初始化时可不按声明顺序书写,未明确初始化的成员会按初始化规则处理。指定初始化器是 C99 起的标准 C 语法。

4.2 有结构体指针,用 ->

c 复制代码
struct Student student = {"Zhang", 20, 1001U};
struct Student *ptr = &student;

ptr->age = 21;
printf("%d\n", (*ptr).age);  /* 21 */

ptr->age 等价于 (*ptr).age。后者的括号非常重要:. 的优先级高于一元 *;若写成 *ptr.age,会先尝试访问 ptr.age,但 ptr 是指针,不是结构体对象。

4.3 把结构体传入函数:值与地址不同

c 复制代码
#include <stdio.h>

struct Point
{
    int x;
    int y;
};

void print_point(struct Point point)
{
    printf("(%d, %d)\n", point.x, point.y);
}

void move_right(struct Point *point)
{
    if (point != NULL)
        point->x += 1;
}

int main(void)
{
    struct Point p = {3, 4};
    print_point(p);
    move_right(&p);
    print_point(p);
    return 0;
}

print_point(p) 接收一份结构体值;move_right(&p) 接收原对象的地址,可以修改原对象。指针解引用之前先确认它满足函数约定,这里用 NULL 检查展示基本防护。


五、优先级与结合性:解决的是分组

优先级与结合性回答的是:一个表达式按什么结构解释。例如:

c 复制代码
3 + 4 * 5      /* 解释为 3 + (4 * 5),结果 23 */
5 * 6 / 2      /* 解释为 (5 * 6) / 2,结果 15 */
a = b = 7      /* 解释为 a = (b = 7) */

*、/ 优先级高于 +;同一级的 *、/ 左结合;赋值操作符右结合。因此这些表达式如何分组可以确定。

不过,"(5 * 6) / 2 左结合"并不是一条"编译器必须先读变量 5 再读 6"的求值时间表。操作数若带函数调用或 ++ 等副作用,就必须另外检查求值顺序规则。

常见的部分层级如下,越往下优先级越低;这张表只用于阅读,不建议靠背完整表写出复杂表达式。

层级 操作符 结合方向
1 后缀 ++ --、调用 ()、下标 []、.、-> 左到右
2 前缀 ++ --、一元 + -、! ~、取地址 &、间接访问 *、sizeof、类型转换 右到左
3 * / % 左到右
4 + - 左到右
5 << >> 左到右
6 < <= > >= 左到右
7 == != 左到右
8 按位与 & 左到右
9 按位异或 ^ 左到右
10 按位或 ` `
11 逻辑与 && 左到右
12 逻辑或 `
13 条件 ?: 右到左
14 = 及 +=、-= 等复合赋值 右到左
15 逗号操作符 , 左到右

括号是给读者的路标:当分组不明显时,主动加括号。它能改变或明确分组,但不能给普通算术操作数凭空增加求值顺序保证。


六、整数提升与通常算术转换

写 char + char,结果不一定还是 char。C 的整数提升会先把较窄的整数类型提升到至少 int 或 unsigned int 再参与运算。

6.1 整数提升:小类型先"长大"

c 复制代码
signed char left = 100;
signed char right = 20;
int sum = left + right;  /* 两个操作数先提升为 int,结果为 120 */

对 char、signed char、unsigned char、short 等,若 int 能表示该类型的所有值 ,就提升为 int;否则提升为 unsigned int。因此不能简单背成"有符号补符号位、无符号补零"并认为那就是完整语言规则。

而且,普通 char 的有符号性由实现决定。若示例需要明确讨论负值,写 signed char;需要明确讨论字节中的非负值,写 unsigned char。

c 复制代码
signed char a = 100;
signed char b = 20;
signed char result = (signed char)(a + b);

上面的加法先在提升后的类型中计算,赋回小类型时再发生转换。若结果超出目标有符号类型可表示范围,转换结果由实现定义,不能通用地说成"只截掉高位"。这里 120 在常见 8 位 signed char 范围内,但示例是否可移植仍应根据实现的类型范围判断。

6.2 通常算术转换:两边类型不同怎么办

当算术或比较操作需要共同类型时,会按一组规则转换操作数。浮点和整数相遇时通常向浮点类型转换;两个整数相遇时还要看整数转换等级 与有符号/无符号范围,不能只把类型名排成一条"谁排得高就转成谁"的链。

一个很典型的例子:

c 复制代码
int negative = -1;
unsigned int positive = 1U;

if (negative < positive)
    puts("negative is smaller");

在这里两者是同等级的 int 与 unsigned int,negative 会转换成 unsigned int;-1 转换后的值是 UINT_MAX,所以比较结果为假。这常让只按数学整数直觉阅读的人吃惊。

需要表达"负数一定小于非负数"时,可以先显式处理负值,再在适当范围内比较:

c 复制代码
if (negative < 0 || (unsigned int)negative < positive)
{
    /* 条件成立 */
}

|| 的短路保证:当 negative < 0 为真时,右边不求值。若为假,negative 已是非负,转换为 unsigned int 不会改变其数学值。


七、表达式求值:分组之外还要看求值顺序

课程最后一节的真正难点,不是记住每种操作符优先级,而是区分下面三件事:

  1. 分组:表达式按什么结构解释,由语法、优先级和结合性决定。
  2. 求值顺序:多个子表达式按什么时间先后计算,必须由专门的规则决定。
  3. 副作用是否冲突:若对同一对象的修改与另一次修改或读取没有必要的先后约束,可能是未定义行为。

7.1 a*b + c*d + e*f:结构确定,时间顺序未全定

分组相当于 (a*b + c*d) + e*f。每个 * 的结果要用于相应的 +,但这不意味着编译器必须在第一次加法前把三个乘法全做完。若 a~f 都是普通稳定数值,这通常不影响最终数学结果;若换成带副作用的函数调用,就应仔细检查。

7.2 c + --c:未定义行为,不是"结果不稳定"

c 复制代码
int c = 5;
int result = c + --c;  /* 不要这样写 */

+ 的左右操作数没有规定先后,表达式一边读取 c,另一边又修改 c,两者没有必要的顺序关系。在 C11 中属于未定义行为。未定义行为不能靠"我的编译器输出 9"或"加一对括号"变成合法题目。

c 复制代码
int c = 5;
--c;
int result = c + c;  /* 定义明确,result = 8 */

课程里的 (++i) + (++i) + (++i),以及同一大表达式里多次 i--、--i、i++、++i,也有同类问题。不同编译器出现不同输出只是症状,不能把某一个输出当成标准答案。

7.3 多次调用会修改状态的函数:先后未指定,结果也可能变

把课程示例里的头文件拼写修正为 <stdio.h> 后,来看:

c 复制代码
#include <stdio.h>

int next_value(void)
{
    static int count = 1;
    return ++count;
}

int main(void)
{
    int answer = next_value() - next_value() * next_value();
    printf("%d\n", answer);
    return 0;
}

这里调用各自完整执行,但三个调用谁先执行没有指定 。它们依次可能返回 2、3、4,而这三个返回值分配给减法左操作数、乘法两操作数的方式可能不同;乘法分组明确,不等于调用次序明确。此例适合归为未指定的求值顺序导致结果可能不同 ,不要与 c + --c 的未定义行为混成同一类。

想要确定的业务含义,就写清调用顺序:

c 复制代码
int first = next_value();
int second = next_value();
int third = next_value();
int answer = first - second * third;  /* 2 - 3 * 4 = -10 */

7.4 哪些操作符给出了先后保证

形式 保证
left, right(逗号操作符) 左操作数完成后才求右操作数
left && right 左边先求;若为假,右边不求
`left
condition ? yes : no 先求条件,只求选中的分支
独立的两个完整语句 前一语句完成,再执行后一语句

注意函数实参列表里的逗号不属于表中的逗号操作符。普通 +、*、- 与括号也没有提供"从左往右逐项执行"的承诺。


八、把风险表达式改写成可预测的代码

分析一个复杂表达式,可以按照这四步走:

  1. 画出分组:先根据语法、优先级和结合性加上帮助阅读的括号。
  2. 标记每个读取与修改 :尤其找 ++、--、赋值、函数调用、volatile 访问等。
  3. 寻找明确的先后关系:逗号操作符、逻辑短路、条件操作符、语句边界等能否约束这些副作用?
  4. 若业务需要唯一顺序,拆成语句:让编译器和读者都无须猜测。

例如想"读取旧值,再递增,最后与另一个数相加":

c 复制代码
int old_value = counter;
counter += 1;
int result = old_value + bonus;

这比把读取、修改和求和挤进一行更稳妥。代码行数多一点,检查成本却低很多。


九、高频问答与练习

Q1:a = b = 7 为什么能连续赋值?

赋值操作符右结合,分组为 a = (b = 7)。先把 7 赋给 b,赋值表达式再产生相应值供外层赋值使用。这里同一个对象没有冲突修改。

Q2:加括号能修好 i++ + ++i 吗?

不能。(i++) + (++i) 只是更明确地写出分组,没有给两次修改建立顺序。拆成独立语句。

Q3:ptr->x 与 (*ptr).x 是否完全一样?

就成员访问而言,两者等价。前提是 ptr 指向有效的相应结构体对象;空指针或失效指针不能因为用了 -> 就安全。

Q4:sizeof 的单位是什么?

结果以 字节 为单位;C 的"字节"是 char 的大小,sizeof(char) == 1。一个字节包含多少位可查 CHAR_BIT,不能对所有实现都默认 8 位。

动手练习

  1. 判断 int x = 1, y = 2; int z = (x += 3, y += x, y); 执行后的三个值。
  2. int arr[4] = {10, 20, 30, 40};,arr[3] 与 arr[4] 哪个有效?
  3. 对 struct Point p 和 struct Point *q = &p,分别写出访问 x 的表达式。
  4. 解释为什么 f(g(), h()) 不保证先调用 g,并改写成保证 g 先执行的代码。
  5. int x = 4; int y = x + ++x; 能否推算 y?为什么?

参考答案 :第 1 题 x=4、y=6、z=6;第 2 题只有 arr[3] 有效;第 3 题 p.x 与 q->x;第 4 题先执行 int gv = g();,再执行 int hv = h();,最后 f(gv, hv);;第 5 题不能,读取与修改同一对象缺少必要顺序,行为未定义。


总结

操作符学习可以分两层:先知道每个符号做什么,再知道类型转换、分组和求值顺序 如何共同影响结果。[]、()、.、-> 解决访问与调用;逗号操作符提供真正的先后;整数提升和通常算术转换改变运算类型;优先级与结合性只解决表达式的结构。遇到多个副作用时,拆成清楚的语句,通常就是最好的答案。

资料 :课程 PDF《第10讲:操作符详解》;ISO C11 工作草案 N1570 的表达式、整数提升与通常算术转换条款。本文示例和示意图为重新整理与绘制。

相关推荐
晓蛋3 小时前
C语言条件编译精讲
c语言·条件编译·宏定义·代码移植·预处理程序
ggaofeng3 小时前
Ctrl+C结束进程是如何实现的
服务器·c语言·网络
水饺编程4 小时前
第1章:开发环境搭建,在 Windows 中安装 QEMU
c语言·汇编
沐晓时光5 小时前
C语言入门,深入理解指针(1)
c语言·开发语言
天l志5 小时前
DL/T 645—2007《多功能电能表通信协议》的跨平台 C 语言实现
c语言·电表·dlt645
老王爱玩车8 小时前
工具函数——清空输入缓冲区
c语言·开发语言·学习
wuminyu8 小时前
JEP491中synchronized关键字引发的平台线程钉住解决方法简介
java·linux·c语言·jvm·c++
phltxy11 小时前
C 语言文件操作:让数据从内存走向持久化
c语言·网络·数据库
大圣编蚕11 小时前
C语言怎么写小游戏?从零开始做一个贪吃蛇
c语言·开发语言