演算子の短絡動作&&
および||
はプログラマーにとって素晴らしいツールです。
しかし、なぜ彼らは過負荷になるとこの動作を失うのでしょうか?演算子は関数の単なる構文糖質であると理解していますが、bool
の演算子にはこの動作があるので、なぜこの単一の型に制限されるべきですか?この背後に技術的な理由はありますか?
すべての設計プロセスは、相互に互換性のない目標間の妥協をもたらします。残念ながら、C++のオーバーロードされた_&&
_演算子の設計プロセスは、混乱を招く最終結果を生み出しました。つまり、_&&
_から必要な機能(その短絡動作)は省略されています。
その設計プロセスの詳細は、残念ながらこの不幸な場所で終わってしまいましたが、私は知りません。ただし、後の設計プロセスでこの不快な結果がどのように考慮されたかを確認することは重要です。 C#では、オーバーロードされた_&&
_演算子is短絡。 C#のデザイナーはどのようにしてそれを達成しましたか?
他の答えの1つは、「ラムダリフティング」を示唆しています。あれは:
_A && B
_
道徳的に同等のものとして実現できます:
_operator_&& ( A, ()=> B )
_
ここで、2番目の引数は遅延評価に何らかのメカニズムを使用しているため、評価されると式の副作用と値が生成されます。オーバーロードされた演算子の実装は、必要な場合にのみ遅延評価を行います。
これは、C#設計チームが行ったことではありません。 (それ以外に:ラムダリフティングis私がやるときにやったこと式ツリー表現特定の変換操作を実行する必要がある_??
_演算子のただし、詳細を説明することは大きな余談になります。言うだけで十分です。ラムダリフティングは機能しますが、それを回避するために十分な重量があります。
むしろ、C#ソリューションは問題を2つの別々の問題に分けます。
したがって、この問題は_&&
_を直接オーバーロードすることを違法にすることで解決されます。むしろ、C#ではtwo演算子をオーバーロードする必要があり、各演算子はこれら2つの質問のいずれかに答えます。
_class C
{
// Is this thing "false-ish"? If yes, we can skip computing the right
// hand size of an &&
public static bool operator false (C c) { whatever }
// If we didn't skip the RHS, how do we combine them?
public static C operator & (C left, C right) { whatever }
...
_
(それ以外に、実際には3つ。C#では、演算子false
が提供されている場合、演算子true
も提供されている必要があります。そのような演算子を1つだけ提供する理由はないので、C#には両方が必要です。)
次の形式のステートメントを検討してください。
_C cresult = cleft && cright;
_
コンパイラは、この疑似C#を記述したと考えられるように、このためのコードを生成します。
_C cresult;
C tempLeft = cleft;
cresult = C.false(tempLeft) ? tempLeft : C.&(tempLeft, cright);
_
ご覧のとおり、左側が常に評価されます。 「偽っぽい」と判断された場合は、結果です。それ以外の場合、右側が評価され、eagerユーザー定義演算子_&
_が呼び出されます。
_||
_演算子は、演算子trueと熱心な_|
_演算子の呼び出しとして、同様の方法で定義されます。
_cresult = C.true(tempLeft) ? tempLeft : C.|(tempLeft , cright);
_
true
、false
、_&
_、_|
_の4つの演算子をすべて定義することにより、C#では_cleft && cright
_と言うだけでなく、短絡_cleft & cright
_、およびif (cleft) if (cright) ...
、_c ? consequence : alternative
_およびwhile(c)
など。
さて、私はすべての設計プロセスが妥協の結果だと言いました。ここで、C#言語のデザイナーは_&&
_と_||
_を正しく短絡させることができましたが、そうするにはtwoではなくfour演算子をオーバーロードする必要があります。混乱する人もいます。演算子のtrue/false機能は、C#で最もよく理解されていない機能の1つです。 C++ユーザーに馴染みのある賢明でわかりやすい言語を作成するという目標は、短絡を持ちたいという要望と、ラムダリフティングやその他の形式の遅延評価を実装しないという要望に反対されました。私はそれが合理的な妥協点であったと思いますが、それがis妥協点であることを認識することが重要です。 C++の設計者が上陸したよりも、ちょうどdifferent妥協点です。
そのような演算子の言語設計の主題に関心がある場合は、C#がこれらの演算子をNULL入力可能なブール値に定義しない理由に関する私のシリーズを読むことを検討してください。
http://ericlippert.com/2012/03/26/null-is-not-false-part-one/
ポイントは、(C++ 98の範囲内で)右側のオペランドがオーバーロードされた演算子関数に引数として渡されることです。そうすることで、既に評価されます。これを回避するoperator||()
またはoperator&&()
コードができることもできなかったこともありません。
元の演算子は関数ではなく、言語の下位レベルで実装されているため、異なります。
追加の言語機能couldは、右側のオペランドを構文的に評価しない可能にしました。ただし、これは意味的にに役立つ場合がほとんどないため、気にしませんでした。 (と同じように ? :
。これは、オーバーロードにはまったく使用できません。
(ラムダを標準にするには16年かかりました...)
意味的な使用に関しては、次のことを考慮してください。
objectA && objectB
これは次のように要約されます。
template< typename T >
ClassA.operator&&( T const & objectB )
ここでは、bool
への変換演算子を呼び出す以外に、objectB(不明な型)で何をしたいのか、そしてそれを言語定義の単語にどのように入れるかを考えてください。
そしてifあなたare boolへの変換の呼び出し、まあ...
objectA && obectB
同じことをするようになりました。そもそもなぜオーバーロードなのでしょうか?
機能は、考え、設計、実装、文書化、および出荷する必要があります。
さて、私たちはそれを考えました、なぜそれが今簡単であるかもしれないのか見てみましょう(そして、それをするのは難しいです)。また、限られた量のリソースしかないため、リソースを追加すると他のものが途切れる可能性があることに注意してください(これを何を控えますか?)。
理論的には、すべての演算子は、C++ 11(ラムダが導入されてから32年後)の「マイナー」の追加言語機能を1つだけ使用して、短絡動作を許可できますCを含むクラス」は1979年に始まりましたが、c ++ 98の後でも16でした。
C++では、必要で許可されるまで(前提条件が満たされるまで)評価を回避するために、引数に遅延評価(非表示のラムダ)として注釈を付ける方法が必要になります。
その理論上の機能はどのようになりますか(新しい機能は広く使用できるはずです)。
関数引数に適用される注釈lazy
は、関数をファンクターを期待するテンプレートにし、コンパイラーが式をファンクターにパックします。
A operator&&(B b, __lazy C c) {return c;}
// And be called like
exp_b && exp_c;
// or
operator&&(exp_b, exp_c);
カバーの下は次のようになります。
template<class Func> A operator&&(B b, Func& f) {auto&& c = f(); return c;}
// With `f` restricted to no-argument functors returning a `C`.
// And the call:
operator&&(exp_b, [&]{return exp_c;});
ラムダは非表示のままであり、最大で1回呼び出されることに注意してください。
これにより、共通部分式が削除される可能性が低下することを別にすれば、パフォーマンスの低下はありません。
実装の複雑さと概念の複雑さ(他の機能の複雑さを十分に緩和しない限り、すべての機能が両方とも増加します)に加えて、別の重要な考慮事項を見てみましょう:後方互換性。
このlanguage-featureはコードを壊しませんが、それを利用してAPIを微妙に変更します。つまり、既存のライブラリでの使用はサイレントブレークチェンジ。
ところで:この機能は使いやすい一方で、C#による分割のソリューション&&
および||
を別々の定義用の2つの関数に分けます。
遡及的合理化により、主に
(新しい構文を導入せずに)短絡を保証するためには、演算子を次のように制限する必要があります。 結果 bool
に変換可能な実際の最初の引数、および
短絡は、必要に応じて他の方法で簡単に表現できます。
たとえば、クラスT
に関連付けられた&&
および||
演算子、次に式
auto x = a && b || c;
ここで、a
、b
、およびc
はT
型の式であり、ショートサーキットで次のように表現できます。
auto&& and_arg = a;
auto&& and_result = (and_arg? and_arg && b : and_arg);
auto x = (and_result? and_result : and_result || c);
またはおそらくより明確に
auto x = [&]() -> T_op_result
{
auto&& and_arg = a;
auto&& and_result = (and_arg? and_arg && b : and_arg);
if( and_result ) { return and_result; } else { return and_result || b; }
}();
明らかな冗長性により、オペレーターの呼び出しによる副作用が保持されます。
ラムダ書き換えはより冗長ですが、カプセル化が優れているため、defineそのような演算子を使用できます。
次のすべての規格に準拠していることは完全にはわかりませんが(まだ少し影響)、Visual C++ 12.0(2013)およびMinGW g ++ 4.8.2で正常にコンパイルされます。
#include <iostream>
using namespace std;
void say( char const* s ) { cout << s; }
struct S
{
using Op_result = S;
bool value;
auto is_true() const -> bool { say( "!! " ); return value; }
friend
auto operator&&( S const a, S const b )
-> S
{ say( "&& " ); return a.value? b : a; }
friend
auto operator||( S const a, S const b )
-> S
{ say( "|| " ); return a.value? a : b; }
friend
auto operator<<( ostream& stream, S const o )
-> ostream&
{ return stream << o.value; }
};
template< class T >
auto is_true( T const& x ) -> bool { return !!x; }
template<>
auto is_true( S const& x ) -> bool { return x.is_true(); }
#define SHORTED_AND( a, b ) \
[&]() \
{ \
auto&& and_arg = (a); \
return (is_true( and_arg )? and_arg && (b) : and_arg); \
}()
#define SHORTED_OR( a, b ) \
[&]() \
{ \
auto&& or_arg = (a); \
return (is_true( or_arg )? or_arg : or_arg || (b)); \
}()
auto main()
-> int
{
cout << boolalpha;
for( int a = 0; a <= 1; ++a )
{
for( int b = 0; b <= 1; ++b )
{
for( int c = 0; c <= 1; ++c )
{
S oa{!!a}, ob{!!b}, oc{!!c};
cout << a << b << c << " -> ";
auto x = SHORTED_OR( SHORTED_AND( oa, ob ), oc );
cout << x << endl;
}
}
}
}
出力:
000-> !! !! || false 001-> !! !! || true 010-> !! !! || false 011-> !! !! || true 100-> !! && !! || false 101-> !! && !! || true 110-> !! && !! true 111-> !! && !! true
ここで各!!
bang-bangは、bool
への変換、つまり引数値のチェックを示します。
コンパイラは同じことを簡単に行い、さらに最適化することができるため、これは可能な実装として実証されており、不可能性の主張は一般的に不可能性の主張と同じカテゴリー、つまり一般的にはブロックに入れなければなりません。
論理演算子の短絡は、関連する真理値表の評価における「最適化」であるため許可されます。 ロジックの機能それ自体であり、このロジックは定義されています。
実際にオーバーロードした理由
&&
および||
短絡しないでください?
カスタムのオーバーロードされた論理演算子は、これらの真理値表の論理に従う義務ではありませんです。
しかし、なぜ彼らは過負荷になるとこの動作を失うのでしょうか?
したがって、通常どおりに関数全体を評価する必要があります。コンパイラは通常のオーバーロードされた演算子(または関数)としてそれを扱わなければならず、他の関数と同様に最適化を適用できます。
人々はさまざまな理由で論理演算子を過負荷にします。例えば;それらは、人々が慣れている「通常の」論理的なものではない特定のドメインで特定の意味を持つ場合があります。
tl; dr:かなり高いコスト(特別な構文が必要)と比較して非常に低い需要(誰がこの機能を使用するのか)のため、努力する価値はありません。
最初に思い浮かぶのは、演算子のオーバーロードは関数を書くための単なる凝った方法であるのに対して、演算子のブールバージョンは||
と&&
はbuitlinのものであるということです。つまり、コンパイラーはそれらを短絡させる自由があり、非ブール値のy
とz
を含む式x = y && z
は、X operator&& (Y, Z)
のような関数の呼び出しにつながる必要があります。これは、y && z
がoperator&&(y,z)
を書くための単なるおしゃれな方法であることを意味します。これは、bothパラメーターが関数を呼び出す前に評価されます(短絡の適切とみなされるものを含む)。
ただし、&&
演算子の変換は、関数operator new
を呼び出してコンストラクター呼び出しを呼び出すように変換されるnew
演算子のように、より洗練されたものにすることができると主張することができます。
技術的にはこれは問題ではなく、短絡を可能にする前提条件に固有の言語構文を定義する必要があります。ただし、短絡の使用は、Y
がX
に変換可能である場合、または実際に短絡を行う方法に関する追加情報が必要な場合(つまり、最初のパラメーターのみから結果を計算する場合)に制限されます。結果は次のようになります。
X operator&&(Y const& y, Z const& z)
{
if (shortcircuitCondition(y))
return shortcircuitEvaluation(y);
<"Syntax for an evaluation-Point for z here">
return actualImplementation(y,z);
}
operator||
とoperator&&
をオーバーロードすることはめったにありません。なぜなら、a && b
の書き込みが実際には非ブールコンテキストで直感的である場合はめったにないからです。私が知っている唯一の例外は、式テンプレートです。組み込みDSL用。そして、これらの少数のケースのうち、ほんのわずかだけが短絡評価の恩恵を受けます。式テンプレートは通常、そうではありません。なぜなら、それらは後で評価される式ツリーを形成するために使用されるため、常に式の両側が必要だからです。
つまり、コンパイラの作成者も標準の作成者も、ユーザー定義のoperator&&
とoperator||
を短絡させるのはいいことだという考えを100万人に1人得るかもしれないからです。 -ただ、手ごとにロジックを書くよりも手間が少ないという結論に達するためです。
短絡は、「and」と「or」の真理値表によるものです。ユーザーがどの操作を定義するのか、2番目の演算子を評価する必要がないことをどのように知るのか。
怠daをもたらす唯一の方法はラムダスではありません。遅延評価は、C++で Expression Templates を使用すると比較的簡単です。キーワードlazy
は不要であり、C++ 98で実装できます。式ツリーについてはすでに上記で言及しています。表現テンプレートは貧弱な(しかし賢い)人間の表現ツリーです。トリックは、式をExpr
テンプレートの再帰的にネストされたインスタンス化のツリーに変換することです。ツリーは、構築後に個別に評価されます。
次のコードは、&&
および||
の無料関数を提供し、それに変換可能である限り、クラスS
の短絡logical_and
およびlogical_or
演算子を実装します。 bool
。コードはC++ 14ですが、この考え方はC++ 98にも適用できます。 実例を参照してください。
#include <iostream>
struct S
{
bool val;
explicit S(int i) : val(i) {}
explicit S(bool b) : val(b) {}
template <class Expr>
S (const Expr & expr)
: val(evaluate(expr).val)
{ }
template <class Expr>
S & operator = (const Expr & expr)
{
val = evaluate(expr).val;
return *this;
}
explicit operator bool () const
{
return val;
}
};
S logical_and (const S & lhs, const S & rhs)
{
std::cout << "&& ";
return S{lhs.val && rhs.val};
}
S logical_or (const S & lhs, const S & rhs)
{
std::cout << "|| ";
return S{lhs.val || rhs.val};
}
const S & evaluate(const S &s)
{
return s;
}
template <class Expr>
S evaluate(const Expr & expr)
{
return expr.eval();
}
struct And
{
template <class LExpr, class RExpr>
S operator ()(const LExpr & l, const RExpr & r) const
{
const S & temp = evaluate(l);
return temp? logical_and(temp, evaluate(r)) : temp;
}
};
struct Or
{
template <class LExpr, class RExpr>
S operator ()(const LExpr & l, const RExpr & r) const
{
const S & temp = evaluate(l);
return temp? temp : logical_or(temp, evaluate(r));
}
};
template <class Op, class LExpr, class RExpr>
struct Expr
{
Op op;
const LExpr &lhs;
const RExpr &rhs;
Expr(const LExpr& l, const RExpr & r)
: lhs(l),
rhs(r)
{}
S eval() const
{
return op(lhs, rhs);
}
};
template <class LExpr>
auto operator && (const LExpr & lhs, const S & rhs)
{
return Expr<And, LExpr, S> (lhs, rhs);
}
template <class LExpr, class Op, class L, class R>
auto operator && (const LExpr & lhs, const Expr<Op,L,R> & rhs)
{
return Expr<And, LExpr, Expr<Op,L,R>> (lhs, rhs);
}
template <class LExpr>
auto operator || (const LExpr & lhs, const S & rhs)
{
return Expr<Or, LExpr, S> (lhs, rhs);
}
template <class LExpr, class Op, class L, class R>
auto operator || (const LExpr & lhs, const Expr<Op,L,R> & rhs)
{
return Expr<Or, LExpr, Expr<Op,L,R>> (lhs, rhs);
}
std::ostream & operator << (std::ostream & o, const S & s)
{
o << s.val;
return o;
}
S and_result(S s1, S s2, S s3)
{
return s1 && s2 && s3;
}
S or_result(S s1, S s2, S s3)
{
return s1 || s2 || s3;
}
int main(void)
{
for(int i=0; i<= 1; ++i)
for(int j=0; j<= 1; ++j)
for(int k=0; k<= 1; ++k)
std::cout << and_result(S{i}, S{j}, S{k}) << std::endl;
for(int i=0; i<= 1; ++i)
for(int j=0; j<= 1; ++j)
for(int k=0; k<= 1; ++k)
std::cout << or_result(S{i}, S{j}, S{k}) << std::endl;
return 0;
}
しかし、boolの演算子はこの振る舞いを持っているのに、なぜこの単一の型に制限すべきなのでしょうか?
この1つの部分に答えたいだけです。その理由は、組み込みの&&
および||
式は、オーバーロードされた演算子のように関数で実装されていません。
コンパイラが特定の式を理解できるように、ショートサーキットロジックを組み込むことは簡単です。他の組み込み制御フローとまったく同じです。
ただし、演算子のオーバーロードは、特定のルールを持つ関数で実装されます。関数の1つは、引数として使用されるすべての式が、関数が呼び出される前に評価されることです。明らかに異なるルールを定義できますが、それはより大きな仕事です。