Лямбда повертається сама: це законно?


124

Розглянемо цю досить марну програму:

#include <iostream>
int main(int argc, char* argv[]) {

  int a = 5;

  auto it = [&](auto self) {
      return [&](auto b) {
        std::cout << (a + b) << std::endl;
        return self(self);
      };
  };
  it(it)(4)(6)(42)(77)(999);
}

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

  • MSVC компілює програму, і вона запускається
  • gcc компілює програму, і вона segfaults
  • clang відхиляє програму з повідомленням:

    error: function 'operator()<(lambda at lam.cpp:6:13)>' with deduced return type cannot be used before it is defined

Який компілятор правильний? Чи є порушення статичного обмеження, UB чи ні?

Оновлення цієї незначної модифікації прийнято clang:

  auto it = [&](auto& self, auto b) {
          std::cout << (a + b) << std::endl;
          return [&](auto p) { return self(self,p); };
  };
  it(it,4)(6)(42)(77)(999);

Оновлення 2 : Я розумію, як написати функтор, який повертається сам, або як використовувати комбінатор Y для досягнення цього. Це більше мовно-юристське питання.

Оновлення 3 : питання полягає не в тому, чи законно лямбда повертатися в цілому, а в законності цього конкретного способу цього.

Питання, пов’язані з цим: лямбда C ++, що повертається .


2
Кланг виглядає більш гідно в цей момент, мені цікаво, чи може така конструкція навіть перевірити, швидше за все, вона опиниться в нескінченному дереві.
bipll

2
Ваше запитання, чи законно це, що говорить, що це питання з питань юристів з мови, але кілька відповідей насправді не підходять до цього підходу ... важливо правильно зрозуміти теги
Shafik Yaghmour

2
@ShafikYaghmour Спасибі, додав тег
n. 'займенники' м.

1
@ArneVogel так, використовується оновлений, auto& selfякий усуває звисаючу проблему.
н. 'займенники' м.

1
@TheGreatDuck лямбди C ++ насправді не є теоретичними лямбда-виразами. C ++ має вбудовані рекурсивні типи, які оригінальний простий набраний лямбдаз чисел не може виразити, тому він може мати ізоморфні речі для: a-> a та інших неможливих конструкцій.
н. 'займенники' м.

Відповіді:


68

Програма неправильно сформована (кланг праворуч) за [dcl.spec.auto] / 9 :

Якщо ім'я суб'єкта з неменшеним типом заповнення з’являється у виразі, програма неправильно формується. Після того, як невідкинутий оператор повернення був помічений у функції, однак тип повернення, що виводиться з цього оператора, може використовуватися в решті функції, в тому числі в інших операторах повернення.

В основному, виведення типу повернення внутрішнього лямбда залежить від самого себе (сутність, яку тут названо, є оператором виклику) - тому вам потрібно чітко вказати тип повернення. У цьому конкретному випадку це неможливо, оскільки вам потрібен тип внутрішньої лямбда, але не можете її назвати. Але є й інші випадки, коли спроба примусити такі рекурсивні лямбда, як це, може спрацювати.

Навіть без цього у вас є звисаючий довідник .


Дозвольте детальніше розглянути, після обговорення з ким-небудь набагато розумнішим (тобто TC) Існує важлива різниця між початковим кодом (трохи зменшеним) та запропонованою новою версією (аналогічно зменшеною):

auto f1 = [&](auto& self) {
  return [&](auto) { return self(self); } /* #1 */ ; /* #2 */
};
f1(f1)(0);

auto f2 = [&](auto& self, auto) {
  return [&](auto p) { return self(self,p); };
};
f2(f2, 0);

І це те, що внутрішнє вираження self(self)не залежить f1, а self(self, p)залежить від f2. Коли вирази не залежні, вони можуть бути використані ... охоче ( [temp.res] / 8 , наприклад, як static_assert(false)це важка помилка незалежно від того, чи є шаблон, в якому він опиняється, примірник чи ні).

Бо f1компілятор (як, скажімо, кланг) може спробувати інстанціювати це з нетерпінням. Ви знаєте виведений тип зовнішньої лямбда, як тільки ви дістанетесь до цього ;у точці #2вище (це тип внутрішнього лямбда), але ми намагаємось використовувати його раніше, ніж це (подумайте про це як у точці #1) - ми намагаємось використовувати його, поки ми ще розбираємо внутрішню лямбда, перш ніж ми дізнаємося, що це за власне тип. Це працює на dcl.spec.auto/9.

Однак, бо f2ми не можемо намагатися придумувати охоче, оскільки це залежить. Ми можемо інстанціювати лише в точці використання, до якої точки ми знаємо все.


Для того, щоб дійсно зробити щось подібне, вам потрібен y-комбінатор . Реалізація з документа:

template<class Fun>
class y_combinator_result {
    Fun fun_;
public:
    template<class T>
    explicit y_combinator_result(T &&fun): fun_(std::forward<T>(fun)) {}

    template<class ...Args>
    decltype(auto) operator()(Args &&...args) {
        return fun_(std::ref(*this), std::forward<Args>(args)...);
    }
};

template<class Fun>
decltype(auto) y_combinator(Fun &&fun) {
    return y_combinator_result<std::decay_t<Fun>>(std::forward<Fun>(fun));
}

І що ти хочеш:

auto it = y_combinator([&](auto self, auto b){
    std::cout << (a + b) << std::endl;
    return self;
});

Як би ви чітко вказали тип повернення? Я не можу це зрозуміти.
Rakete1111

@ Rakete1111 Який? В оригіналі ви не можете.
Баррі

о, добре. Я не рідний, але "так що ви повинні прямо вказати тип повернення", мабуть, це означає, що є спосіб, ось чому я запитав :)
Rakete1111,

4
@PedroA stackoverflow.com/users/2756719/tc є ++ вкладником C. Він також або не AI, або достатньо винахідливий, щоб переконати людину, яка також знає C ++, взяти участь у нещодавній міні-зустрічі LWG у Чикаго.
Кейсі

3
@Casey Або, можливо, людина просто папугує тим, що йому сказав А.І. ... ніколи не знаєш;)
ТК

34

Редагувати : Здається, існує суперечка щодо того, чи строго ця конструкція діє згідно специфікації C ++. Переважає думка, що це неправдиво. Інші відповіді див. Для більш ретельного обговорення. Залишок цієї відповіді застосовується, якщо конструкція є дійсною; наведений нижче код коду працює з MSVC ++ та gcc, а ОП розмістив ще модифікований код, який також працює з clang.

Це невизначена поведінка, оскільки внутрішня лямбда захоплює параметр selfза посиланням, але selfвиходить за межі після returnрядка 7. Таким чином, коли повернута лямбда виконується пізніше, вона отримує доступ до посилання на змінну, яка вийшла за межі області.

#include <iostream>
int main(int argc, char* argv[]) {

  int a = 5;

  auto it = [&](auto self) {
      return [&](auto b) {
        std::cout << (a + b) << std::endl;
        return self(self); // <-- using reference to 'self'
      };
  };
  it(it)(4)(6)(42)(77)(999); // <-- 'self' is now out of scope
}

Запуск програми з valgrindілюструє це:

==5485== Memcheck, a memory error detector
==5485== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==5485== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==5485== Command: ./test
==5485== 
9
==5485== Use of uninitialised value of size 8
==5485==    at 0x108A20: _ZZZ4mainENKUlT_E_clIS0_EEDaS_ENKUlS_E_clIiEEDaS_ (test.cpp:8)
==5485==    by 0x108AD8: main (test.cpp:12)
==5485== 
==5485== Invalid read of size 4
==5485==    at 0x108A20: _ZZZ4mainENKUlT_E_clIS0_EEDaS_ENKUlS_E_clIiEEDaS_ (test.cpp:8)
==5485==    by 0x108AD8: main (test.cpp:12)
==5485==  Address 0x4fefffdc4 is not stack'd, malloc'd or (recently) free'd
==5485== 
==5485== 
==5485== Process terminating with default action of signal 11 (SIGSEGV)
==5485==  Access not within mapped region at address 0x4FEFFFDC4
==5485==    at 0x108A20: _ZZZ4mainENKUlT_E_clIS0_EEDaS_ENKUlS_E_clIiEEDaS_ (test.cpp:8)
==5485==    by 0x108AD8: main (test.cpp:12)
==5485==  If you believe this happened as a result of a stack
==5485==  overflow in your program's main thread (unlikely but
==5485==  possible), you can try to increase the size of the
==5485==  main thread stack using the --main-stacksize= flag.
==5485==  The main thread stack size used in this run was 8388608.

Натомість ви можете змінити зовнішню лямбда, щоб взяти себе за посиланням замість значення, таким чином уникнувши купу непотрібних копій, а також вирішивши проблему:

#include <iostream>
int main(int argc, char* argv[]) {

  int a = 5;

  auto it = [&](auto& self) { // <-- self is now a reference
      return [&](auto b) {
        std::cout << (a + b) << std::endl;
        return self(self);
      };
  };
  it(it)(4)(6)(42)(77)(999);
}

Це працює:

==5492== Memcheck, a memory error detector
==5492== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==5492== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info
==5492== Command: ./test
==5492== 
9
11
47
82
1004

Я не знайомий з родовими лямбдами, але ти не міг зробити selfпосилання?
François Andrieux

@ FrançoisAndrieux Так, якщо ви зробите selfпосилання, ця проблема відходить , але Кланг все ж відкидає її з іншої причини
Джастін

@ FrançoisAndrieux Дійсно, і я додав це до відповіді, дякую!
TypeIA

Проблема такого підходу полягає в тому, що він не усуває можливих помилок компілятора. Так, можливо, це має працювати, але реалізація порушена.
Шафік Ягмур

Дякую, я дивився на це годинами і не бачив, щоб selfце було захоплено посиланням!
н. 'займенники' м.

21

TL; DR;

кланг правильний.

Схоже, розділ стандарту, який робить це неправильно сформованим, є [dcl.spec.auto] p9 :

Якщо ім'я суб'єкта з неменшеним типом заповнення з’являється у виразі, програма неправильно формується. Після того, як невідкинутий оператор повернення був помічений у функції, однак тип повернення, що виводиться з цього оператора, може використовуватися в решті функції, в тому числі в інших операторах повернення. [Приклад:

auto n = n; // error, n’s initializer refers to n
auto f();
void g() { &f; } // error, f’s return type is unknown

auto sum(int i) {
  if (i == 1)
    return i; // sum’s return type is int
  else
    return sum(i-1)+i; // OK, sum’s return type has been deduced
}

—Закінчити приклад]

Оригінальна робота

Якщо ми подивимось на пропозицію Пропозиція Додати Y Combinator до стандартної бібліотеки, вона забезпечує робоче рішення:

template<class Fun>
class y_combinator_result {
    Fun fun_;
public:
    template<class T>
    explicit y_combinator_result(T &&fun): fun_(std::forward<T>(fun)) {}

    template<class ...Args>
    decltype(auto) operator()(Args &&...args) {
        return fun_(std::ref(*this), std::forward<Args>(args)...);
    }
};

template<class Fun>
decltype(auto) y_combinator(Fun &&fun) {
    return y_combinator_result<std::decay_t<Fun>>(std::forward<Fun>(fun));
}

і це прямо говорить, що ваш приклад неможливий:

C ++ 11/14 лямбда не спонукають до рекурсії: немає способу посилання на об’єкт лямбда від тіла лямбда-функції.

і він посилається на дискусію, в якій Річард Сміт натякає на помилку, яку дає вам кланг :

Я думаю, що це було б краще як особливість мови першого класу. Мені не вистачало часу на зустріч до Кони, але я мав намір написати документ, щоб дозволити дати ім’я лямбда (прилаштоване до власного тіла):

auto x = []fib(int a) { return a > 1 ? fib(a - 1) + fib(a - 2) : a; };

Тут 'fib' є еквівалентом * лямбда * це (з деякими дратівливими спеціальними правилами, що дозволяють цьому працювати, незважаючи на те, що тип закриття лямбда не є повним).

Баррі вказав мені на подальшу пропозицію " Рекурсивні лямбда", в якій пояснюється, чому це неможливо, і існує обхід dcl.spec.auto#9обмеження, а також показані методи, щоб досягти цього сьогодні без нього:

Лямбди - корисний інструмент для рефакторингу локального коду. Однак ми іноді хочемо використовувати лямбда зсередини, щоб дозволити пряму рекурсію або дозволити реєстрацію закриття як продовження. Це напрочуд складно досягти в поточному C ++.

Приклад:

  void read(Socket sock, OutputBuffer buff) {
  sock.readsome([&] (Data data) {
  buff.append(data);
  sock.readsome(/*current lambda*/);
}).get();

}

Одна природна спроба посилання на лямбда від себе - це збереження її у змінній та захоплення цієї змінної за посиланням:

 auto on_read = [&] (Data data) {
  buff.append(data);
  sock.readsome(on_read);
};

Однак це неможливо через семантичну окружність : тип автоматичної змінної не виводиться, поки не буде оброблено лямбда-вираз, що означає, що лямбда-вираз не може посилатися на змінну.

Іншим природним підходом є використання функції std :::

 std::function on_read = [&] (Data data) {
  buff.append(data);
  sock.readsome(on_read);
};

Цей підхід складається, але зазвичай вводить покарання абстракції: функція std :: може призвести до розподілу пам’яті, а для виклику лямбда вимагається непрямий виклик.

Для рішення з нульовим режимом часто немає кращого підходу, ніж чітко визначити тип локального класу.


@ Cheersandhth.-Альф Я нарешті знайшов стандартну цитату після прочитання статті, тому вона не є актуальною, оскільки стандартна цитата дає зрозуміти, чому не працює жоден підхід
Шафік Ягмур

"" Якщо ім'я суб'єкта, що не має зміненого типу заповнювача, з'являється в виразі, програма неправильно формується. "Я не бачу подібного явища в програмі. Але selfця структура не здається такою.
n. 'займенники' м.

@nm, окрім можливих формулювань гнід, приклади, мабуть, мають сенс у формулюванні, і я вважаю, що приклади чітко демонструють це питання. Я не думаю, що зараз можу додати більше допомоги.
Шафік Ягмур

13

Здається, Кланг прав. Розглянемо спрощений приклад:

auto it = [](auto& self) {
    return [&self]() {
      return self(self);
    };
};
it(it);

Давайте переглянемо це як компілятор (трохи):

  • Типу itє Lambda1з оператором шаблону виклику.
  • it(it); запускає екземпляр оператора виклику
  • Тип повернення оператора виклику шаблону є auto, тому ми мусимо його вивести.
  • Ми повертаємо лямбда, захоплюючи перший параметр типу Lambda1.
  • У цій лямбда також є оператор виклику, який повертає тип виклику self(self)
  • Зауважте: self(self)саме з цього ми почали!

Як такий, тип неможливо вивести.


Повернення типу Lambda1::operator()просто Lambda2. Тоді , як відомо, в цьому внутрішньому лямбда-виразі також є тип повернення self(self), дзвінок . Можливо, формальні правила стоять на шляху до здійснення цього тривіального вирахування, але тут представлена ​​логіка не відповідає. Логіка тут просто становить твердження. Якщо формальні правила дійсно перешкоджають, то це недолік формальних правил. Lambda1::operator()Lambda2
Ура та хт. - Альф

@ Cheersandhth.-Alf Я погоджуюся, що тип повернення - це Lambda2, але ви знаєте, що ви не можете мати оператора без виправлених викликів лише тому, що це саме те, що ви пропонуєте: Затримати виведення типу повернення оператора виклику Lambda2. Але ви не можете змінити правила для цього, оскільки це досить фундаментально.
Rakete1111

9

Ну, ваш код не працює. Але це робить:

template<class F>
struct ycombinator {
  F f;
  template<class...Args>
  auto operator()(Args&&...args){
    return f(f, std::forward<Args>(args)...);
  }
};
template<class F>
ycombinator(F) -> ycombinator<F>;

Код тесту:

ycombinator bob = {[x=0](auto&& self)mutable{
  std::cout << ++x << "\n";
  ycombinator ret = {self};
  return ret;
}};

bob()()(); // prints 1 2 3

Ваш код є UB та неправильно сформований, діагностика не потрібна. Що смішно; але обидва можуть бути виправлені незалежно.

По-перше, UB:

auto it = [&](auto self) { // outer
  return [&](auto b) { // inner
    std::cout << (a + b) << std::endl;
    return self(self);
  };
};
it(it)(4)(5)(6);

це UB, оскільки зовнішнє приймає selfза значенням, тоді внутрішнє захоплює selfза посиланням, а потім переходить до повернення після outerзакінчення роботи. Так що segfaulting, безумовно, добре.

Виправлення:

[&](auto self) {
  return [self,&a](auto b) {
    std::cout << (a + b) << std::endl;
    return self(self);
  };
};

Залишки коду неправильно сформовані. Щоб побачити це, ми можемо розширити лямбда:

struct __outer_lambda__ {
  template<class T>
  auto operator()(T self) const {
    struct __inner_lambda__ {
      template<class B>
      auto operator()(B b) const {
        std::cout << (a + b) << std::endl;
        return self(self);
      }
      int& a;
      T self;
    };
    return __inner_lambda__{a, self};
  }
  int& a;
};
__outer_lambda__ it{a};
it(it);

це створює __outer_lambda__::operator()<__outer_lambda__>:

  template<>
  auto __outer_lambda__::operator()(__outer_lambda__ self) const {
    struct __inner_lambda__ {
      template<class B>
      auto operator()(B b) const {
        std::cout << (a + b) << std::endl;
        return self(self);
      }
      int& a;
      __outer_lambda__ self;
    };
    return __inner_lambda__{a, self};
  }
  int& a;
};

Отже, далі ми повинні визначити тип повернення __outer_lambda__::operator().

Ми проходимо його по черзі. Спочатку ми створюємо __inner_lambda__тип:

    struct __inner_lambda__ {
      template<class B>
      auto operator()(B b) const {
        std::cout << (a + b) << std::endl;
        return self(self);
      }
      int& a;
      __outer_lambda__ self;
    };

Тепер подивіться там - його тип повернення є self(self), або __outer_lambda__(__outer_lambda__ const&). Але ми в середині намагаємося вивести тип повернення __outer_lambda__::operator()(__outer_lambda__).

Вам заборонено це робити.

Хоча насправді тип повернення __outer_lambda__::operator()(__outer_lambda__)фактично не залежить від типу повернення __inner_lambda__::operator()(int), C ++ не переймається при виведенні типів повернення; він просто перевіряє код за рядком.

І self(self)використовується до того, як ми його вивели. Неправильно сформована програма.

Ми можемо виправити це, приховавши self(self)до пізніше:

template<class A, class B>
struct second_type_helper { using result=B; };

template<class A, class B>
using second_type = typename second_type_helper<A,B>::result;

int main(int argc, char* argv[]) {

  int a = 5;

  auto it = [&](auto self) {
      return [self,&a](auto b) {
        std::cout << (a + b) << std::endl;
        return self(second_type<decltype(b), decltype(self)&>(self) );
      };
  };
  it(it)(4)(6)(42)(77)(999);
}

і тепер код правильний і компілюється. Але я думаю, що це трохи хак; просто використовуйте ycombinator.


Можливо (IDK) цей опис є правильним для формальних правил щодо лямбда. Але з точки зору перезапису шаблону, тип повернення шаблону внутрішньої лямбда, як operator()правило, не може бути виведений, доки він не буде ініційований (викликавшись з деяким аргументом якогось типу). І тому ручне переписування тексту на шаблон на основі шаблону чудово працює.
Ура та хт. - Альф

@cheers ваш код відрізняється; inner - це шаблон шаблону у вашому коді, але він не в моєму чи в коді OP. І це важливо, оскільки методи класового шаблону затримуються до моменту виклику.
Якк - Адам Невраумон

Клас, визначений у шаблонній функції, еквівалентний шаблоновому класу поза цією функцією. Визначення його поза функцією необхідне для демонстраційного коду, коли він має функцію шаблонного члена, оскільки правила C ++ не дозволяють шаблону члена в локальному класі, визначеному користувачем. Це формальне обмеження не стосується того, що створює компілятор.
Ура та хт. - Альф

7

Досить просто переписати код з точки зору класів, які компілятор повинен, а точніше повинен створити для лямбда-виразів.

Коли це зроблено, зрозуміло, що головна проблема - це просто звисаюча посилання, і що компілятор, який не приймає код, дещо оскаржується в лямбда-департаменті.

Перепис показує, що кругових залежностей немає.

#include <iostream>

struct Outer
{
    int& a;

    // Actually a templated argument, but always called with `Outer`.
    template< class Arg >
    auto operator()( Arg& self ) const
        //-> Inner
    {
        return Inner( a, self );    //! Original code has dangling ref here.
    }

    struct Inner
    {
        int& a;
        Outer& self;

        // Actually a templated argument, but always called with `int`.
        template< class Arg >
        auto operator()( Arg b ) const
            //-> Inner
        {
            std::cout << (a + b) << std::endl;
            return self( self );
        }

        Inner( int& an_a, Outer& a_self ): a( an_a ), self( a_self ) {}
    };

    Outer( int& ref ): a( ref ) {}
};

int main() {

  int a = 5;

  auto&& it = Outer( a );
  it(it)(4)(6)(42)(77)(999);
}

Повністю шаблонна версія, що відображає те, як внутрішня лямбда в оригінальному коді фіксує елемент шаблонного типу:

#include <iostream>

struct Outer
{
    int& a;

    template< class > class Inner;

    // Actually a templated argument, but always called with `Outer`.
    template< class Arg >
    auto operator()( Arg& self ) const
        //-> Inner
    {
        return Inner<Arg>( a, self );    //! Original code has dangling ref here.
    }

    template< class Self >
    struct Inner
    {
        int& a;
        Self& self;

        // Actually a templated argument, but always called with `int`.
        template< class Arg >
        auto operator()( Arg b ) const
            //-> Inner
        {
            std::cout << (a + b) << std::endl;
            return self( self );
        }

        Inner( int& an_a, Self& a_self ): a( an_a ), self( a_self ) {}
    };

    Outer( int& ref ): a( ref ) {}
};

int main() {

  int a = 5;

  auto&& it = Outer( a );
  it(it)(4)(6)(42)(77)(999);
}

Я здогадуюсь, що саме такі шаблони у внутрішній техніці формальні правила покликані забороняти. Якщо вони забороняють оригінальну конструкцію.


Розумієте, проблема полягає в тому template< class > class Inner;, що шаблон шаблону operator()... примірник? Ну, неправильне слово. Написано? ... протягом, Outer::operator()<Outer>перш ніж буде виведено тип повернення зовнішнього оператора. І Inner<Outer>::operator()має заклик до Outer::operator()<Outer>себе. І це не дозволено. Тепер, більшість компілятор помічаютьself(self) , тому що вони чекають , щоб вивести тип повернення Outer::Inner<Outer>::operator()<int>, коли intпередається в. Sensible. Але він не вистачає неправильно сформованої коду.
Якк - Адам Невраумон

Добре, я думаю, що вони повинні чекати, щоб вивести тип повернення шаблону функції до тих пір, поки цей шаблон функції не Innner<T>::operator()<U>буде створений. Адже тип повернення може залежати від Uтут. Це не так, але загалом.
Ура та хт. - Альф

впевнений; але будь-яке вираження, тип якого визначається неповним вирахуванням типу повернення, залишається незаконним. Просто деякі компілятори ліниві і не перевіряють до цього пізніше, до якого моменту працює евтуфінг.
Якк - Адам Невраумон
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.