跳过正文

丙加·第5章·私のオナニーを見ないでください!(更多IO与程序健壮性初步)

·1098 字·6 分钟·
目录

本章介绍了更多的输入、输出方式,并就这些输入输出函数引出一些程序安全的讨论

5.1 没有炒饭的酒吧
#

(某酒吧内)
“老板,来杯‘血腥玛丽’。”
“好的,请稍等。”

“老板,来杯‘长岛冰茶’。”
“没问题。”

“老板,来份蛋炒饭,多加葱花。”
“……G̷̮̀Ë̶̻́T̴̤͐ ̵̘̈́Ö̷̖́U̵̮͠T̸̥̊! ̴͗͜! ̶̙͊! ̴̮̓”
段错误(核心已转储)

5.2 格式化输出
#

以下知识不必刻意记忆,有需要时查阅即可。作者本人也没有记全(逃) 读者也可以自己编写代码查看具体效果 由于 printf/scanfstd::cin/std::cout 说起来太麻烦,以下简称 stdioiostream

5.2.1 stdio 的格式化输出
#

printf 的强大之处在于其精细的格式化控制能力。其格式说明符的完整形式如下:

%[flags][width][.precision][length]specifier
  • specifier (转换说明符):最核心的部分,决定如何解释数据。

    • d, i:有符号十进制整数

    • u:无符号十进制整数

    • f, F:十进制浮点数

    • e, E:科学计数法浮点数

    • g, G:根据值的不同,自动选择 %f%e

    • x, X:无符号十六进制整数

    • o:无符号八进制整数

    • s:字符串

    • c:字符

    • p:指针地址

    • %:输出一个 % 符号

  • flags (标志):控制输出的对齐、前缀等。

    • -:左对齐(默认右对齐)。

    • +:强制在正数前显示 + 号。

    • (空格):在正数前留空,负数前显示 -

    • #:替代形式。对于 o, x, X,会加上前缀 0, 0x, 0X;对于 f, e, E, g, G,强制输出小数点。

    • 0:用 0 填充字段宽度,而不是默认的空格。

  • width (宽度):规定输出的最小字符数。如果输出的值比宽度短,则用空格或 0 填充。可以用 * 动态指定,通过参数传入。

  • .precision (精度):对于整数说明符 d, i, o, u, x, X,表示输出的最小数字位数,不足补前导零;对于浮点数,表示小数点后的位数;对于 s,表示输出的最大字符数。

  • length (长度):指定参数的大小。

    • hhchar(如 %hhd

    • hshort

    • llong

    • lllong long

    • Llong double

示例: “|”表示开始和结束,实际输出不会显示的;宽度的单位是(英文)字符

#include <stdio.h>
int main() {
    int num = 42;
    double pi = 3.1415926535;
    char str[] = "Hello";

    printf("|%10d|\n", num);    // |        42| (右对齐,宽度10)
    printf("|%-10d|\n", num);   // |42        | (左对齐,宽度10)
    printf("|%010d|\n", num);   // |0000000042| (宽度10,前部用0填充)
    printf("|%+d|\n", num);     // |+42| (显示正负号)

    printf("|%.2f|\n", pi);     // |3.14| (保留2位小数)
    printf("|%10.2f|\n", pi);   // |      3.14| (宽度10,精度2)
    printf("|%e|\n", pi);       // |3.141593e+00| (科学计数法)

    printf("|%10s|\n", str);    // |     Hello| (右对齐)
    printf("|%-10s|\n", str);   // |Hello     | (左对齐)
    printf("|%.2s|\n", str);    // |He| (只输出2个字符)

    printf("地址: %p\n", &num); // 输出变量地址

    return 0;
}

5.2.2 iostream 的格式化输出
#

iostream 通过 流操纵符成员函数 来控制格式,需要包含 <iomanip> 头文件。它更类型安全,但有时更繁琐。

  • 常用流操纵符

    • std::endl:换行并刷新缓冲区。

    • std::flush:刷新输出缓冲区。(看到这里你应该理解了 std::endl = '\n'+std::flush,所以慢些)

    • std::setw(n):设置下一个输出项的宽度为 n

    • std::setfill(c):设置达不到 std::setw 设置的宽度的时,使用字符 c 填充(默认为空格)。

    • std::left / std::right:设置左/右对齐。

    • std::setprecision(n):设置浮点数的精度(总位数或小数位数,取决于输出模式是 std::fixed 还是 std::scientific)。

    • std::fixed:使用定点小数表示法。

    • std::scientific:使用科学计数法表示法。

    • std::hex / std::dec / std::oct:设置整数为十六/十/八进制输出。

    • std::showbase:显示进制前缀(十六进制:0x, 八进制:0)。

    • std::boolalpha / std::noboolalpha:将 bool 值输出为 true/false1/0

#include <iostream>
#include <iomanip>
int main() {
    int num = 42;
    double pi = 3.1415926535;
    bool flag = true;

    std::cout << std::setw(10) << num << "|\n";        // |        42|
    std::cout << std::left << std::setw(10) << num << "|\n"; // |42        |
    std::cout << std::setw(10) << std::setfill('0') << num << "|\n"; // |0000000042|
    std::cout << std::showpos << num << "\n";          // +42

    std::cout << std::fixed << std::setprecision(2) << pi << "\n"; // 3.14
    std::cout << std::setw(10) << std::setprecision(4) << pi << "|\n"; // |     3.1416|
    std::cout << std::scientific << pi << "\n";        // 3.1416e+00

    std::cout << std::boolalpha << flag << "\n";       // true
    std::cout << std::hex << std::showbase << num << "\n"; // 0x2a

    // 注意:很多操纵符的效果是持久的,直到被修改
    std::cout << 100 << "\n"; // 仍然以16进制输出: 0x64
    std::cout << std::dec << 100 << "\n"; // 恢复10进制: 100

    return 0;
}

5.3 更多输入:昔 gets 已逝 getline 如斯
#

5.3.1 gets:一个“臭名昭著”的函数
#

函数原型:char *gets(char *str);
这个函数非常简单:从标准输入读取一行,直到遇到换行符或 EOF(文件结尾,E nd O f F ile。如果输入流是文件),并将其存储在 str 指向的字符数组中。然后丢掉换行符,并在字符串末尾自动添加空字符 \0

很合理对吧?但是它有和 scanf 一样的致命缺陷:它 无法 知道目标数组 str 的大小(比如下面例子传入的 buffer 这个指针,本身没有包含它指向的 char 数组到底有多大)。它会一直读取,直到遇到换行符,无论是否超出了数组的边界。这使其成为缓冲区溢出漏洞的完美来源,极其危险。

#include <stdio.h>
int main() {
    char buffer[5];
    printf("Enter a string: ");
    gets(buffer); // 用户输入"HelloWorld"就会导致溢出!
    printf("You entered: %s\n", buffer);
    return 0;
}

因此,在 C11 标准中,gets 函数被彻底 从语言中移除。如果你的编译器还允许使用它,请收到警告后立刻弃用!

5.3.2 gets_s:一个打不完的补丁
#

部分编译器引入了 gets_s 作为替代品(s 是“safe”的意思):char *gets_s(char *str, rsize_t n);
它需要多传一个参数 n,表示目标数组的大小。

如果读取的字符数达到 n-1(为 \0 留空间)还没有遇到换行符,它就会停止读取,丢弃多余的字符,并在缓冲区末尾添加 \0
此外,如果你的 n 小于等于 0,或者传入的 str 指向空地址(人话:为 0),或者 n 大于 get_s 自己的极限(RSIZE_MAX),会触发所谓运行时约束处理程序,这往往会立即掐断程序。

问题gets_s 的行为不是所有编译器都一致,因为 gets_s() 不是标准函数。另外,直接腰斩过长的字符的行为对于需要高可用性的程序来说可能过于粗暴。补丁是打不完的,最好的办法是使用更现代、更灵活的方法。

getline

5.3.3 fgets:可靠但稍显繁琐的选择
#

原型:char *fgets(char *str, int n, FILE *stream);
这是一个更安全、更通用的函数。从流 stream(如 stdin。没错 C 也有这个概念。因为所谓“万物皆文件”的思想,所以 stdin 也被视为文件)中读取最多 n-1 个字符,并存储到 str 中。读取会在遇到换行符或 EOF 后停止。换行符会被保留 在字符串中,然后才是 \0

#include <stdio.h>
int main() {
    char buffer[10];
    printf("Enter a string: ");
    if (fgets(buffer, sizeof(buffer), stdin) != NULL) {
        printf("You entered: %s", buffer); // 注意buffer里可能包含换行符
    }
    return 0;
}

这个好处是 绝对安全,不会溢出。美中不足的是它会保留你可能不想要的换行符,需要手动截取处理。

5.3.4 std::getline (C++):现代而优雅的解决方案
#

C++提供了 std::getline 函数,专门用于从输入流中读取一行字符串。它一般和 std::string 组 CP。
函数原型:std::getline(std::istream& is, std::string& str, char delim = '\n'); 第一个参数是使用哪个流(std::cin 就是标准输入流嘛);第二个是一个 std::string 字符串,get 以后的内容会存进来;第三个参数可以不填(char delim = '\n' 的意思就是默认是 '\n'),表示读取到哪里终止。默认是 '\n',所以它才叫 std::getline 嘛!

#include <iostream>
#include <string>
int main() {
 std::string line;
 std::cout << "Enter a line: ";
 std::getline(std::cin, line); // 读取一行,丢弃换行符
 std::cout << "You entered: '" << line << "'\n";
 return 0;
}

优点

  • 绝对安全std::string 自动管理内存,无需担心缓冲区溢出。

  • 方便:自动处理内存分配,自动丢弃分隔符(第三个参数)。

  • 灵活:可以指定任意分隔符。

    它安全、灵活、方便:利用 std::string 自动管理内存,不会发生缓冲区溢出;可以自己指定分隔符(第三个参数),还会自动丢弃它——几乎没有任何缺点。这是从用户读取一行文本的推荐方法。

5.4 溢、溢出来了!(程序安全)
#

5.4.1 一定安全…吗?
#

之前我们了解了 scanf 的缓冲区溢出,并认为 std::cin 是安全的。但它并非绝对安全,只是安全的维度不同。

std::cin类型安全 避免了缓冲区溢出,但它仍有 状态安全 问题——正如上一章课后习题一样。

问题场景:当你用 std::cin 读取数据,但用户不理解你的意思,或者单纯反骨 软件测试工程师圣体,就是不按要求输入。
我们使用上一章的习题:

#include <iostream>
#include <string> 

int main() {
    int id = 0;
    std::string name;

    std::cout << "输入你的学号: ";
    std::cin >> id;

    std::cout << "输入你的姓名: ";
    std::cin >> name;

    std::cout << "你好," << name << ",你的学号是" << id << std::endl;
    return 0;
}

类似的 C 代码:

#include <stdio.h>

int main() {
    int id = 0;
    char name[100] = "";

    printf("输入你的学号\n");
    scanf("%d", &id);

    printf("输入你的姓名\n");
    scanf("%s", name);

    printf("你好,%s,你的学号是%d", name, id);
    return 0;
}

再考虑一轮交互:

输入你的学号: abc
输入你的姓名: 你好, ,你的学号是0
--------------
如果是C代码,应该是:
输入你的学号: abc
输入你的姓名: 你好,abc,你的学号是0

可见

  1. std::cin 进入 失败状态std::cin.fail() 返回 true);scanf 忽略了第一次输入(返回 0,因为读取到了 0 个有效输入)。

  2. 错误输入数据仍然留在输入缓冲区中。

  3. 对于 std::cin 后续所有 iostream 操作都无法进行,包括依赖 std::cinstd::getline,除非你清除错误状态并清空缓冲区;对于 scanf,剩下的数据会干扰下次输入。

    这就是程序的“炒饭”! 现在的程序没有处理这种“奇怪订单”的流程,导致后续无法再为任何顾客(输入语句)服务。

5.4.2 私 の オナニー を 見 ないでください!(处理错误输入)
#

一个健壮(robust,这词有个 很构史 很难懂的音译:鲁棒)的程序必须能检测错误并从中恢复。

上面的 C++代码,较为健壮的版本如下:

#include <iostream>
#include <limits> // 用于std::numeric_limits
#include <string>
using namespace std;

int main() {
    int id = 0;
    string name;

    // 死循环,处理学号输入直到成功(break语句)
    while (true) {
        cout << "输入你的学号: ";
        cin >> id;

        // 检查输入流是否处于错误状态。
        if (cin.fail()) {
            // 将流的状态标志重置为正常,这样后续的输入操作才能继续进行。
            cin.clear();
            // 清空输入缓冲区中所有的错误数据,直到遇到换行符为止。
            cin.ignore(numeric_limits<streamsize>::max(), '\n');
            cout << "输入错误!请只输入数字。\n";
        } else {
            // 清空缓冲区中可能剩余的字符(如用户输入"123abc")
            cin.ignore(numeric_limits<streamsize>::max(), '\n');
            break; // 输入成功,退出循环
        }
    }

    // 处理姓名输入
    cout << "输入你的姓名: ";
    // 使用getline而不是std::cin。这样可以读取包含空格的姓名,并且更安全
    getline(cin, name);

    cout << "你好," << name << ",你的学号是" << id << endl;
    return 0;
}

而 C 版本较健壮版本如下:

#include <stdio.h>
#include <string.h> // 为使用strcspn函数,检索字符串开头是否存在不合理的内容

// 清空输入缓冲区的辅助函数
void eatline() {
    int c;
    while ((c = getchar()) != '\n' && c != EOF) {;} // 读取并丢弃所有字符,直到换行符或文件结束
}

int main() {
    int id = 0;
    char name[100] = "";
    int result;

    // 处理学号输入(死循环直到输入正确)
    while (1) {
        printf("输入你的学号: ");
        result = scanf("%d", &id);

        if (result == 1) {
            // 输入成功,清空缓冲区中可能剩余的字符(如用户输入"123abc")
            eatline();
            break;
        } else if (result == 0) {
            // 输入失败(类型不匹配)
            printf("错误!请输入有效的数字。\n");
            eatline(); // 清空错误的输入
        } else if (result == EOF) {
            // 遇到文件结束或读取错误
            printf("输入结束或发生错误。\n");
            return 1;
        }
    }

    // 处理姓名输入(使用fgets防止缓冲区溢出)
    printf("输入你的姓名: ");
    if (fgets(name, sizeof(name), stdin) != NULL) {
        // 移除fgets读取的换行符(如果存在)
        // 这句话意思是找出name里面的\n,然后用\0终止字符替换它
        name[strcspn(name, "\n")] = '\0';
    } else {
        // fgets失败(如遇到文件结束)
        printf("读取姓名时发生错误。\n");
        return 1;
    }

    printf("你好,%s,你的学号是%d\n", name, id);
    return 0;
}

看到了吗,新程序比原来的长的多,C 版本尤甚。这是因为 C 给程序员更多自由,也相应地带来更多安全问题——正所谓能力越大,责任越大。为了维护程序的健壮,我们往往会让程序变得更长,但这不是徒劳之举,这是远强于亡羊补牢的未雨绸缪!


你已经学习了程序安全的知识了,快来查查这个 35000 行的代码吧!

  1. 假如你是郝哥,你又卖起瓜来了,为了避免再被华强捅,你打算编写一个报价程序。要求:

    • 商品名:西瓜

    • 每斤单价:2 元

    • 重量:15 斤

    • 商品名:大象 可刑可铐!

    • 每斤单价:267.1 元

    • 重量:5.4 吨

    输出格式是:

    买[商品],[单价]元/斤,[重量]斤,[总价]元。
    

    按照格式同时输出这 2 种商品,具体格式如下:

    1. 价格保留 2 位小数,斤使用整数。

    2. 价格固定 8 位宽度左对齐,斤固定 8 位补 0 处理。

  2. 先指出上述代码中至少 3 个安全问题或错误处理不足的地方,然后模仿上面的例子,尝试让这个程序健壮(可以改用 C++)建议尝试多种改法。

    #include <stdio.h>
    
    int main() {
        double num1, num2;
        char operator;
    
        printf("简单计算器 (支持 + - * /)\n");
    
        printf("请输入第一个数字: ");
        scanf("%lf", &num1);
    
        printf("请输入运算符 (+, -, *, /): ");
        scanf(" %c", &operator);
    
        printf("请输入第二个数字: ");
        scanf("%lf", &num2);
    
        printf("结果: ");
    
        switch(operator) {
            case '+':
                printf("%.2f\n", num1 + num2);
                break;
            case '-':
                printf("%.2f\n", num1 - num2);
                break;
            case '*':
                printf("%.2f\n", num1 * num2);
                break;
            case '/':
                if(num2 == 0) {
                    printf("错误:除数不能为零!\n");
                } else {
                    printf("%.2f\n", num1 / num2);
                }
                break;
            default:
                printf("错误:无效的运算符!\n");
        }
    
        return 0;
    }
    
  3. 尝试下面的程序:

    #include <cstdio>
    
    int main() {
        char str[100];
    
        // 读取用户输入
        scanf("%s", str);
    
        // 危险操作:直接将用户输入作为格式化字符串
        printf(str);
    
        return 0;
    }
    

    输入%s,看看会发生什么

  4. 假设你正在开发一个银行 ATM 机系统,用户可以通过以下流程进行操作:

    1. 插入银行卡(磁条卡或芯片卡)

    2. 输入 6 位数字密码(3 次尝试机会)

    3. 选择操作类型(1.查询余额 2.取款 3.存款 4.转账)

    4. 输入金额

    5. 确认交易

    6. 退出系统并退卡

注意:每次操作限时 60 秒;机器只能提供 100 元纸币,且单笔操作最高 5000 元。

请分析在这个 ATM 机系统中可能遇到的各种异常输入情况,并为每种情况设计相应的防范措施(思路即可)

命令提示符@CommandPrompt-Wang
作者
命令提示符@CommandPrompt-Wang