2016-01-18

C++標準化委員会の文書のレビュー: P0144R0-P0159R0

P0144R1: Structured Bindings

多値を返す関数の戻り値を簡単に変数に束縛できるようにするための文法の提案。

現在、C++ではtupleがあるために、多値を返す関数を簡単に宣言することができ、また簡単に多値を返すことができる。

std::tuple< T1, T2, T3 > f( )
{
    T1 a{} ; T2 b {} ; T3 c { }
    return { a, b, c } ;
}

見ればわかる通り、極めて簡単だ。

呼び出し側で多値を受け取るのも、比較的簡単である。

T1 a ; T2 b ; T3 c ;
std::tie( a, b, c ) = f() ;

確かに、比較的簡単ではあるが、このコードはいろいろと問題がある。

変数をあらかじめ宣言しなければならない。もし、型がPOD型ならば、未初期化の状態となり、お作法上あまりよろしくない。

型がクラス型の場合、デフォルト構築が行われる。その直後にコピー/ムーブして上書きするのに、デフォルト構築するのは無駄だ。

そこで、複数の変数の宣言と、その変数群を多値のそれぞれの値で初期化する新しい文法を追加する提案。

現在、以下の文法が提案されている。

auto { a, b, c } = f() ;

この宣言文は、変数、a, b, cを宣言する。変数の型はそれぞれ独立して初期化子から推定される。

この提案では、以下の2つの新しい文法を提案している。

auto { list-of-comma-separated-variable-names } { expression };
auto { list-of-comma-separated-variable-names } = expression;

expressionは、tupleとpairの場合、それぞれの要素で型推定され、初期化される。式は変数と同じ個数の要素をもたなければならない。

std::pair< T1, T2 > p{ ... } ;
auto { a, b } = p ;

std::tuple< T1, T2, T3 > t{ ... } ;
auto { x, y, z } = t ;

式には、クラス型を指定することもできる。このときクラス型のすべての非staticデータメンバーはアクセス可能で、ひとつの基本型(そのクラス型も含む)で宣言されていなければならない。

struct X { int a ; double b ; } ;

X x{ 0, 0.0 } ;

// aはint、bはdouble
auto { a, b } = x ;

クラスが無名unionメンバーを含む場合、最初のメンバーが選ばれる。


struct X
{
    union { int i ; double d ;} ;
    int data ;
} ;

X x{ {0}, 0 } ;

// xはint型で値は0
auto { x, y } = x ;

autoにCV修飾子やlvalueリファレンス修飾子を使うこともできる。

auto const { x, y, z } = f() ;
auto const & { x, y, z } = f() ;

rvalueリファレンス修飾子のサポートについては、議論中。

この文法は、initializer_listからの初期化はサポートしない。

braced-init-listからの初期化もサポートしない。

// サポートしない
auto { x, y, z } = { 1, 2, 3 } ;

理由は、この文法を追加するのは簡単であるが、この文法の有益な利用例がいまのところないため。

再帰的な構造化解除はサポートしない。

std::tuple< T1, std::tuple< T2, T3>, T4 > f() ;

// サポートしない
auto { a, {b, c}, d } = f() ; 

将来の拡張案としては興味深い。

[PDF] P0145R0: Expression Order of Evaluation

式のオペランドの評価順序を固定する提案。

たとえば、f( a, b, c )という式があるとき、オペランドf, a, b, cがどの順番で評価されるのかは、規格上は未規定(unspecified)とされていた。そのため、シークエンスポイントを隔てることなく、2つのオペランド中の式が同じオブジェクトを変更するとき、挙動は未定義となる。例えば、f( i++, i )のような式で、iが整数型の変数の場合、挙動は未定義となる。v[i] = i++も同じだ。

f( i++, i )やv[i] = i++のような昔からよく知られた問題ばかりではない。例えば、以下のようなコードの挙動も未規定だ。


#include <map>

int main()
{
    std::map< int, int > m ;
    m[0] = m.size() ; // #1
}

#1が評価された後のmapの中身はどうなっているだろうか。{{0,0}}だろうか、{{0,1}}だろうか。規格上は未規定だ。

式の評価順序が未規定という問題は、単にプログラマーの娯楽とか、採用試験とか、学術的な興味にとどまる問題ではない。現在の規格の制約は、現実の日常的なプログラミングに問題を引き起こしている。例えば以下のコードだ。

void f()
{
    std::string s = “but I have heard it works even if you don’t believe in it”
    s.replace(0, 4, “”).replace(s.find(“even”), 4, “only”).replace(s.find(“ don’t”), 6, “”);
    assert(s == “I have heard it works only if you believe in it”);
}

s.replace(...).replace(...).replace(...)と、いわゆる"method chaining"的なメンバー関数呼び出しの仕方をしている。これらがすべて、ひとつの式の中のサブ式であるので、その評価順序は未規定である。評価順序が未定義な以上、assertは引っかかる可能性がある。findの後に、そのfindを含まない別のreplaceが評価されるとassertに引っかかる。

このコードの問題点は、極最近になってツールで検証した結果明らかになった。

このコードは、Bjarne StroustrupのThe Programming Langauge 4thに載っているコードであり、この本は世界屈指のC++専門家達によって査読されていた。そのような環境ですらこの問題が発覚しなかったということは、現在の規程に問題がある。

このようなメソッドチェイニングが問題なのだとする批判は当たらない。なぜならば、std::cout << e1 << e2 << e3のような式も影響を受けるし、std::future<T>はメソッドチェイニングを前提としたthen()メンバー関数を追加する予定である。問題はメソッドチェイニングではない。

しかし、評価順序の未規定ルールは、何十年も存在する。なぜ今変えるのか。当時の制約ある環境では、この規程は理由があるものであった。時代と環境が変わった今、当時は適切だった規程が適切ではなくなっている。そのために変える必要がある。

この文書が提案する評価順序は以下の通り。

  • 後置式は左から右に評価される。これには関数呼び出し式とメンバー選択式も含まれる。
  • 代入式は右から左に評価される。これには複合代入も含まれる
  • シフト演算子のオペランドは左から右に評価される。

結果として、以下の式はすべて、a, b, c, dの順に評価される。

a.b
a->b
a( b, c, d )
b @= a
{ a, b, c, d }
a[b]
a << b
a >> b

オーバーロードされた演算子を使った式の評価順序は、組み込み演算子の評価順序と同じになる。関数呼び出しと同じ順序ではない。

P0147R0: The Use and Implementation of Contracts

現在提案されているcontracts案と似たような文法を使ってどのようなコードが書けるかという例示のための文書。contractsは、関数が満たすべきpreconditions, invariants and postconditionsを記述できる。

[PDF] P0148R0: memory_resource_ptr: A Limited Smart Pointer for memory_resource Correctness

memory_resourceをラップするスマートポインター、memory_resource_ptrの提案。

memory_resourceとは、ライブラリに追加される各種ヒープメモリーを実装したクラスのポリモーフィックな基本クラスだが、生のポインターを使うのはいろいろと不便なので、memory_resourceに特化したスマートポインターを追加する。

[PDF] P0151R0: Proposal of Multi-Declarators

多値を個々の変数で受け取る宣言文法として、以下のようなものがP0144で提案されている。

std::tuple< T1, T2, T3 > f() ;
auto { a, b, c } = f() ;

この文書は、以下のような別の文法を提案している。

tuple<T1, T2, T3><T1 x, T2 y, T3 z> = f(); // 多値のクラスと変数型の明示的な指定
tuple<T1, T2, T3><x, y, z> = f(); // 多値のクラスの明示的な指定
<T1 x, T2 y, T3 z> = f(); // 変数型の明示的な指定
<x,y,z> = f(); // 明示的な指定なし

また、使わない変数の省略を認めている。

// 2番めの変数は無視される
<a, c> = f() ; 

個人的には、autoキーワードを使う文法のほうがわかりやすいし、この文法が提案している柔軟な機能にどの程度の需要があるのか疑問だ。

P0152R0: constexpr atomic<T>::is_always_lock_free

コンパイル時にatomic<T>が常にロックフリーかどうかを確認できるconstexprメンバー関数is_always_lock_freeを追加する提案。

P0153R0: std::atomic_object_fence(mo, T&&...)

atomic_thread_fence(memory_order)に似ているが、指定したオブジェクトのみsequenced before関係を発生させるatomic_object_fence( memory_order, T && ... )の提案。

P0154R0: constexpr std::hardware_{constructive,destructive}_interference_size

std::hardware_constructive_interference_sizeとstd::hardware_destructive_interference_sizeの提案。

この2つのconstexpr関数は、一般にキャッシュラインサイズと呼ばれている値を取得するためのもの。

2つのオブジェクトがあり、ランタイムアクセスパターンがそれぞれ異なる(例えばあるオブジェクトは頻繁に変更するのに、もう一方のオブジェクトはほとんど変更しない)とする。CPUのキャッシュはキャッシュラインサイズと呼ばれる単位で行われており、この2つのアクセスパターンの異なるオブジェクトが同じキャッシュライン上に載っている場合、一方のアクセスパターンに引きづられて、本来必要のないキャッシュからメモリへの書き込みが行われてしまう。これをfalse-sharingと呼ぶ。

false-sharingを避けるには、異なるキャッシュライン上にオブジェクトが配置されるために、オブジェクトの配置されるメモリアドレスに十分なオフセットを儲けなければならない。hardware_destructive_interference_sizeは、false-sharingを避けるために必要な最小限のオフセットサイズを教えてくれる。

逆に、2つのオブジェクトのランタイムアクセスパターンが似通っていて、同じキャッシュライン上に載っている場合を、true-sharingと呼ぶ。true-sharingが行われるためには、2つのオブジェクトの合計サイズがキャッシュラインサイズに収まらなければならない。

hardware_constructive_interference_sizeはtrue-sharingされるための上限のサイズを教えてくれる。

この2つの値の定義は、実質の同じ意味なので、同じ値になるのではないかと思うのだが、2つに分けたのは、単にコードの意図をわかりやすくするためだろうか。それともこの値の異なる環境が実際に存在するのだろうか。

[PDF] P0155R0: Task Block R5

fork-join並列コードを書くためのライブラリ、task_blockの提案。

例えば、ツリー構造を並列実行でたどるときに

template<typename Func>
int traverse(node *n, Func&& compute)
{
    int left = 0, right = 0;
    define_task_block([&](task_block& tb) {
        if (n->left)
            tb.run([&] { left = traverse(n->left, compute); });
        if (n->right)
            tb.run([&] { right = traverse(n->right, compute); });
    });
    return compute(n) + left + right;
}

このように、define_task_blockに関数オブジェクトを渡すと、task_blockが実引数に渡される。あとはそのメンバー関数のrunを実行するたびに並列に実行が分岐する。

P0156R0: Variadic lock_guard (Rev. 2)

lock_guardをVariadic Templatesにする提案。

std::mutex m1, m2 ;

void f()
{
    // m1, m2に対してlock()が呼び出される
    std::lock_guard<std::mutex, std::mutex> lock( m1, m2 ) ;
    // 処理

    // lockのデストラクターでm1, m2にunlock()が呼び出される。
}

なお、make関数はない。lock_guardはコピーもムーブもできないからだ。

P0157R0: Handling Disappointment in C++

ある関数が呼び出し元の期待する処理を完了できなかった場合、呼び出し元は失望(disappointment)する。関数はその失望をどのようにして呼び出し元に伝えるのか。

この文書は、慣習的に使われている通知方法を列挙して考察している。

戻り値

戻り値は、最も一般的なC言語的手法であり、大抵はintかenumが使われる。失敗時には成功時特別可能な特別な値が使われる。この通知方法には問題がある。

エラー処理と通常の処理とが混ざってしまう。エラー処理が面倒なため、プログラマーはエラーを無視したがる。エラー処理に戻り値を使うと、通常の結果の値を戻り値ではなく実引数を経由した上書きで渡す必要が出てくる。呼び出し元がエラー通知に反応するには、事前に通知される値について知っていなければならない。

特別な戻り値

C言語で慣習的に用いられている方法で、戻り値を通常の結果通知に使うと同時に、特別な値を使って、エラー通知にも使う方法だ。特別な値には、nullポインターやゼロや-1などが用いられる。

この方法で通知できるのは、たいていはエラーの有無だけであり、エラーの具体的な内容については、別の方法で通知しなければならない。別の方法には、errnoのようなグローバルなオブジェクトが使われる。これは並列化を阻害する。

実引数を経由したエラー通知

これもC言語で慣習的に行われている方法で、実引数にエラー通知を受け取るためのオブジェクトへのポインターを取る方法だ。

これは、ループ文の条件式の中で使えないとか。エラー通知を完全に無視まではできないものの、結局無視されやすいという問題はある。

多値

関数の結果と、エラー通知の両方を多値で返す。これは古典的なC言語では行われていない方法だが、Goのような最近の言語では組み込みの多値を返す機能があるために使われている。

long jump

エラー通知にlong jumpを使う例が存在する。long jumpは関数内で起こった状態を解消するための手段を持たず、関数内で状態を持たないか、エラー発生時に状態を無視していい、極めて制限された環境でしか使えない。

例外

例外は上記のエラー処理の問題をいくつも解決している。通常のコードとエラー処理コードを分離できる。戻り値の型にデフォルトコンストラクターが要らない。補足されなかった例外はコールスタックを上がっていく。例外は未知のエラー通知にも使える。スタックフレームを遡る例外通知は、ローカルのオブジェクトを破棄していくため、エラー専用の破棄処理がいらない。

例外には欠点もある。まず例外は重い処理であるということ。頻繁に発生する「失望」を例外で伝えるには重すぎる。例外の存在は関数呼び出しにオーバーヘッドを発生させるので、極めて資源制約の強い環境では使えない。

エラーに対処してもう一度試行する処理を書けない。

論文では、様々なエラー処理を比較した結果、今後の規格は、現在提案中のexpectedやstatus_valueのような多値を返すエラー処理を推奨している。

P0158R0: Coroutines belong in a TS

コルーチンには様々な問題が山積みで、C++17に直接追加するのは時期尚早であるので、TSとして出すべきだと主張する文書。

現在コルーチンに持ち上がっている様々な問題が列挙されている。

Technical Specification for C++ Extensions for Concurrency, DTS

複数のfutureがready状態になるまで待つwhen_all、複数のfutureのうちどれかひとつがready状態になるまで待つwhen_any、futureのmethod chaining的に使えるメンバー関数then、wait_for, wait_untillatchとbarrier、atomic_shared_ptrといった並列同期に関するライブラリのTS。

ドワンゴ広告

今日は雪だったが、いつもどおりドワンゴ標準時で出社した結果、特に影響はなかった。

ドワンゴは本物のC++プログラマーを募集しています。

採用情報|株式会社ドワンゴ

CC BY-ND 4.0: Creative Commons — Attribution-NoDerivatives 4.0 International — CC BY-ND 4.0

1 comment:

Anonymous said...

関数チェインがダメってことになったらかなり絶望するのでヒヤヒヤしながら読んでました。
タプルはライブラリですが、新しい文法というのは明らかに言語に組み込みますよね。
タプルはファーストシチゼンになるんですか?便利だとは思いますが、粘土過ぎるのもどうかと思います。