湯婆婆 in Zig 0.15

今から約5年前、Javaで湯婆婆を実装してみるなる記事がそれはそれはバズり、世のプログラマがこぞって他言語に移植するという出来事がありました。

ふと思い出したので、Zig 0.15でやってみることにした記事です。

まずは愚直に

とりあえず、何も考えずに普通に書いてみましょう。

標準出力/標準入力を扱うだけでも一苦労です。このあたりに、Zigの設計思想が見えますね。

他にちょっと特殊な点と言うと、乱数生成の部分でしょうか。シード値を手動で与えてやる必要があるので、std.posix.getrandom()で一度OSから乱数をもらいます。

const std = @import("std");

pub fn main() !void {
    var write_buffer: [1024]u8 = undefined;
    var writer = std.fs.File.stdout().writer(&write_buffer);
    const stdout = &writer.interface;

    try stdout.writeAll("契約書だよ。そこに名前を書きな。\n");
    try stdout.flush();

    // 契約書を渡す
    var read_buffer: [1024]u8 = undefined;
    var reader = std.fs.File.stdin().reader(&read_buffer);
    const contract = &reader.interface;
    const name = try contract.takeDelimiterExclusive('\n');

    try stdout.print("フン。{s}というのかい。贅沢な名だねぇ。\n", .{name});
    try stdout.flush();

    // 名前を奪う
    var seed: u64 = undefined;
    try std.posix.getrandom(std.mem.asBytes(&seed));
    var rng = std.Random.DefaultPrng.init(seed);
    const random = rng.random();
    const idx = random.uintLessThan(usize, name.len);

    const new_name = name[idx];

    try stdout.print("今からお前の名前は{0c}だ。いいかい、{0c}だよ。分かったら返事をするんだ、{0c}!!\n", .{new_name});
    try stdout.flush();
}
契約書だよ。そこに名前を書きな。
SyoBoN
フン。SyoBoNというのかい。贅沢な名だねぇ。
今からお前の名前はBだ。いいかい、Bだよ。分かったら返事をするんだ、B!!

わたしの名前はBです。

𠮷田さん対応

さて、湯婆婆界隈(?)には有名な𠮷田さん問題があります。元ネタの湯婆婆はJavaで記述されていたわけですが、Javaは文字列をUTF-16の符号単位列として扱います。そのため、「𠮷」のようにサロゲートペアで表される文字が入ってくると、サロゲートペアの片方のみが取り出されてしまいおかしくなる、という問題です。

では、Zigではどうでしょうか。ZigはCと同じようなアプローチを取り、「文字列型」を個別で作ることはせず、ただのバイト列として扱います。もう一度言います。Zigは、文字列をバイト列として扱います。もうおわかりですね。上に挙げたコードでは、𠮷田さんはおろか元ネタである「荻野千尋」すら正しく処理できません。なんてこった。

契約書だよ。そこに名前を書きな。
荻野千尋
フン。荻野千尋というのかい。贅沢な名だねぇ。
今からお前の名前は�だ。いいかい、�だよ。分かったら返事をするんだ、�!!

というわけで修正しましょう。Zigの標準ライブラリには、UTF-8のバイト列からUnicodeコードポイントのイテレータを作るためのUtf8Viewがあるので、これを使います。コードポイントになるので、これだけで𠮷田さん問題にも対処できます。

const std = @import("std");

pub fn main() !void {
    var write_buffer: [1024]u8 = undefined;
    var writer = std.fs.File.stdout().writer(&write_buffer);
    const stdout = &writer.interface;

    try stdout.writeAll("契約書だよ。そこに名前を書きな。\n");
    try stdout.flush();

    // 契約書を渡す
    var read_buffer: [1024]u8 = undefined;
    var reader = std.fs.File.stdin().reader(&read_buffer);
    const contract = &reader.interface;
    const name = try contract.takeDelimiterExclusive('\n');

    const len = try std.unicode.utf8CountCodepoints(name);

    try stdout.print("フン。{s}というのかい。贅沢な名だねぇ。\n", .{name});
    try stdout.flush();

    // 名前を奪う
    var seed: u64 = undefined;
    try std.posix.getrandom(std.mem.asBytes(&seed));
    var rng = std.Random.DefaultPrng.init(seed);
    const random = rng.random();
    const idx = random.uintLessThan(usize, len);

    const new_name = new_name: {
        var iter = (try std.unicode.Utf8View.init(name)).iterator();
        var i: usize = 0;
        while (iter.nextCodepointSlice()) |s| : (i += 1) {
            if (i == idx) {
                break :new_name s;
            }
        }
        unreachable;
    };

    try stdout.print("今からお前の名前は{0s}だ。いいかい、{0s}だよ。分かったら返事をするんだ、{0s}!!\n", .{new_name});
    try stdout.flush();
}
契約書だよ。そこに名前を書きな。
𠮷田
フン。𠮷田というのかい。贅沢な名だねぇ。
今からお前の名前は𠮷だ。いいかい、𠮷だよ。分かったら返事をするんだ、𠮷!!

全国の𠮷田さん大歓喜。

🇨🇦🇫🇷🇩🇪🇮🇹🇯🇵🇬🇧🇺🇸さん対応

これで完璧……と言いたいところですがまだ罠がありますね。そう、みなさんご存知、複数のUnicodeコードポイントで一つの文字を形成するやつです。顕著な例が絵文字で、例えば「👨‍👨‍👧‍👧」という絵文字は「👨👨👧👧」がゼロ幅結合子で繋がれた、計7つのコードポイントからなる1文字です。

というわけで、「明るい家族👨‍👨‍👧‍👧」さんとか「pͪoͣnͬpͣoͥnͭpͣa͡inͥ」さんとか「🇨🇦🇫🇷🇩🇪🇮🇹🇯🇵🇬🇧🇺🇸」さんとかが来てもいいように改修しましょう。

こういった名前に対応するためには文字列を書記素クラスタ単位で処理すればよいのですが、さすがにZigの標準ライブラリに文字列を書記素クラスタ単位に分割するような関数はなさそうなので、外部ライブラリに頼ることにします。今回は、軽く調べた中で活発にメンテナンスされていそうだったzgを使用してみました。

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

pub fn main() !void {
    const allocator = std.heap.smp_allocator;

    var write_buffer: [1024]u8 = undefined;
    var writer = std.fs.File.stdout().writer(&write_buffer);
    const stdout = &writer.interface;

    try stdout.writeAll("契約書だよ。そこに名前を書きな。\n");
    try stdout.flush();

    // 契約書を渡す
    var read_buffer: [1024]u8 = undefined;
    var reader = std.fs.File.stdin().reader(&read_buffer);
    const contract = &reader.interface;
    const name = try contract.takeDelimiterExclusive('\n');

    const graphemes = try Graphemes.init(allocator);
    defer graphemes.deinit(allocator);

    const len = count_len: {
        var iter = graphemes.iterator(name);
        var i: usize = 0;
        while (iter.next()) |_| {
            i += 1;
        }
        break :count_len i;
    };

    try stdout.print("フン。{s}というのかい。贅沢な名だねぇ。\n", .{name});
    try stdout.flush();

    // 名前を奪う
    var seed: u64 = undefined;
    try std.posix.getrandom(std.mem.asBytes(&seed));
    var rng = std.Random.DefaultPrng.init(seed);
    const random = rng.random();
    const idx = random.uintLessThan(usize, len);

    const new_name = new_name: {
        var iter = graphemes.iterator(name);
        var i: usize = 0;
        while (iter.next()) |gc| : (i += 1) {
            if (i == idx) {
                break :new_name gc.bytes(name);
            }
        }
        unreachable;
    };

    try stdout.print("今からお前の名前は{0s}だ。いいかい、{0s}だよ。分かったら返事をするんだ、{0s}!!\n", .{new_name});
    try stdout.flush();
}
契約書だよ。そこに名前を書きな。
🇨🇦🇫🇷🇩🇪🇮🇹🇯🇵🇬🇧🇺🇸
フン。🇨🇦🇫🇷🇩🇪🇮🇹🇯🇵🇬🇧🇺🇸というのかい。贅沢な名だねぇ。
今からお前の名前は🇺🇸だ。いいかい、🇺🇸だよ。分かったら返事をするんだ、🇺🇸!!

完璧ですね。

空文字列

さて、この湯婆婆ですが、名前として空文字列を与えると狂います。unreachableに到達してしまうIllegal Behaviorが起こるからですね。

契約書だよ。そこに名前を書きな。

フン。というのかい。贅沢な名だねぇ。
thread 12432960 panic: reached unreachable code
/opt/homebrew/Cellar/zig/0.15.2/lib/zig/std/debug.zig:559:14: 0x10297fe07 in assert (yubaba)
    if (!ok) unreachable; // assertion failure
             ^
/opt/homebrew/Cellar/zig/0.15.2/lib/zig/std/Random.zig:141:11: 0x102a127d3 in uintLessThan__anon_19318 (yubaba)
    assert(0 < less_than);
          ^
/Users/syobon/Devel/yubaba/src/main.zig:40:36: 0x102a1197f in main (yubaba)
    const idx = random.uintLessThan(usize, len);
                                   ^
/opt/homebrew/Cellar/zig/0.15.2/lib/zig/std/start.zig:627:37: 0x102a12ef3 in main (yubaba)
            const result = root.main() catch |err| {
                                    ^
???:?:?: 0x1865f5d53 in ??? (???)
???:?:?: 0x0 in ??? (???)
fish: Job 1, './zig-out/bin/yubaba' terminated by signal SIGABRT (Abort)

これに関しては、元ネタ記事にて仕様であると明言されているため、wontfixとします。油屋RTAを走る方はどうぞ活用してください。

おわり

というわけで、2020年のバズ記事に2026年の今あえて乗ってみる記事でした。

と、ここまで書いて気付いたのですが、“zig 湯婆婆”で検索しても全然ヒットしないので、もしかするとこれが世界初の湯婆婆 in Zigかもしれません。やったね。

個人的にはこの手のネタは大好物なので、新ネタを見つけた方はご連絡ください。何か書きます。