概述
在本实践章节中,我们构建一个微小的、分配器友好的路径助手,它与Zig的标准库配合良好,并且跨平台工作。我们将采用测试优先的方式开发它——然后还提供一个小型CLI演示,以便您无需测试工具就能看到实际输出。在此过程中,我们故意引入泄漏并观察Zig的测试分配器捕获它,然后修复并验证。
目标不是替换std.fs.path,而是在一个现实的、小型的实用程序中练习API设计、测试驱动开发(TDD)和防泄漏分配。参见13__testing-and-leak-detection.xml和path.zig。
学习目标
- 设计一个小型、可组合的API:连接、基本名称/目录路径、扩展名和更改扩展名。
- 正确使用分配器,在成功和失败路径下避免泄漏。10__allocators-and-memory-management.xml
- 使用
std.testing练习TDD,并将TDD与zig run演示配对以获得可见输出。13__testing-and-leak-detection.xml
小型API表面
我们将在pathutil命名空间中实现四个助手:
joinAlloc(allocator, parts)→[]u8:使用单个分隔符连接组件,保留绝对根路径basename(path)→[]const u8:最后一个组件,忽略尾部分隔符dirpath(path)→[]const u8:目录部分,无尾部分隔符(裸名称使用".",根路径使用"/")extname(path)→[]const u8和changeExtAlloc(allocator, path, new_ext)→[]u8
这些函数强调可预测的、教学友好的行为;对于生产级的边缘情况,请优先使用std.fs.path。
const std = @import("std");
// 用于教学目的的、小巧且对分配器友好的路径工具。
// 注意:这些工具不尝试实现完整的平台语义;它们旨在为教学提供可预测性
// 和可移植性。生产代码请优先使用 std.fs.path。
pub const pathutil = struct {
// 用一个分隔符连接组件。
// - 合并边界处的重复分隔符
// - 如果第一个非空部分以分隔符开头,则保留前导根(例如 POSIX 上的“/”)
// - 不解析点段或驱动器号
pub fn joinAlloc(allocator: std.mem.Allocator, parts: []const []const u8) ![]u8 {
var list: std.ArrayListUnmanaged(u8) = .{};
defer list.deinit(allocator);
const sep: u8 = std.fs.path.sep;
var has_any: bool = false;
for (parts) |raw| {
if (raw.len == 0) continue;
// Trim leading/trailing separators from this component
var start: usize = 0;
var end: usize = raw.len;
while (start < end and isSep(raw[start])) start += 1;
while (end > start and isSep(raw[end - 1])) end -= 1;
const had_leading_sep = start > 0;
const core = raw[start..end];
if (!has_any) {
if (had_leading_sep) {
// Preserve absolute root
try list.append(allocator, sep);
has_any = true;
}
} else {
// Ensure exactly one separator between components if we have content already
if (list.items.len == 0 or list.items[list.items.len - 1] != sep) {
try list.append(allocator, sep);
}
}
if (core.len != 0) {
try list.appendSlice(allocator, core);
has_any = true;
}
}
return list.toOwnedSlice(allocator);
}
// 返回最后一个路径组件。尾部多余的分隔符将被忽略。
// 示例: "a/b/c" -> "c", "/a/b/" -> "b", "/" -> "/", "" -> ""
pub fn basename(path: []const u8) []const u8 {
if (path.len == 0) return path;
// Skip trailing separators
var end = path.len;
while (end > 0 and isSep(path[end - 1])) end -= 1;
if (end == 0) {
// path was all separators; treat it as root
return path[0..1];
}
// Find previous separator
var i: isize = @intCast(end);
while (i > 0) : (i -= 1) {
if (isSep(path[@intCast(i - 1)])) break;
}
const start: usize = @intCast(i);
return path[start..end];
}
// 返回目录部分(不带尾随分隔符)。
// 示例: "a/b/c" -> "a/b", "a" -> ".", "/" -> "/"
pub fn dirpath(path: []const u8) []const u8 {
if (path.len == 0) return ".";
// Skip trailing separators
var end = path.len;
while (end > 0 and isSep(path[end - 1])) end -= 1;
if (end == 0) return path[0..1]; // all separators -> root
// Find previous separator
var i: isize = @intCast(end);
while (i > 0) : (i -= 1) {
const ch = path[@intCast(i - 1)];
if (isSep(ch)) break;
}
if (i == 0) return ".";
// Skip any trailing separators in the dir portion
var d_end: usize = @intCast(i);
while (d_end > 1 and isSep(path[d_end - 1])) d_end -= 1;
if (d_end == 0) return path[0..1];
return path[0..d_end];
}
// 返回最后一个组件的扩展名(不带点),如果没有则返回""。
// 示例: "file.txt" -> "txt", "a.tar.gz" -> "gz", ".gitignore" -> ""
pub fn extname(path: []const u8) []const u8 {
const base = basename(path);
if (base.len == 0) return base;
if (base[0] == '.') {
// Hidden file as first character '.' does not count as extension if there is no other dot
if (std.mem.indexOfScalar(u8, base[1..], '.')) |idx2| {
const idx = 1 + idx2;
if (idx + 1 < base.len) return base[(idx + 1)..];
return "";
} else return "";
}
if (std.mem.lastIndexOfScalar(u8, base, '.')) |idx| {
if (idx + 1 < base.len) return base[(idx + 1)..];
}
return "";
}
// 返回一个新分配的路径,其扩展名被`new_ext`(不带点)替换。
// 如果不存在现有扩展名,且`new_ext`不为空,则追加一个。
pub fn changeExtAlloc(allocator: std.mem.Allocator, path: []const u8, new_ext: []const u8) ![]u8 {
const base = basename(path);
const dir = dirpath(path);
const sep: u8 = std.fs.path.sep;
var base_core = base;
if (std.mem.lastIndexOfScalar(u8, base, '.')) |idx| {
if (!(idx == 0 and base[0] == '.')) {
base_core = base[0..idx];
}
}
const need_dot = new_ext.len != 0;
const dir_has = dir.len != 0 and !(dir.len == 1 and dir[0] == '.' and base.len == path.len);
// Compute length at runtime to avoid comptime_int dependency
var new_len: usize = 0;
if (dir_has) new_len += dir.len + 1;
new_len += base_core.len;
if (need_dot) new_len += 1 + new_ext.len;
var out = try allocator.alloc(u8, new_len);
errdefer allocator.free(out);
var w: usize = 0;
if (dir_has) {
@memcpy(out[w..][0..dir.len], dir);
w += dir.len;
out[w] = sep;
w += 1;
}
@memcpy(out[w..][0..base_core.len], base_core);
w += base_core.len;
if (need_dot) {
out[w] = '.';
w += 1;
@memcpy(out[w..][0..new_ext.len], new_ext);
w += new_ext.len;
}
return out;
}
};
inline fn isSep(ch: u8) bool {
return ch == std.fs.path.sep or isOtherSep(ch);
}
inline fn isOtherSep(ch: u8) bool {
// 解析时要宽容:在任何平台上都将'/'和'\\'视作分隔符
// 但在连接时只发出 std.fs.path.sep。
return ch == '/' or ch == '\\';
}
出于教学目的,我们在解析时接受任何平台上的'/'或'\\'作为分隔符,但在连接时始终发出本地分隔符(std.fs.path.sep)。
尝试:运行演示(可见输出)
为了在测试运行器之外保持输出可见,这里有一个小型CLI,它调用我们的助手并打印结果。
const std = @import("std");
const pathutil = @import("path_util.zig").pathutil;
pub fn main() !void {
var out_buf: [2048]u8 = undefined;
var out_writer = std.fs.File.stdout().writer(&out_buf);
const out = &out_writer.interface;
// Demonstrate join
const j1 = try pathutil.joinAlloc(std.heap.page_allocator, &.{ "a", "b", "c" });
defer std.heap.page_allocator.free(j1);
try out.print("join a,b,c => {s}\n", .{j1});
const j2 = try pathutil.joinAlloc(std.heap.page_allocator, &.{ "/", "usr/", "/bin" });
defer std.heap.page_allocator.free(j2);
try out.print("join /,usr/,/bin => {s}\n", .{j2});
// Demonstrate basename/dirpath
const p = "/home/user/docs/report.txt";
try out.print("basename({s}) => {s}\n", .{ p, pathutil.basename(p) });
try out.print("dirpath({s}) => {s}\n", .{ p, pathutil.dirpath(p) });
// Extension helpers
try out.print("extname({s}) => {s}\n", .{ p, pathutil.extname(p) });
const changed = try pathutil.changeExtAlloc(std.heap.page_allocator, p, "md");
defer std.heap.page_allocator.free(changed);
try out.print("changeExt({s}, md) => {s}\n", .{ p, changed });
try out.flush();
}
$ zig run chapters-data/code/14__project-path-utility-tdd/path_util_demo.zigjoin a,b,c => a/b/c
join /,usr/,/bin => /usr/bin
basename(/home/user/docs/report.txt) => report.txt
dirpath(/home/user/docs/report.txt) => /home/user/docs
extname(/home/user/docs/report.txt) => txt
changeExt(/home/user/docs/report.txt, md) => /home/user/docs/report.md测试优先:编码行为和边缘情况
TDD有助于澄清意图并锁定边缘情况。我们保持测试小型且快速;它们使用Zig的测试分配器运行,该分配器默认捕获泄漏。本章包含测试,因为内容计划要求TDD;在其他地方,我们将优先使用zig run风格的演示以获得可见输出。参见13__testing-and-leak-detection.xml和testing.zig。
const std = @import("std");
const testing = std.testing;
const pathutil = @import("path_util.zig").pathutil;
fn ajoin(parts: []const []const u8) ![]u8 {
return try pathutil.joinAlloc(testing.allocator, parts);
}
test "joinAlloc basic and absolute" {
const p1 = try ajoin(&.{ "a", "b", "c" });
defer testing.allocator.free(p1);
try testing.expectEqualStrings("a" ++ [1]u8{std.fs.path.sep} ++ "b" ++ [1]u8{std.fs.path.sep} ++ "c", p1);
const p2 = try ajoin(&.{ "/", "usr/", "/bin" });
defer testing.allocator.free(p2);
try testing.expectEqualStrings("/usr/bin", p2);
const p3 = try ajoin(&.{ "", "a", "", "b" });
defer testing.allocator.free(p3);
try testing.expectEqualStrings("a" ++ [1]u8{std.fs.path.sep} ++ "b", p3);
const p4 = try ajoin(&.{ "a/", "/b/" });
defer testing.allocator.free(p4);
try testing.expectEqualStrings("a" ++ [1]u8{std.fs.path.sep} ++ "b", p4);
}
test "basename and dirpath edges" {
try testing.expectEqualStrings("c", pathutil.basename("a/b/c"));
try testing.expectEqualStrings("b", pathutil.basename("/a/b/"));
try testing.expectEqualStrings("/", pathutil.basename("////"));
try testing.expectEqualStrings("", pathutil.basename(""));
try testing.expectEqualStrings("a/b", pathutil.dirpath("a/b/c"));
try testing.expectEqualStrings(".", pathutil.dirpath("a"));
try testing.expectEqualStrings("/", pathutil.dirpath("////"));
}
test "extension and changeExtAlloc" {
try testing.expectEqualStrings("txt", pathutil.extname("file.txt"));
try testing.expectEqualStrings("gz", pathutil.extname("a.tar.gz"));
try testing.expectEqualStrings("", pathutil.extname(".gitignore"));
try testing.expectEqualStrings("", pathutil.extname("noext"));
const changed1 = try pathutil.changeExtAlloc(testing.allocator, "a/b/file.txt", "md");
defer testing.allocator.free(changed1);
try testing.expectEqualStrings("a/b/file.md", changed1);
const changed2 = try pathutil.changeExtAlloc(testing.allocator, "a/b/file", "md");
defer testing.allocator.free(changed2);
try testing.expectEqualStrings("a/b/file.md", changed2);
const changed3 = try pathutil.changeExtAlloc(testing.allocator, "a/b/.profile", "txt");
defer testing.allocator.free(changed3);
try testing.expectEqualStrings("a/b/.profile.txt", changed3);
}
$ zig test chapters-data/code/14__project-path-utility-tdd/path_util_test.zigAll 3 tests passed.捕获故意泄漏 → 修复它
测试分配器在测试结束时标记泄漏。首先,一个忘记free的失败示例:
const std = @import("std");
const testing = std.testing;
const pathutil = @import("path_util.zig").pathutil;
test "deliberate leak caught by testing allocator" {
const joined = try pathutil.joinAlloc(testing.allocator, &.{ "/", "tmp", "demo" });
// Intentionally forget to free: allocator leak should be detected by the runner
// defer testing.allocator.free(joined);
try testing.expect(std.mem.endsWith(u8, joined, "demo"));
}
$ zig test chapters-data/code/14__project-path-utility-tdd/leak_demo_fail.zig[gpa] (err): memory address 0x… leaked: … path_util.zig:49:33: … in joinAlloc … leak_demo_fail.zig:6:42: … in test.deliberate leak caught by testing allocator All 1 tests passed. 1 errors were logged. 1 tests leaked memory. error: the following test command failed with exit code 1: …/test --seed=0x…
然后使用defer修复它,并观察测试套件变为绿色:
const std = @import("std");
const testing = std.testing;
const pathutil = @import("path_util.zig").pathutil;
test "fixed: no leak after adding defer free" {
const joined = try pathutil.joinAlloc(testing.allocator, &.{ "/", "tmp", "demo" });
defer testing.allocator.free(joined);
try testing.expect(std.mem.endsWith(u8, joined, "demo"));
}
$ zig test chapters-data/code/14__project-path-utility-tdd/leak_demo_fix.zigAll 1 tests passed.注意事项
- 对于生产级路径处理,请参考
std.fs.path以了解平台细微差别(UNC路径、驱动器字母、特殊根路径)。 - 在成功分配后立即优先使用
defer allocator.free(buf);它通过构造使成功和错误路径都正确。04__errors-resource-cleanup.xml - 当您需要可见输出(教程、演示)时,优先使用
zig run示例;当您需要保证(CI)时,优先使用zig test。本章演示了两种方法,因为它明确关注TDD。13__testing-and-leak-detection.xml
练习
- 扩展
joinAlloc以省略.段并折叠中间的..对(在根路径附近要小心)。为边缘情况添加测试,然后使用zig run进行演示。 - 添加
stem(path),返回不带扩展名的基本名称;验证.gitignore、多点名称和尾随点的行为。 - 编写一个微型CLI,接受
--change-ext md file1 file2 …并打印结果,使用页面分配器和缓冲写入器。28__filesystem-and-io.xml
替代方案与边缘情况
- 在Windows上,此教学工具在输入时接受
'/'和'\\'作为分隔符,但始终打印本地分隔符。如果你需要确切的Windows行为,std.fs.path具有更丰富的语义。 - 分配失败处理:演示使用
std.heap.page_allocator并在OOM时中止;测试使用std.testing.allocator来系统性地捕获泄漏。10__allocators-and-memory-management.xml - 如果你将这些助手嵌入到更大的工具中,通过你的API传递分配器并保持所有权规则明确;避免全局状态。36__style-and-best-practices.xml