📝

Zig @memcpy, copyForwards, copyBackwards, @memmoveの違いについて

に公開

Zigでメモリコピーする方法を調べていたら

  1. @memcpy
  2. std.mem.copyForwards
  3. std.mem.copyBackwards
  4. @memmove

という4つの手段が言語、標準ライブラリより提供されていることを見つけました。

しかし、それぞれの違いや使い分けについて解説している日本語の記事が見当たらなかったので、自分なりに調べた内容をまとめて記事にすることにしました。

この記事が同じように上記4手段の使い分けに迷った人の参考になれば幸いです。

前提

Zigバージョン

> zig version
0.16.0-dev.747+493ad58ff

3行まとめ

  • メモリコピーに際して、コピー先とコピー元のメモリ領域が重複している否かで使い分ける必要がある
  • メモリ領域に重複がない場合は、@memcpyを使えば良い
  • メモリ領域に重複がある場合は、@memmoveを使えば良い

@memcpy

メモリコピーに際して、コピー先とコピー元のメモリ領域が重複がない場合は、ビルトイン関数である@memcpyを使えば問題ないです。

関数シグネチャは

@memcpy(noalias dest, noalias source) void

となっており、第一引数にコピー先のスライスを、第二引数にコピー元のスライスを渡してあげるだけで、期待した通りのメモリコピー処理が実行されます。

具体的な使用例は以下の通りです。

const std = @import("std");
const Allocator = std.mem.Allocator;

pub fn concat(allocator: Allocator, a: []const u8, b: []const u8) ![]u8 {
    const result_len = a.len + b.len;
    var result = try allocator.alloc(u8, result_len);
    @memcpy(result[0..a.len], a);
    @memcpy(result[a.len..result_len], b);
    return result;
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    const first = "Hello, ";
    const second = "World!";
    const combined = try concat(allocator, first, second);
    defer allocator.free(combined);

    std.debug.print("{s}\n", .{combined});
}

noaliasキーワードについて

記事執筆時点では
https://ziglang.org/documentation/master/#Keyword-Reference

公式ドキュメントでのnoaliasについての説明は

TODO add documentation for noalias

となっています😢
Issueにもなっていますが、依然として公式ドキュメントでの説明はまだ対応されていません。
https://github.com/ziglang/zig/issues/1521

複数の情報源より調べた結果、noaliasは

  • 複数の変数が同一メモリを参照することをAliasingと言う
  • Aliasingは予期せぬバグの原因になることがある
  • LLVMのnoaliasと同義で、「このエイリアスは他の引数とエイリアスしていない」というヒントをコンパイラに与えるもの
  • noaliasはAliasing自体を禁止する訳ではないが、コンパイラはこの前提のもとで最適化できる

だと理解しました。

@memcpyで問題が発生するケース

@memcpyで問題が発生するケースはどういったものでしょうか。

先程、前提としてお伝えした

メモリコピーに際して、コピー元とコピー先のメモリ領域が重複がない場合

が正しくない場合、即ち「メモリコピーに際して、コピー元とコピー先のメモリ領域が重複がある場合」においては@memcpyで問題が発生します。

以下のように

const std = @import("std");
const Allocator = std.mem.Allocator;

pub fn main() !void {
    var arr = [_]u8{ 'H', 'e', 'l', 'l', 'o', ',', ' ', 'W', 'o', 'r', 'l', 'd', '!' };

    @memcpy(arr[0..7], arr[5..12]); // overlapping regions

    std.debug.print("{s}\n", .{arr});
}

といった形で@memcpyの引数に渡す変数が指すメモリ領域に重複がある場合には、

@memcpy arguments alias

というランタイムエラーが発生します。これらの問題を解消する手段として後述する3つの関数が存在します。

std.mem.copyForwards / std.mem.copyBackwards

いきなりですが、具体例を交えて説明します。

const std = @import("std");

fn cp(comptime T: type, dest: []T, src: []const u8) void {
    std.debug.assert(dest.len == src.len);
    for (0..src.len) |i| {
        dest[i] = src[i];
    }
}

pub fn main() !void {
    var buf = [4]u8{ 1, 2, 3, 4 };
    cp(u8, buf[1..3], buf[0..2]);
    std.debug.print("{any}", .{&buf});
}

このプログラムでは1, 2, 3, 4を値に持つ配列のインデックス1, 2の値(つまり2, 3)をインデックス0, 1の値(つまり1, 2)で置換しようとしています。

期待値としては1, 1, 2, 4ですが、このプログラムを実際に実行してみると実行結果は

1, 1, 1, 4

になります。

なぜそのような結果になったかというと、関数cpのforループの処理で

dest[1] = src[0]; // buf = {1, 1, 3, 4}
dest[2] = src[1]; // buf = {1, 1, 1, 4}

となるためです。

以上のようにメモリコピー先、コピー元のメモリ領域が重複している場合は、コピーする順番が重要になります。今回の例で言うとインデックス2, 1の順番でコピーしていれば

dest[2] = src[1]; // buf = {1, 2, 2, 4}
dest[1] = src[0]; // buf = {1, 1, 2, 4}

となり期待した通りの結果を得られます。

このようにコピー先のインデックスがコピー元のそれより後ろにある場合はstd.mem.Backwardsを使うことになります(後方よりコピーしていくといことでBackwards)。

pub fn main() !void {
    var buf = [4]u8{ 1, 2, 3, 4 };
    std.mem.copyBackwards(u8, buf[1..3], buf[0..2]);
    std.debug.print("{any}", .{&buf});
}

逆に、コピー先のインデックスがコピー元のそれより前にある場合はstd.mem.Forwardsを使うことになります(前方よりコピーしていくということでForwards)。

pub fn main() !void {
    var buf = [4]u8{ 1, 2, 3, 4 };
    std.mem.copyForwards(u8, buf[0..2], buf[1..3]);
    std.debug.print("{any}", .{&buf});
}

@memmove

ここまで説明しておいて何ですが、本記事の執筆において利用しているZigバージョン0.16.0-dev.747+493ad58ffでは既に上述した

  • std.mem.copyForwards
  • std.mem.copyBackwards

関数はdeprecatedになっています。その代わりとして使えるのがここで紹介するビルトイン関数@memmoveです。

@memmoveでは前方コピー/後方コピーを意識する必要がなく、メモリ領域に重複がある場合でも@memcpyのように簡単にメモリ領域のコピーを実行できます。

具体的な使い方は以下の通りです。

pub fn main() !void {
    var buf = [4]u8{ 1, 2, 3, 4 };
    @memmove(buf[1..3], buf[0..2]);
    std.debug.print("{any}", .{&buf}); // { 1, 1, 2, 4 }
}
pub fn main() !void {
    var buf = [4]u8{ 1, 2, 3, 4 };
    @memmove(buf[0..2], buf[1..3]);
    std.debug.print("{any}", .{&buf}); // { 2, 3, 3, 4 }
}

まとめ

  • メモリコピーに関してはビルトイン関数の@memcpy@memmoveさえ覚えておけば十分です。
  • その使い分けは、メモリコピー先、コピー元のメモリ領域に重複があるか否かです。

参考資料

Discussion