Chapter 05Project Tempconv Cli

项目

概述

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

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

学习目标

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

塑造命令界面

在接触任何数学之前,我们需要一个可预测的契约:三个参数(来源单位目标单位)外加--help用于文档。程序应该预先解释错误,这样调用者就永远不会看到恐慌。

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在嵌入脚本中时保持弹性,具体如#enum中所述。

转换和报告结果

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

完整的TempConv列表

下面的列表包括参数解析、单位助手和转换逻辑。请关注CLI结构如何使每个失败路径都显而易见,同时保持愉快路径的简洁。

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

// Chapter 5 – TempConv CLI: walk from parsing arguments through producing a
// 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
    // remains forgiving about casing.
    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 {
    // Section 2: normalise through Kelvin so every pair of units reuses the
    // same formulas, keeping the CLI easy to extend.
    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 {
        // Section 1 also highlights how parsing failures become user-facing
        // diagnostics rather than backtraces.
        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及其单字母缩写。
  • 添加一个标志,使用Zig的格式化动词在四舍五入的输出({d:.2})和全精度之间切换;参见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.