止めよう!
memsetの乱用

agate-pris

自己紹介

  • 中目黒のあるゲーム会社で働いています.
  • 趣味でプライベートで
    ゲーム作ったりしてるマンです.
  • その過程で書いたコードを
    GitHubに投げたりしてました.
  • 最近は「でもUnityあるじゃん」に
    なってしまって絶賛放置中.

前書き

  • 初心者向けの内容です.
  • 必ずしも内容の全てが初心者向けな訳ではありません.
  • 内容は寧ろ初心者に「こそ」知ってほしい内容です.

C++ MIX #1 - connpass

C++ MIXは発表者に、「すごい発表者による、すごく高度な技術の発表」は求めません。

C++ MIX #1 - connpass

C++ MIXでは「こんな初歩的な発表は歓迎されないだろうな〜」とか悲観的なことは考える必要なく、カジュアルに「こんな話を持ってきたから聞いて聞いて!」という感じで発表してもらいたいです。

素晴らしい!!

  • C++に興味を持った初学者に「難しすぎて何も分からなかった」という気持ちで帰ってほしくない, 何か持ち帰って欲しい.
  • この勉強会が初心者から上級者まで, 幅広い層に支持されるものになって欲しい.
  • そのために自分から積極的に発信していく.
  • 独習C++とEffective C++やModern Effective C++の間に知るべき「まとまったノウハウ」という物は意外と多くない.
  • 世の中から残念なコードを少しでも減らしたい.
  • 自分自身が残念なコードによって苦しめられたくない.

memsetとは

namespace std {
    void* memcpy(void* s1, const void* s2, size_t n);
    void* memmove(void* s1, const void* s2, size_t n);
    void* memset(void* s, int c, size_t n);
}
  • memcpymemmove もついでに記載.
  • s で指定された領域に c をコピーする.
  • n で初期化する領域のバイト長を指定する.

初心者にありがちな過ち

  • オブジェクトの初期化でデータメンバを初期化し忘れると値が不定になる.
  • そのまま読み取るとUndefined Behaviour.
  • じゃあ memset で初期化すればいいじゃん!

Bad object initialization code

struct s {
    int i;
    long l;
    float f;
    double d;
};

void f(s& a) {
    std::memset(&a, 0, sizeof(a));
}

ダメです!!

何がだめなのか

  • データ型の内部表現は処理系定義
  • 対象のデータ次第ではその動作は未定義

データ型の内部表現は処理系定義

int i = 1;
std::memset(&i, 0, sizeof(i));
assert(i == 0);

C++では整数型の内部表現は「pure binary numeration system」によって表現される, という以上の規程がほぼ無いため, 0 初期化された領域の値が必ずしも 0 とは限らない.

N4659 6.9.1 Fundamental types [basic.fundamental]

この項目に基本型に関する要件が書かれています. 基本型は新しい規格程要件の定義が厳しくなっていく傾向にあります. 一部はCの標準を参照しています.

  1. … The representations of integral types shall define values by use of a pure binary numeration system.

52) A positional representation for integers that uses the binary digits 0 and 1, in which the values represented by successive bits are additive … (Adapted from the American National Dictionary for Information Processing Systems.)

自分が書いているコードがターゲットとする全ての処理系における整数型, 浮動小数点型, その他の型の内部的表現を把握している者だけが memset によって初期化してもよい.

自分が書いているコードが将来的に恒久的にターゲットとしうる全ての処理系を把握している者だけが memset によって初期化してもよい.

対象のデータ次第ではその動作は未定義

struct foo { int i; };
class bar { virtual void f(); };
void f(foo& f, bar& b) {
    std::memset(&f, 0, sizeof(f));  // defined
    std::memset(&b, 0, sizeof(b));  // not defined
}

foo の初期化においても i の「値」が 0 になる訳ではなく, あくまでそのメモリ領域が 0 初期化されるに過ぎない事に注意. 後者の場合そもそも正しく領域が初期化されない可能性がある.

  • トリビアルコピー可能な型のみ memcpy によって正しくコピー可能な事が保証されている.
  • 要件は決して単純ではなく, 特に複数人が携わるプロジェクトともなれば容易にメモリ破壊を引き起こしうる.
  • あなた自身, あるいは他の誰かの改修によってプログラムが壊れない事を保証するのが極めて困難.

6.9 Types [basic.types]

2 For any object … of trivially copyable type T, … the underlying bytes (4.4) making up the object can be copied into an array of char, unsigned char, or std::byte (21.2.1). If the content of that array is copied back into the object, the object shall subsequently hold its original value.

6.9 Types [basic.types]

3 For any trivially copyable type T, if two pointers to T point to distinct T objects obj1 and obj2, … if the underlying bytes (4.4) making up obj1 are copied into obj2, obj2 shall subsequently hold the same value as obj1.

12 Classes [class]

6 A trivially copyable class is a class:
(6.1) where each copy constructor, move constructor, copy assignment operator, and move assignment operator (15.8, 16.5.3) is either deleted or trivial,
(6.2) that has at least one non-deleted copy constructor, move constructor, copy assignment operator, or move assignment operator, and
(6.3) that has a trivial, non-deleted destructor (15.4).
A trivial class is a class that is trivially copyable and has one or more default constructors (15.1), all of which are either trivial or deleted and at least one of which is not deleted. [Note: In particular, a trivially copyable or trivial class does not have virtual functions or virtual base classes. — end note]

もしもあなたのコードに memset が多用されている場合, それはその全てを把握しなければ安全なコードを書くことが出来ない事を意味します.

更には将来的にその型に変更を加えたくなった時, トリビアルコピー可能でない型への変更を伴う変更が実質的に不可能になる事を意味し, 柔軟性, 保守性を大きく損なう事を意味します.

ここから導き出せる結論は「余程の理由がない限り, オブジェクトの初期化に memsetを使ってはならない」という事です.

「でも, 速いんでしょ?」

あなたのプログラムのボトルネックは本当にそこですか?

早期最適化ではありませんか?

そもそもきちんとプロファイラで実行時間を計測しましたか?

可読性と実行速度は
必ずしも相反する訳ではないが,
通常「まず可読性と可搬性. 最適化は最後」.
可読性, 可搬性, 実行速度の比重は
プロダクト次第.

ロブ・パイクのプログラミングの5つのルール

ルール1: プログラムがどこで時間を消費することになるか知ることはできない。ボトルネックは驚くべき箇所で起こるものである。したがって、どこがボトルネックなのかをはっきりさせるまでは、推測を行ったり、スピードハックをしてはならない。

ルール2: 計測すべし。計測するまでは速度のための調整をしてはならない。コードの一部が残りを圧倒しないのであれば、なおさらである。

ルール4: 凝ったアルゴリズムはシンプルなそれよりバグを含みやすく、実装するのも難しい。シンプルなアルゴリズムとシンプルなデータ構造を使うべし。

ではどうオブジェクトを初期化するか?

  • クラスの初期化でデータメンバが多すぎて初期化を忘れる場合, そもそも忘れてしまう程データメンバが多い設計自体が破綻している可能性が高い.
  • 集成体は集成体初期化を行うべし.
  • 集成体は集成体初期化を使う事で安全に初期化出来る.
  • 集成体でない型, かつトリビアルコピー可能な型であっても, memsetmemcpy を不用意に用いてよい理由にはならない.

集成体はユーザ定義のコンストラクタを持たない. ユーザ定義のコンストラクタを持つ型であれば, memset による初期化はその型の設計思想に反するので一般に避けるべきである.

集成体は private または protected な非静的なデータメンバを持たない. それらのデータメンバを持つ型を memset によって初期化する事はアクセス指定子によるカプセル化を破壊する行為であり, その型の設計思想に反するので一般に避けるべきである.

集成体は仮想関数を持たない. 仮想関数を持つ型はトリビアルコピー不可能な型になるため, memsetmemcpy を使用してはならない.

集成体は virtual な基底クラスを持たない. virtual 基底クラスを持つ型は非トリビアルコピー不可能な型であるため. memsetmemcpy を適用してはならない.

集成体は private または protected な基底クラスを持たない. privateprotected な基底クラスを memsetmemcpy によって変更する行為はカプセル化を破壊する行為で一般的に避けるべきである. 更にはそれは「基底クラスの設計が派生クラスに対する処理に依存している状況」であり, 一般的に避けるべきである.

以上より「集成体は集成体初期化を行うべき」であり, 「集成体以外に memset を適用するのは避けるべき」である.

「でも, 速いんでしょ?」 (2回目)

速くない

  • 条件付き.
  • ボトルネックは本当にそこですか?
  • 早期最適化では?
  • プロファイラで測りましたか?
  • アセンブリを読みましたか?
  • そもそもそこまでして速くしたいなら初期化しないのが速い.

init_obj_agr.cpp

#include <iostream>
struct s { int i; long l; float f; double d; };
void print(s const& a) {
    std::cout
        << a.i << '\n' << a.l << '\n'
        << a.f << '\n' << a.d << std::endl;
}
int main() {
    auto const a = s{};
    print(a);
}

init_obj_mem.cpp

#include <cstring>
#include <iostream>
struct s { int i; long l; float f; double d; };
void print(s const& a) {
    std::cout
        << a.i << '\n' << a.l << '\n'
        << a.f << '\n' << a.d << std::endl;
}
int main() {
    s a;
    std::memset(&a, 0, sizeof(s));
    print(a);
}
$ clang++ --version
clang version 7.0.0 (tags/RELEASE_700/final)
Target: x86_64-w64-windows-gnu
Thread model: posix
InstalledDir: C:\tools\msys64\mingw64\bin
$ clang++ init_obj_agr.cpp -O -S
$ clang++ init_obj_mem.cpp -O -S

全く同じアセンブリを出力する

配列なら?

init_ary_agr.cpp

#include <iostream>
struct s { int i; long l; float f; double d; };
void print(s const& a) {
    std::cout
        << a.i << '\n' << a.l << '\n'
        << a.f << '\n' << a.d << std::endl;
}
int main() {
    s const a[16]{};
    for (auto const& e : a) {
        print(e);
    }
}

init_ary_mem.cpp

#include <cstring>
#include <iostream>
struct s { int i; long l; float f; double d; };
void print(s const& a) {
    std::cout
        << a.i << '\n' << a.l << '\n'
        << a.f << '\n' << a.d << std::endl;
}
int main() {
    s a[16];
    std::memset(a, 0, sizeof(s) * 16);
    for (auto const& e : a) {
        print(e);
    }
}
$ clang++ --version
clang version 7.0.0 (tags/RELEASE_700/final)
Target: x86_64-w64-windows-gnu
Thread model: posix
InstalledDir: C:\tools\msys64\mingw64\bin
$ clang++ init_ary_agr.cpp -O -S
$ clang++ init_ary_mem.cpp -O -S

全く同じアセンブリを出力する

  • 最適化はプロファイラでボトルネックを特定してから行うのが基本.
  • 規格とコンパイラを正しく理解しないと誤った最適化を施してしまう.
  • コンパイラは想像する以上に頭が良い.

Cの場合

typedef struct s { int i; long l; float f; double d; } s_typedef;
int main() {
    struct s a = {};
    s_typedef b = {};
    struct s c[16] = {};
    s_typedef d[16] = {};
}

例えCであっても memset を用いて初期化してよい理由にはならない.

集成体初期化と代入

#include <array>
struct s { int i; long l; float f; double d; };
void f() {
    auto s_obj = s{};
    s s_array[16] = {};
    auto std_array_s = std::array<s, 16>{};
    s_obj = {};
    for (auto& e : s_array) e = {};
    std_array_s = {};
}

std::array は生配列と比較してパフォーマンスを損なわず, 再代入もより簡潔に書ける.

C++では auto を優先的に使う.

ではいつ memsetmemcpy を使うのか

  • バイト列の移動.
  • バイト列のコピー.
  • バイト列の初期化.
  • C++でのバイト列とは char の配列を指す.

特に文字列の処理で使用する事を想定しているのは定義されているヘッダが <cstring> である事からも明白である.

(ただしC++では文字列は std::stringstd::stringstream を使いましょう)

その他はコンテナの実装, メモリ管理系の実装, メモリバンク関連の実装等.

つまりは, 一般的なアプリケーションで memsetmemcpy memmove の様な破壊的な行為はしないし, すべきではない.

boost::optionalboost::container::static_vector の様なplacement-newを行うことでコンストラクタ/デストラクタの明示的な操作とメモリ割り当てを行うようなライブラリの多くは内部的に割当を行う領域を char (または unsigned char) の配列で持っている.

コンストラクタでの集成体の初期化

struct s { int i; long l; float f; double d; }
class c {
    private:
    s m_s;
    public:
    c() : m_s() {}
};

この様に初期化する.
値をセットする場合は波括弧を用いれば良い.

メンバ自身のデフォルトコンストラクタでの初期化

デフォルトコンストラクタで値を初期化するために boost::value_initialized を使うという手もあります (ただしアクセスが冗長になります).

リンク・参考文献 1

リンク・参考文献 2

リンク・参考文献 3

余談

このスライドはreveal-ckを使って作っています.

不慣れなのでトラブル続きでとても大変でした.

  • 文字コードで激しくエラーが発生した.
  • 解決方法が分からず仮想環境をインストールした.
  • 環境はVirtual Box + Vagrant + Amazon Linux AMI
  • 仮想環境で上手く動いたが,
    共有フォルダ上のファイルを
    Windows から編集した時,
    rebuildとreloadが上手く走らなかった.
  • この点については rsync を使うと上手く動くと聞いたが未検証.
  • 更にブラウザのreloadを自動で走らせようと思うと
    たぶん仮想環境上でブラウザを動かし
    それをX Window Server等で表示する必要あり.
  • 最終的には環境変数の設定で
    Windowsで普通に動作した.
  • Rubyは2.0からUTF-8がデフォルトだった筈では……?
  • 更に reveal-ck のバグっぽい挙動を引いた (現在 issue を投げてやりとり中)
  • config.yml が空だと関数 each_pair が見つからないというエラーが発生.
    原因が分かりづらかった.

質疑・応答