Chapter 05Project Tempconv Cli

项目

概述

我们的第一个项目将第1-4章的语言基础转化为一个手持命令行实用程序,可以在摄氏度、华氏度和开尔文之间转换温度。我们将参数解析、枚举和浮点数学组合成一个单一程序,同时保持对最终用户友好的诊断信息,如#命令行标志#浮点数中所述。

在此过程中,我们强化了前一章的错误处理理念:验证产生人类可读的提示,进程以意图退出而不是堆栈跟踪;参见#错误处理

学习目标

  • 构建一个最小的CLI框架,读取参数,处理--help,并发出使用指南。
  • 使用枚举表示温度单位,并使用switch来规范化转换,如#switch中所述。
  • 在通过简洁诊断而不是展开堆栈跟踪来显示验证失败的同时,呈现转换结果。

塑造命令接口

在接触任何数学运算之前,我们需要一个可预测的契约:三个参数(valuefrom-unitto-unit)加上用于文档的--help。程序应该提前解释错误,这样调用者永远不会看到panic。

CLI参数如何到达你的程序

当你从命令行运行程序时,操作系统在你的main()函数运行之前通过明确定义的启动序列传递参数。理解这个流程阐明了std.process.args()从哪里获取其数据:

graph TB OS["操作系统"] EXEC["execve() 系统调用"] KERNEL["内核加载 ELF"] STACK["堆栈设置:<br/>argc, argv[], envp[]"] START["_start 入口点<br/>(裸汇编)"] POSIX["posixCallMainAndExit<br/>(argc_argv_ptr)"] PARSE["解析堆栈布局:<br/>argc 在 [0]<br/>argv 在 [1..argc+1]<br/>envp 在 NULL 之后"] GLOBALS["设置全局状态:<br/>std.os.argv = argv[0..argc]<br/>std.os.environ = envp"] CALLMAIN["callMainWithArgs<br/>(argc, argv, envp)"] USERMAIN["你的 main() 函数"] ARGS["std.process.args()<br/>读取 std.os.argv"] OS --> EXEC EXEC --> KERNEL KERNEL --> STACK STACK --> START START --> POSIX POSIX --> PARSE PARSE --> GLOBALS GLOBALS --> CALLMAIN CALLMAIN --> USERMAIN USERMAIN --> ARGS

关键点:

  • 操作系统准备:操作系统在将控制权转移到你的程序之前,将argc(参数计数)和argv(参数数组)放在堆栈上。
  • 汇编入口_start符号(用内联汇编编写)是真正的入口点,而不是main()
  • 堆栈解析posixCallMainAndExit读取堆栈布局以提取argcargv和环境变量。
  • 全局状态:在调用你的main()之前,运行时用解析的数据填充std.os.argvstd.os.environ
  • 用户访问:当你调用std.process.args()时,它只是返回一个迭代器,遍历已经填充的std.os.argv切片。

这对CLI程序的重要性:

  • 参数从main()运行的那一刻起就可用——不需要单独的初始化。
  • 第一个参数(argv[0])始终是程序名称。
  • 参数解析在启动期间发生一次,而不是每次访问时。
  • 无论你使用zig run还是编译后的二进制文件,这个序列都是相同的。

这种基础设施意味着你的TempConv CLI可以立即开始解析参数,而无需担心它们如何到达的低级细节。

使用防护栏解析参数

入口点分配完整的参数向量,检查--help,并验证参数数量。当违反规则时,我们打印使用横幅并以失败代码退出,依靠std.process.exit来避免嘈杂的堆栈跟踪。

单位和验证助手

我们使用枚举和parseUnit助手来描述支持的单元,该助手接受大写或小写标记。无效标记会触发友好的诊断并立即退出,当嵌入脚本时保持CLI的弹性,如#枚举中所述。

转换和报告结果

接口就位后,程序的其余部分依赖于确定性转换:每个值都归一化为开尔文,然后投影到请求的单位,保证无论输入组合如何都能获得一致的结果。

完整的TempConv清单

下面的清单包括参数解析、单位助手和转换逻辑。重点关注CLI结构如何使每个失败路径显而易见,同时保持成功路径的简洁性。

Zig
const std = @import("std");

// Chapter 5 – TempConv CLI: walk from parsing arguments through producing a
// 第5章 - TempConv CLI:从解析参数到生成格式化结果的完整过程
// formatted result, exercising everything we have learned about errors and
// 练习我们学到的所有错误处理和
// deterministic cleanup along the way.
// 确定性清理知识。

const CliError = error{ MissingArgs, BadNumber, BadUnit };

const Unit = enum { c, f, k };

fn printUsage() void {
    std.debug.print("usage: tempconv <value> <from-unit> <to-unit>\n", .{});
    std.debug.print("units: C (celsius), F (fahrenheit), K (kelvin)\n", .{});
}

fn parseUnit(token: []const u8) CliError!Unit {
    // Section 1: we accept a single-letter token and normalise it so the CLI
    // 第1节:我们接受单个字母标记并规范化它,以便CLI对大小写保持宽容。
    if (token.len != 1) return CliError.BadUnit;
    const ascii = std.ascii;
    const lower = ascii.toLower(token[0]);
    return switch (lower) {
        'c' => .c,
        'f' => .f,
        'k' => .k,
        else => CliError.BadUnit,
    };
}

fn toKelvin(value: f64, unit: Unit) f64 {
    return switch (unit) {
        .c => value + 273.15,
        .f => (value + 459.67) * 5.0 / 9.0,
        .k => value,
    };
}

fn fromKelvin(value: f64, unit: Unit) f64 {
    return switch (unit) {
        .c => value - 273.15,
        .f => (value * 9.0 / 5.0) - 459.67,
        .k => value,
    };
}

fn convert(value: f64, from: Unit, to: Unit) f64 {
    // 第2节:通过开尔文进行标准化,使每对单位都能重用
    // 相同的公式,保持CLI易于扩展。
    if (from == to) return value;
    const kelvin = toKelvin(value, from);
    return fromKelvin(kelvin, to);
}

pub fn main() !void {
    const allocator = std.heap.page_allocator;
    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    if (args.len == 1 or (args.len == 2 and std.mem.eql(u8, args[1], "--help"))) {
        printUsage();
        return;
    }

    if (args.len != 4) {
        std.debug.print("error: expected three arguments\n", .{});
        printUsage();
        std.process.exit(1);
    }

    const raw_value = args[1];
    const value = std.fmt.parseFloat(f64, raw_value) catch {
        // 第1节还突出了解析失败如何成为面向用户的
        // 诊断信息,而不是堆栈跟踪。
        std.debug.print("error: '{s}' is not a floating-point value\n", .{raw_value});
        std.process.exit(1);
    };

    const from = parseUnit(args[2]) catch {
        std.debug.print("error: unknown unit '{s}'\n", .{args[2]});
        std.process.exit(1);
    };

    const to = parseUnit(args[3]) catch {
        std.debug.print("error: unknown unit '{s}'\n", .{args[3]});
        std.process.exit(1);
    };

    const result = convert(value, from, to);

    std.debug.print(
        "{d:.2} {s} -> {d:.2} {s}\n",
        .{ value, @tagName(from), result, @tagName(to) },
    );
}
运行
Shell
$ zig run tempconv_cli.zig -- 32 F C
输出
Shell
32.00 f -> 0.00 c

程序在检测到无效值或单位时会在退出前打印诊断信息,因此脚本可以依赖非零退出状态而无需解析堆栈跟踪。

练习额外转换

你可以对开尔文或摄氏度输入运行相同的二进制文件——共享的转换助手保证对称性,因为所有内容都通过开尔文流动。

Shell
$ zig run tempconv_cli.zig -- 273.15 K C
输出
Shell
273.15 k -> 0.00 c

注意事项

  • 参数解析在设计上保持最小化;生产工具可能会使用相同的防护模式添加长格式标志或更丰富的帮助文本。
  • 温度转换是线性的,因此双精度浮点数足够;如果你添加像兰金温标这样的特殊温标,请仔细调整公式。
  • std.debug.print写入stderr,这保持了脚本管道的安全性——如果你需要干净的stdout输出,请切换到缓冲的stdout写入器;参见#Debug

练习

  • 扩展parseUnit以识别完整的单词celsiusfahrenheitkelvin以及它们的单字母缩写。
  • 添加一个标志,在四舍五入输出({d:.2})和完整精度之间切换,使用Zig的格式化动词;参见fmt.zig
  • 引入一个--table模式,打印一系列值的转换,通过for加强切片迭代,如#for中所述。

替代方案与边缘情况:

  • 开尔文永远不会低于零;如果你的CLI应该拒绝负的开尔文输入而不是接受数学值,请附加一个防护。
  • 国际受众有时期望逗号小数;如果你需要这种行为,请将std.fmt.formatFloat与本地化感知的后处理连接起来。
  • 为了支持脚本使用而不调用zig run,请使用zig build-exe打包程序,并将二进制文件放在你的PATH上。

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.