Поведение операторов при коротком замыкании &&
а также ||
это удивительный инструмент для программистов.
Но почему они теряют это поведение при перегрузке? Я понимаю, что операторы являются просто синтаксическим сахаром для функций, но операторы для bool
есть такое поведение, почему оно должно быть ограничено этим единственным типом? Есть ли какие-либо технические причины этого?
Все процессы проектирования приводят к компромиссу между несовместимыми целями. К сожалению, процесс проектирования для перегруженного &&
Оператор в C ++ дал странный конечный результат: это та самая особенность, которую вы хотите от &&
— его короткое замыкание — опущено.
Детали того, как этот процесс проектирования оказался в этом неудачном месте, те, кого я не знаю. Однако важно увидеть, как более поздний процесс проектирования принял во внимание этот неприятный результат. В C # перегружен &&
оператор является короткое замыкание. Как дизайнеры C # достигли этого?
Один из других ответов предлагает «лямбда-лифтинг». То есть:
A && B
может быть реализовано как нечто морально эквивалентное:
operator_&& ( A, ()=> B )
где второй аргумент использует некоторый механизм для отложенной оценки, так что при оценке создаются побочные эффекты и значение выражения. Реализация перегруженного оператора будет выполнять ленивую оценку только при необходимости.
Это не то, что сделала команда разработчиков C #. (В сторону: хоть лямбда лифтинг является что я сделал, когда пришло время делать представление дерева выражений из ??
оператор, который требует, чтобы определенные операции преобразования выполнялись лениво. Однако подробное описание этого было бы серьезным отступлением. Достаточно сказать: лямбда-лифтинг работает, но достаточно тяжелый, чтобы мы хотели его избежать.)
Скорее, решение C # разбивает проблему на две отдельные проблемы:
Поэтому проблема решается путем недопустимости перегрузки &&
непосредственно. Скорее в C # вы должны перегрузить два операторы, каждый из которых отвечает на один из этих двух вопросов.
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 }
...
(Помимо: на самом деле, три. C # требует, что если оператор false
затем предоставляется оператор true
также должно быть предоставлено, что отвечает на вопрос: действительно ли эта вещь «правда»? Как правило, нет причин предоставлять только один такой оператор, поэтому C # требует и того, и другого.)
Рассмотрим утверждение в форме:
C cresult = cleft && cright;
Компилятор генерирует код для этого, как вы думали, вы написали этот псевдо-C #:
C cresult;
C tempLeft = cleft;
cresult = C.false(tempLeft) ? tempLeft : C.&(tempLeft, cright);
Как видите, левая часть всегда оценивается. Если он определен как «ложный», то это результат. В противном случае оценивается правая часть, а нетерпеливый пользовательский оператор &
вызывается.
||
Оператор определяется аналогичным образом, как вызов оператора истинного и нетерпеливого |
оператор:
cresult = C.true(tempLeft) ? tempLeft : C.|(tempLeft , cright);
Определив все четыре оператора — true
, false
, &
а также |
— C # позволяет не только сказать cleft && cright
но также не короткое замыкание cleft & cright
, а также if (cleft) if (cright) ...
, а также c ? consequence : alternative
а также while(c)
, и так далее.
Теперь я сказал, что все процессы проектирования являются результатом компромисса. Здесь разработчикам языка C # удалось получить короткое замыкание &&
а также ||
правильно, но для этого требуется перегрузка четыре операторы вместо два, что некоторые люди находят запутанным. Функция true / false оператора является одной из наименее понятных функций в C #. Целью создания разумного и понятного языка, знакомого пользователям C ++, было нежелание иметь короткие замыкания и желание не реализовывать лямбда-лифтинг или другие формы ленивых вычислений. Я думаю, что это была разумная компромиссная позиция, но важно понимать, что она является компромиссная позиция. Просто разные компромиссная позиция, на которую приземлились дизайнеры C ++.
Если вас интересует тема языкового дизайна для таких операторов, подумайте о том, чтобы прочитать мою серию о том, почему C # не определяет эти операторы для логических значений, допускающих обнуляемость:
http://ericlippert.com/2012/03/26/null-is-not-false-part-one/
Дело в том, что (в пределах C ++ 98) правый операнд будет передан перегруженной операторной функции в качестве аргумента. При этом, это уже будет оценено. Там нет ничего operator||()
или же operator&&()
код может или не может этого избежать.
Оригинальный оператор отличается, потому что это не функция, а реализован на более низком уровне языка.
Дополнительные языковые функции мог сделали не-оценку синтаксически правого операнда возможный. Тем не менее, они не беспокоились, потому что есть только несколько случаев, когда это было бы семантически полезно. (Как ? :
, который не доступен для перегрузки вообще.
(Им потребовалось 16 лет, чтобы получить лямбды в стандарт …)
Что касается семантического использования, рассмотрим:
objectA && objectB
Это сводится к:
template< typename T >
ClassA.operator&&( T const & objectB )
Подумайте о том, что именно вы хотели бы сделать с objectB (неизвестного типа) здесь, кроме вызова оператора преобразования в bool
и как бы вы выразились в словах для определения языка.
А также если вы являются вызывая преобразование в bool, хорошо …
objectA && obectB
делает то же самое, теперь делает это? Так зачем перегружать в первую очередь?
Функция должна быть продумана, разработана, реализована, задокументирована и доставлена.
Теперь мы подумали об этом, давайте посмотрим, почему это может быть легко сейчас (и тогда это трудно сделать). Также имейте в виду, что количество ресурсов ограничено, поэтому его добавление могло бы привести к потере чего-то другого (чего бы вы хотели от этого отказаться?).
Теоретически, все операторы могут допускать короткое замыкание только с одним «второстепенным» дополнительная языковая функция, по состоянию на C ++ 11 (когда были введены лямбды, спустя 32 года после «C с классами», начавшегося в 1979 году, все еще респектабельный 16 после C ++ 98):
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;});
Обратите особое внимание, что лямбда остается скрытой и будет вызвана не более одного раза.
Там должен быть нет снижения производительности из-за этого, кроме уменьшенных шансов на устранение общего подвыражения.
Помимо сложности реализации и концептуальной сложности (каждая функция увеличивает обе, если только она недостаточно облегчает эти сложности для некоторых других функций), давайте рассмотрим еще одно важное соображение: обратная совместимость.
Хотя это языка функция не будет нарушать какой-либо код, он будет тонко менять любой API, используя его преимущества, а это означает, что любое использование в существующих библиотеках будет молчаливым изменением.
Кстати, эта функция, хотя и проще в использовании, строго сильнее, чем решение C # для разделения &&
а также ||
на две функции каждая для отдельного определения.
С ретроспективной рационализацией, главным образом потому, что
для обеспечения гарантированного короткого замыкания (без введения нового синтаксиса) операторы должны быть ограничены Результаты фактический первый аргумент, конвертируемый в 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; }
}();
Кажущаяся избыточность сохраняет любые побочные эффекты от вызовов оператора.
В то время как лямбда-переписывание более многословно, его лучшая инкапсуляция позволяет определять такие операторы.
Я не совсем уверен в соответствии стандарту всего перечисленного ниже (все еще с небольшим влиянием), но он полностью компилируется с 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 -> !! !! || ложный 001 -> !! !! || правда 010 -> !! !! || ложный 011 -> !! !! || правда 100 -> !! !! || ложный 101 -> !! !! || правда 110 -> !! !! правда 111 -> !! !! правда
Здесь каждый !!
взрыва показывает преобразование в bool
то есть проверка значения аргумента.
Поскольку компилятор может легко сделать то же самое и дополнительно оптимизировать его, это продемонстрированная возможная реализация, и любое утверждение о невозможности должно быть отнесено к той же категории, что и утверждение о невозможности в целом, а именно, как правило, бред.
ТЛ; др: это не стоит усилий из-за очень низкого спроса (кто будет использовать эту функцию?) по сравнению с довольно высокими затратами (необходим специальный синтаксис).
Первое, что приходит на ум, это то, что перегрузка операторов — это просто модный способ написания функций, тогда как булева версия операторов ||
а также &&
являются вещами Buitlin. Это означает, что компилятор может свободно замыкать их, а выражение x = y && z
с небулевым y
а также z
должен привести к вызову такой функции, как X operator&& (Y, Z)
, Это будет означать, что y && z
это просто модный способ написать operator&&(y,z)
который является просто вызовом функции со странным именем, где и то и другое параметры должны быть оценены перед вызовом функции (включая все, что может привести к короткому замыканию).
Однако можно утверждать, что должна быть возможность сделать перевод &&
операторы несколько более сложные, как это для new
оператор, который переводится в вызов функции operator 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||
— просто чтобы прийти к выводу, что это не меньше усилий, чем написание логики на руку.
Допускается короткое замыкание логических операторов, поскольку это «оптимизация» при оценке связанных таблиц истинности. Это функция логики сама по себе, и эта логика определена.
Есть ли на самом деле причина, почему перегружены
&&
а также||
не короткое замыкание?
Пользовательские перегруженные логические операторы не обязан следовать логике этих таблиц правды.
Но почему они теряют это поведение при перегрузке?
Следовательно, вся функция должна быть оценена в соответствии с нормой. Компилятор должен обращаться с ним как с обычным перегруженным оператором (или функцией), и он все равно может применять оптимизацию, как и с любой другой функцией.
Люди перегружают логические операторы по разным причинам. Например; у них может быть определенное значение в определенной области, которая не является «нормальными» логическими, к которым люди привыкли.
Короткое замыкание происходит из-за таблицы истинности «и» и «или». Как вы узнаете, какую операцию будет определять пользователь, и как вы узнаете, что вам не нужно оценивать второго оператора?
Лямбда не единственный способ ввести лень. Ленивая оценка относительно проста, используя Шаблоны выражений в C ++. Там нет необходимости для ключевого слова 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;
}