Лямбда-вирази в С++

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

В математиці лямбда-численням називають систему для дослідження визначення та застосування функції. Таким чином математики спростили формат запис функції і намагалися формалізувати обчислення. В нас є такий вираз:

λх.х

Де λ означає, що це лямбда-функція. Після цього перша «х» - список аргументів, в яких може бути інша лямбда-функція. «х» після крапки – тіло функції, а вже після нього іде аргумент, який передається. Наприклад,

λх.х+2 5

В результаті повернеться 7, оскільки 5 передається в якості аргументу, тому 5+2 = 7. З цього можна зробити висновок, що будь-яка змінна – це лямбда-вираз. Більш детально можна дізнатися по посиланню на сторінку Вікіпедії.

Перед тим як почати розбір лямбда-виразу в програмуванні, давайте згадаємо що таке функтор. В різних мовах програмування цей термін може мати різне значення. В мові С++ функтор – це скорочена назва від «функціональний об’єкт». Функтор являє собою екземпляр, в класі якого визначено перевантажений operator() . Розглянемо приклад:

class Functor
{
public:
    Functor();
    void operator()() const;
};

Functor::Functor()
{
}

void Functor::operator()() const
{
    cout << "Hi, I am functor." << endl;
}

int main()
{
    Functor funObj;
    funObj();
}

У класі Functor оголошений перевантажений operator() . В функції main() створюється об’єкт класа Functor з іменем funObj . Цей об’єкт і є функтором. Після створення об’єкта ви бачите вираз funObj() – використання функтора. В результаті на вивід ми отримаємо повідомлення > «Hi, I am functor.».

Що ж, з функтором розібралися. Мабуть, у вас виникло запитання, при чому тут функтор і лямбда-вирази? Відповідь проста.

Іншими словами лямбда-вираз – це безіменна локальна функція. І щоб краще зрозуміти значення «безіменна функція» та «анонімний функтор», розглянемо наступний код.

void output(int i) // функція output() не може бути перевантажена, оскільки виникне помилка в for_each
{
    cout << i << " ";
}

void outputVec(vector<int>& vec)
{
    for (vector<int>::iterator iter = vec.begin(); iter != vec.end(); ++iter)
        cout << *iter << " ";
    cout << endl;
}

int main()
{
    vector<int> myVector;

    for (int i = 0; i < 10; ++i)
        myVector.push_back(i);

    outputVec(myVector); // вивід за допомогою звичайної функції

    for_each(myVector.begin(), myVector.end(), output);  // 1
    cout << endl;
    for_each(myVector.begin(), myVector.end(), [](int i) //2
                                                {
                                                    cout << i << " ";
                                                }
            );

    cout << endl;

    return 0;
}

В цій програмі створюється вектор і заповнюється числами від 0 до 9.
В рядку 1 в for_each() викликається функція з іменем output, яка виводить вміст вектора. В рядку 2 використовується функція for_each(), яка приймає посилання на безіменну функцію, тобто лямбда-вираз. В усіх випадках ми отримуємо однаковий результат: 0 1 2 3 4 5 6 7 8 9.

Як вже було згадано, функтор є екземпляром класу, в якому перевантажений operator(), а лямбда-вираз – скорочена форма анонімного функтора. Отже, в наступному прикладі наочно представлене дане порівняння.

class Anonim
{
public:
    void operator()(int _value) const;
};

void Anonim::operator()(int _value) const
{
    cout << _value << " ";
}

int main()
{
    vector<int> myVector;

    for (int i = 0; i < 10; ++i)
        myVector.push_back(i);

    for_each(myVector.begin(), myVector.end(), Anonim());  // 1
    cout << endl;
    for_each(myVector.begin(), myVector.end(), [](int _value) //2
                                                {
                                                    cout << _value << " ";
                                                }
            );

    cout << endl;

    return 0;
}

В рядку 1 у функцію передається анонімний функтор класу AnonimAnonim(), який виводить елементи вектора.
В рядку 2 передається лямбда-вираз, який якраз є скороченою формою запису анонімного функтора Anonim() і теж виводить елементи вектора.
Такий підхід з лямбда-виразами дозволяє уникати створення класів і спрощує процес написання коду.

Структура лямбда-виразів

В загальному лямбда-вираз має таку структуру:

По замовчуванню функція лямбда-виразу повертає тип void. Тобто, дану вище структуру можна записати так:

[]()->void {}

Це означає, що ми можемо явно вказувати тип повернення за допомогою такого синтаксису:

[]()-><тип> {}

Розглянемо приклад:

int main()
{
    vector<int> myVector(10, 5);

    for_each(myVector.begin(), myVector.end(), [](int _value) 
                                                {
                                                    cout << _value << " ";
                                                }
            );

    cout << endl;

    return 0;
}

В даному прикладі створюється вектор і одразу ініціалізується 10-ма значеннями «5». Після цього виконується фукція for_each і, як ви побачили, використвується лямбда-вираз. Список захоплення [ ] порожній (його розглянемо пізніше), а от в списку параметрів приймаєтся _value, що є елементом вектору. Компілятор розуміє, що функція повертає тип void, оскільки ми не вказали явно інший тип і не використовуємо оператори return. Результат програми – 5 5 5 5 5 5 5 5 5 5.

Ми можемо не вказувати тип повернення функції лямбда-виразу, якщо він void або якщо функція використовує один оператор return – тоді компілятор сам визначає який тип повертати.

int main()
{
    vector<int> myVector(10, 5);

    bool sum;
    sum = count_if(myVector.begin(), myVector.end(), [](int _value)
                                                    {
                                                        return (_value + 3) == 8;
                                                    }
                  );
    cout << sum << endl;

    return 0;
}

В програмі заповнюється вектор 10-ма елементами із значенням 5. В функцію count_if() посилається лямбда-вираз, який має один оператор return, в якому перевіряється умова чи додавання до елемента вектора значення 3 буде рівно 8.
В цьому випадку компілятор визначить тип повернення як bool, що є аналогічним такому запису:

int main()
{
    vector<int> myVector(10, 5);

    bool sum;
    sum = count_if(myVector.begin(), myVector.end(), [](int _value)-> bool
                                                    {
                                                        return (_value + 3) == 8;
                                                    }
                  );
    cout << sum << endl;

    return 0;
}

Якщо ж кількість операторів return 2 і більше, то тип необхідно вказати явно, адже в іншому випадку компілятор не зможе інтерпретувати який тип повертати. Явне вказування типів, як ви вже знаєте, має вигляд:

[]()->bool {}
[]()->int {}
[]()->double {}
    ...

Список захоплення [ ]

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

int main()
{
    vector<int> myVector(10, 5);

    int number1, number2;
    cin >> number1;
    cin >> number2;

    bool sum;
    sum = count_if(myVector.begin(), myVector.end(), [number1, number2](int _value)-> bool
                                                    {
                                                        return _value == (number1 + number2);
                                                    }
                  );
    cout << sum << endl;

    return 0;
}

Як бачимо, в [ ] захоплюються змінні number1 і number2 з зовнішнього контексту, які можна тепер використовувати в лямбда-виразі. Слід зауважити, що у лямбда-виразі використовуются копії цих змінних, а не їх оригінали. Для роботи з оригіналами використовується захоплення по посиланню [&], яке ми розглянемо трохи пізніше.

Тому для того, щоб мати змогу редагувати захвачені змінні слід використовувати специфікатор mutable.

int main()
{
    vector<int> myVector(10, 5);

    int number1, number2;
    cin >> number1;
    cin >> number2;

    bool sum;
    sum = count_if(myVector.begin(), myVector.end(), [number1, number2](int _value) mutable -> bool
                                                    {
                                                        number1 = number2 + number1;
                                                        return _value == (number1 + number2);
                                                    }
                  );
    cout << sum << endl;

    return 0;
}

Тепер, в цьому коді, коли використовується специфікатор mutable, ми можемо переініціалізувати зовнішні змінні number1, number2 та виконувати операції над ними.

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

[=]() {};
[&]() {};

[=] використовується для захоплення змінних по значенню. Всі змінні з зовнішнього контексту, які використовуються в тілі лямбда, автоматично захоплюються. Точніше захоплюється їхнє значення.

int main()
{
    vector<int> myVector(10, 5);

    int number1, number2;
    cin >> number1;
    cin >> number2;

    bool sum;
    sum = count_if(myVector.begin(), myVector.end(), [=](int _value) mutable -> bool
                                                    {
                                                        number1 = number2 + number1;
                                                        return _value == (number1 + number2);
                                                    }
                  );
    cout << sum << endl;

    return 0;
}

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

    [var1, var2, var3]()                            [=]()
    {                                               {
        var1;                                           var1;
        var2;                   ==                      var2;
        var3;                                           var3;
    }                                               }

[&] використовується аналогічно до [=] за винятком, що це захоплення за посиланням.

    [&]()    
    {       
        var1;
        var2;
        var3;
    }

Якщо ми вказуємо [&], то зміна значення змінної в зовнішньому контексті відобразиться в тілі лямбда і навпаки, якщо ми змінюємо значення змінної в тілі лямбди - зміна відбудеться і в зовнішньому контексті. Слід зауважити, специфікатор mutable не обов'язково використовувати при операції над зовнішніми змінними в тілі лямбди.
При використанні [=] (змінити значення без mutable не можна) і зміні значень зовнішніх змінних в тілі лямбди ці змінні не змінюватимуться в зовнішньому контексті. Тобто, операції над зовнішніми змінними в тілі лямбди при захопленні по значенню відбуватимуться над копіями цих змінних, в той час як [&] працює з оригіналами.

Є такі варіанти використання режимів захоплення:

    []() {};                // без захоплення
    [=]() {};               // всі змінні захоплюються по значенню
    [&]() {};               // всі змінні захоплюються по посиланню
    [var1, var2]() {};      // захоплення var1 і var2 по значенню
    [&var1, &var2]() {};    // захоплення var1 і var2 по посиланню
    [var1, &var2]() {};     // захоплення var1 по значенню, а var2 по посиланню
    [=, &var1, &var2]() {}; // всі змінні захоплюються по значенню
                            // крім var1 і var2, які захоплюються по посиланню
    [&, var1, var2]() {};   // всі змінні захоплюються по посиланню
                            // крім var1 і var2, які захоплюються по значенню

При такому виразі:

[=](int& _value) mutable -> {...};

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

Особливості лямбда-виразів

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

                                                    int var1, var2, var3;
    for (;;)                                        for (;;)
        int var1, var2, var3;                       var1, var2, var3;
    [=]() mutable                                   [=]() mutable
    {                                               {
        var1;                                           var1;
        var2;                                           var2;
        var3;                                           var3;
    }                                               }
      // wrong                                         // right

Однак, якщо в нас є наступний випадок:

class Example
{
public:
    Example(int value = 0);
    void Output();
private:
    int m_value;
};

Example::Example(int value) : m_value(value)
{
}

void Example::Output()
{
    function<int(int)> lambdaSave = [m_value](int& _val) mutable -> int // error
    {
        _val = m_value;
        return _val;
    };
}

Тоді компілятор не допускає використання змінної m_value в лямбда-виразі, оскільки вона є членом даних (неважливо закритим, захищеним чи відкритим) і знаходиться поза зоною видимості. Для рішення цієї проблеми використовується вказівник this. Тепер даний код можна переписати таким чином:

class Example
{
public:
    Example(int value = 0);
    void Output();
private:
    int m_value;
};

Example::Example(int value) : m_value(value)
{
}

void Example::Output()
{
    auto lambdaSave = [this](int& _val) mutable -> int // error
    {
        _val = m_value;
        return _val;
    };
}

Зверніть увагу на вираз function<int(int)> lambdaSave і auto в прикладах. Незабаром ми до цього повернемося.

Генерація лямбда-виразів

Як ви помітили, в останньому прикладі використовується такий вираз - function<int(int)> lambdaSave. Цей вираз зберігає стан лямбда-виразу.
Для цього небхідно підключити відповідну бібліотеку:

#include <functional>

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

Приклад:

function<function<int(int)> (int)> Generation = [](int _value) -> function<int(int)>
                                                    {
                                                        return [_value](int _secondValue) -> int
                                                                {
                                                                    return _value + _secondValue;
                                                                };
                                                    };

Тепер пояснення:

В результаті Generation зберігає стан _secondValue + _value. Це все досить намудрено і на практиці навряд чи використовуватиметься, але наведено як приклад, щоб ви мали про це уявлення.

Крім того, ми згадували auto. Ключове слово auto дозволяє автоматично визначати тип. Наприклад:

auto a = 5;

Тут компілятор автоматично визначить тип змінної aint. Однак змінити тип в подальшому не можна, тобто якщо переініціалізувати змінну а = 3.56, компілятор виведе число 3, так як він попередньо визначив тип а як int, а не float. За допомогою auto попередній код можна сильно спростити.

auto Generation = [](int _value) -> function<int(int)>
                                        {
                                            return [_value](int _secondValue) -> int
                                                    {
                                                        return _value + _secondValue;
                                                    };
                                        };

Такий підхід реалізовує абстрагування і є практичним, оскільки програмістові не доведеться самому визначати типи.

Думаю, після прочитання цієї статті лямбда-вирази не є для вас чимось страшним і незрозумілим. Якщо ж все сталося навпаки, і після прочитання ви вирішили покинути світ ІТ, тоді вибачте, я не ставив це головною метою даної статті.

Дякую, що дочитали до кінця. Сподіваюся, ця стаття була корисною та не викликала великих труднощів.

Удачі!

Коментарі 3

whirlwind - 22 травня 2017, 15:29

Не варто було оформлювати код у вигляді зображень. Вони повільно завантажуються, а згодом можуть зовсім зникнути.

septimomend - 22 травня 2017, 17:12

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

septimomend - 01 червня 2017, 20:02

Стаття обновлена, переписаний код і трохи доповлена.

Коментувати
© 2009 - 2017, Розробка - соціальна ІТ спільнота.
Контакти: info@rozrobka.com
Правила користування