Действительно ли причина перегрузки && и || не замыкаться?

Короткое замыкание операторов && и || является удивительным инструментом для программистов.

Но почему они теряют это поведение при перегрузке? Я понимаю, что операторы являются просто синтаксическим сахаром для функций, но операторы для bool имеют такое поведение, почему он должен быть ограничен этим единственным типом? Есть ли какие-либо технические аргументы по этому поводу?

Ответ 1

Все процессы проектирования приводят к компромиссам между взаимно несовместимыми целями. К сожалению, процесс проектирования для перегруженного оператора && в С++ привел к запутанному конечному результату: эта функция, которую вы хотите от && - ее короткое замыкание, - опускается.

Детали того, как этот процесс проектирования оказался в этом несчастном месте, я не знаю. Однако имеет смысл увидеть, как более поздний процесс проектирования учитывал этот неприятный исход. В С# перегруженный оператор && имеет короткое замыкание. Как дизайнеры С# достигли этого?

Один из других ответов предлагает "лямбда-лифтинг". То есть:

A && B

может быть реализовано как нечто морально эквивалентное:

operator_&& ( A, ()=> B )

где второй аргумент использует некоторый механизм для ленивой оценки, так что при оценке возникают побочные эффекты и значение выражения. Реализация перегруженного оператора будет делать только ленивую оценку, когда это необходимо.

Это не то, что сделала команда разработчиков С#. (Кроме того, хотя лямбда-подъем - это то, что я сделал, когда пришло время сделать представление дерева выражений оператора ??, что требует выполнения определенных операций преобразования лениво. Описывая это подробно, было бы, однако, основным отступлением.: лямбда-лифтинг работает, но достаточно тяжеловес, которого мы хотели избежать.)

Скорее, решение С# разбивает проблему на две отдельные проблемы:

  • следует ли оценить правый операнд?
  • Если ответ на это был "да", то как мы объединим два операнда?

Таким образом, проблема решается путем недопустимости перегрузки &&. Скорее, в С# вы должны перегрузить два оператора, каждый из которых отвечает на один из этих двух вопросов.

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 }
    ...

(Кроме того, на самом деле, три. С# требует, чтобы оператор false был предоставлен, тогда должен быть предоставлен оператор true, который отвечает на вопрос: эта вещь "истина-иш?". причина для предоставления только одного такого оператора, поэтому для С# требуются оба.)

Рассмотрим формулу вида:

C cresult = cleft && cright;

Компилятор генерирует для этого код, так как вы думали, что вы написали этот псевдо-С#:

C cresult;
C tempLeft = cleft;
cresult = C.false(tempLeft) ? tempLeft : C.&(tempLeft, cright);

Как вы можете видеть, левая сторона всегда оценивается. Если он определен как "false-ish", то это результат. В противном случае оценивается правая сторона и вызывается нетерпеливый пользовательский оператор &.

Оператор || определяется аналогичным образом, как вызов оператора true и нетерпеливого оператора |:

cresult = C.true(tempLeft) ? tempLeft : C.|(tempLeft , cright);

Определив все четыре оператора - true, false, & и | - С#, вы можете не только сказать cleft && cright, но и не короткое замыкание cleft & cright, а также if (cleft) if (cright) ... и c ? consequence : alternative и while(c) и т.д.

Теперь я сказал, что все процессы проектирования являются результатом компромисса. Здесь разработчикам языка С# удалось получить короткое замыкание && и || вправо, но для этого требуется перегрузка четырех операторов вместо двух, которые некоторые люди считают запутанными. Функция true/false оператора является одной из наименее понятных функций на С#. Задача иметь разумный и понятный язык, знакомый пользователям С++, была противопоставлена ​​желанием иметь короткое замыкание и желание не выполнять лямбда-подъем или другие формы ленивой оценки. Я думаю, что это была разумная компромиссная позиция, но важно понять, что это компромиссная позиция. Просто другое компромиссное положение, чем разработчики С++ приземлились.

Если вам интересует тема дизайна языка для таких операторов, подумайте о том, чтобы прочитать мою серию о том, почему С# не определяет эти операторы для нулевых булевых объектов:

http://ericlippert.com/2012/03/26/null-is-not-false-part-one/

Ответ 2

Дело в том, что (в пределах С++ 98) правый аргумент передается перегруженной функции оператора в качестве аргумента. При этом он уже будет оценен. Нет ничего, что мог бы или не мог сделать код operator||() или operator&&(), который мог бы избежать этого.

Исходный оператор отличается, потому что он не является функцией, а реализован на более низком уровне языка.

Дополнительные возможности языка могли бы сделать синтаксически невозможным неосуществление правого операнда. Однако они не беспокоились, потому что есть только несколько избранных случаев, когда это было бы семантически полезно. (Так же, как ? :, который вообще недоступен для перегрузки.

(Им потребовалось 16 лет, чтобы получить лямбды в стандарт...)

Что касается семантического использования, рассмотрим:

objectA && objectB

Это сводится к:

template< typename T >
ClassA.operator&&( T const & objectB )

Подумайте, что именно вы хотели бы сделать с objectB (неизвестного типа) здесь, кроме вызова оператора преобразования в bool, и как вы могли бы поместить это в слова для определения языка.

И если вы вызываете преобразование в bool, ну...

objectA && obectB

делает то же самое, теперь делает? Так почему же перегрузка в первую очередь?

Ответ 3

Следует учитывать, конструировать, реализовывать, документировать и отгружать функцию.

Теперь мы подумали об этом, посмотрим, почему это может быть легко сейчас (и трудно сделать тогда). Также имейте в виду, что существует только ограниченное количество ресурсов, поэтому добавление его могло бы нарезать что-то еще (что бы вы хотели отказаться от него?).


Теоретически все операторы могут допускать короткое замыкание с помощью только одной "незначительной" дополнительной языковой функции, начиная с С++ 11 (когда были введены лямбда, через 32 года после "C" классы "начались в 1979 году, еще респектабельным 16 после С++ 98):

С++ просто нужен способ аннотировать аргумент как ленивый-оцененный - скрытый-лямбда - во избежание оценки до тех пор, пока это не будет необходимо и не разрешено (соблюдены предварительные условия).


Какова будет эта теоретическая функция (помните, что любые новые функции должны широко использоваться)?

Аннотации 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, используя ее, что означает, что любое использование в существующих библиотеках было бы тихим изменением.

BTW: эта функция, будучи более простой в использовании, строго сильнее, чем решение С# для разделения && и || на две функции для отдельного определения.

Ответ 4

С ретроспективной рационализацией, главным образом потому, что

  • чтобы гарантировать гарантированное короткое замыкание (без введения нового синтаксиса), операторы должны быть ограничены фактическим первым аргументом results, преобразованным в 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; }
}();

Очевидная избыточность сохраняет любые побочные эффекты от операторов-операторов.


В то время как переписывание лямбда более подробное, его лучшая инкапсуляция позволяет определять такие операторы.

I & rsquo; m не полностью уверен в стандартном соответствии всех следующих (все еще немного влияний), но он компилируется с помощью Visual С++ 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, то есть проверку значения аргумента.

Поскольку компилятор может легко сделать то же самое и дополнительно оптимизировать его, это продемонстрированная возможная реализация, и любое требование о невозможности должно быть поставлено в ту же категорию, что и требования о невозможности вообще, а именно, как правило, bollocks.

Ответ 5

tl; dr: это не стоит усилий из-за очень низкого спроса (кто будет использовать эту функцию?) по сравнению с довольно высокими затратами (необходим специальный синтаксис).

Первое, что приходит в голову, это то, что перегрузка оператора - всего лишь причудливый способ записи функций, тогда как логическая версия операторов || и && - это вещи 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|| - только для того, чтобы прийти к выводу, что это не меньше усилий, чем запись логики на руку.

Ответ 6

Короткое замыкание логических операторов разрешено, поскольку оно является "оптимизацией" при оценке связанных таблиц истинности. Это функция самого логического, и эта логика определена.

Есть ли причина, по которой перегруженные && и || не замыкаются?

Пользовательские перегруженные логические операторы не обязаны следовать логике этих таблиц истинности.

Но почему они теряют это поведение при перегрузке?

Следовательно, вся функция должна оцениваться как нормальная. Компилятор должен рассматривать его как нормальный перегруженный оператор (или функцию), и он все равно может применять оптимизацию, как и для любой другой функции.

Люди перегружают логические операторы по разным причинам. Например; они могут иметь определенное значение в определенном домене, который не является "нормальным" логическим, к которому привыкли люди.

Ответ 7

Короткое замыкание происходит из-за таблицы истинности "и" и "или". Как вы узнаете, какую операцию пользователь определит и как вы узнаете, что вам не придется оценивать второй оператор?

Ответ 8

Лямбдас - это не единственный способ ввести лень. Ленькая оценка относительно прямолинейна с использованием Expression Templates на С++. Нет необходимости в ключевом слове lazy, и его можно реализовать на С++ 98. Деревья выражений уже упоминаются выше. Шаблоны выражений являются плохими (но умными) деревьями выражений человека. Трюк состоит в том, чтобы преобразовать выражение в дерево рекурсивно вложенных экземпляров шаблона Expr. Дерево оценивается отдельно после построения.

В следующем коде реализованы короткозамкнутые операторы && и || для класса S, если он предоставляет свободные функции logical_and и logical_or, и он конвертируется в bool. Код находится в С++ 14, но идея применима и в С++ 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;
}

Ответ 9

но операторы для bool имеют такое поведение, почему он должен быть ограничен этим единственным типом?

Я просто хочу ответить на эту часть. Причина в том, что встроенные выражения && и || не реализованы с функциями, так как перегруженные операторы.

Наличие логики короткого замыкания, встроенной в компилятор, для понимания конкретных выражений легко. Это точно так же, как и любой другой встроенный поток управления.

Но перегрузка операторов реализована с помощью функций, которые имеют определенные правила, одним из которых является то, что все выражения, используемые в качестве аргументов, вычисляются до вызова функции. Очевидно, что могут быть определены разные правила, но это большая работа.