この記事はChatGPTによって中国語から翻訳されたもので、いくつかの誤りが含まれているかもしれません。不正確な部分があればご了承ください。
❖GNU MPライブラリのC++バインディング
GNU MPライブラリは、大きな整数と多精度浮動小数点数の計算ライブラリです。それ自体はC言語で書かれていますが、C++バインディングも提供しています。C++でプログラムを書くとき、あなたが自己虐待狂や手動コンパイラ変換の熱狂的な愛好家でないなら、C++バインディングを使うことは間違いなく良い選択です。
これは、C言語バージョンのバインディングがすべての操作をアセンブリ言語の命令のようにカプセル化しているからです。例えば、大きな整数バージョンの 1+2
を計算したい場合、次のように書くべきです:
1mpz_t a, b, c;
2mpz_init_set_ui(a, 1);
3mpz_init_set_ui(b, 2);
4mpz_add(c, a, b);
5mpz_clear(a);
6mpz_clear(b);
7mpz_clear(c);
正直に言うと、これは直接アセンブリを書くよりも簡潔ではありません:
1mov $1, %eax
2mov $2, %ebx
3add %ebx, %eax
4mov %ecx, %eax
この状況の根本的な原因は、C言語にはメモリリソース管理や高速構造構築の便利な手段がない(もちろん、新しい標準にはいくつかある)ため、C言語は式評価モデルを実装することができますが、カスタム型の式評価モデルを便利に実装することはできません。
式評価モデルとレジスタマシンモデルの間の変換はコンパイルの本質であり、このようにC言語でコードを書くことは、部分的にコンパイラが行う変換(例えばANF)を自分で行うことに相当します。だから、このようにコードを書くのが好きな人は、アセンブリを書くのが好きな人か、手動でコンパイラ変換を行うのが好きな人です。
C++バインディングはこの時点で救世主となり、C/C++を使用しなければならない場合(例えば私たちの学校の「現代暗号学実験」のコース)、C++バインディングを使用することでこのような困難を避けることができます:
1const mpz_class a {1}, b {2};
2const auto c = a + b;
このコードは、上記のC言語コードと完全に同じ動作を実行します。これは、C++がCに比べていくつかの優れた特性を持っているためで、その中でも最も優れているのはRAII、つまりResource Acquisition Is Initialization、リソースの取得は初期化です。ここでは、実行スタックと一緒に動作し、簡単に言えば、コンストラクタとデストラクタの組み合わせにより、スタック上のオブジェクトが構築されるとき(手動でバインディングを導入するとき)リソースを取得し、破棄されるとき(現在のスコープを退出するときに自動的に破棄される)リソースを解放します。メモリというリソースに対しては、これにより我々は『GCのある言語』を使用しているかのように、どんなメモリ問題も気にせずに済むようになります。
自然に生じる一つの問題は、スタック型RAIIが本当にGCを置き換えることができるのかということです。以下のGMPの説明を通じて、読者の皆さんが自分自身の答えを出すことができるでしょう。
❖奇妙な問題
既に述べたように、私がGNU MPライブラリを使用する主な目的は暗号学の実験を行うためです。私たちの暗号学の実験では、DLP(離散対数)の計算問題があり、その規模は非常に大きく、実行速度は重要な要素です。そのため、私は速度が保証されているGNU MPのようなライブラリを使用しなければなりません。しかし、実験中に、私は非常に奇妙な問題に遭遇しました。それは、時々一部のコードが頻繁に不正確な結果を出すというもので、私が何度もコードをチェックしても問題の原因を見つけることができませんでした。さらに深刻なのは、これらの問題が幽霊のように、時々現れたり消えたりし、現れたときの結果が時々異なるということです。
これに対して、私の最初の反応は何かメモリの問題があるのではないかということでした。しかし、私はすぐにこの考えを否定しました。GNU MPのような多くの人々に使用されているライブラリは、一般的にはこのような悪性の問題を起こすことはありません。しかし、私のコードには単純な計算しか含まれていませんでした。例えば:
1/*
2 * Pohlig-Hellman algorithm for Group of prime power order
3 */
4mpz_class
5pohligHellmanP(const mpz_class& g, const mpz_class& h,
6 const mpz_class& pn, const mpz_class& en,
7 const mpz_class& p) {
8 const auto y = fastPow(g, Pow(pn, en - 1), p);
9 assert (fastPow(y, pn, p) == 1);
10 mpz_class x{0};
11 for (auto i = 0; i < en; ++i) {
12 auto hi = fastPow(Inverse(fastPow(g, x, p), p) * h,
13 Pow(pn, en - 1 - i), p);
14 auto di = pDlp(y, hi, pn, p);
15 x = x + Pow(pn, i) * di;
16 }
17 return x;
18}
一連の困難な探求の後、私は『最小問題構造』を確定しました。『最小問題構造』とは、この問題を引き起こす最も単純で、行数が最も少ないコードを指します。それは次のようなものです:
1mpz_class nothing() {
2 const auto a = mpz_class { 1 } + mpz_class { 2 };
3 std::cout << a << std::endl;
4 return a;
5}
6
7int main() {
8 std::cout << nothing();
9}
私のコンピュータ上では、このコードは非常に驚くべき結果を出します:
1➜ gmp_error git:(master) ✗ g++ test.cpp -o a -g -lgmp -O0 -lgmpxx
2➜ gmp_error git:(master) ✗ ./a
394361021124304
494361021124336%
、それとも ?
これほど単純なコードがこれほど奇妙なエラーを生じさせるなんて、本当に奇妙です!
❖GNU MPライブラリの設計
この謎を解くためには、別の奇妙な現象から手をつけるべきです。それは次のようなものです:
1mpz_class nothing() {
2 const mpz_class a = mpz_class { 1 } + mpz_class { 2 };
3 std::cout << a << std::endl;
4 return a;
5}
6
7int main() {
8 std::cout << nothing();
9}
このコードは全く問題がない?読者の皆さんはこの事実を信じるのが難しいかもしれませんが、それは現実です:
1➜ gmp_error git:(master) ✗ g++ test.cpp -o a -g -lgmp -O0 -lgmpxx
2➜ gmp_error git:(master) ✗ ./a
33
43%
それにより問題は明確になります。auto
というキーワードはa
を何の型に推論するのでしょうか?IDEやc++filtで確認すると、答えはますます混乱します:
1const __gmp_expr<mpz_t, __gmp_binary_expr<mpz_class, mpz_class, __gmp_binary_plus>> a
この型は何ですか?答えを見つけるためにはgmpxx.h
というファイルを見る必要があるようです。
gmpxx.h
を見ると、mpz_class
は実際にはmpz_expr<mpz_t, mpz_t>
であることがわかります:
1/**************** mpz_class -- wrapper for mpz_t ****************/
2
3template <> // line 1572
4class __gmp_expr<mpz_t, mpz_t>{ ... };
5
6typedef __gmp_expr<mpz_t, mpz_t> mpz_class; // line 1756
では、この__gmp_expr
という高階型(理論的には確かに高階型に相当します)は他にも特化があるのでしょうか?確かに、このファイルには__gmp_expr
の多くの特化が定義されています。例えば、先ほど見たa
の実際の型は以下のようになります:
1template <class T, class Op>
2class __gmp_expr
3<T, __gmp_binary_expr<__gmp_expr<T, T>, __gmp_expr<T, T>, Op> >
このクラスのコンストラクタを見てみましょう:
1__gmp_expr(const val1_type &val1, const val2_type &val2)
2 : expr(val1, val2) { }
expr
はクラスのメンバ変数で、以下のように宣言されています:
1__gmp_binary_expr<val1_type, val2_type, Op> expr;
この__gmp_binary_expr
とは何者なのでしょうか?その定義は以下の通りです:
1template <class T, class U, class Op>
2struct __gmp_binary_expr
3{
4 typename __gmp_resolve_ref<T>::ref_type val1;
5 typename __gmp_resolve_ref<U>::ref_type val2;
6
7 __gmp_binary_expr(const T &v1, const U &v2) : val1(v1), val2(v2) { }
8private:
9 __gmp_binary_expr();
10};
これは少し混乱を招くかもしれません。このようなコンストラクタしか持たないクラスを定義する特別な意味は何でしょうか?それを使用する関数を見つける必要があります。以前に述べたように、右辺の型がmpz_class
であれば問題は発生しません。mpz_expr<..>
からmpz_class
になるとき、型変換が行われています。この型変換関数はどこにあるのでしょうか?再びmpz_class
の定義に戻ります:
1template <class T>
2__gmp_expr(const __gmp_expr<mpz_t, T> &expr)
3{ mpz_init(mp); __gmp_set_expr(mp, expr); }
4template <class T, class U>
5explicit __gmp_expr(const __gmp_expr<T, U> &expr)
6{ mpz_init(mp); __gmp_set_expr(mp, expr); }
この関数は間違いなく__gmp_expr<...>
をmpz_class
に変換しています。では、__gmp_set_expr
は何をしているのでしょうか?
その定義を見てみましょう:
1template <class T>
2inline void __gmp_set_expr(mpz_ptr z, const __gmp_expr<mpz_t, T> &expr)
3{
4 expr.eval(z);
5}
え?このeval
関数は__gmp_expr<T ...>
で定義されているようです、先ほどの定義を再度確認してみましょう:
1void eval(typename __gmp_resolve_expr<T>::ptr_type p) const
2{ Op::eval(p, expr.val1.__get_mp(), expr.val2.__get_mp()); }
これはOp::eval
関数に転送されています。以前の型のOp
は__gmp_binary_plus
で、そのeval
関数はどのように定義されているのでしょうか?
1struct __gmp_binary_plus
2{
3 static void eval(mpz_ptr z, mpz_srcptr w, mpz_srcptr v)
4 { mpz_add(z, w, v); }
これは非常に親切で、ついにこの一連のコンボが何をしているのかを理解しました。
まず、__gmp_expr< ... >
は構文木のようなもので、すべての操作情報を記録しています。この型の値がmpz_class
に変換されるとき、評価が行われ、評価後の値が変換後のバインドに格納されます。
しかし、これが何を意味するのでしょうか?私の見解では、このようなコードはロジックを何も簡略化していません。C++コンパイラは余分なコピーを生成しないことを完全に保証できます。実際、このように複雑な構造と直接クラスを書いて演算子をオーバーロードする効果はほぼ同じです。
唯一の利点は、変数がauto
を使用している場合、変数自体が値ではなく構文木であり、その式の値が必要になる(つまり型変換が行われる)まで評価されないことです。これはいわゆる「遅延評価」です。
数値計算タスクで遅延評価を行う利点が何なのか、私には理解できません。遅延評価の最大の利点は、不必要な値を計算しないことです。例えば:
1(define (f) (f))
2(define (g t1 t2) (t2))
3
4(g (f) 1) ;schemeでは、無限ループ
1f = f
2g t1 t2 = t2
3g f 1 --haskellでは、これは 1 を返します
しかし、このような数値計算タスクでは、通常は余計な計算は行わない。遅延評価自体は必要な計算を簡略化できず、パフォーマンス上の利点はありません。
さらに、この設計は先ほどの深刻なエラーを引き起こします。これは、各__gmp_binary_expr
が実際に保存しているのは2つの変数のconst
参照であり、基本的にconst
参照は右辺値をキャプチャできないからです。呼び出し
1__gmp_binary_expr(const T &v1, const U &v2) : val1(v1), val2(v2) { }
は、v1
へのポインタをval1
に、v2
へのポインタをval2
に割り当てるだけです。
このコードを再度見てみましょう:
1const auto a = mpz_class { 1 } + mpz_class { 2 };
2...
実際には次のようになります:
1mpz_class temp1 {1}, temp2 {2};
2a = temp1 + temp2;
3~temp1(); ~temp2();
4...
デストラクタが実行された後、a
の構文木のノードが指す対象は完全にデストラクトされ、これらのオブジェクトにアクセスするコードはすべてエラーです。言い換えれば、a
が有効なのは現在の文が完了し、次の文がまだ実行されていない瞬間だけです。
❖問題の解決
問題を解決するには2つの方法があります:
gmpxx.h
を修正する。- すべての宣言を
mpz_class
で行い、auto
を使用しない。
しかし、このファイルを修正しても、const&
が右辺値をキャプチャできない問題は依然として解決できません。
__gmp_binary_expr
を値セマンティクスに変更するのはどうでしょうか?つまり、val1
とval2
をconst &T
とconst &U
ではなく、実際のT
とU
にするということです。
これは const auto a = mpz_class { 1 } + mpz_class { 2 };
の問題を痛みなく解決できます。なぜなら、mpz_class{1}
とmpz_class{2}
はどちらも「右値」、つまり「X値」で、「右値参照」によってリソースを痛みなく引き継ぐことができるからです。実際、加法の問題を解決するだけなら、いくつかの箇所を修正するだけで済みます:
1/* 修改这个宏,使得加法有右值引用的版本 */
2#define __GMPP_DEFINE_BINARY_FUNCTION(fun, eval_fun) \
3 \
4template <class T, class U, class V, class W> \
5inline __gmp_expr<typename __gmp_resolve_expr<T, V>::value_type, \
6__gmp_binary_expr<__gmp_expr<T, U>, __gmp_expr<V, W>, eval_fun> > \
7fun(const __gmp_expr<T, U> &expr1, const __gmp_expr<V, W> &expr2) \
8{ \
9 return __gmp_expr<typename __gmp_resolve_expr<T, V>::value_type, \
10 __gmp_binary_expr<__gmp_expr<T, U>, __gmp_expr<V, W>, eval_fun> > \
11 (expr1, expr2); \
12} \
13template <class T, class U, class V, class W> \
14inline __gmp_expr<typename __gmp_resolve_expr<T, V>::value_type, \
15__gmp_binary_expr<__gmp_expr<T, U>, __gmp_expr<V, W>, eval_fun> > \
16fun(__gmp_expr<T, U> &&expr1, __gmp_expr<V, W> &&expr2) \
17{ \
18 return __gmp_expr<typename __gmp_resolve_expr<T, V>::value_type, \
19 __gmp_binary_expr<__gmp_expr<T, U>, __gmp_expr<V, W>, eval_fun> > \
20 (std::move(expr1), std::move(expr2)); \
21}
1/* 修改这个类,使得构造函数有右值引用的版本 */
2template <class T, class Op>
3class __gmp_expr
4<T, __gmp_binary_expr<__gmp_expr<T, T>, __gmp_expr<T, T>, Op> >
5{
6private:
7 typedef __gmp_expr<T, T> val1_type;
8 typedef __gmp_expr<T, T> val2_type;
9
10 __gmp_binary_expr<val1_type, val2_type, Op> expr;
11public:
12 __gmp_expr(const val1_type &val1, const val2_type &val2)
13 : expr(val1, val2) { }
14 __gmp_expr(val1_type &&val1, val2_type &&val2) // 新加入的构造函数
15 : expr(std::move(val1), std::move(val2)) { }
1template <class Op>
2struct __gmp_binary_expr<mpz_class, mpz_class, Op>
3{
4 mpz_class val1;
5 mpz_class val2;
6 __gmp_binary_expr(const mpz_class &v1, const mpz_class &v2)
7 : val1(v1), val2(v2) { }
8 __gmp_binary_expr(mpz_class &&v1, mpz_class &&v2)
9 : val1(std::move(v1)), val2(std::move(v2)) { }
10private:
11 __gmp_binary_expr();
12};
__gmp_binary_expr
の特化を自分で定義し、両方がmpz_class
の場合を処理します。
これにより、上記のコードは正しく3
を得ることができます。
しかし、修正が完了するまでの作業量はさておき、このように修正すると必ず一つの問題に直面します:左値が渡された場合、痛みなく移動することはできず、コピーを行う必要があり、これはパフォーマンスにとって不利です。
この問題をどのように解決するのでしょうか?答えは(少なくとも私には)解決できません。
❖GCとRAII
上記の問題は、GCがある言語では、簡単に言えば問題ではありません。たとえばPythonのような言語では、リソースのバインドを繰り返しても、コピーは発生しません:
1a = [1, 2, 3, 4]
2b = a
3c = b
もちろん、これはa
、b
、c
が実際には同じオブジェクトを指しているためで、const &
のようなものです。
しかし、GCがある言語では、const &
は「右値をキャプチャできない」という問題を完全に解決できます:
1class A:
2 def __init__(self, arr):
3 self.arr = arr
4
5a = A([1,2,3,4])
根本的には、RAIIでは同じスタック上のオブジェクトを2つのバインドが同時に「所有」することはできず、スタック上のオブジェクトが消去されるルールは厳格なスコープルールであり、「スタックからオブジェクトを借りる」という状況は発生しません。一方、GCがある言語では、「オブジェクト」と「オブジェクトが所有するリソース」が堆上にあり、さらに一体化しているため、この問題は発生しません。
このように見ると、RAIIはGCを置き換えることはできません。もちろん、Rustなどの言語では、他の方法でこの問題を解決できるかもしれません。しかし、結論として、C++では、RAIIの能力は結局のところ限定的です。