オフィスアワーがそろそろ始まるよ!()

未定義動作

未定義動作 (Undefined Behavir; UB) とは、その状態に陥るとプログラムがどのような挙動になるかわからないことを意味します。未定義動作の結果、プログラムがクラッシュすることもあれば、不正なメモリアクセスを繰り返しながら意図せぬ動作をする場合もあります。

Zenにはいくつかの未定義動作があります。C言語の未定義動作をご存知の方は悪夢の再来のように感じるかもしれませんが、Zenの未定義動作は安全性保護付き未定義動作です。安全性保護が意味するところは、未定義動作を起こす振る舞いがチェックされ、未定義動作が発見された場合には、Zenのパニック機構が働くということです。Zenのパニック機構は、定義された動作 (プロセスを終了するなど) を提供するため、プログラム全体としては動作が定義された状態であると言えます。

未定義動作に対する安全性保護を無効にすることも可能です。ReleaseFastモードでのビルドは全ての安全性保護を無効にします。また、組込み関数のsetRuntimeSafetyにより、ブロック単位で安全性保護を有効にするか無効にするかを制御できます。

未定義動作がコンパイル時に発見された場合は、コンパイルエラーになります。しかし、多くの未定義動作はコンパイル時に検出することができません。実行時に未定義動作が発生した場合、デフォルトではスタックトレースを出力し、プロセスが停止します

まず、コンパイル時に発見されてコンパイルエラーになる例です。comptime_ub.zenを次の内容で作成して下さい。後ほど説明しますが、unreachableに到達することは未定義動作を引き起こします。次のコードは、incurUndefinedBehavior()関数がコンパイル時に呼び出され、unreachableに到達します。

comptime {
    incurUndefinedBehavior();
}

fn incurUndefinedBehavior() void {
    unreachable;
}

このcomptime_ub.zenを次のコマンドでビルドします。

$ zen build-exe comptime_ub.zen

すると、次の通りコンパイルエラーが発生します。

comptime_ub.zen:6:5: error[E04000]: unable to evaluate constant expression
    unreachable;
    ~
comptime_ub.zen:2:27: note[E00029]: called from here
    incurUndefinedBehavior();
                          ~
comptime_ub.zen:1:10: note[E00029]: called from here
comptime {
         ~

コンパイルエラー発生時、バックトレースが出力されるため、どのような経路で未定義動作に到達したか、がすぐに分かるようになっています。

続いて、実行時に未定義動作が発生し、パニックでプログラムが終了する例です。runtime_ub.zenを次の内容で作成します。

pub fn main() void {
    incurUndefinedBehavior();
}

fn incurUndefinedBehavior() void {
    unreachable;
}

runtime_un.zenのビルドは成功します。

$ zen build-exe runtime_ub.zen

しかし、実行ファイルを実行すると、未定義動作によりパニックが発生し、プログラムが終了します。

$ ./runtime_ub 
reached unreachable code
examples/ch11-advanced/ub/src/runtime_ub.zen:6:5: 0x227a36 in incurUndefinedBehavior (runtime_ub)
    unreachable;
    ^
examples/ch11-advanced/ub/src/runtime_ub.zen:2:27: 0x227958 in main (runtime_ub)
    incurUndefinedBehavior();
                          ^
zen/std/special/start.zen:129:22: 0x226a50 in std.special.posixCallMainAndExit (runtime_ub)
            root.main();
                     ^
zen/std/special/start.zen:56:5: 0x22695f in std.special._start (runtime_ub)
    @noInlineCall(posixCallMainAndExit);
    ^
Aborted (core dumped)

結果的にプログラムはクラッシュしますが、この動作は定義された動作です。Zenの標準ライブラリ内にはデフォルトのパニックハンドラ (std/special/panic.zen) が実装されています。OS上で動作するプログラムは、そのパニックハンドラからstd.debug.panicExtra関数を呼び出し、その中でスタックトレースを出力し、std.os.abort関数でプログラムを終了しています。パニックハンドラをカスタマイズすることも可能ですが、ここでは説明しません。

それでは、Zenに存在する未定義動作について、個々に説明します。コードの例として、目にする機会が多い、実行時に未定義動作が発生するものを多く紹介しています。コンパイル時に未定義動作が発生した場合は、エラーメッセージが異なるので注意して下さい。

想定外コードの実行

プログラマが実行されることを想定していないコードを実行した時に発生する未定義動作です。

unreachableへの到達

unreachableは、到達しない (評価されない) コードを表すキーワードです。unreachableは式として評価され、コンパイル時もしくは実行時に評価されると、未定義動作となります。

アサーションで利用するstd.debug.assert関数は次の通り実装されており、引数がfalseであればunreachableに到達し、プログラムが停止するようになっています。

pub fn assert(ok: bool) void {
    if (!ok) unreachable; // assertion failure
}

オプション型のnullのアンラップ

オプション型にnullが格納されている状態で、.?演算子でアンラップすると発生する未定義動作です。

pub fn main() void {
    var optional: ?u32 = null;
    var value = optional.?;
}

このコードを実行すると、実行時に未定義動作が発生し、プログラムが停止します。

$ zen run unwrap_null.zen 
attempt to unwrap null
examples/ch11-advanced/ub/src/unwrap_null.zen:3:25: 0x22797c in main (run)
    var value = optional.?;
                        ^
# 中略

Aborted (core dumped)

この未定義動作を防止するには、ifを使ってnullかどうかをチェックします。

    var optional: ?u32 = null;
    if (optional) |value| {
        // use value here
    } else {
        // do something
    }

メモリアクセス

不正なメモリアクセスを行った際に発生する未定義動作です。

範囲外への要素アクセス

配列やスライスに対して、範囲外の要素アクセスを行った場合、未定義動作となります。

コンパイル時に未定義動作になる例です。

test "comptime out of bounds" {
    var array: [5]u8 = undefined;
    array[10] = 0;
}
$ zen test index_out_of_bounds.zen --test-filter "comptime out of bounds"
index_out_of_bounds.zen:3:10: error[E04028]: index is out of bounds
    array[10] = 0;
         ~

実行時に未定義動作になるコードです。

fn index(i: usize) u32 {
    var a: [5]u32 = undefined;
    return a[i];
}

test "runtime out of bounds" {
    var i = usize(10);
    _ = index(i);
}
$ zen test index_out_of_bounds.zen --test-filter "runtime out of bounds"
1/1 test "runtime out of bounds"...index out of bounds
examples/ch11-advanced/ub/src/index_out_of_bounds.zen:8:13: 0x20578a in index (test)
    return a[i];
            ^
examples/ch11-advanced/ub/src/index_out_of_bounds.zen:13:14: 0x2055a8 in test "runtime out of bounds" (test)
    _ = index(i);
             ^
# 中略

Tests failed. Use the following command to reproduce the failure:

算術演算 / ビット演算

算術演算やビット演算を行った際に発生する未定義動作です。

整数オーバーフロー

整数オーバーフローが発生する可能性がある演算には、次のものがあります。演算子の+ / - (二項) / - (単項) / * / /、を使う演算と、組込み関数の@divTrunc / @divFloor / @divExactを使う演算です。

オーバーフローが発生する加算の例を示します。

pub fn main() void {
    var byte: u8 = 255;
    byte += 1;
}

このコードを実行すると、実行時に未定義動作が発生し、プログラムが停止します。

$ zen run integer_overflow.zen 
integer overflow
examples/ch11-advanced/ub/src/integer_overflow.zen:3:10: 0x227980 in main (run)
    byte += 1;
         ^
# 中略
Aborted (core dumped)

この未定義動作を防ぐ方法は3つあります。

標準ライブラリの数値計算関数

std.mathには、次の関数があり、それぞれオーバーフローの発生をエラー (error.Overflow) として返します。

  • add
  • sub
  • negate
  • mul
  • divTrunc
  • divFloor
  • divExact

add関数のシグネチャはpub fn add(comptime T: type, a: T, b: T) (error{Overflow}!T)となっています。引数はターゲットとする整数型、演算する2つの整数値で、戻り値はオーバーフローを表すエラーか演算結果が格納されるエラー共用体です。negateを除く他の関数のシグネチャはaddと同様です。negateは整数値1つだけを引数に取ります。

test "standard library math functions" {
    const math = std.math;
    const byte: u8 = 255;
    const result = math.add(u8, byte, 1);
    err(error.Overflow, result);
}

これらの標準ライブラリの関数を使うと、次のようなコードでオーバーフロー発生時のエラーを処理することができます。

    const math = std.math;
    const byte: u8 = 255;
    // エラーが発生していなければ、`byte`に`result`が格納される
    byte = if (math.add(u8, byte, 1)) |result| result else |_| {
        // ここでエラーを処理する
    };
組込みオーバーフロー関数

組込み関数には、オーバーフロー発生時にブール型を返す整数演算があります。

  • @addWithOverflow
  • @subWithOverflow
  • @mulWithOverflow

加算の例に取ると、関数シグネチャは@addWithOverflow(comptime T: type, a: T, b: T, result: *T) boolです。引数に整数型、演算する2つの整数値に加えて、結果を格納するためのポインタを取ります。オーバーフローが発生した場合、この結果には、オーバーフローした後の値が格納されます。戻り値は、オーバーフローが発生した場合はtrueが、それ以外ではfalseが返ってきます。

test "builtin overflow functions" {
    var byte: u8 = 0xFF; // 255
    const overflow = @addWithOverflow(u8, byte, 5, &byte);
    ok(overflow == true);
    // 0xFF + 0x05 = 0x104だが、`u8`に格納できない`0x100`はクリアされている
    ok(byte == 4);
}

これらの組込み関数を使うと、次のようなコードでオーバーフロー発生時のエラーを処理することができます。

    var byte: u8 = 0xFF; // 255
    if (@addWithOverflow(u8, byte, 5, &byte)) {
        // ここでエラー処理をする
    }
ラップアラウンド演算子

次の演算子を使用すると、結果がラップアラウンドすることが保証されます。

  • +%
  • -% (二項)
  • -% (単項)
  • *%

これらのラップアラウンド演算子を使うと、オーバーフローは発生しません。

test "wrapping operators" {
    var byte: u8 = 255;
    byte +%= 1;
    ok(byte == 0);
}

ゼロ除算 / ゼロ剰余

/で除算する際、もしくは%で剰余を求める際、除数 (右辺オペランド) が0の場合に発生する未定義動作です。

pub fn main() void {
    var a: u32 = 0;
    const result = 1 / a;
}

このコードを実行すると、実行時に未定義動作が発生し、プログラムが停止します。

$ zen run division_by_zero.zen 
division by zero
examples/ch11-advanced/ub/src/division_by_zero.zen:3:22: 0x22797c in main (run)
    const result = 1 / a;
                     ^
# 中略
Aborted (core dumped)

この未定義動作を防ぐには、除数が0でないかどうか、ifでチェックします。

    if (a == 0) {
        // ここでエラーを処理する
    }

余りの出る整除

整除演算の結果が余りを持つ場合に発生する未定義動作です。

ノート: 整除とは、整数が整数を余りがでないように割り切ることです。

組込み関数の@divExactは、整除を計算します。@divExactを呼ぶ場合、除数が0でない、かつ、被除数が除数で割り切れることを保証しなければなりません。

pub fn main() void {
    var a: u32 = 2;
    const result = @divExact(5, a);
}

このコードを実行すると、実行時に未定義動作が発生し、プログラムが停止します。

$ zen run exact_divition_remainder.zen 
exact division produced remainder
examples/ch11-advanced/ub/src/exact_divition_remainder.zen:3:20: 0x2279b5 in main (run)
    const result = @divExact(5, a);
                   ^

std.math.divExactを使用すると、整除が余りを持つ場合、error.UnexpectedRemainderを返します。

test "exact division remainder" {
    const math = std.math;
    var a: u32 = 2;
    const result = math.divExact(u32, 5, a);
    err(error.UnexpectedRemainder, result);
}

ビットパターン保証シフトのオーバーフロー

組込み関数の@shlExact@shrExactとで、1の立っているビットがシフトにより消えてしまう場合に発生する未定義動作です。

pub fn main() void {
    var a: u8 = 0b1111_0000;
    const result = @shlExact(a, 1);
}

このコードを実行すると、実行時に未定義動作が発生し、プログラムが停止します。

$ zen run src/shift_overflow.zen 
left shift overflowed bits
examples/ch11-advanced/ub/src/shift_overflow.zen:3:20: 0x22798e in main (run)
    const result = @shlExact(a, 1);
                   ^
Aborted (core dumped)

組込み関数には、このオーバーフロー発生時にブール型を返すシフト演算があります。

  • @shlWithOverflow
  • @shrWithOverflow

左シフトを例に取ると、関数シグネチャは@shlWithOverflow(comptime T: type, a: T, shift_amt: Log2T, result: *T) boolです。引数に整数型、演算する2つの整数値に加えて、結果を格納するためのポインタを取ります。オーバーフローが発生した場合、この結果には、オーバーフローした後の値が格納されます。戻り値は、オーバーフローが発生した場合はtrueが、それ以外ではfalseが返ってきます。

test "exact shift overflow" {
    var a: u8 = 0b1111_0000;
    const overflow = @shlWithOverflow(u8, a, 1, &a);
    ok(overflow == true);
    ok(a == 0b1110_0000);
}

型変換

型変換を実行する際に発生する未定義動作です。

負数から符号なし整数への変換

負数を符号なし整数へ変換しようとすると発生する未定義動作です。

pub fn main() void {
    var negative: i32 = -1;
    const unsigned: u32 = @intCast(u32, negative);
}

このコードを実行すると、実行時に未定義動作が発生し、プログラムが停止します。

$ zen run src/negative_to_uint.zen 
attempt to cast negative value to unsigned integer
/home/tomoyuki/src/01.zen/zen-book/examples/ch11-advanced/ub/src/negative_to_uint.zen:3:27: 0x227988 in main (run)
    const unsigned: u32 = @intCast(u32, negative);
                          ^
# 中略
Aborted (core dumped)

同じビットの並びを符号なし整数型に格納したい場合、@bitCastを使用します。

test "cast negative number to unsgined integer" {
    // 内部のビット表現は`0xFFFF_FFFF`になる
    var negative: i32 = -1;
    const unsigned: u32 = @bitCast(u32, negative);
    ok(unsigned == 0xFFFF_FFFF);
}

整数値の切り捨て

ターゲットとする整数型の範囲に収まらない整数値を型変換すると発生する未定義動作です。

pub fn main() void {
    var x: u32 = 0x1234_5678;
    const y = @intCast(u16, x);
}

このコードを実行すると、実行時に未定義動作が発生し、プログラムが停止します。

$ zen run src/truncate.zen 
integer cast truncated bits
examples/ch11-advanced/ub/src/truncate.zen:3:15: 0x227990 in main (run)
    const y = @intCast(u8, x);
              ^
# 中略
Aborted (core dumped)

組込み関数の@truncateを使用することで、未定義動作を起こさず、常に上位ビットが切り捨てられた整数値を得ることができます。

test "truncate" {
    var x: u32 = 0x1234_5678;
    const result = @truncate(u16, x);
    ok(result == 0x5678);
}

範囲外の浮動小数点型から整数型への変換

ターゲットとする整数型の範囲に収まらない浮動小数点値を型変換すると発生する未定義動作です。

pub fn main() void {
    var x: f64 = 256.0;
    const y: u8 = @floatToInt(u8, x);
}

このコードを実行すると、実行時に未定義動作が発生し、プログラムが停止します。

$ zen run src/float_to_int.zen 
integer part of floating point value out of bounds
/home/tomoyuki/src/01.zen/zen-book/examples/ch11-advanced/ub/src/float_to_int.zen:3:19: 0x2279c8 in main (run)
    const y: u8 = @floatToInt(u8, x);
                  ^
# 中略
Aborted (core dumped)

浮動小数点値が取り得る値に対して、十分大きな整数型をターゲットとするか、std.math.intMaxを使用して変換可能かどうかをチェックして下さい。

test "out of bounds float to integer" {
    const math = @import("std").math;
    var x: f64 = 256.0;
    if (x > @intToFloat(f64, math.maxInt(u8))) {
        // ここでエラーを処理する
    } else {
        const y: u8 = @floatToInt(u8, x);
    }
}

NULLポインタへの変換

0番地を指しているポインタを、0番地が指せないポインタに型変換すると発生する未定義動作です。

ノート: Zenの普通のポインタは0番地を指すことができません。0番地を指すことが許されるポインタは、オプション型に包まれたポインタ、allowzero修飾子がついたポインタ、Cポインタ、の3つのみです。オプション型に包まれたポインタのnullは、内部データとして0を格納しています。

pub fn main() void {
    var c_ptr: [*c]i32 = 0;
    const ptr: *i32 = @ptrCast(*i32, c_ptr);
}

このコードを実行すると、実行時に未定義動作が発生し、プログラムが停止します。

$ zen run src/null_pointer_cast.zen 
cast causes pointer to be null
examples/ch11-advanced/ub/src/null_pointer_cast.zen:3:23: 0x227980 in main (run)
    const ptr: *i32 = @ptrCast(*i32, c_ptr);
                      ^
# 中略
Aborted (core dumped)

ほとんど全ての場合、このような型変換は不要です。オプション型に包まれたポインタの場合、ifnullかどうかをチェックしてからポインタを利用しましょう。Cポインタやallowzeroポインタを利用する機会は非常に限られていますが、これらのポインタをキャストする場合には、オプション型に包まれたポインタに変換するか、0番地を指していないかのチェック (nullチェック) を行うようにしましょう。

誤ったアライメントを持つポインタへの変換

組込み関数@alignCastでポインタの型変換を実施する際に、ポインタのアドレスがアライメントを満たさない場合に発生する未定義動作です。

次のコードサンプルでは、ptr4番地を指すポインタで、4バイト境界でアライメントされています。このptr8バイト境界でアライメントしようとすると未定義動作となります。

pub fn main() void {
    var ptr = @intToPtr(*i32, 0x4);
    const aligned = @alignCast(8, ptr);
}

このコードを実行すると、実行時に未定義動作が発生し、プログラムが停止します。

$ zen run src/pointer_alignment.zen 
incorrect alignment
examples/ch11-advanced/ub/src/pointer_alignment.zen:3:35: 0x227996 in main (run)
    const aligned = @alignCast(8, ptr);
                                  ^
# 中略
Aborted (core dumped)

std.mem.isAligned関数を使用することで、ポインタがアライメントを満たすかどうかチェックすることができます。

test "pointer alignment check" {
    const mem = std.mem;
    var ptr = @intToPtr(*i32, 0x4);
    const result = mem.isAligned(@ptrToInt(ptr), 8);
    ok(result == false);
}

次のようにifでチェックすることで、未定義動作を防ぐことができます。

    if (!mem.isAligned(@ptrToInt(ptr), 8)) {
        // ここでエラーを処理する
    } else {
        const aligned = @alignCast(8, ptr);
    }

割り切れないスライスへの変換

組込み関数@bytesToSliceでバイト列をスライスに変換する時に、バイト列のサイズ % スライスの要素サイズ == 0を満たさない場合に発生する未定義動作です。

pub fn main() void {
    var bytes = [5]u8{ 0, 1, 2, 3, 4 };
    const slice = @bytesToSlice(u32, bytes[0..]);
}

このコードを実行すると、実行時に未定義動作が発生し、プログラムが停止します。

$ zen run src/slice_widen_remainder.zen 
slice widening size mismatch
examples/ch11-advanced/ub/src/slice_widen_remainder.zen:3:19: 0x2279bd in main (run)
    const slice = @bytesToSlice(u32, bytes[0..]);
                  ^
# 中略
Aborted (core dumped)

バイト列のサイズ % スライスの要素サイズ == 0が満たされるかどうかチェックすることで、この未定義動作を防ぐことができます。

test "slice widen remainder" {
    var bytes = [5]u8{ 0, 1, 2, 3, 4 };
    if ((bytes[0..].len % @sizeOf(u32)) != 0) {
        // ここでエラーを処理する
    } else {
        const slice = @bytesToSlice(u32, bytes[0..]);
    }
}

対応する整数値がない列挙型への変換

組込み関数の@intToEnumで整数型を列挙型に変換する際に、ヴァリアントに対応する整数値がない場合に発生する未定義動作です。

const Value = enum(u32) {
    U32 = 0,
    F64 = 1,
    String = 2,
};

pub fn main() void {
    var x: u32 = 5;
    const result = @intToEnum(Value, x);
}

このコードを実行すると、実行時に未定義動作が発生し、プログラムが停止します。

$ zen run src/invalid_enum.zen 
invalid enum value
examples/ch11-advanced/ub/src/invalid_enum.zen:9:20: 0x227983 in main (run)
    const result = @intToEnum(Value, x);
                   ^
# 中略
Aborted (core dumped)

この未定義動作を防ぐには、switchで整数値が列挙型のヴァリアントに対応しているかどうかをチェックします。

test "enum cast" {
    var x: u32 = 5;
    const result = switch(x) {
        @enumToInt(Value.U32)
      , @enumToInt(Value.F64)
      , @enumToInt(Value.String)
            => @intToEnum(Value, x),
        else
            => error.InvalidEnumValue,
    };

    err(error.InvalidEnumValue, result);
}

このような変換処理を列挙型のメソッドとして実装しておくと良いでしょう。

対応する整数値がないエラー型への変換

組込み関数の@intToErrorで整数型をエラー型に変換する際に、エラー種別に対応する整数値がない場合に発生する未定義動作です。

pub fn main() void {
    var err = error.AnError;
    var err_int = @errorToInt(err);
    const invalid_error = @intToError(err_int + 1);
}

このコードを実行すると、実行時に未定義動作が発生し、プログラムが停止します。

 zen run src/invalid_error.zen 
invalid error code
examples/ch11-advanced/ub/src/invalid_error.zen:4:27: 0x2279c8 in main (run)
    const invalid_error = @intToError(err_int + 1);
                          ^
# 中略
Aborted (core dumped)

組込み関数の@errorToIntで取得した整数値に対して、演算を行うこと自体が適切ではありません。この未定義動作を防ぐ方法は、@errorToIntで取得した整数値に対して演算を行わないことです。

誤ったエラー型同士の変換

組込み関数@errSetCastを使用して、あるエラー型から別のエラー型に変換する際、変換先にないエラー種別を変換した時に発生する未定義動作です。

const AnErrorSet = error {
    A,
    B,
};

const AnotherErrorSet = error {
    A,
    C,
};

pub fn main() void {
    var err = AnErrorSet.B;
    const invalid_err = @errSetCast(AnotherErrorSet, err);
}

このコードを実行すると、実行時に未定義動作が発生し、プログラムが停止します。

$ zen run src/invalid_error_set.zen 
invalid error code
examples/ch11-advanced/ub/src/invalid_error_set.zen:13:25: 0x2279a5 in main (run)
    const invalid_err = @errSetCast(AnotherErrorSet, err);
                        ^
# 中略
Aborted (core dumped)

@errSetCastはスーパーセットからサブセットへのエラー型変換にだけ用いるべきです。

誤った共用体ヴァリアントへのアクセス

共用体のデータを利用する際、誤ったヴァリアントにアクセスした場合に発生する未定義動作です。

const Value = union {
    U32: u32,
    F64: f64,
};

pub fn main() void {
    var x = Value { .U32 = 42 };
    const y = x.F64;
}

このコードを実行すると、実行時に未定義動作が発生し、プログラムが停止します。

$ zen run src/wrong_union.zen 
access of inactive union field
examples/ch11-advanced/ub/src/wrong_union.zen:8:6: 0x227996 in main (run)
    const y = x.F64;
               ^
# 中略
Aborted (core dumped)

この未定義動作を防ぐには、タグ付き共用体を利用し、ヴァリアントをチェックしてからアクセスすると良いでしょう。

const TaggedValue = union(enum) {
    U32: u32,
    F64: f64,
};

test "variant access" {
    var x = TaggedValue { .U32 = 42 };
    const result = if (x == TaggedValue.F64) x.U32 else error.NotF64;
    err(error.NotF64, result);
}

☰ 人の生きた証は永遠に残るよう ☰
Copyright © 2018-2019 connectFree Corporation. All rights reserved.
Zen, the Zen three-circles logo and The Zen Programming Language are trademarks of connectFree corporation in Japan and other countries.