C语言基础三件套:数据类型、常量与const限定符全面解析
发布时间:2026/10/1 15:21:10来源:尧图网络
我改作业的时候经常碰到一种典型场面C语言新手能把代码写得风生水起但你追问一句“int到底占几个字节”“const能不能直接当数组长度”“指针常量和常量指针谁锁谁”很多人当场卡壳。基本数据类型、常量、const限定符这三位是C语言最底层的地基恰恰又是大家学得最囫囵的地方。这篇不绕弯子把这三块掰开揉碎讲透适合正在刷翁恺课程或者啃《C Primer Plus》的初学者也适合被笔试题里const组合拳折磨过的复习党。1. 基本数据类型全解析C语言的基本数据类型说白了就是内存的刻度。程序里的变量不过是一块内存的别名数据类型决定了两件事这块内存有多大以及里面的0和1怎么被解释。同一个二进制序列01000001按char看是字符A按int看就是数字65按float看又是一个完全不同的浮点数。这就是类型的本质。1.1 整型家族C语言里的整数不止int一种而是分成了short、int、long、long long四兄弟再加上有无符号之分一共八种组合。C标准只规定了它们的最小尺寸没有把字节数写死short至少16位int至少16位long至少32位long long至少64位。在绝大多数现代平台上实际布局是这样的类型常见平台字节数典型取值范围以4字节int为例char1-128 ~ 127 或 0 ~ 255short2-32768 ~ 32767int4-2147483648 ~ 2147483647long4或8Linux 64位下是8至少 -2^31 ~ 2^31-1long long8-2^63 ~ 2^63-1新手最容易踩的坑是long。很多教材还停留在“long占4字节”的旧观念但在Linux的64位系统上long实打实是8字节在Windows 64位上又是4字节。平台差异这么大所以你写的代码不该依赖某个类型的固定字节数而应该用sizeof计算或者直接用stdint.h里的int32_t、uint64_t这些精确宽度类型。有符号整数在内存里用的是补码表示。最高位是符号位正数原码就是本身负数则取反加一。比如-1在32位int里是全32个1而不是“负号后面跟一个1”。补码的好处是加减法不用区分符号电路实现简单。这也解释了为什么int的取值范围不是对称的正数最大到2147483647负数最小能到-2147483648多出来的那个负数是-0的位置被复用成了最小值。unsigned类型把符号位也用来存数值所以unsigned int能到0 ~ 4294967295。但别指望它“多一位精度”它只是把负数那半边挪成了更大的正数。什么时候用无符号位运算、计数器、或者明确不会出现负数的场景。什么时候别用写循环倒着数的时候for (unsigned i n; i 0; i--)是死循环因为i减到0再减就回绕成了UINT_MAX。1.2 浮点型和字符型浮点型家族有float、double、long double。float占4字节double占8字节按IEEE 754标准存储分成符号位、指数位、尾数位三段。float的有效数字大约6到7位double是15到16位。别小看这个差别我曾见过有人用float存接口返回的毫秒级时间戳累加一天之后误差已经能到几十毫秒。#include stdio.h int main(void) { float f 123456789.123456789f; double d 123456789.123456789; printf(float 的精度表现: %.6f\n, f); printf(double 的精度表现: %.15f\n, d); return 0; }输出结果会让你印象很深刻float版小数点后几位基本是乱的double版也只能精确到前面15位左右。浮点数的“范围大”是假象指数位让它能表示3.4e38这么大的数但精度是实打实的限制。所以不要用直接比较浮点数而是比较绝对值差是否小于某个误差阈值。字符类型char本质上是1字节的小整数。它存的是字符编码最常见的ASCII码里A是65a是970是48。char参与算术运算时会自动提升成int所以A 1的结果是66对应的字符是B。还有一个隐秘的坑C标准并没有规定char究竟是signed char还是unsigned char这由实现决定。如果你要用char做算术运算或者存小数值最好显式写成signed char或unsigned char否则在极端边界情况下的行为可能不一致。1.3 类型转换与截断C语言是静态类型语言但允许隐式类型转换。核心规则一句话char和short先提升成int然后小的向大的转有符号和无符号混用时有符号会转换成无符号。比如int和unsigned int一起运算int会被转成unsigned int于是-1 1u这个表达式在C语言里是真值因为-1被解释成了4294967295。类型转换带来的截断问题更值得警惕。把int赋值给char时超过低8位的部分会被直接丢掉等价于对256取模无符号情况下。比如int x 0x12345678; char c x; // 只保留低8位即0x78我在实际调试中看到过有人把设备上报的温度值直接塞进char结果温度一超过127就变成负数查了半天才发现是截断问题。强制类型转换(int)expr能解决显式需求但改变不了底层表示的丢失该截断还是会截断。2. 常量从字面值到符号常量常量就是程序运行期间值不会变的数据。C语言里的常量不是一个单一概念它至少有三种形态字面常量、符号常量宏、枚举常量。三种形态的用途和坑完全不一样。2.1 字面常量字面常量就是你直接写在代码里的值10、3.14、A、hello。每个字面量都有默认类型细节多到能单独写一篇文章。整型字面量默认是int类型。10是十进制012是八进制等于100x0A是十六进制等于10。后缀用来强制类型10U是无符号10L是long10LL是long long10UL是无符号长整型。我见过不少人在写位运算掩码的时候忘了加U后缀结果带符号数做右移符号位扩展把结果搞错。浮点字面量默认是double想存成float要加f或F比如3.14f。想指定long double就加L。字符字面量A的类型其实是int不是char这在sizeof(A)的时候会暴露出来——在32位平台上是4而不是1。转义序列也算字符常量\n换行、\t制表、\x41十六进制转义、\101八进制转义。字符串字面量hello的类型是char[6]尾部的\0是字符串结束标志。所以sizeof(hello)是6不是5。这个\0是无数新手调试噩梦的源头——strlen数到它为止数组空间不够就会缓冲区越界。2.2 符号常量符号常量用#define定义本质是预处理器做的文本替换#define PI 3.1415926535 #define MAX_SIZE 1024宏在编译之前就把代码里的PI替换成了数字所以它有两大好处不占内存、没有运行时开销。但它也有一个著名的坑宏只是文本替换没有类型检查。更经典的坑是宏参数不加括号#define SQUARE(x) x * x int y SQUARE(1 1); // 展开成 1 1 * 1 1结果是3正确写法必须给每个参数和整体都加括号#define SQUARE(x) ((x) * (x))。这条规则我每次讲宏都要强调因为真的见太多次了。宏还有#undef可以取消定义也可以用#ifdef做条件编译这些是后话。如果把#define、const、enum放在一起对比它们的定位差异就很清楚方式本质类型检查作用时机典型用途#define文本替换无预处理常量、宏函数、条件编译const只读变量有编译期运行期不被修改的数据enum枚举常量有编译期一组相关的命名整型常量2.3 枚举常量和“表达式必须含有常量值”枚举是另一种定义常量的方式enum Color { RED, GREEN, BLUE };不指定值时从0开始依此递增也可以手动指定后续的自动加一enum Status { OK 0, ERROR -1, UNKNOWN 1 }。枚举值的本质是int常量所以它能用在所有要求整型常量的地方。这就引出了那个在编译器中报烂了的错误表达式必须含有常量值expression must have a constant value。典型场景有三种const int N 5; int a[N]; // 场景1数组长度 switch (x) { case N: } // 场景2case标签 struct S { int bit : N; }; // 场景3位域宽度头两个场景在C语言里很可能直接报错。原因在于const int N修饰的变量是“只读的变量”但它本质还是变量不是编译期常量。编译期常量只有字面量、枚举常量、sizeof表达式等少数几种。数组长度、case标签、位域宽度这些位置在C标准里明确要求“整数常量表达式”而const int不在其列C里又是另外一套规则别拿C的思维写C。解决方案很朴素。要么用#define N 5要么用枚举enum { N 5 }。很多初学者想不通“为什么const不行我用它不就是为了不变吗”这个问题我在第三节还会细讲。3. const限定符只读不是一个常量const是C语言里被误解最深的关键字没有之一。它真正的含义是“只读”而不是“常量”。3.1 const的定位与初始化const修饰变量后这个变量的值在初始化之后就不允许被直接修改const int MAX 100; MAX 200; // 编译错误正因为这样const变量必须在声明时就初始化。后面没法赋值你不给它初值它就永远是个不确定的只读垃圾值。const和#define最大的区别是const变量有自己的类型会经过编译器类型检查也能被调试器看到宏在预处理阶段就被替换掉了调试器里根本没有这个符号。生成机器码时const int MAX通常会被分配到只读数据段试图写它会触发段错误相当于硬件层面的保护。但别急着把const当常量用。C语言里const int N 5;做数组长度的问题我在2.3节已经说了它会报错。C99引入变长数组之后某些编译器允许int a[N]把它当VLA处理但VLA有栈溢出风险而且不是所有C编译器都支持。所以稳定做法依然是编译期需要常量位置用宏或枚举运行期只是不想被改用const。还有一个被很多人试过的骚操作用强制类型转换去掉const。比如const int x 10; int *p (int *)x; *p 20; // 危险行为这在语法上能编译通过但x如果被放进了只读段运行时直接段错误就算没崩修改const对象也是未定义行为编译器的优化可能会让结果完全不符合预期。永远不要在正经代码里这么干。3.2 指针常量和常量指针这是笔试题的重灾区区分方法其实很简单看const紧挨着谁写法语义类比const int *pconst修饰int目标不可改指针可改手里的钥匙可以换但门锁死了int * const pconst修饰p指针不可改目标可改钥匙固定了但门能开const int * const p两者都不可改钥匙固定门也锁死注意const int *p和int const *p是等价的都是常量指针指向常量的指针只是写法习惯不同。记住这个口诀const靠近谁谁就不能变。用代码演示最直观#include stdio.h int main(void) { int x 10, y 20; const int *p x; p y; // OK指针可以换指向 // *p 30; // 错误不能通过p修改目标 int * const q x; *q 30; // OK目标可以改 // q y; // 错误指针本身不能换 const int * const r x; // r y; // 错误 // *r 30; // 错误 return 0; }很多人一开始分不清我就让他们背“钥匙和门”的例子。后来发现还有一种更真实的记忆法把const int *p理解成“p看的是一个不能动的值”把int * const p理解成“p自己焊死了”。写代码时不确定就停下来问一句我这个const到底是想锁值还是锁指针这个习惯能省掉大量调试时间。3.3 const在函数与项目实战中的应用const在真实项目里用得最多的地方是函数参数。传数组或指针进函数时如果不打算修改数据一定要在参数上加const这既是给调用者的承诺也是给自己上保险size_t my_strlen(const char *s) { size_t len 0; while (s[len] ! \0) { len; } return len; }const char *s保证了函数体内不会误改传入字符串的内容。如果不小心在函数里写了*s A编译器会直接报错而不是等到运行时崩溃。再比如打印数组void print_array(const int *arr, int n) { for (int i 0; i n; i) { printf(%d , arr[i]); } putchar(\n); }这里的const int *arr告诉编译器我只读这块内存不会动它。如果另一个函数需要修改数组比如排序那就用int *arr不加const。这种区分度一旦建立代码的接口自文档化就完成了一大半。函数返回值也能加const比如const char *get_name(void)意思是返回的字符串不允许被修改适合返回内部只读数据。4. 异常排查与避坑心法基础概念讲完了但真到写代码的时候报错和诡异行为往往来自几个反复出现的暗坑。这里整理成一份可直接对照的排查手册。4.1 类型转换的隐藏陷阱第一个陷阱是整型溢出。int能装的最大值是2147483647再加1就变成-2147483648这是有符号溢出的回绕行为。注意C标准对signed溢出其实没有定义编译器可能优化成完全不同的逻辑最好的做法是提前判断边界或者在需要大数的时候直接用long long。第二个陷阱是浮点数精度。0.1 0.2在绝大多数机器上不等于0.3而是0.30000000000000004。这跟十进制表示无关是因为二进制浮点数无法精确表示0.1。做金额计算用整数分单位做科学计算用double并接受误差别用float存高精度数据。第三个陷阱是无符号和有符号混用。写这段代码时我经常用来当面试题int a -1; unsigned int b 1; if (a b) { printf(a b\n); } else { printf(a b\n); }结果是a b因为a被隐式转换成了unsigned int4294967295。这种转换发生在混合运算里没有什么警告非常坑。排查方法是打开编译器告警gcc加-Wsign-compare看到告警就立刻重构。第四个陷阱是整数除法。5 / 2的结果是2而不是2.5因为两个整数相除的结果还是整数。想得到浮点结果至少把一个操作数转成浮点5 / 2.0。4.2 经典报错速查与处理报错场景典型提示处理方法用const int做数组长度expression must have a constant value改用#define或enum对数组名直接赋值assignment to expression with array type用指针操作或memcpyscanf没加取地址符variable is being used without being initialized改成变量宏展开结果被运算符优先级坑无报错逻辑错宏内整体加括号字符串拷贝越界stack smashing detected用strncpy并确认空间浮点比较失败无报错分支不进改成差值绝对值比较scanf忘了是我在答疑里排第一的错误。scanf(%d, n)能编译过但一运行就段错误因为scanf需要的是地址而n是值。有些IDE会在新版本里给出警告但C语言初学者经常忽略警告直接跑后果就是崩溃得莫名其妙。还有一种和字符串常量相关的报错比如把hello当成char*去修改char *p hello; p[0] x;。字符串字面量在C标准里是只读的修改它是未定义行为。正确写法是char p[] hello;这样会生成可修改的字符数组。4.3 避坑心法根据我的实际经验下面几条能减少90%的排查时间一是编译时把告警级别拉满。gcc -Wall -Wextra -Werror把警告当错误处理。很多隐藏问题在编译阶段就被暴露了。C语言是粗心的开发者也能正常编译的语言你在它面前越严格越好。二是用sizeof而不是硬编码尺寸。需要知道数组长度时写成sizeof(arr) / sizeof(arr[0])平台换了一样能跑。我自己移植过Linux程序到Windows因为硬编码的字节数导致的内存布局错位查了两天。三是变量必须初始化。C语言的局部变量不是默认0而是上次某个函数用剩的栈内容它可能是任何值。忘了初始化会导致“明明没改动值却一直在变”的灵异现象。四是写代码时多问自己一句这个数据需要被修改吗如果需要不加const如果不需要务必加const。这个习惯能养成接口清晰的代码风格也能提前把误改的风险交给编译器。5. 综合实战演练概念讲了这么多最终要看能不能落在代码上。我这里演示两个小巧的实战练习把前面所有内容串起来。5.1 用limits.h打印数据范围C标准头文件limits.h定义了一组描述各类型范围的宏float.h定义了浮点类型的精度参数。我把它们一次性打印出来亲身感受一下数据类型到底是怎么限定你的程序的#include stdio.h #include limits.h #include float.h int main(void) { printf(char 范围: %d ~ %d\n, CHAR_MIN, CHAR_MAX); printf(short 范围: %d ~ %d\n, SHRT_MIN, SHRT_MAX); printf(int 范围: %d ~ %d\n, INT_MIN, INT_MAX); printf(unsigned int 范围: 0 ~ %u\n, UINT_MAX); printf(long 范围: %ld ~ %ld\n, LONG_MIN, LONG_MAX); printf(long long 范围: %lld ~ %lld\n, LLONG_MIN, LLONG_MAX); printf(float 有效数字: %d 位\n, FLT_DIG); printf(double 有效数字: %d 位\n, DBL_DIG); return 0; }如果你在Linux 64位下编译运行会看到long的范围是922亿亿级而在Windows下只有20亿级。这就是为什么跨平台代码不该假设long的宽度。跑这段程序的正确姿势是换到三种环境各跑一遍比背十遍教材都管用。5.2 给冒泡排序加上const经典冒泡排序谁都会写但能不能把const用对才是检验理解程度的试金石。我给排序和打印各设计了一个函数看看该在哪加const#include stdio.h void bubble_sort(int *arr, int n) { for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; } } } } void print_array(const int *arr, int n) { for (int i 0; i n; i) { printf(%d , arr[i]); } putchar(\n); } int main(void) { int values[] { 5, 2, 9, 1, 7 }; print_array(values, 5); // 修改前打印只读权限 bubble_sort(values, 5); // 修改数组需要int *权限 print_array(values, 5); // 修改后打印 return 0; }bubble_sort必须用int *arr因为排序过程要交换元素用const的话编译器直接拒绝。print_array用const int *arr因为它只读数据不该动。如果有人在print_array里手滑写了arr[0] 0编译期就会报错——这就是const的护城河效应。最后在main里把同一个数组先只读打印、再修改、再只读打印接口的权限边界一目了然。我个人的体会是这三个主题看起来简单但把它们彻底分清之后很多后续的高级话题都顺了。理解类型转换能帮你读得懂API的签名理解常量能帮你设计更好的全局配置理解const能帮你写出意图清晰的函数接口。如果非要给一条捷径那就是亲手把上面两个实战练习敲一遍然后在自己的项目里坚持给“只读函数参数”加const跑通一次你就再也不会混淆了。
网站建设高端定制企业官网