Lesson 09: 整型转字符串 CNB
练习任务
本课包含两个互相关联的练习:
练习 1(itoa):在 main 中实现整型转十进制字符串——读取用户输入的整数,用 do-while 逐位提取数字并转为字符存入数组,最后逆序数组输出。
期望输出:
buf = 123buf = 9876练习 2(itoa_hex):实现 itoa_hex 函数,将整数转为十六进制字符串(大写 A-F)。输入 255 输出 FF,输入 0 输出 0。
提示:十进制 itoa 的核心是每次取
num % 10得到一位数字,加上'0'转为 ASCII 字符——但这样存入数组的顺序是逆序的(个位在前),你需要写一个逆序交换算法。字符串末尾别忘了'\0'终止符。十六进制版本只需把基数改为 16,并用映射表"0123456789ABCDEF"代替+ '0'。
核心知识点
- 字符数组声明与索引 —
char buf[N]在栈上分配 N 个连续字节,索引从 0 开始 sizeof运算符与栈内存布局 —sizeof(buf)返回数组总字节数,数组在栈上按地址递增排列- 数组退化指针 — 数组名作为函数参数时退化为指向首元素的指针,丢失长度信息
- C 字符串与 NUL 终止符 — C 字符串是以
'\0'(ASCII 码 0)结尾的字符数组,printf("%s")依赖终止符定位结尾 - ASCII 字符编码全表 — 数字
0~9的 ASCII 码连续排列(48~57,即 0x30~0x39),字母 A~Z(65~90)、a~z(97~122) digit + '0'原理 — 数字 0~9 的 ASCII 码连续,digit + 48即得到对应字符的 ASCII 码do-while处理 num==0 — 当输入为 0 时必须至少执行一次,输出"0"而非空串- 字符映射表 —
"0123456789ABCDEF"用数组下标索引实现数字→十六进制字符的映射(0→'0', 10→'A', 15→'F') - 双指针逆序算法 — 头尾两指针向中间靠拢,对称交换,含完整追踪表
tmp临时变量交换 — 经典三步骤,XOR 无变量交换与加减法交换的适用场景与优缺点- 进制转换统一框架 — 十进制 10、十六进制 16、二进制 2、八进制 8 的统一提取与映射
itoa函数签名设计 — 返回char*支持链式调用(printf("%s", itoa(num, buf))),参数用数组接收结果#define函数式宏定义 vsinline函数 — 宏的文本替换机制、副作用陷阱与 inline 的类型安全- 三元运算符
?:深度回顾 — 简洁的条件赋值、嵌套三元与可读性边界 - 缓冲区溢出 —
itoa的溢出风险、数组大小估算(32 位 int 最多 12 字符) - 负数处理 —
%运算符对负数的行为、符号位处理策略 snprintfvs 自定义itoa— 标准库的安全性优势与学习价值对比- 字符串拷贝与指针传递 — 函数传数组的本质是传指针,修改会影响调用方
sizeof与数组 — 栈数组sizeof返回总字节数,退化后sizeof返回指针大小
代码框架
练习 1:十进制 itoa
#include <stdio.h>
int main(void)
{
int num;
char buf[10];
int i = 0;
int j = 0;
scanf("%d", &num);
// 第一步: 用 do-while 逐位提取数字并转为字符
// buf[i] = num % 10 + '0'; // 取个位,转为字符
// i++;
// num /= 10; // 去掉个位
// 直到 num == 0
// 第二步: 添加字符串终止符
// buf[i] = '\0';
// 第三步: 逆序 buf,让高位在前
// 用 j 从头,i-1 从尾,向中间交换
printf("buf = %s\n", buf);
return 0;
}练习 2:十六进制 itoa_hex
#include <stdio.h>
char *itoa_hex(int num, char *buf)
{
char *hex = "0123456789ABCDEF"; // 数字到十六进制字符的映射表
int i = 0;
// 第一步: do-while 提取十六进制位
// int rest = num % 16;
// buf[i++] = hex[rest]; // 用映射表查字符
// num /= 16;
// 第二步: 加 \0
// 第三步: 逆序
return buf;
}
int main(void)
{
int num;
char buf[64];
scanf("%d", &num);
itoa_hex(num, buf);
printf("%s\n", buf);
return 0;
}填充框架的关键思考:'0' 加上一个数字后为什么能变成对应的字符?do-while 和 while 的区别在这里为什么至关重要?逆序算法中两指针的起始位置分别是什么?
TIP
先不要往下翻看参考解答。尝试自己实现 itoa 和 itoa_hex——先做完十进制再做十六进制,你会发现两个算法框架惊人地相似。
深度讲解
1. 字符数组:字符串的容器
1.1 数组的声明与内存布局
char buf[10];这行代码做了两件事:
- 在栈上分配了 10 个连续字节的内存空间
- 数组名
buf代表这片内存的首地址(即&buf[0])
栈内存布局 (buf[10]):
低地址 高地址
↓ ↓
buf → ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ ? │ ? │ ? │ ? │ ? │ ? │ ? │ ? │ ? │ ? │ 初始为垃圾值(未初始化)
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
[0] [1] [2] [3] [4] [5] [6] [7] [8] [9]数组索引从 0 开始——buf[0] 是第一个元素,buf[9] 是最后一个元素。buf[10] 是越界访问,属于未定义行为(Undefined Behavior, UB)。C 标准明确规定:访问数组边界之外的内存,程序的行为完全不可预测——可能读回垃圾值,可能覆盖其他变量,可能触发段错误(Segmentation Fault),甚至可能表面上「正常工作」。
1.2 数组的读写与下标运算
buf[i] = num % 10 + '0'; // 写入:在第 i 个位置存储值
char ch = buf[3]; // 读取:取第 3 个位置的值buf[i] 的访问是 O(1)——编译器将其翻译为 *(buf + i),即从首地址偏移 i 个元素位置:
// 以下两行完全等价(编译器生成相同的机器码)
buf[i] = 'A';
*(buf + i) = 'A'; // 指针算术:buf + i 指向第 i 个元素,* 解引用这种 [] 运算符本质上是指针算术的语法糖。理解这一点对于后续理解数组退化(array decay)和指针运算至关重要。
WARNING
C 语言不检查数组越界。写 buf[1000] 超出数组范围,编译器不报错,运行时可能覆盖栈上的其他变量、返回地址等——导致段错误或更隐蔽的数据损坏。这是 C 语言与 Java、Python 等高级语言的关键区别之一:性能优先于安全,程序员自己负责边界检查。
1.3 sizeof 运算符与数组
sizeof 是编译期运算符(不是函数),用于获取类型或变量占用的字节数:
#include <stdio.h>
int main(void)
{
char buf[10];
int arr[5];
double darr[3];
printf("sizeof(buf) = %zu\n", sizeof(buf)); // 10 = 10 * 1
printf("sizeof(arr) = %zu\n", sizeof(arr)); // 20 = 5 * 4(32 位 int)
printf("sizeof(darr) = %zu\n", sizeof(darr)); // 24 = 3 * 8
// 计算数组元素个数
printf("元素个数: %zu\n", sizeof(buf) / sizeof(buf[0])); // 10 / 1 = 10
return 0;
}关键点:sizeof 对数组名返回整个数组的字节数,但对指针返回指针本身的字节数(通常是 4 或 8 字节)。这引出了「数组退化」的概念。
1.4 数组退化(Array Decay)
当数组作为函数参数传递时,它会退化为指向首元素的指针,丢失长度信息:
#include <stdio.h>
void func(char arr[]) // arr 实际上是一个 char* 指针
{
printf("func 内 sizeof(arr) = %zu\n", sizeof(arr)); // 输出 8(指针大小),而非数组大小!
}
int main(void)
{
char buf[100];
printf("main 内 sizeof(buf) = %zu\n", sizeof(buf)); // 输出 100
func(buf); // 传入的是 &buf[0]
return 0;
}输出示例(64 位系统):
main 内 sizeof(buf) = 100
func 内 sizeof(arr) = 8这就是为什么 itoa(num, buf) 调用时,函数内部无法通过 sizeof 获取数组长度——必须由调用方确保数组足够大,或额外传入长度参数。这也是很多 C 安全漏洞(缓冲区溢出)的根源。
IMPORTANT
数组退化是 C 语言最核心的概念之一。记住两条规则:① 数组名在表达式中(除 sizeof 和 & 操作数外)退化为指针;② 函数参数中 char arr[] 和 char *arr 完全等价。
2. C 字符串与 NUL 终止符
2.1 C 字符串的本质
C 语言没有内置的「字符串类型」——字符串就是以 '\0'(ASCII 码 0,称为 NUL 字符)结尾的字符数组。
char s[] = {'H', 'e', 'l', 'l', 'o', '\0'};
// 索引: 0 1 2 3 4 5内存布局:
s → ┌───┬───┬───┬───┬───┬───┐
│ H │ e │ l │ l │ o │\0 │
└───┴───┴───┴───┴───┴───┘
65 101 108 108 111 0 ← ASCII 值printf("%s", s) 的工作原理:从 s 首地址开始,逐个输出字符,直到遇到 '\0'——这就是它知道字符串在哪结束的方式。没有长度参数,没有边界信息,完全依赖终止符。
2.2 printf("%s") 的停止规则:逐字节扫描
#include <stdio.h>
int main(void)
{
char s[] = {'H', 'e', 'l', 'l', 'o', '\0', 'X', 'Y', 'Z'};
// ^^^^
// printf 在此停止,后面的 'X''Y''Z' 不会被输出
printf("%s\n", s); // 输出: Hello
return 0;
}printf("%s") 并不「知道」数组的长度——它只是忠实地从起始地址逐字节读取并输出,直到遇到值为 0 的字节。如果 '\0' 丢失或位置错误,printf 会越过数组边界继续读取,直到偶然遇到内存中的某个 0 字节,或触发段错误。
2.3 忘记 '\0' 的后果
char buf[10];
buf[0] = 'A';
buf[1] = 'B';
// 忘记 buf[2] = '\0';
printf("%s\n", buf); // 输出: AB?????... 然后可能段错误printf 会从 AB 之后继续读取,直到碰巧遇到内存中某个字节为 0——可能输出一长串垃圾字符,甚至访问非法内存导致段错误。
IMPORTANT
字符串操作后永远不要忘记 '\0'。这是 C 语言字符串编程的第一条军规。无数安全漏洞(包括经典的 Heartbleed、缓冲区溢出攻击)都与 '\0' 处理不当有关。
2.4 字面量字符串自带 '\0'
char *hex = "0123456789ABCDEF";编译器自动在末尾添加 '\0',所以实际上 hex 指向 17 个字符(16 个可见字符 + 1 个 '\0')。
内存中: '0''1''2''3''4''5''6''7''8''9''A''B''C''D''E''F''\0'
索引: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16这就是为什么 hex[15] == 'F',而 hex[16] == '\0'。字面量字符串在只读数据段(.rodata)中分配,修改它会导致段错误。
2.5 字符串的遍历模式
// 模式一:用索引遍历直到 \0
int len = 0;
while (buf[len] != '\0')
len++;
// 模式二:用指针遍历(更地道的 C 写法)
char *p = buf;
while (*p != '\0')
p++;
int len = p - buf; // 指针差 = 字符串长度两种模式等价,但指针版本在许多编译器上生成更高效的代码。本课的双指针逆序算法本质上是模式一的变体。
3. ASCII 编码与数字到字符的转换
3.1 完整 ASCII 表概览
ASCII(American Standard Code for Information Interchange)使用 7 位编码,共 128 个字符。以下是关键区间:
十进制 十六进制 字符范围 含义
─────────────────────────────────────────
0 0x00 NUL 空字符(字符串终止符)
1~31 0x01~0x1F 控制字符(如 \n=10, \t=9, \r=13)
32 0x20 (空格)
33~47 0x21~0x2F !"#$%&'()*+,-./ 标点符号
48~57 0x30~0x39 0~9 数字字符(连续!)
58~64 0x3A~0x40 :;<=>?@ 标点符号
65~90 0x41~0x5A A~Z 大写字母(连续!)
91~96 0x5B~0x60 [\]^_` 标点符号
97~122 0x61~0x7A a~z 小写字母(连续!)
123~126 0x7B~0x7E {|}~ 标点符号
127 0x7F DEL 删除字符3.2 digit + '0' 的深层原理
关键洞察:数字字符的码值是连续的——'0' 到 '9' 的码值依次为 48~57(十六进制 0x30~0x39)。
int digit = 5;
char ch = digit + '0'; // 5 + 48 = 53 = '5' 的 ASCII 码转换表:
digit + '0' (=48) → ASCII码 → 字符
0 + 48 = 48 → '0'
1 + 48 = 49 → '1'
2 + 48 = 50 → '2'
...
9 + 48 = 57 → '9'可以用十六进制更直观地表达:digit + 0x30。'0' 的码值 0x30 恰好是 48 的十六进制表示——这种巧合(或者说刻意的设计)使得十六进制调试时数字字符很容易识别。
反过来,字符转回数字只需 ch - '0':
char ch = '7';
int digit = ch - '0'; // 55 - 48 = 7这是 C 语言处理数字字符的基础技巧——在后续的字符串解析、数字校验等场景中反复使用。
3.3 十六进制映射:为何不能用 + '0'
十六进制中 10~15 分别对应 A~F,但 A 的 ASCII 码(65)不紧跟在 9(57)之后——中间隔着标点符号字符:
ASCII: ... '9'(57) ':'(58) ';'(59) '<'(60) '='(61) '>'(62) '?'(63) '@'(64) 'A'(65) ...如果用 digit + '0' 处理十六进制:
int digit = 10;
char ch = digit + '0'; // 10 + 48 = 58 = ':' 错误!期望的是 'A'所以十六进制转换不能用简单的 digit + '0',而需要映射表(lookup table):
char *hex = "0123456789ABCDEF"; // 索引即十六进制值
buf[i] = hex[rest]; // rest=0→'0', rest=10→'A', rest=15→'F'映射表的精妙之处:数组索引直接对应数字值,数组值直接给出字符——一次 O(1) 的数组访问,没有条件判断,没有算术偏移。这是用数组做查表映射的经典实例。
3.4 大小写字母的 ASCII 关系
大写字母 A(65)和小写字母 a(97)恰好相差 32(0x20):
char upper = 'G'; // 71
char lower = upper + 32; // 103 = 'g'
// 或使用位运算:lower = upper | 0x20; // 将第 5 位置 1这在实现不区分大小写的字符串比较时非常有用。例如十六进制输出通常要求大写("0123456789ABCDEF"),但某些场景下也需要小写("0123456789abcdef")——只需换一张映射表即可。
4. do-while:为什么 num==0 也必须执行一次
4.1 问题:如果输入是 0
// while 版本(错误!)
int i = 0;
while (num != 0) // num=0 时条件为假,循环体不执行!
{
buf[i++] = num % 10 + '0';
num /= 10;
}
buf[i] = '\0'; // buf[0] = '\0' → 空字符串!
printf("%s\n", buf); // 输出空行(不是 "0")而 do-while 版本:
// do-while 版本(正确!)
int i = 0;
do
{
buf[i++] = num % 10 + '0'; // 0 % 10 = 0, '0'+0 = '0'
num /= 10; // 0 / 10 = 0
} while (num != 0); // num=0,退出
buf[i] = '\0'; // buf = "0\0"至少执行一次可以正确处理 num = 0 的情况——buf 中有一个字符 '0',然后 '\0' 终止。
4.2 do-while 的执行语义分析
while 和 do-while 的区别不仅仅是「先判断还是后判断」的语法差异——它们表达了不同的语义承诺:
| 循环结构 | 执行次数 | 语义承诺 |
|---|---|---|
while (condition) | 0 或多次 | 「可能不执行」 |
do { } while (condition); | 1 或多次 | 「至少执行一次」 |
在 itoa 场景中,「至少执行一次」是数学必然性——任何整数的十进制表示至少有一位数字(0 也有一位:「0」)。选择 do-while 是选择了与问题本质匹配的结构。
4.3 从 Lesson 08 到 Lesson 09
回顾 Lesson 08 的 find 函数也是用 do-while(逐位提取数字判断是否等于 9)——同一个原因:即使 num=0,也至少有一位数字需要处理。find(0, 9) 应该检查个位 0 是否等于 9(结果为 0),而不是直接返回 0(表示「没有数字 9」)。
IMPORTANT
do-while 不是语法糖——它表达了一种语义承诺:「循环体至少执行一次」。选择 while 还是 do-while,取决于你对「至少一次」的需求,而非个人偏好。如果逻辑上循环体至少执行一次,就应该用 do-while——这既让代码自文档化,也避免了 num==0 这类边界 bug。
4.4 do-while 的开销分析
有些学生担心 do-while 会有额外的性能开销。实际上:
; while 版本编译后的汇编(简化)
jmp check ; 无条件跳转到条件检查
loop:
...循环体...
check:
test eax, eax ; 测试 num 是否为 0
jne loop ; 非零则跳转
; do-while 版本编译后的汇编(简化)
loop:
...循环体...
test eax, eax ; 测试 num 是否为 0
jne loop ; 非零则跳转do-while 版本少了一条无条件跳转指令(jmp check),在极端情况下反而略快。但更重要的是:正确性永远优先于微小的性能差异。让代码结构匹配问题的逻辑结构,编译器会处理好优化。
5. 双指针逆序算法
5.1 问题:为什么需要逆序
逐位提取产生的是逆序结果——个位最早被提取,在数组中排在前面:
num = 123 的提取过程(十进制):
第 1 步: buf[0] = 123 % 10 + '0' = '3' (个位)
第 2 步: buf[1] = 12 % 10 + '0' = '2' (十位)
第 3 步: buf[2] = 1 % 10 + '0' = '1' (百位)
buf = "321\0" ← 逆序了!期望是 "123"5.2 双指针交换算法:完整追踪
for (j = 0, i--; j < i; j++, i--)
{
char tmp;
tmp = buf[i];
buf[i] = buf[j];
buf[j] = tmp;
}以 buf = "321\0"(即 num=123)为例,执行前 i=3(指向 '\0' 的位置):
初始状态:
buf = ['3']['2']['1']['\0']
索引: 0 1 2 3
↑ i=3 (指向 \0)
步骤 1: i-- → i=2, j=0
buf = ['3']['2']['1']['\0']
↑ ↑
j=0 i=2
检查 j < i? 0 < 2? 是 → 进入循环体
tmp = buf[2] = '1'
buf[2] = buf[0] = '3'
buf[0] = tmp = '1'
→ buf = ['1']['2']['3']['\0']
j++, i-- → j=1, i=1
步骤 2: j=1, i=1
检查 j < i? 1 < 1? 否 → 退出循环
最终结果:
buf = ['1']['2']['3']['\0'] = "123" ✓5.3 两指针起始位置与终止条件详解
// 逆序前: i 指向 buf 中最后一个有效字符的下一个位置(即 \0 的位置)
i--; // i 现在指向最后一个有效字符
j = 0; // j 指向第一个有效字符
for (; j < i; j++, i--) // 当两指针相遇或交错时停止
{
// 交换 buf[j] 和 buf[i]
}不同长度字符串的交换过程:
| buf 长度 | 初始位置 | 交换次数 | 最终位置 | 终止条件 |
|---|---|---|---|---|
| "0" (1字符) | j=0, i=0 | 0 次 | j=0, i=0 | j==i(相遇) |
| "12" (2字符) | j=0, i=1 | 1 次 | j=1, i=0 | j>i(交错) |
| "123" (3字符) | j=0, i=2 | 1 次 | j=1, i=1 | j==i(相遇) |
| "9876" (4字符) | j=0, i=3 | 2 次 | j=2, i=1 | j>i(交错) |
| "12345" (5字符) | j=0, i=4 | 2 次 | j=2, i=2 | j==i(相遇) |
规律:交换次数 = floor(长度 / 2)。中间字符(奇数长度时)不需要交换——它和自己对称。
5.4 逆序算法的其他实现方式
方式一:单指针(从尾部向前写入)
// 先算出位数,从尾部向前写入——天然避免逆序
int len = 0, n = num;
do { len++; n /= 10; } while (n);
int i = len;
buf[i--] = '\0'; // 先放 \0 在最后
do {
buf[i--] = num % 10 + '0'; // 从后往前写
num /= 10;
} while (num);
// buf = "123\0",无需逆序!优点:不需要额外的逆序步骤。缺点:需要预先计算位数(多一轮 do-while),代码对初学者不够直观。
方式二:递归
void itoa_rec(int num, char *buf) {
static int i = 0; // static 保持跨递归调用的状态
if (num / 10)
itoa_rec(num / 10, buf); // 先递归处理高位
buf[i++] = num % 10 + '0'; // 回来时写低位
buf[i] = '\0';
}递归利用「先处理高位再回来写低位」的调用栈顺序天然解决逆序问题。但递归引入了函数调用开销,且 static 变量使函数不可重入(不能同时用于两个转换)。
方式三:栈辅助
利用显式栈(数组模拟)先 push 再 pop——和递归思想类似但避免了递归深度限制。
TIP
双指针逆序 + do-while 的组合虽然需要逆序步骤,但代码结构清晰、无额外内存分配、无递归深度限制——是本课推荐的标准方案。理解了逆序的底层机制后,可以自行探索其他变体。
6. swap 交换:tmp 变量与 XOR 技巧
6.1 经典三步骤(tmp 变量法)
char tmp;
tmp = buf[i]; // 步骤1: 保存 buf[i] 的原始值
buf[i] = buf[j]; // 步骤2: 用 buf[j] 覆盖 buf[i]
buf[j] = tmp; // 步骤3: 用保存的原始值覆盖 buf[j]tmp 作为「临时存储」——在交换过程中保存 buf[i] 的原始值,防止被覆盖丢失。这和方格纸上的手写交换是一样的:你要交换两个格子里的数字,必须先抄录其中一个到草稿纸上。
编译器对此优化极为成熟:在现代编译器(GCC/Clang)中,tmp 通常不会真正分配栈空间——编译器识别出交换模式后直接使用寄存器操作,生成的机器码与 XOR 版本几乎相同。
6.2 XOR 无变量交换(趣味技巧)
// 不需要 tmp 变量
buf[i] ^= buf[j];
buf[j] ^= buf[i];
buf[i] ^= buf[j];原理基于 XOR(异或)的三个性质:
- 交换律:
a ^ b = b ^ a - 自反性:
a ^ a = 0 - 恒等性:
a ^ 0 = a
逐步推导(假设 a = 5, b = 3):
步骤1: a = a ^ b → a = 5 ^ 3 = 6 (0110)
步骤2: b = b ^ a → b = 3 ^ 6 = 5 (0101) b 变成原来的 a
步骤3: a = a ^ b → a = 6 ^ 5 = 3 (0011) a 变成原来的 bXOR 交换的致命缺陷:如果 buf[i] 和 buf[j] 是同一个位置(即 i == j):
buf[i] ^= buf[i]; // 自己 XOR 自己 = 0
buf[i] ^= buf[i]; // 0 ^ 0 = 0
buf[i] ^= buf[i]; // 0 ^ 0 = 0
// 结果:buf[i] 被清零!在逆序算法中,当字符串长度为奇数时,中间元素 j == i——此时 XOR 交换会将其清零。
6.3 加减法交换(同样不推荐)
a = a + b;
b = a - b; // b = (a+b) - b = a(原来的 a)
a = a - b; // a = (a+b) - a = b(原来的 b)问题:
- 整数溢出:如果
a + b超过类型最大值,结果是未定义的(对int是 UB) - 仅适用于整数:不能用于浮点数或指针
- 可读性差:需要推理才能理解意图
6.4 三种交换方式对比
| 方式 | 可读性 | 安全性 | 性能 | 适用场景 |
|---|---|---|---|---|
| tmp 变量 | ★★★★★ | ★★★★★ | ★★★★★ | 所有场景(推荐) |
| XOR | ★☆☆☆☆ | ★★☆☆☆ | ★★★★★ | 面试趣味题、嵌入式极端内存受限 |
| 加减法 | ★★☆☆☆ | ★☆☆☆☆ | ★★★★☆ | 不推荐(溢出风险) |
IMPORTANT
在生产代码中,始终使用 tmp 变量法。XOR 交换和加减法交换属于「奇技淫巧」——它们在特定场景下有趣,但在团队协作、代码审查、长期维护中弊大于利。现代编译器对 tmp 三步骤的优化已经极其高效(通常直接翻译为寄存器操作),无需为了节省一个变量而牺牲可读性和安全性。
7. 进制转换的统一框架
7.1 十进制与十六进制的并列对比
把 itoa 和 itoa_hex 并列对比,会发现它们共享完全相同的三步框架:
| 步骤 | 十进制 itoa | 十六进制 itoa_hex |
|---|---|---|
| 逐位提取 | num % 10 | num % 16 |
| 数字→字符 | digit + '0' | hex[digit](映射表) |
| 去掉一位 | num /= 10 | num /= 16 |
| 循环方式 | do-while | do-while |
加 '\0' | ✓ | ✓ |
| 逆序 | ✓ | ✓ |
7.2 统一框架:itoa_base 通用函数
#include <stdio.h>
void reverse(char *buf, int len)
{
for (int j = 0, i = len - 1; j < i; j++, i--)
{
char tmp = buf[i];
buf[i] = buf[j];
buf[j] = tmp;
}
}
void itoa_base(int num, char *buf, int base, char *map)
{
int i = 0;
do {
buf[i++] = map[num % base];
num /= base;
} while (num != 0);
buf[i] = '\0';
reverse(buf, i);
}这就是从具体到抽象的思维训练——找到两个看似不同问题的共同结构,抽象成通用方案。这正是函数设计的精髓。
7.3 扩展:任意进制(2~36)
给定不同的 base 和映射表,同一个框架支持所有进制:
char *decimal_map = "0123456789"; // 十进制 (base=10)
char *hex_map = "0123456789ABCDEF"; // 十六进制 (base=16)
char *binary_map = "01"; // 二进制 (base=2)
char *octal_map = "01234567"; // 八进制 (base=8)
char *base36_map = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; // 36进制
// 使用示例
char buf[64];
itoa_base(255, buf, 2, binary_map); // "11111111"
itoa_base(255, buf, 8, octal_map); // "377"
itoa_base(255, buf, 10, decimal_map); // "255"
itoa_base(255, buf, 16, hex_map); // "FF"7.4 进制转换的数学本质
为什么「取余+除法」能提取每一位?以十进制 123 为例:
123 = 1×10² + 2×10¹ + 3×10⁰
123 % 10 = 3 → 个位
123 / 10 = 12 → 去掉个位
12 % 10 = 2 → 十位(原来的十位变成了个位)
12 / 10 = 1 → 去掉十位
1 % 10 = 1 → 百位
1 / 10 = 0 → 退出这个过程的数学本质是数位展开的逆运算:把一个数按基数展开为 d₀×base⁰ + d₁×base¹ + d₂×base² + ...,然后从低位到高位依次提取系数 d₀, d₁, d₂, ...。
7.5 为什么总是从低位开始提取?
数学上,提取最低位只需要一次取余和一次除法——操作最简单。如果要从高位开始提取,需要先确定最高位的权重(即数字的位数),这需要额外的计算或对数运算。因此「从低位提取再逆序」是效率最高的方案。
8. itoa 函数签名设计与 C 语言惯用法
8.1 返回 char* 支持链式调用
char *itoa_hex(int num, char *buf)
{
// ... 转换逻辑 ...
return buf; // 返回传入的缓冲区指针
}返回 buf 指针使得函数可以嵌入到表达式中——这就是链式调用(chaining):
char buf1[64], buf2[64];
// 链式调用:直接在 printf 中使用返回值
printf("hex: %s, dec: %s\n",
itoa_hex(255, buf1), // 返回 buf1
itoa(255, buf2)); // 返回 buf2这是 C 标准库中 strcpy、strcat 等函数的惯用法——返回目标缓冲区指针,方便串联操作。
8.2 为什么参数是 char *buf 而非返回新分配的内存?
// 设计 A(本课采用):调用方提供缓冲区
char *itoa(int num, char *buf); // buf 在调用方分配
// 设计 B(不推荐):函数内部分配内存
char *itoa(int num); // 内部 malloc,调用方负责 free设计 A 的优势:
- 调用方控制内存:栈数组、全局数组、堆内存均可传入
- 无内存泄漏风险:不需要调用方记得
free - 可重入:使用独立的缓冲区,多次调用互不干扰
- 性能更好:栈分配比
malloc快得多
设计 B 虽然看似「方便」(不需要预先声明数组),但引入了内存管理负担——这正是 C 语言与 Java/Python 等带 GC 语言的核心设计哲学差异:显式优于隐式,可控优于便利。
8.3 数组参数传递的底层语义
void itoa(int num, char buf[]) // buf[] 实际是 char *buf
{
buf[0] = 'A'; // 修改会反映到调用方
}
int main(void)
{
char arr[10];
itoa(123, arr); // 传递的是 &arr[0]
// arr[0] 现在等于 'A'——被 itoa 修改了
return 0;
}char buf[] 作为函数参数时,编译器将其视为 char *buf——传递的是数组首地址,不是数组的拷贝。这意味著:
- 函数内部对
buf元素的修改会直接反映到调用方的原数组 - 函数内部无法通过
sizeof(buf)获取数组长度(返回的是指针大小) - 这是 C 语言的传指针(pass-by-pointer)语义,区别于传值(pass-by-value)
9. #define 函数式宏定义 vs inline 函数
9.1 #define 宏的基本机制
#define MAX(a, b) ((a) > (b) ? (a) : (b))宏是预处理阶段的文本替换——编译器在编译前将宏名替换为定义体。注意宏参数周围的括号是必须的,否则会导致运算符优先级问题:
#define BAD_MAX(a, b) a > b ? a : b // 危险!缺少括号
int x = BAD_MAX(3, 5) + 1;
// 展开后: 3 > 5 ? 3 : 5 + 1
// 由于 + 优先级高于 ?:,实际是: 3 > 5 ? 3 : (5 + 1)
// 期望结果: max(3,5)+1 = 6,实际结果: 5+1 = 6(碰巧相同)9.2 宏的副作用陷阱
#define SQUARE(x) ((x) * (x))
int a = 5;
int b = SQUARE(++a); // 展开为 ((++a) * (++a))
// a 被自增了两次!结果不是 36(6*6),而是未定义的宏的参数在每次出现时都会重新求值——如果参数表达式有副作用(++、函数调用等),副作用会执行多次。这是宏最危险的陷阱。
9.3 inline 函数:类型安全的替代方案
inline int max(int a, int b)
{
return a > b ? a : b;
}inline 函数的优势:
- 类型安全:编译器检查参数和返回值类型
- 无副作用陷阱:参数只求值一次
- 可调试:可以设置断点、单步执行
- 作用域控制:遵循 C 的作用域规则
现代编译器(GCC/Clang -O2 以上)会自动内联合适的小函数,inline 关键字更多是给编译器的建议而非命令。
9.4 宏的合理使用场景
尽管宏有诸多陷阱,某些场景下宏仍然不可替代:
// 1. 条件编译
#ifdef DEBUG
#define LOG(msg) printf("DEBUG: %s\n", msg)
#else
#define LOG(msg)
#endif
// 2. 常量定义
#define BUFFER_SIZE 64
// 3. 通用类型(C11 之前无 _Generic)
#define SWAP(a, b) do { typeof(a) _t = a; a = b; b = _t; } while(0)TIP
优先使用 inline 函数、const 常量和 enum,仅在它们无法胜任时(条件编译、通用类型等)才使用宏。记住 K&R 的告诫:「如果可以用函数实现,就不要用宏。」
10. 三元运算符 ?: 深度回顾
10.1 基本语法与语义
// 语法: 条件 ? 表达式1 : 表达式2
int max = (a > b) ? a : b; // 如果 a>b,取 a;否则取 b三元运算符是 C 语言中唯一的三目运算符(需要三个操作数)。它本质上是一个返回值的条件选择——不是语句,而是表达式。
10.2 与 if-else 的对比
// 三元: 可以在表达式中使用
printf("状态: %s\n", (score >= 60) ? "及格" : "不及格");
// if-else: 需要先声明变量再赋值(不能内嵌到表达式中)
char *status;
if (score >= 60)
status = "及格";
else
status = "不及格";
printf("状态: %s\n", status);三元表达式适合简单的条件取值,if-else 适合多步骤的条件逻辑。一条经验法则:如果 ?: 嵌套超过两层,改用 if-else。
10.3 嵌套三元与可读性边界
// 两层嵌套:尚可接受
char grade = (score >= 90) ? 'A'
: (score >= 80) ? 'B'
: (score >= 70) ? 'C'
: 'D';
// 深层嵌套 + 副作用:灾难
int x = (a > 0) ? (b > 0 ? foo(a) : bar(b))
: (c > 0 ? baz(c) : qux(a, b, c)); // 难以理解三元运算符追求的是简洁,但当简洁以可读性为代价时,就走向了反面。
10.4 三元表达式的类型规则
// 两个分支的类型必须兼容
int x = 5;
double y = 3.14;
// 表达式 (flag ? x : y) 的类型是 double(int 被隐式转换为 double)
double result = (1) ? x : y; // result = 5.0(x 被提升为 double)
// void 表达式不能用于三元(GCC 扩展除外)
// int z = (flag) ? printf("yes") : printf("no"); // 标准 C 中无效C 标准规定:如果第二和第三操作数类型不同,会进行通常算术转换(usual arithmetic conversions)以确定公共类型。
11. 缓冲区溢出与安全性
11.1 itoa 的溢出风险
int main(void)
{
char buf[5]; // 只能容纳 4 个字符 + \0
itoa(12345, buf); // "12345" 需要 6 个字符(5 + \0)→ 溢出!
return 0;
}十进制 32 位 int 的取值范围是 -2147483648 ~ 2147483647:
- 最大值
2147483647:10 位数字 +'\0'= 11 字符 - 最小值
-2147483648:负号 + 10 位数字 +'\0'= 12 字符
结论:十进制 itoa 的缓冲区至少需要 12 字节(考虑负号)。本课练习中 buf[10] 对于正整数(最多 10 位)恰好够用,但对于完整的 int 范围是不够的。
11.2 如何防护缓冲区溢出
#include <stdio.h>
#include <limits.h>
char *itoa_safe(int num, char *buf, int buf_size)
{
if (buf_size < 2) return NULL; // 至少需要空间存一个字符 + \0
int i = 0;
int is_negative = 0;
// 处理负数
if (num < 0) {
is_negative = 1;
// 注意:不能直接 num = -num,因为 INT_MIN 的相反数溢出!
// 本课暂不展开此细节,使用 unsigned 技巧处理
}
do {
if (i >= buf_size - 1) { // 防止越界
buf[0] = '\0';
return NULL; // 返回 NULL 表示失败
}
buf[i++] = num % 10 + '0';
num /= 10;
} while (num != 0);
if (is_negative && i < buf_size - 1) {
buf[i++] = '-';
}
buf[i] = '\0';
// 逆序
for (int j = 0, k = i - 1; j < k; j++, k--) {
char tmp = buf[j];
buf[j] = buf[k];
buf[k] = tmp;
}
return buf;
}安全版本的关键改进:
- 接受
buf_size参数,每次写入前检查边界 - 溢出时返回
NULL,让调用方能检测到错误 - 处理负数(虽然需要更复杂的
INT_MIN处理)
11.3 snprintf 标准库 vs 自定义 itoa
#include <stdio.h>
int main(void)
{
char buf[12];
int num = -2147483648;
// 标准库方案:安全、简洁、功能完备
snprintf(buf, sizeof(buf), "%d", num);
printf("%s\n", buf); // "-2147483648"
return 0;
}| 特性 | 自定义 itoa | snprintf |
|---|---|---|
| 安全性 | 需手动检查边界 | 自动截断,不越界 |
| 负数处理 | 需自己实现 | 自动处理 |
| 功能 | 仅转换整数 | 支持所有 printf 格式化 |
| 学习价值 | ★★★★★ | ★★☆☆☆ |
| 生产推荐 | ★★☆☆☆ | ★★★★★ |
NOTE
实际项目中推荐用 C 标准提供的 snprintf(buf, sizeof(buf), "%d", num) 替代自己实现 itoa——它已经处理了所有边界情况(包括负数、INT_MIN、缓冲区溢出等)。自己实现 itoa 是极好的学习练习,因为它串联了数组、字符串、循环、逆序、ASCII 编码、进制转换等核心概念——理解这些底层机制,才能真正掌握 C 语言。
11.4 负数处理专题
#include <stdio.h>
int main(void)
{
int a = -123;
printf("a %% 10 = %d\n", a % 10); // 输出 -3(C99 后:结果符号与被除数相同)
printf("a / 10 = %d\n", a / 10); // 输出 -12(向零取整,C99 标准)
// 对 itoa 的影响:
// -123 % 10 = -3, -3 + '0' = 45('-' 的 ASCII),而非 '3'!
// 因此处理负数时需要额外逻辑
return 0;
}C99 标准规定:% 的结果符号与被除数(左操作数)相同,/ 向零取整。这意味着负数取余会产生负数——直接 num % 10 + '0' 对负数会得到错误的字符。
处理策略:
- 取绝对值:
num = -num,但INT_MIN的绝对值会溢出 - 用
unsigned:unsigned n = (unsigned)num,然后对n操作 - 统一处理:先记录符号,对绝对值操作,最后添加负号
本课练习为简化起见,默认输入为正整数——负数处理的深入讨论可作为课后思考题。
参考解答
练习1: 十进制 itoa(在 main 中实现)
#include <stdio.h>
int main(void)
{
int num;
char buf[10];
int i = 0;
int j = 0;
scanf("%d", &num);
do
{
buf[i] = num % 10 + '0';
i++;
num /= 10;
} while (num != 0);
buf[i] = '\0';
for (j = 0, i--; j < i; j++, i--)
{
char tmp;
tmp = buf[i];
buf[i] = buf[j];
buf[j] = tmp;
}
printf("buf = %s\n", buf);
return 0;
}核心三步:(1)do-while 用 %10 和 +'0' 逐位转为字符;(2)buf[i] = '\0' 添加终止符;(3)头尾两指针交换实现逆序。注意 i-- 让 i 指向最后一个字符而非 '\0'。
练习2: 十六进制 itoa_hex
#include <stdio.h>
char *itoa_hex(int num, char *buf)
{
char *hex = "0123456789ABCDEF";
int i = 0;
int j = 0;
do
{
int rest = num % 16;
buf[i] = hex[rest];
i++;
num /= 16;
} while (num != 0);
buf[i] = '\0';
for (j = 0, i--; j < i; j++, i--)
{
char tmp;
tmp = buf[i];
buf[i] = buf[j];
buf[j] = tmp;
}
return buf;
}
int main(void)
{
int num;
char buf[64];
scanf("%d", &num);
itoa_hex(num, buf);
printf("%s\n", buf);
return 0;
}框架与十进制完全相同,仅两处变化:(1)基数从 10 变为 16;(2)字符映射从 digit + '0' 变为 hex[rest](用映射表处理 10~15 的 A~F)。return buf 返回传入的数组首地址,支持链式调用。
抽取为独立 itoa 函数
#include <stdio.h>
void itoa(int num, char buf[])
{
int i = 0;
int j = 0;
do
{
buf[i++] = num % 10 + '0';
num /= 10;
} while (num);
buf[i] = '\0';
for (j = 0, i--; j < i; j++, i--)
{
char tmp;
tmp = buf[i];
buf[i] = buf[j];
buf[j] = tmp;
}
}
int main(void)
{
int num;
char buf[100];
scanf("%d", &num);
itoa(num, buf);
printf("number string = %s\n", buf);
return 0;
}提取 itoa 为独立函数后,main 变得极简——与 Lesson 08 的 find 函数同样的模块化思想。注意 buf[] 作为参数传递时实为指针,函数内部对数组的修改会反映到调用方。
对照检查:用了
do-while而非while吗?buf[i] = '\0'加了吗?逆序的起始位置(j=0, i--)正确吗?十六进制版用了映射表而非+'0'吗?
课堂讨论
do-while的循环可以用while循环替换吗?为什么这里必须用do-while?- 如果要将输入数字按照十六进制转换为字符串,如何修改?二进制呢?
- 为了交换两个字节的内容,示例中引入了一个
tmp变量,这个是必须的吗?有没有其他方法? - 在 itoa 中逆序是必不可少的步骤——能想办法不用逆序就得到正确顺序的字符串吗?
buf数组的大小为什么是10(十进制)和64(十六进制)?给int类型的最大值预留多大才够?- 如果输入是负数(如
-123),本课的代码还能正常工作吗?如果不能,应该怎么修改?
讨论答案
Q1: do-while 能用 while 替换吗?
不能。 当 num = 0 时,while (num != 0) 条件直接为假,循环体一次也不执行——buf 中只有 '\0',输出空串而非 "0"。
这在 itoa 中是致命的:数字 0 的字符串表示必须是 "0",不能是空串。do-while 保证循环体至少执行一次,即便 num = 0 也会存入一个 '0' 字符。
// while 版本的问题
while (num) { // num=0 → 跳过
buf[i++] = num % 10 + '0';
num /= 10;
}
buf[i] = '\0'; // buf = ""(空串!)
// do-while 版本正确
do {
buf[i++] = num % 10 + '0'; // 0 % 10 = 0, '0' 被写入
num /= 10; // 0 / 10 = 0
} while (num); // num=0 → 退出
buf[i] = '\0'; // buf = "0" ✓这与 Lesson 08 中 find 函数用 do-while 的理由一致。对这种「至少需要处理一位」的场景,do-while 是唯一正确的选择。更深层地看:选择循环结构不仅仅是语法选择,更是语义表达——你选择的循环形式向读者传达了「这个循环体是否可能不执行」的信息。
Q2: 如何修改为十六进制?二进制呢?
十六进制只需改两处:基数 16 和字符映射表。
char *hex = "0123456789ABCDEF";
do {
buf[i++] = hex[num % 16]; // 用映射表替代 + '0'
num /= 16;
} while (num);二进制同理——基数 2,映射表 "01":
char *bin = "01";
do {
buf[i++] = bin[num % 2];
num /= 2;
} while (num);八进制同理——基数 8,映射表 "01234567"。
框架完全一致——这体现了良好抽象的价值。变化的只有两点:基数(% N 和 /= N)和映射表(base_map)。更进一步可以抽象为通用版本:
void itoa_base(int num, char *buf, int base, char *map)
{
int i = 0, j = 0;
do {
buf[i++] = map[num % base];
num /= base;
} while (num);
buf[i] = '\0';
for (j = 0, i--; j < i; j++, i--) {
char tmp = buf[i];
buf[i] = buf[j];
buf[j] = tmp;
}
}这个统一框架一次解决了所有进制转换问题——从特殊到一般,是编程中最有价值的思维方式。
Q3: tmp 变量是必须的吗?
不是必须的,但强烈推荐。 交换两个值有三种方式:
- 临时变量(推荐):
tmp = a; a = b; b = tmp;- XOR 技巧(不推荐,有清零隐患):
a ^= b; b ^= a; a ^= b; // 当 a==b 时会将值清零!- 加减法(不推荐,有溢出风险):
a = a + b; b = a - b; a = a - b; // 可能溢出为什么推荐 tmp:
- 意图清晰:一眼看出是交换操作
- 性能等价:编译器优化后与 XOR 版本生成的机器码相同
- 安全性高:没有 XOR 的「同位置清零」隐患,没有加减法的溢出风险
- 通用性强:可用于任何类型(整数、浮点、指针、结构体)
XOR 技巧更多是面试题和趣味知识——了解其原理有助于理解位运算,但生产代码中应避免。正如 Knuth 所说:「过早优化是万恶之源」——不要为了节省一个变量而牺牲代码的清晰性和安全性。
Q4: 能不用逆序就得到正确顺序吗?
有两种思路:
思路 1:从数组高位开始写(预计算位数)
// 先确定 num 的位数
int len = 0, n = num;
do { len++; n /= 10; } while (n);
// 从数组尾部向前写
int i = len;
buf[i--] = '\0';
do {
buf[i--] = num % 10 + '0';
num /= 10;
} while (num);
// buf = "123\0",无需逆序先算出位数,然后从 buf[位数-1] 倒着往前写——天然得到正确顺序。代价是:多一轮循环计算位数,代码的直观性不如「先提取再逆序」。
思路 2:递归
void itoa_rec(int num, char *buf) {
static int i = 0;
if (num / 10)
itoa_rec(num / 10, buf); // 先递归处理高位
buf[i++] = num % 10 + '0'; // 回来时写低位
buf[i] = '\0';
}递归利用「先处理高位再回来写低位」的执行顺序天然解决逆序问题。代价是:
- 每次递归调用都有函数调用开销(压栈、跳转、返回)
static变量使函数不可重入- 对很大或很深的数字,递归深度可能成为问题
比较:对于入门阶段,双指针逆序 + 循环是最直观的方案——它显式地展示了「为什么需要逆序」和「如何实现逆序」,教育价值最高。
Q5: buf 数组需要多大才够?
十进制:32 位 int 的取值范围是 -2147483648 ~ 2147483647。
- 最大值
2147483647:10 位数字 - 最小值
-2147483648:1 位负号 + 10 位数字 - 加上
'\0'终止符:至少需要 12 字节
所以本课代码中的 buf[10] 对正整数(最多 10 位 + '\0' = 11)实际上不够——如果输入 2147483647(10 位),buf[10] 无法容纳 '\0'。这是有意为之的简化:练习只测试小数字,但你应该意识到这个限制。
十六进制:32 位 int 最多 8 个十六进制位(FFFFFFFF = 4294967295 作为 unsigned,或 7FFFFFFF = 2147483647 作为 signed)。
- 8 个十六进制字符 +
'\0'= 9 字节 buf[64]远大于需求,但安全余量充足——这是一种防御性编程实践
通用计算公式:
- 十进制位数:
ceil(log10(2^(位数))) + 1(负号) + 1(\0) - N 进制位数:
ceil(log_base(2^(位数))) + 1(\0)
实际编程中,给缓冲区留足余量总是好的——内存很便宜,但缓冲区溢出 bug 的代价极高。
Q6: 负数输入会怎样?如何修复?
问题分析:当 num = -123 时:
int digit = (-123) % 10; // C99: 结果 = -3
char ch = digit + '0'; // -3 + 48 = 45 = '-'(碰巧是负号!)
// buf = "----..." 完全错误C99 标准规定 % 的结果符号与被除数相同,且 / 向零取整。因此负数取余会产生负数——不能直接用 digit + '0'。
修复方案:
void itoa_with_negative(int num, char buf[])
{
int i = 0, j = 0;
int is_negative = 0;
if (num < 0) {
is_negative = 1;
num = -num; // 取绝对值(注意:INT_MIN 的 -num 会溢出!)
}
do {
buf[i++] = num % 10 + '0';
num /= 10;
} while (num);
if (is_negative)
buf[i++] = '-';
buf[i] = '\0';
// 逆序
for (j = 0, i--; j < i; j++, i--) {
char tmp = buf[i];
buf[i] = buf[j];
buf[j] = tmp;
}
}INT_MIN 陷阱:-(-2147483648) 的结果是 2147483648,超出 int 的正数范围——这是有符号整数溢出,属于未定义行为。处理 INT_MIN 需要转换为 unsigned int 或使用更复杂的逻辑。这超出了本课范围,但值得知道:itoa 看似简单,完整的工业级实现需要处理大量边界情况。
课后练习
回文判断:用户输入一个字符串,判断它是否为回文(正反读都一样)。例如
"abcba"是回文,"hello"不是。知识点提示:双指针从两端向中间比较、字符数组遍历、
'\0'终止符识别长度数字字符串加法:实现两个数字字符串的大数加法。例如
"123" + "45" = "168"。模拟竖式加法(从个位开始,有进位)。知识点提示:字符串逐位解析(
ch - '0'转回数字)、进位carry变量、结果逆序输出压缩多余空格:输入一个可能包含连续多个空格的字符串,将连续空格压缩为一个。例如
"abc ab a c"→"abc ab a c"。知识点提示:双数组或原地覆盖、空格标志位、
getchar逐个字符处理任意进制转换:扩展 itoa,实现一个
itoa_base函数,支持将整数转换为 2~36 任意进制的字符串。输入数字和进制基数,输出对应字符串。知识点提示:字符映射表扩展至 36 个字符、进制基数为参数、统一框架的泛化
字符串拷贝实现:不调用
strcpy,自己实现一个my_strcpy函数,将一个字符串拷贝到另一个字符数组中(含'\0')。然后思考:如果目标数组不够大会发生什么?知识点提示:指针/索引遍历、
'\0'判断、缓冲区溢出风险
练习1: 回文判断
#include <stdio.h>
int main(void)
{
char s[100];
int len = 0, i, j;
scanf("%s", s);
while (s[len] != '\0')
len++;
for (i = 0, j = len - 1; i < j; i++, j--)
{
if (s[i] != s[j])
{
printf("NOT palindrome\n");
return 0;
}
}
printf("palindrome\n");
return 0;
}与逆序算法完全相同的双指针结构,只是从「交换」变为「比较」。先计算字符串长度(遍历至 '\0'),再用头尾两指针向中间靠拢,任何不对称即判定非回文。
练习2: 数字字符串加法
#include <stdio.h>
#include <string.h>
int main(void)
{
char a[100], b[100];
int result[101] = {0};
int len_a, len_b, max_len;
int carry = 0;
int i, j, k, digit;
scanf("%s %s", a, b);
len_a = strlen(a);
len_b = strlen(b);
max_len = len_a > len_b ? len_a : len_b;
for (k = 0; k < max_len; k++)
{
int da = (k < len_a) ? a[len_a - 1 - k] - '0' : 0;
int db = (k < len_b) ? b[len_b - 1 - k] - '0' : 0;
int sum = da + db + carry;
result[k] = sum % 10;
carry = sum / 10;
}
if (carry)
result[max_len++] = carry;
for (k = max_len - 1; k >= 0; k--)
printf("%d", result[k]);
printf("\n");
return 0;
}模拟竖式加法:从个位开始逐位相加,处理进位。用 a[len_a-1-k] - '0' 将字符转回数字——这正是 digit + '0' 的逆操作。结果数组同样逆序输出——这和 itoa 的逆序原因相同。
练习3: 压缩多余空格
#include <stdio.h>
int main(void)
{
char ch;
int in_space = 0; // 标志:上一个字符是否是空格
while ((ch = getchar()) != '\n' && ch != EOF)
{
if (ch == ' ')
{
if (!in_space)
{
putchar(' '); // 只在遇到连续空格的第一个时输出
in_space = 1;
}
}
else
{
putchar(ch);
in_space = 0;
}
}
printf("\n");
return 0;
}核心思想:用 in_space 标志位跟踪「前一个字符是不是空格」。只有从非空格进入空格时才输出空格——连续的后续空格被跳过。这是一种状态机(state machine)的雏形:系统在两个状态(「在空格中」和「不在空格中」)之间切换,根据当前状态决定行为。
练习4: 任意进制转换
#include <stdio.h>
void reverse(char *buf, int len)
{
for (int j = 0, i = len - 1; j < i; j++, i--)
{
char tmp = buf[i];
buf[i] = buf[j];
buf[j] = tmp;
}
}
char *itoa_base(int num, char *buf, int base)
{
char *map = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
int i = 0;
if (base < 2 || base > 36) {
buf[0] = '\0';
return buf; // 无效基数
}
do {
buf[i++] = map[num % base];
num /= base;
} while (num != 0);
buf[i] = '\0';
reverse(buf, i);
return buf;
}
int main(void)
{
int num, base;
char buf[64];
printf("输入数字和进制 (如 255 16): ");
scanf("%d %d", &num, &base);
itoa_base(num, buf, base);
printf("%d 的 %d 进制 = %s\n", num, base, buf);
return 0;
}这是从本课 itoa 和 itoa_hex 泛化而来的统一框架。只需改变基数和映射表即可支持 2~36 任意进制——映射表 "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" 恰好 36 个字符,覆盖了所有可能。
练习5: 字符串拷贝实现
#include <stdio.h>
char *my_strcpy(char *dest, const char *src)
{
int i = 0;
while (src[i] != '\0')
{
dest[i] = src[i];
i++;
}
dest[i] = '\0'; // 不要忘记拷贝终止符!
return dest;
}
// 指针版本(更地道的 C 写法)
char *my_strcpy_ptr(char *dest, const char *src)
{
char *d = dest;
while ((*d++ = *src++) != '\0')
; // 空循环体——赋值和判断在同一行完成
return dest;
}
int main(void)
{
char src[] = "Hello, World!";
char dest1[50], dest2[50];
my_strcpy(dest1, src);
my_strcpy_ptr(dest2, src);
printf("dest1: %s\n", dest1);
printf("dest2: %s\n", dest2);
// 危险示范:目标数组太小
char small[5];
// my_strcpy(small, src); // 溢出!src 需要 14 字节
return 0;
}注意指针版本的紧凑写法 while ((*d++ = *src++) != '\0')——它在一行中完成了赋值、指针自增、条件判断三个操作,是经典 C 代码风格。但紧凑不等于可读:对于初学者,索引版本更容易理解。
参考资料
- ASCII Table — 完整 ASCII 码表(含控制字符、可打印字符、扩展 ASCII 的完整说明)
- ISO C99 Standard, Section 7.1.1 — C 语言标准中对字符串字面量与字符集的定义
- The C Programming Language, Section 3.5 — Kernighan & Ritchie 对
itoa函数的经典实现与讨论 - XOR Swap Algorithm — XOR 交换算法的原理、数学推导、限制条件及现代编译器优化分析
- CERT C Coding Standard: STR31-C — 关于字符串操作中缓冲区溢出防护的安全编码标准
"Premature optimization is the root of all evil." — Donald E. Knuth