C语言操作符详解:从二进制到表达式求值(面试复习版)

📖 本文目录

  • [1. 操作符的分类](#1. 操作符的分类)
  • [2. 二进制和进制转换](#2. 二进制和进制转换)
    • [2.1 二进制转十进制](#2.1 二进制转十进制)
    • [2.2 二进制转八进制和十六进制](#2.2 二进制转八进制和十六进制)
  • [3. 原码、反码、补码](#3. 原码、反码、补码)
  • [4. 移位操作符](#4. 移位操作符)
    • [4.1 左移操作符](#4.1 左移操作符)
    • [4.2 右移操作符](#4.2 右移操作符)
  • [5. 位操作符:&、|、^、~](#5. 位操作符:&、|、^、~)
  • [6. 单目操作符](#6. 单目操作符)
  • [7. 逗号表达式](#7. 逗号表达式)
  • [8. 下标访问\[\]、函数调用()](#8. 下标访问[]、函数调用())
  • [9. 结构成员访问操作符](#9. 结构成员访问操作符)
  • [10. 操作符的属性:优先级、结合性](#10. 操作符的属性:优先级、结合性)
  • [11. 表达式求值](#11. 表达式求值)
  • [12. 小总结](#12. 小总结)
  • [13. 经典面试题](#13. 经典面试题)

1. 操作符的分类

C语言提供了丰富的操作符,它们是构建表达式的基础。根据功能,我们可以将操作符分为以下几类:

  • 算术操作符+-*/%
  • 移位操作符<<>>
  • 位操作符&|^~
  • 赋值操作符=+=-=*=/=%=<<=>>=&=|=^=
  • 单目操作符!++--&*+-~sizeof(类型)
  • 关系操作符>>=<<===!=
  • 逻辑操作符&&||
  • 条件操作符? :
  • 逗号表达式,
  • 下标引用[]
  • 函数调用()
  • 结构成员访问.->

算术操作符、赋值操作符、逻辑操作符、条件操作符和部分单目操作符我们已经在前面的文章中学习过。今天,我们将重点介绍与二进制密切相关的操作符,以及一些容易混淆的细节。

2. 二进制和进制转换

我们经常听到 2进制、8进制、10进制、16进制 这样的说法。其实,它们只是数值的不同表示形式而已。

例如,数值15的各种进制表示:

  • 15的2进制:1111
  • 15的8进制:17
  • 15的10进制:15
  • 15的16进制:F

小贴士 :在C语言中,16进制数值前加 0x,8进制数值前加 0

2.1 二进制转十进制

10进制是我们最熟悉的。10进制中,每一位都有权重:个位是 10 0 10^0 100,十位是 10 1 10^1 101,百位是 10 2 10^2 102,以此类推。

例如,10进制的 123 表示: 1 × 10 2 + 2 × 10 1 + 3 × 10 0 = 100 + 20 + 3 = 123 1 \times 10^2 + 2 \times 10^1 + 3 \times 10^0 = 100 + 20 + 3 = 123 1×102+2×101+3×100=100+20+3=123

二进制同理,每一位的权重是 2 0 2^0 20、 2 1 2^1 21、 2 2 2^2 22......

例如,2进制的 1101 表示: 1 × 2 3 + 1 × 2 2 + 0 × 2 1 + 1 × 2 0 = 8 + 4 + 0 + 1 = 13 1 \times 2^3 + 1 \times 2^2 + 0 \times 2^1 + 1 \times 2^0 = 8 + 4 + 0 + 1 = 13 1×23+1×22+0×21+1×20=8+4+0+1=13

十进制转二进制

十进制转二进制的方法是除2取余,逆序排列

2.2 二进制转八进制和十六进制

二进制转八进制

8进制的每一位数字是 0~7,而 7 的二进制是 111,只需要3个二进制位就能表示。因此,从二进制序列的右边低位开始,每3个二进制位换算成一个八进制位,剩余不够3位的直接换算。

例如,二进制 01101011 转八进制:

结果为 0153(以 0 开头表示八进制)。

二进制转十六进制

16进制的数字每⼀位是0~9, a~f 的,0~9, a~f的数字,各⾃写成2进制,最多有4个2进制位就⾜够了,⽐如 f 的⼆进制是1111,所以在2进制转16进制数的时候,从2进制序列中右边低位开始向左每4个2进制位会换算⼀个16进制位,剩余不够4个⼆进制位的直接换算。

例如,二进制 01101011 转十六进制:

结果为 0x6b(以 0x 开头表示十六进制)。

3. 原码、反码、补码

整数的二进制表示方法有三种:原码、反码和补码

对于有符号整数,这三种表示方法都包含符号位数值位 两部分。二进制序列的最高位是符号位,0 表示正,1 表示负,剩余位是数值位。

  • 正整数的原码、反码、补码都相同
  • 负整数的三种表示方法各不相同

负整数的三种编码

-10 为例(假设int占4字节,32位):

  1. 原码:直接将数值按照正负数的形式翻译成二进制。

    • -10 的原码:10000000 00000000 00000000 00001010
  2. 反码:将原码的符号位不变,其他位按位取反。

    • -10 的反码:11111111 11111111 11111111 11110101
  3. 补码:反码 + 1。

    • -10 的补码:11111111 11111111 11111111 11110110

重要 :对于整形来说,数据存放在内存中实际存放的是补码

为什么使用补码?

在计算机系统中,数值一律用补码来表示和存储。原因在于:

  • 使用补码,可以将符号位和数值域统一处理;
  • 加法和减法也可以统一处理(CPU只有加法器);
  • 补码与原码相互转换,其运算过程是相同的(取反+1),不需要额外的硬件电路。

4. 移位操作符

移位操作符的操作数只能是整数。

  • <<:左移操作符
  • >>:右移操作符

4.1 左移操作符

移位规则:左边抛弃,右边补0

c 复制代码
#include <stdio.h>
int main()
{
    int num = 10;
    int n = num << 1;
    printf("n = %d\n", n);   // 输出 20
    printf("num = %d\n", num); // 输出 10,num本身不变
    return 0;
}

num 的二进制是 00000000 00000000 00000000 00001010,左移1位后变为 00000000 00000000 00000000 00010100,即20。

4.2 右移操作符

右移运算分两种:

  1. 逻辑右移:左边用0填充,右边丢弃。
  2. 算术右移:左边用原该值的符号位填充,右边丢弃。

注意:C语言标准没有明确规定右移是逻辑右移还是算术右移,这取决于编译器。但大多数编译器对于有符号整数采用算术右移,对于无符号整数采用逻辑右移。

c 复制代码
#include <stdio.h>
int main()
{
    int num = 10;
    int n = num >> 1;
    printf("n = %d\n", n);   // 输出 5
    printf("num = %d\n", num); // 输出 10
    return 0;
}

逻辑右移1位演示:

算数右移1位演示:

警告 :对于移位运算符,不要移动负数位,这是标准未定义的。例如 num >> -1 是错误的。

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

位操作符的操作数必须是整数。

操作符 名称 规则
& 按位与 两个位都为1时,结果为1
` ` 按位或
^ 按位异或 两个位相同为0,相异为1
~ 按位取反 0变1,1变0
c 复制代码
#include <stdio.h>
int main()
{
    int num1 = -3;
    int num2 = 5;
    printf("%d\n", num1 & num2);
    printf("%d\n", num1 | num2);
    printf("%d\n", num1 ^ num2);
    printf("%d\n", ~0);
    return 0;
}

经典面试题:不创建临时变量,交换两个整数

c 复制代码
#include <stdio.h>
int main()
{
    int a = 10;
    int b = 20;
    a = a ^ b;
    b = a ^ b;
    a = a ^ b;
    printf("a = %d  b = %d\n", a, b);
    return 0;
}

原理 :异或运算满足交换律和结合律,且 a ^ a = 0a ^ 0 = a

练习1:求一个整数存储在内存中的二进制中1的个数

方法1(取模法,有局限性):

c 复制代码
#include <stdio.h>
int main()
{
    int num = 10;
    int count = 0;
    while (num)
    {
        if (num % 2 == 1)
            count++;
        num = num / 2;
    }
    printf("二进制中1的个数 = %d\n", count);
    return 0;
}

思考:这种方法对于负数会出问题,因为负数取模的结果不是我们期望的。

方法2(逐位判断,循环32次):

c 复制代码
#include <stdio.h>
int main()
{
    int num = -1;
    int i = 0;
    int count = 0;
    for (i = 0; i < 32; i++)
    {
        if (num & (1 << i))
            count++;
    }
    printf("二进制中1的个数 = %d\n", count);
    return 0;
}

方法3n & (n-1) 技巧,高效):

c 复制代码
#include <stdio.h>
int main()
{
    int num = -1;
    int count = 0;
    while (num)
    {
        count++;
        num = num & (num - 1);
    }
    printf("二进制中1的个数 = %d\n", count);
    return 0;
}

原理n & (n-1) 这个操作会消去 n 的二进制表示中最右边的那个1。循环几次,就有几个1。这是非常高效的算法,建议牢记。

练习2:二进制位置0或置1

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

13的2进制序列: 00000000000000000000000000001101

将第5位置为1后:00000000000000000000000000011101

将第5位再置为0:00000000000000000000000000001101

c 复制代码
#include <stdio.h>
int main()
{
    int a = 13;
    // 将第5位置为1
    a = a | (1 << 4);
    printf("a = %d\n", a); // 输出 29
    // 将第5位再置为0
    a = a & ~(1 << 4);
    printf("a = %d\n", a); // 输出 13
    return 0;
}

技巧总结

  • 将某位置1:a |= (1 << n)
  • 将某位置0:a &= ~(1 << n)

6. 单目操作符

单目操作符的特点是只有一个操作数。包括:!++--&*+-~sizeof(类型)

其中 &* 与指针相关,我们将在学习指针时详细介绍。

7. 逗号表达式

exp1, exp2, exp3, ...expN

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

c 复制代码
// 代码1
int a = 1;
int b = 2;
int c = (a > b, a = b + 10, a, b = a + 1); // 逗号表达式
// c 是多少?

执行过程:

  1. a > b:结果为假,但不影响后续
  2. a = b + 10:a 变为 12
  3. a:值为12,但不赋值
  4. b = a + 1:b 变为 13,这是最后一个表达式,所以 c = 13

实用场景:简化循环中的重复代码。

c 复制代码
// 改写前
a = get_val();
count_val(a);
while (a > 0)
{
    // 业务处理
    a = get_val();
    count_val(a);
}

// 使用逗号表达式改写后
while (a = get_val(), count_val(a), a > 0)
{
    // 业务处理
}

8. 下标访问\[\]、函数调用()

下标引用操作符 \[\]

操作数:一个数组名 + 一个索引值(下标)。

c 复制代码
int arr[10];   // 创建数组
arr[9] = 10;   // 使用下标引用操作符,[]的两个操作数是arr和9

函数调用操作符 ()

接受一个或多个操作数:第一个操作数是函数名,剩余的操作数是传递给函数的参数。

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;
}

9. 结构成员访问操作符

9.1 结构体

C语言提供了内置类型(如 charshortintlongfloatdouble 等),但有时我们需要描述更复杂的数据,比如一个学生(名字、年龄、学号、身高、体重),这时就需要结构体这种自定义数据类型。

结构是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量,如标量、数组、指针,甚至是其他结构体。

结构体的声明
c 复制代码
struct tag
{
    member-list;
} variable-list;

例如,描述一个学生:

c 复制代码
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 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};

9.2 结构成员访问操作符

结构体成员的直接访问(.

通过点操作符(.)访问,点操作符接受两个操作数。

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 复制代码
#include <stdio.h>
struct Point
{
    int x;
    int y;
};
int main()
{
    struct Point p = {3, 4};
    struct Point *ptr = &p;
    ptr->x = 10;
    ptr->y = 20;
    printf("x = %d y = %d\n", ptr->x, ptr->y);
    return 0;
}

使用方式:结构体指针->成员名

综合举例
c 复制代码
#include <stdio.h>
#include <string.h>
struct Stu
{
    char name[15]; // 名字
    int age;       // 年龄
};
void print_stu(struct Stu s)
{
    printf("%s %d\n", s.name, s.age);
}
void set_stu(struct Stu *ps)
{
    strcpy(ps->name, "李四");
    ps->age = 28;
}
int main()
{
    struct Stu s = {"张三", 20};
    print_stu(s);
    set_stu(&s);
    print_stu(s);
    return 0;
}

10. 操作符的属性:优先级、结合性

C语言的操作符有2个重要的属性:优先级结合性,这两个属性决定了表达式求值的计算顺序。

10.1 优先级

优先级指的是,如果一个表达式包含多个运算符,哪个运算符应该优先执行。

c 复制代码
3 + 4 * 5;

由于乘法的优先级高于加法,所以先计算 4 * 5,而不是先计算 3 + 4

10.2 结合性

如果两个运算符优先级相同,优先级没办法确定先计算哪个了,这时候就看结合性了。大部分运算符是左结合 (从左到右执行),少数运算符是右结合 (从右到左执行),比如赋值运算符(=)。

c 复制代码
5 * 6 / 2;

*/ 的优先级相同,它们都是左结合运算符,所以从左到右执行,先计算 5 * 6,再计算 / 2

部分运算符优先级(从高到低)

  1. 圆括号 ()
  2. 自增运算符 ++,自减运算符 --
  3. 单目运算符 +-
  4. 乘法 *,除法 /
  5. 加法 +,减法 -
  6. 关系运算符 <>
  7. 赋值运算符 =

由于圆括号的优先级最高,可以使用它来改变其他运算符的优先级。

11. 表达式求值

11.1 整型提升

C语言中整型算术运算总是至少以缺省整型类型的精度来进行的。为了获得这个精度,表达式中的 charshort 类型操作数在使用之前会被转换为普通整型,这种转换称为整型提升

整型提升的意义 :表达式的整型运算要在CPU的相应运算器件内执⾏,CPU内整型运算器(ALU)的操作数的字节⻓> 度⼀般就是int的字节⻓度,同时也是CPU的通⽤寄存器的⻓度。

因此,即使两个char类型的相加,在CPU执⾏时实际上也要先转换为CPU内整型操作数的标准⻓度。

通⽤CPU(general-purpose CPU)是难以直接实现两个8⽐特字节直接相加运算(虽然机器指令中可能有这种字节相> 加指令)。所以,表达式中各种⻓度可能⼩于int⻓度的整型值,都必须先转换为int或unsigned int,然后才能送CPU去执⾏运算。

c 复制代码
// 实例
char a, b, c;
a = b + c;

bc 的值被提升为普通整型,然后再执行加法运算。加法运算完成之后,结果将被截断,然后再存储于 a 中。

如何进行整型提升?

  1. 有符号整数提升是按照变量的数据类型的符号位来提升的。
  2. 无符号整数提升,高位补0。
c 复制代码
// 负数的整形提升
char c1 = -1;
// 变量c1的二进制位(补码)中只有8个比特位:11111111
// 因为 char 为有符号的 char
// 所以整形提升的时候,高位补充符号位,即为1
// 提升之后的结果:11111111 11111111 11111111 11111111

// 正数的整形提升
char c2 = 1;
// 变量c2的二进制位(补码)中只有8个比特位:00000001
// 因为 char 为有符号的 char
// 所以整形提升的时候,高位补充符号位,即为0
// 提升之后的结果:00000000 00000000 00000000 00000001

// 无符号整形提升,高位补0

11.2 算术转换

如果某个操作符的各个操作数属于不同的类型,那么除非其中一个操作数的转换为另一个操作数的类型,否则操作就无法进行。下面的层次体系称为寻常算术转换

复制代码
long double
double
float
unsigned long int
long int
unsigned int
int

如果某个操作数的类型在上面这个列表中排名靠后,那么首先要转换为另外一个操作数的类型后执行运算。

11.3 问题表达式解析

即使有了操作符的优先级和结合性,有些表达式依然存在歧义,因为优先级和结合性不能决定所有子表达式的求值顺序

表达式1
c 复制代码
a * b + c * d + e * f

由于 *+ 的优先级高,只能保证 * 的计算比 + 早,但是优先级并不能决定第三个 * 比第一个 + 早执行。所以可能的计算顺序有:

复制代码
顺序1:a*b → c*d → a*b + c*d → e*f → a*b + c*d + e*f
顺序2:a*b → c*d → e*f → a*b + c*d → a*b + c*d + e*f
表达式2
c 复制代码
c + --c;

操作符的优先级只能决定自减 -- 的运算在 + 的运算的前面,但是我们无法得知 + 操作符的左操作数的获取在右操作数之前还是之后求值,所以结果是不可预测的。

表达式3
c 复制代码
int main()
{
    int i = 10;
    i = i-- - --i * (i = -3) * i++ + ++i;
    printf("i = %d\n", i);
    return 0;
}

这个表达式在不同编译器中的测试结果不同,属于非法表达式。

表达式4
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;
}

虽然大多数编译器结果相同,但函数的调用先后顺序无法通过操作符的优先级确定,因此代码存在潜在问题。

表达式5
c 复制代码
#include <stdio.h>
int main()
{
    int i = 1;
    int ret = (++i) + (++i) + (++i);
    printf("%d\n", ret);
    printf("%d\n", i);
    return 0;
}

gcc编译器执行结果:

VS2022运行结果:

在gcc和VS2022下运行结果不同,因为第一个 + 在执行时,第三个 ++ 是否执行是不确定的。这段代码中的第⼀个 + 在执⾏的时候,第三个**++**是否执⾏,这个是不确定的,因为依靠操作符的优先级和结合性是⽆法决定第⼀个 + 和第三个前置 ++ 的先后顺序。

11.4 总结

即使有了操作符的优先级和结合性,我们写出的表达式依然有可能不能通过操作符的属性确定唯一的计算路径,那这个表达式就是存在潜在风险的。建议不要写出特别复杂的表达式,保持代码的清晰和可读性。

12. 小总结

本文详细介绍了C语言中各类操作符的用法,重点讲解了与二进制密切相关的移位操作符、位操作符,以及原码、反码、补码的概念。同时,我们还深入探讨了结构体、逗号表达式、整型提升、算术转换等进阶话题,并通过多个问题表达式解析,揭示了操作符优先级和结合性的局限性。

核心要点回顾

  • 数据在内存中以补码形式存储
  • 位操作是C语言的精髓,n & (n-1) 等技巧需要熟练掌握
  • 结构体是自定义数据类型的基础
  • 复杂表达式存在歧义,应避免编写

13. 经典面试题

面试题1:不创建临时变量,交换两个整数的值

c 复制代码
#include <stdio.h>
int main()
{
    int a = 10, b = 20;
    a = a ^ b;
    b = a ^ b;
    a = a ^ b;
    printf("a = %d, b = %d\n", a, b);
    return 0;
}

解答 :利用异或运算的性质:a ^ a = 0a ^ 0 = a,且异或满足交换律和结合律。

面试题2:求一个整数二进制表示中1的个数

c 复制代码
int count_ones(int num)
{
    int count = 0;
    while (num)
    {
        count++;
        num = num & (num - 1);
    }
    return count;
}

解答n & (n-1) 会消去 n 最右边的1,循环次数就是1的个数。时间复杂度为 O(1的个数),效率很高。

面试题3:将整数的某一位设置为1或0

c 复制代码
// 将第n位置1
void set_bit(int *num, int n)
{
    *num |= (1 << n);
}

// 将第n位置0
void clear_bit(int *num, int n)
{
    *num &= ~(1 << n);
}

解答 :置1用 |,置0用 & 配合取反。

面试题4:判断一个数是否是2的幂次方

c 复制代码
int is_power_of_two(int n)
{
    return n > 0 && (n & (n - 1)) == 0;
}

解答 :2的幂次方的二进制表示中只有一个1,n & (n-1) 会消去这个1,结果为0。

面试题5:以下代码的输出结果是什么?

c 复制代码
#include <stdio.h>
int main()
{
    unsigned char a = 200;
    unsigned char b = 100;
    unsigned char c = 0;
    c = a + b;
    printf("%d\n", c);
    return 0;
}

解答a + b = 300,但 unsigned char 的范围是 0~255,发生截断。300的二进制是 1 0010 1100,截断后取低8位 0010 1100,即44。所以输出 44。这道题考察了整型提升和截断的概念。