
文章目录
- 单目操作符
- 逗号表达式
- [下标访问操作符 []、函数调用 操作符()](#下标访问操作符 []、函数调用 操作符())
-
- [下标访问操作符 []](#下标访问操作符 [])
- 函数调用操作符
- 结构成员访问操作符
- 操作符的优先级和结合性
- 表达式求值
- 总结
这里是think的博客
希望可以一起交流知识,一起think
今天我们继续来学习各种操作符
一起来think吧
上文我们对不太熟悉的移位操作符进行了细致的讲解,接下来,我们会将除了&*这两个操作符之外的操作符都讲解完(这两个再讲解指针的时候会详细讲解的)
单目操作符
单目操作符有这些:
!、++、--、&、*、+、-、~、sizeof、(类型)
单目操作符的特点是只有一个操作数,在单目操作符中只有 & 和 * 没有介绍,
这 2 个操作符上面说了,我们会在学习指针的时候学习。
逗号表达式
- 逗号表达式,就是用逗号隔开的多个表达式。
- 逗号表达式,从左向右依次执行。整个表达式的结果是最后一个表达式的结果。(记住是每一个表达式都会执行到,并且是从左到右,但是表达式的结果是最后一个表达式的结果)
//代码1
int a = 1;
int b = 2;
int c = (a > b, a = b + 10, a, b = a + 1);//逗号表达式
c是多少?
是13,就是因为a = b + 10是要执行,但是最终结果是最后一个表达式的结果,它的结果就是b==13,那么将b再赋值给c就有了c==13
//代码2if (a = b + 1, c = a / 2, d > 0)
//结果是否为真,只和d的正负有关
//代码3a = get_val();
count_val(a);
while (a > 0)(这里需要提前定义a,可能do while不适合下面的业务逻辑,只要是用do while就变成了执行了两个函数,再执行了业务处理才判断的,可能写的代码逻辑就是想直接执行两个函数后就直接判断了,所以可以用逗号表达式来优化一下)
{
//业务处理(这里需要提前调用(这两个函数)
//...
a = get_val();
count_val(a);
}
如果使用逗号表达怯,改写:
while (a = get_val(), count_val(a), a>0)
{
//业务处理
}
//非常的巧妙,使用于因为判断中要用到a,所以要将一份代码写成两份的冗余情况,函数外先写一份,给一个初始a值,内部再写一个,配合内部的代码实现,并且内部的细节实现不适合用do while,这个时候就想到有没有一种办法优化一下 ?
优化的实现可以想到,内部两个函数的实现是在最后的,后面是没有其他代码的,那么最后的两个代码是可以放在最前面,或者直接写在判断条件中(不是真正的写在判断条件中,而是写在判断的那个()中),
我们知道写在最前面会和外面的提前写的代码会冲突,所以优化就可以朝着将函数的实现写在判断条件中,这个时候可以利用逗号表达式是从左到右执行的代码并且结果只有最后一行代码决定的特性来对代码进行优化。
补充点:其实写成上面那个冗余代码的时候,逻辑都还是先执行两个函数再判断a是否>0,它们的逻辑还是一样的。
下标访问操作符 []、函数调用 操作符()
下标访问操作符 []
操作数:一个数组名 + 一个索引值(下标)
如:
int arr[4];
arr[3]=1;
//其中arr和3就是[ ]的操作数
//而第一个的[ ]不是下标访问操作符,是语法,是来告诉编译器这个数组的长度的,比如第一个arr数组就是长度为4。
函数调用操作符
接受一个或者多个操作数:第一个操作数是函数名,剩余的操作数就是传递给函数的参数。
c
#include <stdio.h>
void test1()
{
printf("hehe\n");
}
void test2(const char *str)
{
printf("%s\n", str);
}
int main()
{
test1(); // 这里的 () 就是作为函数调用操作符。
test2("hello bit."); // 这里的 () 就是函数调用操作符。
return 0;
}
函数调用操作符最少的操作符数量为1,就是无参调用的时候,操作数就只有函数名。
结构成员访问操作符
先说一下结构体是什么?
结构体
C 语言已经提供了内置类型,如:char、short、int、long、float、double等,但是只有这些内置类型还是不够的,假设我想描述学生,描述一本书,这时单一的内置类型是不行的。
描述一个学生需要名字、年龄、学号、身高、体重,分数等;
描述一本书需要书名、作者、出版社、定价等。
C 语言为了解决这个问题,增加了结构体这种自定义的数据类型,让程序员可以自己创造适合的类型。
- 结构是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量,如:标量、数组、指针,甚至是其他结构体。
- 内置类型:是 C/C++ 语言自带的、不需要自己定义的基础类型,比如 char、int、float、double 这些。(其中int*不是内置类型)
- 标量类型:是一个更宽泛的概念,指的是单个、不可再拆分的类型。(int*是标量类型,因为它不可拆分,内部只有一个值就是变量的地址,后面要讲到的enum也是标量,因为其生成的定义的变量一次只能存一个值,属于单值类的)
- 其中int arr[5]是可以拆分的,后面讲到的结构体也是可以拆分的,其定义的变量内部是可能存在多个值的。
- 内置的基础类型(如 int、char、float 等)都是标量,但标量不一定是内置类型,还可以是指针、枚举这类单个值的派生 / 自定义类型。
结构体的声明
struct Stu
{
char name[20];//名字
int age;//年龄
char sex[5];//性别
char id[20];//学号
}; //分号不能丢
这个只是声明,还没有定义,不要因为有分号就认为有定义了,要看是否有变量。
结构体变量的定义和初始化
c
//代码1:变量的定义
struct Point
{
int x;
int y;
}p1; //声明类型的同时定义变量p1(这里是声明加定义同时进行,函数中只有定义的时候是声明和定义在一起的,可以多次声明的,可以单独声明)
struct Point p2; //定义结构体变量p2(这个没有声明作用,只有定义作用,函数在定义的时候就自然声明了)
//代码2:初始化。
struct Point p3 = {10, 20};
struct Stu //类型声明
{
char name[15];//名字
int age; //年龄
};
struct Stu s1 = {"zhangsan", 20};//初始化
struct Stu s2 = {.age=20, .name="lisi"};//指定顺序初始化
//代码3
struct Node
{
int data;
struct Point p;
struct Node* next;
}n1 = {10, {4,5}, NULL}; //结构体嵌套初始化
struct Node n2 = {20, {5, 6}, NULL};//结构体嵌套初始化
结构体声明,定义,初始化可以一起,但是不会像函数一样定义就一定声明了,结构体只是可以这样,它也可以三个分开进行,互不影响。
结构体成员访问操作符
结构体成员的直接访问
结构体成员的直接访问是通过点操作符(.)访问的。点操作符接受两个操作数。
c
#include <stdio.h>
struct Point
{
int x;
int y;
}p = {1,2};
int main()
{
printf("x: %d y: %d\n", p.x, p.y);
return 0;
}
结构体成员的间接访问
这个涉及到了指针,后续我们会讲的。
操作符的优先级和结合性
这个在我的<<C语言变量及操作符(部分)>>的博客中有讲到,可以去看看。
表达式求值
整型提升
- C 语言中整型算术运算总是至少以缺省(默认)整型类型的精度来进行的。
为了获得这个精度,表达式中的字符和短整型操作数在使用之前被转换为普通整型,这种转换称为整型提升。
整型提升的意义:
- 表达式的整型运算要在 CPU 的相应运算器件内执行,CPU 内整型运算器 (ALU) 的操作数的字节长度一般就是int的字节长度,同时也是 CPU 的通用寄存器的长度。
- 因此,即使两个char类型的相加,在 CPU 执行时实际上也要先转换为 CPU 内整型操作数的标准长度。
- 通用 CPU 是难以直接实现两个 8 比特位直接相加运算的。所以,表达式中各种长度可能小于int长度的整型值,都必须先转换为int或unsigned int,然后才能送入 CPU 去执行运算。
//示例
char a,b,c;
...
a = b + c;
b和c是char类型的,要整型提升才可以计算,于是b和c从char类型的8个bit位提升到了32个bit位,计算结果就是32个bit位,然后要存入到a这个char变量中就要将32个bit位截断位8个bit位,然后再存入a中(就是char类型变量计算的时候要整型提升,从8->32,然后要存入char变量中就要截断从32->8,对于无符号字符类型可能会越界的,然后被截断的,但是在大多数编译器中char认为是有符号的,不会因为越界被截断的,但是会影响符号位的)
整型提升规则
有符号整数提升:高位补符号位。若原数值最高位是 1,补 1;是 0,补 0。
无符号整数提升:高位补0。
讲解一下为什么?
为了保证负数在整型提升中符号位不变,同时又不改变原有的数值大小(原码的数值位部分),就采用高位补 1 的方式。
这样做的原因是:
- 负数在计算机中以补码形式存储,高位补 1(补码规则),在后续运算(比如取反)时,就等价于原码高位补 0,能保证数值不变 。(同时因为补码变为原码采取的方式也是符号位不变,其他位按位取反,所以,高位补1,符号位也不会变的 )【要保证的是原码中的数值位不变,因为原码才是展现出来的,补码只是存在内存中的,用来方便计算的,所以要保证改变补码的时候,保证原码不变,看了一下在原码的符号位和数值位之间直接补0即可,那么我们只能改变补码的情况下,依据原码和补码的差距是取反+1的规律,并且取反的时候符号位不变,那么就直接高位补1,保证取反的时候,原码中为0,不改变数值位,而取反的时候符号位不变也保证了原码中的符号位不变】。
注意在计算的时候,可能两个正数相加的时候会溢出到符号位,导致结果变为了负数,这个时候结果还是补码哦,计算后的结果就是补码,只有在面临打印的时候和其他要展示的情况下,要转为原码。
算数转换
- 当运算符两侧的操作数类型不同时,为了能够进行运算,系统必须将其中一个操作数转换为另一个操作数的类型。这个转换遵循一个固定的层次体系。
类型转换层次(从高到低)(低位转高位,短的转长的,否则计算就不会准确的,因为会直接将一个溢出的数直接截断,会有极大的误差)

问题表达式解析
优先级和结合性你真的搞懂了吗?
在来讲问题表达式前我们先来将一下,我们之前将各种操作符的优先级和结合性,已经摆出来了,但是你真的明白优先级和结合性在哪里用了吗?
//表达式1
a * b + c * d + e * f
- 我们以这个表达式为例子来看看,编译器在处理的时候,会依据优先级和结合性对表达式进行各种捆绑,比如表达式1中我们都明确知道*的优先级是要大于+的,所以编译器会先将*捆绑起来,所以表达式也等价于(a*b)+(c*d)+(e*f),这样的好处在于处理后面的结合性那里,就是对*进行捆绑之后,我们发现,()里的内容可以类似看作一个变量,无论里面多么复杂,如果将其看作变量的时候,我们发现两个+是挨在一起的,中间没有任何的其他操作符(为什么要进行捆绑,就是这里可以方便理解,其实优先级高的本来就是先算的,但是加()就是为了更好观察那个操作符先算,本来*就是先算的,所以不加不会又任何的问题),到了这一步,其实我们就可以利用结合性了,+是左结合性的,所以最终可以化成这样[(a*b)+(c*d)]+(e*f),先算左边加号再算右边,那么你可能要问为什么*没有结合性 ,其实这一个表达式中*没有办法表现出结合性啊,因为结合性是用在相邻 的两个操作符优先级一样的情况下,来规定的计算顺序的,左结合性就左边先算,右就右先算。
- 通过这个我们总结出一套适合全部的问题表达式的逻辑,首先为什么会有问题表达式? 相信你通过上面例子的解释也不难看出,C语言官方规定的结合性是用在相邻 的两个操作符优先级一样 的情况下,来规定的计算顺序的,左结合性就左边先算,右就右先算,这个相邻就是编译的"空子",为什么? 就是因为这个相邻,导致如果遇到不相邻的,但是优先级是一样的操作符的话,编译器先算谁的是不被定义的,就会导致一个代码在不同平台,编译器下,会有各种各样的答案,上面举的例子是不会有这样情况的,但是如果a,b,c,d,e,f它们都变为了表达式,并且计算的结果和计算的先后顺序有关的话(比如a表达式中有g变量,f中也有,......,就会导致比如先算a,后算a的结果是不同的),就会导致这个平台兼容性很差,本质上我们是要用一个表达式在所有平台上都实现的是一个计算路径,得到的结果都是一样的才符合逻辑,所以不要写出这样的表达式了哦。
- OK,我们来将一套适合全部的问题表达式的逻辑,就是这样,如果你想看看你写的表达式是不是问题表达式的话,就这样,将最高优先级的操作符和它的操作数用()括起来 ,要是没有相邻的,就用不上结合性,如果有的话,就要用结合性了,怎么用? 再加一个括号,但是可以是[ ]括号,其他的也行,就是要区分一下,优先级的括号和优先级相同下的结合性的括号,至于优先级的辨别的话,就是只要从最内层的()开始计算即可,要是判断结合性的话,看看最近的那个()内的操作符都是什么即可,就知道他是哪个操作符的结合性了,然后不断的类似于递归的过程,然后最终就可以划分到最后一种操作符了,最后一种按照结合性来分即可,你也不需要全部化完的,只要发现,有两个以上的优先级相同的,又没有加括号运算符提高优先级的,有不是相邻的,用不了结合性的,都存在潜在风险的,不一定会错,但是风险少不了。
- 最后再来仔细讲讲编译器是怎么钻"空子"的,还是以a*b+c*d+e*f来举例,我们依据上面的原则来划分一下,[(a*b)+(c*d)]+(e*f),[ ]是不影响计算结果的,因为C语言官方只规定了要符合优先级和结合性即可,我们是优先优先级的,就是说[ ]对()产生不了阻碍的,那么三个()内的表达式谁先算并不清楚的,是由编译器的设计来决定的。
- 其实很多人看到这里还是会有疑问的,就是为什么C语言官方,就不可以让不相邻的优先级一样的运算符就之间也按照结合性来呢,就是为什么不把结合性概念扩展一下呢? 就是因为效率问题,当编译器看到系统内寄存器很多的时候,就可以同时用不同的寄存器算很多的值的,如果规定死了,就会导致效率暴跌的,会跌个30%左右,而推出结合性是为了正确性的,因为a-b-c如果没有结合性,让编译器,从右向左算的话,就会导致答案是错误的,而如果不相邻的不保证结合性,只要你不写出问题表达式,那么正确性上是可以保证的,那么这种情况下,自然是优先保证效率的。
以上就是我对于优先级和结合性的细致讲解了,
下面我们讲讲几个真的会在不同编译器上有不同计算路径和不同结果的表达式,让我们一起看看吧!
c
#include <stdio.h>
int fun()
{
static int count = 1;
return ++count;
}
int main()
{
int answer;
answer = fun() - fun() * fun();
printf( "%d\n", answer);//输出多少?
return 0;
}
这个也是典型的未定义行为了,就是*比-先算是对的,但是那个fun先调用取决于编译器,一旦一个表达式的结果要取决于编译器了,这个表达式就是问题表达式了
c
#include <stdio.h>
int main()
{
int i = 1;
int ret = (++i) + (++i) + (++i);
printf("%d\n", ret);
printf("%d\n", i);
return 0;
}
同样的,也是类似的问题++先算,但是每一个++都是不相邻的,这个单目还是天生就不能相邻的类型嘞,那么优先级一样,又不相邻,先算谁就由编译器决定了。
第二类问题表达式
//表达式2
c + --c;
- 操作符的优先级只能决定自减 -- 的运算在 + 的运算的前面,但是我们并没有办法得知, + 操作符的左操作数的获取在右操作数之前还是之后求值,所以结果是不可预测的,是有歧义的。(C语言官方并未强制规定)
- 这个的主要问题就是优先级和变量的具体数值什么时候给之间的问题,就是-- 优先级是比较高的,但是什么时候给c一个值这个就是编译器决定的了,优先级决定了- -先算,此时+也并未先算,但是c的具体数值是算完后给,还是算之前给是未知的,是编译器决定的,这个就是问题表达式。
问题表达式的总结
即使有了操作符的优先级和结合性,我们写出的表达式依然有可能不能通过操作符的属性确定唯一的计算路径,那这个表达式就是存在潜在风险的,建议不要写出特别复杂的表达式。
总结
我们的操作符讲完了,下期就是指针的讲解了,谢谢大家!