跳转到内容

Lesson 09: 整型转字符串 CNB

练习任务

本课包含两个互相关联的练习:

练习 1(itoa):在 main 中实现整型转十进制字符串——读取用户输入的整数,用 do-while 逐位提取数字并转为字符存入数组,最后逆序数组输出。

期望输出:

buf = 123
buf = 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 函数式宏定义 vs inline 函数 — 宏的文本替换机制、副作用陷阱与 inline 的类型安全
  • 三元运算符 ?: 深度回顾 — 简洁的条件赋值、嵌套三元与可读性边界
  • 缓冲区溢出 — itoa 的溢出风险、数组大小估算(32 位 int 最多 12 字符)
  • 负数处理 — % 运算符对负数的行为、符号位处理策略
  • snprintf vs 自定义 itoa — 标准库的安全性优势与学习价值对比
  • 字符串拷贝与指针传递 — 函数传数组的本质是传指针,修改会影响调用方
  • sizeof 与数组 — 栈数组 sizeof 返回总字节数,退化后 sizeof 返回指针大小

代码框架

练习 1:十进制 itoa

itoa_skeleton.c
c
#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

itoa_hex_skeleton.c
c
#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-whilewhile 的区别在这里为什么至关重要?逆序算法中两指针的起始位置分别是什么?

TIP

先不要往下翻看参考解答。尝试自己实现 itoa 和 itoa_hex——先做完十进制再做十六进制,你会发现两个算法框架惊人地相似。


深度讲解

1. 字符数组:字符串的容器

1.1 数组的声明与内存布局

array_declare.c
c
char buf[10];

这行代码做了两件事:

  1. 上分配了 10 个连续字节的内存空间
  2. 数组名 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 数组的读写与下标运算

array_access.c
c
buf[i] = num % 10 + '0';    // 写入:在第 i 个位置存储值
char ch = buf[3];            // 读取:取第 3 个位置的值

buf[i] 的访问是 O(1)——编译器将其翻译为 *(buf + i),即从首地址偏移 i 个元素位置:

pointer_arithmetic.c
c
// 以下两行完全等价(编译器生成相同的机器码)
buf[i]     = 'A';
*(buf + i) = 'A';    // 指针算术:buf + i 指向第 i 个元素,* 解引用

这种 [] 运算符本质上是指针算术的语法糖。理解这一点对于后续理解数组退化(array decay)和指针运算至关重要。

WARNING

C 语言不检查数组越界。写 buf[1000] 超出数组范围,编译器不报错,运行时可能覆盖栈上的其他变量、返回地址等——导致段错误或更隐蔽的数据损坏。这是 C 语言与 Java、Python 等高级语言的关键区别之一:性能优先于安全,程序员自己负责边界检查。

1.3 sizeof 运算符与数组

sizeof 是编译期运算符(不是函数),用于获取类型或变量占用的字节数:

sizeof_demo.c
c
#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)

当数组作为函数参数传递时,它会退化为指向首元素的指针,丢失长度信息:

array_decay.c
c
#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 字符)结尾的字符数组

c_string_essence.c
c
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") 的停止规则:逐字节扫描

printf_s_demo.c
c
#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' 的后果

missing_null.c
c
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'

string_literal.c
c
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 字符串的遍历模式

string_traverse.c
c
// 模式一:用索引遍历直到 \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)。

digit_to_char.c
c
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_to_digit.c
c
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' 处理十六进制:

hex_wrong.c
c
int digit = 10;
char ch = digit + '0';   // 10 + 48 = 58 = ':'  错误!期望的是 'A'

所以十六进制转换不能用简单的 digit + '0',而需要映射表(lookup table):

hex_lookup.c
c
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):

case_convert.c
c
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_zero_bug.c
c
// 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_zero_fix.c
c
// 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 的执行语义分析

whiledo-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 会有额外的性能开销。实际上:

asm
; 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 双指针交换算法:完整追踪

two_pointer_reverse.c
c
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 两指针起始位置与终止条件详解

reverse_init.c
c
// 逆序前: i 指向 buf 中最后一个有效字符的下一个位置(即 \0 的位置)
i--;               // i 现在指向最后一个有效字符
j = 0;             // j 指向第一个有效字符
for (; j < i; j++, i--)   // 当两指针相遇或交错时停止
{
    // 交换 buf[j] 和 buf[i]
}

不同长度字符串的交换过程

buf 长度初始位置交换次数最终位置终止条件
"0" (1字符)j=0, i=00 次j=0, i=0j==i(相遇)
"12" (2字符)j=0, i=11 次j=1, i=0j>i(交错)
"123" (3字符)j=0, i=21 次j=1, i=1j==i(相遇)
"9876" (4字符)j=0, i=32 次j=2, i=1j>i(交错)
"12345" (5字符)j=0, i=42 次j=2, i=2j==i(相遇)

规律:交换次数 = floor(长度 / 2)。中间字符(奇数长度时)不需要交换——它和自己对称。

5.4 逆序算法的其他实现方式

方式一:单指针(从尾部向前写入)

reverse_alt1.c
c
// 先算出位数,从尾部向前写入——天然避免逆序
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),代码对初学者不够直观。

方式二:递归

reverse_alt2.c
c
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 变量法)

tmp_swap.c
c
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 无变量交换(趣味技巧)

xor_swap.c
c
// 不需要 tmp 变量
buf[i] ^= buf[j];
buf[j] ^= buf[i];
buf[i] ^= buf[j];

原理基于 XOR(异或)的三个性质:

  1. 交换律a ^ b = b ^ a
  2. 自反性a ^ a = 0
  3. 恒等性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 变成原来的 b

XOR 交换的致命缺陷:如果 buf[i]buf[j]同一个位置(即 i == j):

xor_swap_bug.c
c
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 加减法交换(同样不推荐)

addsub_swap.c
c
a = a + b;
b = a - b;      // b = (a+b) - b = a(原来的 a)
a = a - b;      // a = (a+b) - a = b(原来的 b)

问题:

  1. 整数溢出:如果 a + b 超过类型最大值,结果是未定义的(对 int 是 UB)
  2. 仅适用于整数:不能用于浮点数或指针
  3. 可读性差:需要推理才能理解意图

6.4 三种交换方式对比

方式可读性安全性性能适用场景
tmp 变量★★★★★★★★★★★★★★★所有场景(推荐)
XOR★☆☆☆☆★★☆☆☆★★★★★面试趣味题、嵌入式极端内存受限
加减法★★☆☆☆★☆☆☆☆★★★★☆不推荐(溢出风险)

IMPORTANT

在生产代码中,始终使用 tmp 变量法。XOR 交换和加减法交换属于「奇技淫巧」——它们在特定场景下有趣,但在团队协作、代码审查、长期维护中弊大于利。现代编译器对 tmp 三步骤的优化已经极其高效(通常直接翻译为寄存器操作),无需为了节省一个变量而牺牲可读性和安全性。


7. 进制转换的统一框架

7.1 十进制与十六进制的并列对比

把 itoa 和 itoa_hex 并列对比,会发现它们共享完全相同的三步框架:

步骤十进制 itoa十六进制 itoa_hex
逐位提取num % 10num % 16
数字→字符digit + '0'hex[digit](映射表)
去掉一位num /= 10num /= 16
循环方式do-whiledo-while
'\0'
逆序

7.2 统一框架:itoa_base 通用函数

itoa_base.c
c
#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 和映射表,同一个框架支持所有进制:

multi_base.c
c
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* 支持链式调用

itoa_signature.c
c
char *itoa_hex(int num, char *buf)
{
    // ... 转换逻辑 ...
    return buf;    // 返回传入的缓冲区指针
}

返回 buf 指针使得函数可以嵌入到表达式中——这就是链式调用(chaining):

itoa_chain.c
c
char buf1[64], buf2[64];

// 链式调用:直接在 printf 中使用返回值
printf("hex: %s, dec: %s\n",
       itoa_hex(255, buf1),    // 返回 buf1
       itoa(255, buf2));       // 返回 buf2

这是 C 标准库中 strcpystrcat 等函数的惯用法——返回目标缓冲区指针,方便串联操作。

8.2 为什么参数是 char *buf 而非返回新分配的内存?

why_buffer_param.c
c
// 设计 A(本课采用):调用方提供缓冲区
char *itoa(int num, char *buf);   // buf 在调用方分配

// 设计 B(不推荐):函数内部分配内存
char *itoa(int num);              // 内部 malloc,调用方负责 free

设计 A 的优势:

  1. 调用方控制内存:栈数组、全局数组、堆内存均可传入
  2. 无内存泄漏风险:不需要调用方记得 free
  3. 可重入:使用独立的缓冲区,多次调用互不干扰
  4. 性能更好:栈分配比 malloc 快得多

设计 B 虽然看似「方便」(不需要预先声明数组),但引入了内存管理负担——这正是 C 语言与 Java/Python 等带 GC 语言的核心设计哲学差异:显式优于隐式,可控优于便利

8.3 数组参数传递的底层语义

array_param_semantics.c
c
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_macro.c
c
#define MAX(a, b)  ((a) > (b) ? (a) : (b))

宏是预处理阶段的文本替换——编译器在编译前将宏名替换为定义体。注意宏参数周围的括号是必须的,否则会导致运算符优先级问题:

macro_paren_bug.c
c
#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 宏的副作用陷阱

macro_side_effect.c
c
#define SQUARE(x)  ((x) * (x))

int a = 5;
int b = SQUARE(++a);   // 展开为 ((++a) * (++a))
// a 被自增了两次!结果不是 36(6*6),而是未定义的

宏的参数在每次出现时都会重新求值——如果参数表达式有副作用(++、函数调用等),副作用会执行多次。这是宏最危险的陷阱。

9.3 inline 函数:类型安全的替代方案

inline_function.c
c
inline int max(int a, int b)
{
    return a > b ? a : b;
}

inline 函数的优势:

  1. 类型安全:编译器检查参数和返回值类型
  2. 无副作用陷阱:参数只求值一次
  3. 可调试:可以设置断点、单步执行
  4. 作用域控制:遵循 C 的作用域规则

现代编译器(GCC/Clang -O2 以上)会自动内联合适的小函数,inline 关键字更多是给编译器的建议而非命令。

9.4 宏的合理使用场景

尽管宏有诸多陷阱,某些场景下宏仍然不可替代:

macro_good_use.c
c
// 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 基本语法与语义

ternary_basic.c
c
// 语法: 条件 ? 表达式1 : 表达式2
int max = (a > b) ? a : b;    // 如果 a>b,取 a;否则取 b

三元运算符是 C 语言中唯一的三目运算符(需要三个操作数)。它本质上是一个返回值的条件选择——不是语句,而是表达式

10.2 与 if-else 的对比

ternary_vs_if.c
c
// 三元: 可以在表达式中使用
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 嵌套三元与可读性边界

ternary_nested.c
c
// 两层嵌套:尚可接受
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 三元表达式的类型规则

ternary_type.c
c
// 两个分支的类型必须兼容
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 的溢出风险

itoa_overflow.c
c
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 如何防护缓冲区溢出

safe_itoa.c
c
#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;
}

安全版本的关键改进:

  1. 接受 buf_size 参数,每次写入前检查边界
  2. 溢出时返回 NULL,让调用方能检测到错误
  3. 处理负数(虽然需要更复杂的 INT_MIN 处理)

11.3 snprintf 标准库 vs 自定义 itoa

snprintf_comparison.c
c
#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;
}
特性自定义 itoasnprintf
安全性需手动检查边界自动截断,不越界
负数处理需自己实现自动处理
功能仅转换整数支持所有 printf 格式化
学习价值★★★★★★★☆☆☆
生产推荐★★☆☆☆★★★★★

NOTE

实际项目中推荐用 C 标准提供的 snprintf(buf, sizeof(buf), "%d", num) 替代自己实现 itoa——它已经处理了所有边界情况(包括负数、INT_MIN、缓冲区溢出等)。自己实现 itoa 是极好的学习练习,因为它串联了数组、字符串、循环、逆序、ASCII 编码、进制转换等核心概念——理解这些底层机制,才能真正掌握 C 语言。

11.4 负数处理专题

negative_number.c
c
#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' 对负数会得到错误的字符。

处理策略:

  1. 取绝对值num = -num,但 INT_MIN 的绝对值会溢出
  2. unsignedunsigned n = (unsigned)num,然后对 n 操作
  3. 统一处理:先记录符号,对绝对值操作,最后添加负号

本课练习为简化起见,默认输入为正整数——负数处理的深入讨论可作为课后思考题。


参考解答

练习1: 十进制 itoa(在 main 中实现)
itoa_decimal.c
c
#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
itoa_hex.c
c
#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 函数
itoa_function.c
c
#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' 吗?


课堂讨论

  1. do-while 的循环可以用 while 循环替换吗?为什么这里必须用 do-while
  2. 如果要将输入数字按照十六进制转换为字符串,如何修改?二进制呢?
  3. 为了交换两个字节的内容,示例中引入了一个 tmp 变量,这个是必须的吗?有没有其他方法?
  4. 在 itoa 中逆序是必不可少的步骤——能想办法不用逆序就得到正确顺序的字符串吗?
  5. buf 数组的大小为什么是 10(十进制)和 64(十六进制)?给 int 类型的最大值预留多大才够?
  6. 如果输入是负数(如 -123),本课的代码还能正常工作吗?如果不能,应该怎么修改?

讨论答案

Q1: do-while 能用 while 替换吗?

不能。num = 0 时,while (num != 0) 条件直接为假,循环体一次也不执行——buf 中只有 '\0',输出空串而非 "0"

这在 itoa 中是致命的:数字 0 的字符串表示必须是 "0",不能是空串。do-while 保证循环体至少执行一次,即便 num = 0 也会存入一个 '0' 字符。

while_vs_do_while.c
c
// 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 和字符映射表。

itoa_hex_mod.c
c
char *hex = "0123456789ABCDEF";

do {
    buf[i++] = hex[num % 16];    // 用映射表替代 + '0'
    num /= 16;
} while (num);

二进制同理——基数 2,映射表 "01"

itoa_binary_mod.c
c
char *bin = "01";

do {
    buf[i++] = bin[num % 2];
    num /= 2;
} while (num);

八进制同理——基数 8,映射表 "01234567"

框架完全一致——这体现了良好抽象的价值。变化的只有两点:基数(% N/= N)和映射表(base_map)。更进一步可以抽象为通用版本

itoa_generic.c
c
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 变量是必须的吗?

不是必须的,但强烈推荐。 交换两个值有三种方式:

  1. 临时变量(推荐):
swap_tmp.c
c
tmp = a; a = b; b = tmp;
  1. XOR 技巧(不推荐,有清零隐患):
swap_xor.c
c
a ^= b; b ^= a; a ^= b;   // 当 a==b 时会将值清零!
  1. 加减法(不推荐,有溢出风险):
swap_addsub.c
c
a = a + b; b = a - b; a = a - b;   // 可能溢出

为什么推荐 tmp

  • 意图清晰:一眼看出是交换操作
  • 性能等价:编译器优化后与 XOR 版本生成的机器码相同
  • 安全性高:没有 XOR 的「同位置清零」隐患,没有加减法的溢出风险
  • 通用性强:可用于任何类型(整数、浮点、指针、结构体)

XOR 技巧更多是面试题和趣味知识——了解其原理有助于理解位运算,但生产代码中应避免。正如 Knuth 所说:「过早优化是万恶之源」——不要为了节省一个变量而牺牲代码的清晰性和安全性。

Q4: 能不用逆序就得到正确顺序吗?

有两种思路:

思路 1:从数组高位开始写(预计算位数)

reverse_free1.c
c
// 先确定 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:递归

reverse_free2.c
c
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 时:

negative_bug.c
c
int digit = (-123) % 10;    // C99: 结果 = -3
char ch = digit + '0';      // -3 + 48 = 45 = '-'(碰巧是负号!)
// buf = "----..." 完全错误

C99 标准规定 % 的结果符号与被除数相同,且 / 向零取整。因此负数取余会产生负数——不能直接用 digit + '0'

修复方案

negative_fix.c
c
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 看似简单,完整的工业级实现需要处理大量边界情况。


课后练习

  1. 回文判断:用户输入一个字符串,判断它是否为回文(正反读都一样)。例如 "abcba" 是回文,"hello" 不是。

    知识点提示:双指针从两端向中间比较、字符数组遍历、'\0' 终止符识别长度

  2. 数字字符串加法:实现两个数字字符串的大数加法。例如 "123" + "45" = "168"。模拟竖式加法(从个位开始,有进位)。

    知识点提示:字符串逐位解析(ch - '0' 转回数字)、进位 carry 变量、结果逆序输出

  3. 压缩多余空格:输入一个可能包含连续多个空格的字符串,将连续空格压缩为一个。例如 "abc ab a c""abc ab a c"

    知识点提示:双数组或原地覆盖、空格标志位、getchar 逐个字符处理

  4. 任意进制转换:扩展 itoa,实现一个 itoa_base 函数,支持将整数转换为 2~36 任意进制的字符串。输入数字和进制基数,输出对应字符串。

    知识点提示:字符映射表扩展至 36 个字符、进制基数为参数、统一框架的泛化

  5. 字符串拷贝实现:不调用 strcpy,自己实现一个 my_strcpy 函数,将一个字符串拷贝到另一个字符数组中(含 '\0')。然后思考:如果目标数组不够大会发生什么?

    知识点提示:指针/索引遍历、'\0' 判断、缓冲区溢出风险

练习1: 回文判断
palindrome.c
c
#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: 数字字符串加法
string_addition.c
c
#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: 压缩多余空格
compress_spaces.c
c
#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: 任意进制转换
any_base_conversion.c
c
#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: 字符串拷贝实现
my_strcpy.c
c
#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 代码风格。但紧凑不等于可读:对于初学者,索引版本更容易理解。


参考资料

"Premature optimization is the root of all evil." — Donald E. Knuth

Released under the MIT License.