【C语言】操作符详解

一、操作符分类

算数操作符:【+、-、*、/、%】

移位操作符:<<、>>

位操作符:&、|、^、~

赋值操作符:【=、+=、-=、*=、/=、%=】、<<=、>>=、&=、|=、^=

单目操作符:【!、+、-、sizeof、(类型)、++、--】、~、&、*

关系操作符:【>、>=、<、<=、==、!=】

逻辑操作符:【&&、||】

条件操作符:【? :】

逗号表达式:,

下标引用:【[]】

函数调用:【()】

        【】里面的操作符在之前已经学习过,今天学习的是没有被【】括起来的操作符。

        【+、-、*、/、%】、【=、+=、-=、*=、/=、%=】、【!、+、-、sizeof、(类型)、++、--】请看文章:http://t.csdnimg.cn/msdld 操作符部分。

        【>、>=、<、<=、==、!=】、【&&、||】、【? :】请看文章:http://t.csdnimg.cn/drUIS 操作符部分。

        【[]】请看文章:http://t.csdnimg.cn/OF3yE 访问一维数组元素的部分。

        【()】请看文章:http://t.csdnimg.cn/3CtAH 函数调用操作符部分。

        下面涉及原码、反码、补码的知识,请看文章:http://t.csdnimg.cn/sn9lE

二、移位操作符

        注意:

  • 移位操作符的操作数只能是整数;它右边的操作数(移动几位)只能是非负整数
  • 移动的位数范围是0~31(int类型4个字节为32 bit)。超过这个范围是不符合标准的,会给出警告。

(1)左移位操作符

        规则:左抛弃,右补0。

        例子:-12<<2

         -12

         = [1000 0000 0000 0000 0000 0000 0000 1100]原

         = [1111 1111 1111 1111 1111 1111 1111 0011]反

         = [1111 1111 1111 1111 1111 1111 1111 0100]补

        左移两位:

        [1111 1111 1111 1111 1111 1111 1101 0000]补

        = [1111 1111 1111 1111 1111 1111 1100 1111]反

        = [1000 0000 0000 0000 0000 0000 0011 0000]原

        = -48

(2)右移位操作符

        规则:

        逻辑右移:右抛弃,左补0。

        算术右移:右抛弃,左补原数的符号。

        例子:-12>>2 

        -12

        = [1111 1111 1111 1111 1111 1111 1111 0100]补

        ① 逻辑右移两位:

        [0011 1111 1111 1111 1111 1111 1111 1101]补

        = 1073741821

        ② 算术右移两位:

        [1111 1111 1111 1111 1111 1111 1111 1101]补

        = [1111 1111 1111 1111 1111 1111 1111 1100]反

        = [1000 0000 0000 0000 0000 0000 0000 0011]原

        = -3

        执行逻辑右移还是算术右移取决于编译器,编译器通常是算术右移。

        代码验证:

        观察上面的例子,可以发现左移操作符有乘以2的幂次方的效果,算术右移操作符有除以2的幂次方的效果,移动几位,幂指数就是几。

三、位操作符

        下面按位的意思是按照二进制位(bit)计算,且它们的操作数必须为整数。

  • 按位与(&):两者都为1,取1;其它取0。
  • 按位或(|):两者都为0,取0;其它取1。
  • 按位异或(^):两者相同取0;相反取1。
  • 按位取反(~):每一位都取反。

        举例:

        5

        = [0000 0000 0000 0000 0000 0000 0000 0101]补

        -3

        = [1000 0000 0000 0000 0000 0000 0000 0011]原

        = [1111 1111 1111 1111 1111 1111 1111 1100]反

        = [1111 1111 1111 1111 1111 1111 1111 1101]补

        5 & -3

        = [0000 0000 0000 0000 0000 0000 0000 0101]补

        = 5

        5 | -3

        = [1111 1111 1111 1111 1111 1111 1111 1101]补

        = [1000 0000 0000 0000 0000 0000 0000 0010]取反

        = [1000 0000 0000 0000 0000 0000 0000 0011]原

        = -3

        5 ^ -3

        = [1111 1111 1111 1111 1111 1111 1111 1000]补

        = [1000 0000 0000 0000 0000 0000 0000 0111]取反

        = [1000 0000 0000 0000 0000 0000 0000 1000]原

        = -8

        0

        = [0000 0000 0000 0000 0000 0000 0000 0000]补

        ~0

        = [1111 1111 1111 1111 1111 1111 1111 1111]补

        = [1000 0000 0000 0000 0000 0000 0000 0000]取反

        = [1000 0000 0000 0000 0000 0000 0000 0001]原

        = -1

        代码验证:

四、移位、位操作符的综合练习

(1)不用临时变量,交换两个整数

        题目:不能创建临时变量(第三个变量),实现两个整数的交换。

        若不考虑“不能创建临时变量”这个条件的情况下,我们通常使用的方法是:       

        但这里不能使用临时变量,需要想想其它方法。

        方法1:(加减法实现)

        首先,我们要明白,第三个变量是用来保留数据,避免数据在交换过程中被覆盖掉的。现在,我们不能使用第三个变量,那么就需要换一种方式把a、b的数据都保留下来。

        第37行a被赋值为 原a + 原b,这样既保留了 原a 的数据,又保留了 原b 的数据。然后,第38行,b被赋值为 a - 原b 等于 原a + 原b - 原b 等于 原a,此时a还保留着 原a+原b,不用担心原b的数据被覆盖掉了,在这一过程中,完成了 原a 向 原b 赋值。第39行a被赋值为 a - b 等于 原a + 原b - 原a 等于 原 b ,在这一过程中,完成了 原b 向 原a 赋值。

        但是这种方法有很大的局限性,就是当 a 和 b 的值过大时,因为第一个式子有加法运算,所以会发生溢出,得到不正确的值。

        因此,我们有需要寻找其它更优的办法。

        方法2:(位异或操作符^实现)

        因为 ^ 就是两数相同则为0,两数不同则为1,所以自身位异或自身等于0,如a^a得0,b^b得0。因为0^0得0,1^0得1,所以一个数位异或0等于自身,如a^0得a,b^0得b。

        故第52行的b被赋值为:a ^ b 等于 原a ^ 原b ^ 原b 等于 原a ^ 0 等于 原a,这一过程完成了原a 向 原b 赋值;第53行a被赋值为:a^b 等于 原 a ^ 原b ^ 原a 等于 原b ^ 0 等于 原b,这一过程完成了 原b 向 原a 赋值。

(2)求内存中整数的二进制中1的个数

        题目:求一个整数存储在内存中的二进制中1的个数。

        如果想计数1的个数,我们会想到取出该整数的每一位数字。取出每一位数字有一个常用的方法,就是:

        那么对于二进制整数,我们可以换成 %2 和 /2 :

        1234

        = [0000 0000 0000 0000 0000 0100 1101 0010]补

        当二进制整数为整数时,计算结果正确。

        -1234

        = [1000 0000 0000 0000 0000 0100 1101 0010]原

        = [1111 1111 1111 1111 1111 1011 0010 1101]反

、    = [1111 1111 1111 1111 1111 1011 0010 1110]补

        当二进制整数为负数时,计算结果错误。分析原因:因为a是负数,所以不管是余数还是商都是带有负号的,对于余数来说只有0和-1两情况,所以 一次都没计数。因此,a % 2 == 1对负数并不适用。如果把 a % 2 == 1 改成 a % 2 == -1 呢?结果如下:

        对比负数 -1234 的原码会发现只计数了原码的数值位上的1,符号位上的1根本没被计入;并且题目要求的是,计数在内存中整数的二进制中1的个数,内存中是用的补码形式而不是原码形式。因此,这种取余、求商的方法,是不能解决负数的情况的。

        方法1:

        首先,要清楚位与的规则,全1才为1,存在0就为0。然后,举例说明:

        -2

        = [1000 0000 0000 0000 0000 0000 0000 0010]原

        = [1111 1111 1111 1111 1111 1111 1111 1101]反

        = [1111 1111 1111 1111 1111 1111 1111 1110]补

        [1111 1111 1111 1111 1111 1111 1111 1110] ^ [0000 0000 0000 0000 0000 0000 0000 0001]

        = 0 (结果为0,第一位为0,不计数)

        [1111 1111 1111 1111 1111 1111 1111 1110] ^ [0000 0000 0000 0000 0000 0000 0000 0010]

        = [0000 0000 0000 0000 0000 0000 0000 0010](结果非0,第二位为1,计数器加1)

        ……

        从上面的例子可以看到,用这种方法可以计数负数补码的每一位1。与-2作位与的数字中1的位置变化可以用左移位操作符实现。

        方法2:

        首先思考下面的问题:

        二进制:若n = 1100,则1100 - 0001 = 1011

        二进制:若n = 1000,则1000 - 0001 = 0111

        ……

        观察上面的结果,我们可以得到一个结论,n - 1 会将 n 的最低位1改为0,最低位1后面的0都改为1。原因:减1必向最低位1借一个1,被借1后自然变成了0;而最低位1后面的0,因为自身没有1,都会向前一位借1,来减当前这个1,又因为是二进制,向前一位借到的1其实是2,2-1 = 1,最终最低位1后面的0都变为了1。

        因此,原数最低位的1以及之后的0,在n-1后都被更改为相反数,相反数(0和1)作位与的结果会是0;而n的最低位1前面的数在n-1后都未改变,1 & 1 得1,0 & 0 得0,即自身和自身作位与的结果是自身。

        综上所述,n & (n - 1) 的结果是,n的最低位1之前的高位数不变,之后的低位数以及最低位1都变为0。得到的效果简而言之就是,会将n最低位的1改为0

        源代码的最终效果:n 的补码中有多少个1,就会进多少次 while 循环,每次进循环都会更改一个1并计数器加1,直到所有的1都被更改为0,n的值变为0结束循环。

        对比方法1:方法1必须要对补码的每一位作位与运算,对int类型来说,要循环固定的32次,才能统计出1的个数;而方法2则是有多少个1,就作几次循环,效率比方法2更优。

(3)二进制位置0或置1

        题目:编写代码将13的二进制序列的第5位修改为1,然后再改为0。

        13 = [0000 0000 0000 0000 0000 0000 0000 1101]补

        第5位置1:[0000 0000 0000 0000 0000 0000 0001 1101]补 = 29

        第5位置回0:13

        代码及运行结果:

五、单目操作符

        还剩&和*,在后面学习指针后再讲解。

六、逗号表达式

        语法形式,用逗号隔开多个表达式:

表达式1, 表达式2, 表达式3......

        逗号表达式从左向右依次执行表达式,整个表达式的结果是最后一个表达式的结果。

        应用场景:

a = get_val();
count_val(a);
while(a){
    ... // 业务处理

    a = get_val();
    count_val(a);
}


可用逗号表达式改写:
while(a = get_val(), count_val(a), a){
    ... // 业务处理
}

        改写后,可以减少冗余的代码。

七、结构体成员访问操作符

(1)结构体

        我们有了内置类型还不够,比如我想描述一个学生,他有姓名、年龄、性别、学号....,这时应该怎么定义这个学生呢?于是,自定义类型结构体解决了这个问题。

        结构体是一些变量的集合,这些变量叫做它的成员变量,每个成员可以是不同类型的变量:字符型、整型、浮点型,甚至其它的结构体类型。

(2)结构体的声明

        语法形式如下:

struct tag{
    成员列表
}变量列表;

举例:学生结构体类型的声明
struct student{
    char name[20]; // 姓名
    int age; // 年龄
    char sex[5]; // 性别
    char id[20]; // 学号
};

(3)结构体变量的定义和初始化

// 结构体类型变量的定义:
// 班级结构体类型声明
struct Class{
    int grade[20]; // 年级
    char college[20]; // 学院
    char major[20]; // 专业
    char class[20]; // 班级
};

struct Class p1; // 定义班级结构体类型的变量p1(第一种方式的定义)

// 学生结构体类型的声明
struct Student{
    char name[20]; // 姓名
    int age; // 年龄
    char sex[5]; // 性别
    char id[20]; // 学号
    struct Class class; // 班级
} stu1, stu2; // 声明结构体类型的同时,定义变量(第二种方式的定义)


// 结构体类型变量的初始化:
struct Student stu3 = {'zhangsan', 20, 'male', '12003990101', NULL}; // 默认顺序初始化
struct Student stu4 = {.age = 21, .name = 'xiaoming', .sex = 'famale', .id = '12003990102', .class = NULL}; // 指定顺序初始化

struct Point{
    int data;
    struct Point p;
} n1 = {1, {2, NULL}}; // 结构体嵌套初始化

struct Point p1 = {.p = {.p.p = NULL, .p.data = 2}, .data = 2}; // 结构体嵌套初始化

(4)结构体成员访问操作符

① 结构体成员的直接访问

        直接访问用操作符(.),如下例子:

#include<stdio.h>

struct Point{
    int x;
    int y;
} p = {1, 2};

int main(){
    printf("%d, %d", p.x, p.y); // 结构体变量.成员名
    return 0;
}

② 结构体成员的间接访问

    暂留,学习完指针后补充

八、操作符的属性:优先级和结合性

        操作符的优先级和结合性两个属性,决定了表达式的计算顺序。

(1)优先级

        对于相邻的两个操作符,优先级高的操作符先执行计算,优先级低的操作符后执行计算。如下例子:

5 + 3 * 4

        乘号 * 比加号 + 的优先级高,所以先执行3 * 4 得 12,再执行5 + 12。

(2)结合性

        对于相邻的两个操作符的优先级相同时,无法再用操作符的优先级判断它们执行顺序。这时,就根据操作符的结合性决定执行顺序。结合性分为左结合(从左向右执行)和右结合(从右向左执行)。如下例子:

2 * 6 / 4 

        因为乘号 * 和除号 / 的优先级相同,所以根据结合性决定执行顺序。* 和 / 都为左结合,故先执行2 * 6 得 12,再执行 12 / 4。

(3)优先级和结合性表

        参考: C 运算符优先级 - cppreference.com

        这些也不用全记住,需要的时查表就行,况且还有万能的()。不过我们记住一些常用的更好:

  • 圆括号(()):最高级,从左到右。
  • 后缀自增与自减(++、--):1级,从左到右。
  • 前缀自增与自减(++、--):2级,从右到左。
  • 单目运算符(+、-):2级,从右到左。
  • 乘除法(*、/):3级,从左到右。
  • 加减法(+、-):3级,从左到右。
  • 关系运算符(<、>、<=、>=):6级,从左到右。
  • 各种赋值运算符:倒数第2级,从右到左。

九、表达式求值

(1)整型提升

        整型提升就是,对于字符型短整型的操作数(长度小于 int 类型),当他们在表达式中发生运算时,会先提升为 int 类型,再进行计算。

        为什么要进行整型提升:整型运算要在CPU中的器件 ALU 里执行,里面的操作数字节长度一般是 int 类型字节长度,同时CPU的通用寄存器里的操作数也是这个长度。而通用CPU是难以实现8 bit 长度的直接相加运算(虽然机器指令中可能有 8 bit 长度的相加指令),因此,对于长度小于 int 类型的整型会先转换为标准的 int 或者 unsigned int,再送进CPU执行运算。

        整型提升规则:

  • 有符号整数:高位补符号位上的值。
  • 无符号整数:高位补0。 

        在整型提升后,送入CPU执行运算完毕,会将 int 类型长度的数据截断为原本定义的数据类型长度,最后存储到变量中。举例:

(2)算术转换

        若一个操作符的操作数的类型不同时,要先将它们的类型转换成同一类型,才能进行运算。下面的层次体系被成为寻常算术转换

        例如:

        a 为 double 类型8字节,b  为 int 类型 4字节,根据转换层次体系,b转换为 double 类型,得到计算结果也为 double类型 8字节。

十、问题表达式的解析

(1)表达式1

a * b + c * d + e * f

        对于这个表达式,我们可以确定的是,相邻的 * 比 + 的优先级高,故先执行 * 再执行 + 。但是我们确定的只是相邻的 * 和 + 的执行顺序,不相邻的 * 和 + 的执行顺序是确定不了的。因此,第一个 + 和第三个 * (不相邻)到底是哪个先执行,是存在歧义的

        执行顺序可以是这样:

        按照优先级,1和2比4先执行;2和3比5先执行。按照 + 的结合性(左结合),4比5先执行。在这个执行路线中,第一个 + 比第三个 * 执行。

        执行顺序也可以是这样:

        按照优先级,1和2比3先执行;2和4比5先执行。按照 + 的结合性(左结合),3比5先执行。在这个执行路线中,第一个 + 比第三个 * 执行。

(2)表达式2

c + --c

        对于这个表达式,我们可以确定的是: -- 比 + 的优先级高,故先执行 -- 再执行 + 。但是,到底是执行表达式前就把值放入了第一个c里,还是执行了--c后,才把值放入第一个c里,是存在歧义的。

        假如c的值初始化为1。若在执行表达式前就把值放入了第一个c里,那么 1 + 0 得 1;若执行了--c后,才把值放入第一个c里,那么 0 + 0 得 0。

(3)表达式3

int main()
{
    int i = 10;
    i = i-- - --i * ( i = -3 ) * i++ + ++i;
    printf("i = %d\n", i);
    return 0;
}

        此代码来自于《C和指针》这本书,作者将该代码在不同的编译器中执行,得到了截然不同的结果:

        可以得出,由于这些歧义,以及不同编译器的实现不同,而导致执行结果不同。这种有歧义的代码,可移植性是很差的。

(4)表达式4

#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 函数是无从而知的,这也造成了歧义。
        如果执行顺序是:

        结果为:2 - 3 * 4 得 2 - 12 等于-10。

        如果执行顺序是: 

        结果为:3 - 2 * 4 得 3 - 8 等于 -5。

        当然还有其它的执行顺序,就不一一例举。

(5)表达式5

#include <stdio.h>
int main()
{
    int i = 1;
    int ret = (++i) + (++i) + (++i);
    printf("%d\n", ret);
    printf("%d\n", i);
    return 0;
}

        这个代码在gcc编译器上运行的结果:

        在VS2019上运行的结果:

        为什么运行得到的结果不同呢?其实还是跟表达式1类似的问题:我们只知道相邻 ++ 比 + 的优先级高,+ 为左结合,但是不相邻的第一个 + 和第三个 ++ 哪个先执行是不知道的。

        对于VS2019的编译器,是先执行第三个 ++,后执行第一个 +:执行第一个 ++i,i为2 >> 执行第二个 ++i,i为3 >> 执行第三个 ++i,i为4 >> 执行第一个 + ,4 + 4 为 8 >> 执行第二个 + ,8 + 4 为12。

        对于gcc编译器,是先执行第一个 +,后执行第三个 ++:执行第一个 ++i,i为2 >> 执行第二个 ++i,i为3 >> 执行第一个 + ,3 + 3 为 6 >> 执行第三个 ++i,i为4 >> 执行第二个 + ,6 + 4 为10。

        以上的过程,都可以通过调试中的反汇编窗口验证(以下是VS2019的):

(6)总结

        就算有了优先级和结合性,表达式仍然可能不能通过操作符的属性来确定唯一的路径。为了降低表达式存在的风险,我们写表达式时,建议将一个复杂表达式分解为简单的表达式

  • 31
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值