C++ :: Чувствуя себя в гостях

Особенности нового стандарта языка

Автор: Владимиров Константин Игоревич
Перевод: Фамилия Имя Отчество
Источник: Название источника где статья была опубликована впервые
Материал предоставил: Фамилия Имя Отчество
Опубликовано: 25.04.2012
Версия текста: 1.1
1. Введение
2. Rvalue references и std::move
2.1. Отличаем rvalue от lvalue
2.2. Отличаем rvalue refernces от lvalue references
2.3. Свёртка ссылочных типов
2.4. Применение rvalue-ссылок
3. Lambda expressions и std::function
3.1. Lambda hello world
3.2. Захват контекста
3.3. Элементы высшего пилотажа
3.4. Осваиваем std::function
Список литературы

1. Введение

Новый стандарт C++ предлагает много нововведений. Часть из них относится к тому, чего все давно ждали и успели освоить, что перекочевало из библиотеки boost или из technical reports к C++98. Но есть и такие усовершенствования, при работе с которыми требуется оперировать совершенно новыми понятиями и заставлять себя мыслить иначе, мыслить в духе действительно новых возможностей. Цель этой статьи - рассказать о двух принципиальных и наиболее сложных для восприятия нововведениях: rvalue references и lambda expressions, затрагивая работу с std::move и std::function.

Цифры в круглых скобках в тексте статьи означают нормативные ссылки на стандарт обсуждаемого языка (автор старался, чтобы то, какой стандарт обсуждается, было понятно из контекста). Цитируются только стандарты ISO/IEC, поэтому цитируется C90, а не C89. Стандарт ISO/IEC 9899:2011 не обсуждается и не цитируется, поскольку мало отличается от С99 и слабо поддержан современными компиляторами. Заголовочные файлы, в которых размещены упомянутые в тексте библиотечные функции, следуют из пунктов стандарта, поэтому не упоминаются в тексте и не включаются в примеры кода.

2. Rvalue references и std::move

Ссылки (references) – это то, что наиболее сложно воспринимается при переходе с C на C++. Человеку с опытом программирования на C может быть непонятно, зачем они нужны, когда уже есть такие удобные и привычные указатели, которые позволяют делать всё то же самое, только лучше... Подлинное осознание того, каким именно инструментом являются ссылки, и как ими оперируют, приходит с опытом программирования на C++. При переходе на новый стандарт C++11 идея о том, что теперь есть ещё один вид ссылок, ссылки на rvalue (rvalue references), кажется новаторской и неочевидной (особенно неочевидна её полезность). Скорее всего, многим придётся преодолеть внутреннее сопротивление, прежде чем rvalue references станут удобным и привычным инструментом. Но прежде чем переходить к их описанию, есть смысл напомнить о том, что такое rvalue и lvalue.

2.1. Отличаем rvalue от lvalue

Прежде чем появиться в C++, термин lvalue был документально зафиксирован в стандарте языка C, поэтому логично начать изложение с него. Для языка C lvalue (от left hand side value) – то, что может появиться слева в выражении присваивания. Формально (6.3.2.1 стандарта C99), "An lvalue is an expression with an object type or an incomplete type other than void" (под object type в стандарте понимаются все типы, не являющиеся function type или incomplete type).

        int c = a * b; /* ok, переменная типа int это lvalue */
  a * b = 42; /* error, (a * b) не принадлежит к object type */
  foo() = 42; /* error, foo() принадлежит к function type и не может быть lvalue */

Собственно, в стандарте языка C90 и последовавшем за ним C99 нет термина rvalue. В C99 есть оговорка "What is sometimes called ‘‘rvalue’’ is in this International Standard described as the ‘‘value of an expression’’", позволяющая предположить, что к этому времени этот термин существовал и использовался, но фиксировать его строго в случае языка C было не нужно.

Всё изменилось с приходом языка C++, который принёс с собой references. Выражение из прошлого примера:

  foo() = 42; /* is it okay??? */

может быть ошибкой, если foo возвращает значение, но совершенно корректно, если foo возвращает ссылку. Чтобы разрешить эту неоднозначность, стандарт C++ 98 объявляет (3.10.1) два термина – lvalue и rvalue, причём каждое выражение языка C++98 является либо тем, либо другим. При этом определение в (3.10.2) гласило "An lvalue refers to an object or function."

То есть lvalue – это не первоклассный объект, как в C, а просто некое выражение, ссылающееся на область памяти. Таким образом, в качестве главного различия выступает возможность взять адрес, а не отношение к операции равенства.

        int& foo();
  foo() = 42; /* ok, foo() это lvalue */int* p1 = &foo(); /* ok, foo() это lvalue */int foobar();
  int* p2 = &foobar(); /* error, нельзя взять адрес rvalue */

В некотором смысле, lvalue в C++98 содержательно становится locator value (а не left hand side value). Так, например, если есть указатель val, то выражение val+1 можно разыменовать, но нельзя взять его адрес.

        int *val;
  int correct = *(val + 1); /* ok */int **wrong = &(val + 1); /* error */

Cтандарт С++11 объявляет (3.10) пять терминов: lvalue, rvalue, xvalue, а также glvalue (обобщение lvalue и xvalue) и prvalue (более узкая специализация rvalue). Базовыми терминами являются знакомые нам lvalue, rvalue и новый термин xvalue (от expiring value). Забегая вперёд – например, xvalue является результатом функции, возвращающей rvalue ссылку. То, что понималось под rvalue в C++98, в C++11 стало prvalue (в то время как rvalue обобщает xvalue и prvalue), а новый термин glvalue обобщает lvalue и xvalue. Можно (неточно, но образно) сказать, что prvalue – это то, брать адрес от чего нельзя, lvalue – от чего можно, а xvalue – от чего бесполезно.

Если всё это не вызывает вопросов, настало время перейти к объяснению ссылок.

2.2. Отличаем rvalue refernces от lvalue references

В C++11, если X – это тип, то X& – это lvalue reference, а X&& – это rvalue reference для этого типа (8.3.2.2). При этом важно понимать, что X&, X&& – это разные типы. С одной стороны, семантически они эквивалентны, и о них можно говорить как о "ссылках вообще". Тем не менее, поскольку это разные типы, для них работает перегрузка:

        int a, b;
  foo(int &x);
  foo(int &&x);
  foo(a * b); /* вызовет foo(int &&x) */
  foo(a); /* вызовет foo(int &x) */

При этом сама по себе rvalue reference может быть lvalue (и является им, если она именована).

        int &&x = a * b;
  foo(x); /* вызовет foo(int &x) */

Это очень важное правило. Представьте ситуацию, в которой объявлен класс Base, и в нём есть необходимость определить конструкторы:

  Base(Base const & rhs); /* перегрузили lvalue, так называемая copy semantics */
  Base(Base&& rhs); /* перегрузили rvalue, так называемая move semantics */

Теперь в наследуемом от него классе Derived есть необходимость эти конструкторы переопределить:

  Derived(Derived const & rhs) : Base(rhs) {}; /* верно, вызовется Base(Base const & rhs); */
  Derived(Derived&& rhs) : Base(rhs) {}; /* неприятный сюрприз, снова вызовется Base(Base const & rhs); */

Перемещающий конструктор переопределён неверно. Поскольку у rhs есть имя, оно есть lvalue, а значит, Base(rhs) в данном случае вызовет снова Base(Base const & rhs), что, вероятно, нежелательно. Вместо этого следует писать

  Derived(Derived&& rhs) : Base(std::move(rhs)) {}; /* вот теперь вызовется Base(Base&& rhs), но пока что ещё непонятно почему */

Сюрпризы подобные этому ожидают, казалось бы, в очевидных вещах. Для примера можно взять общее утверждение, что ссылка на ссылку – невозможна. Это все знают и тысячу раз отвечали на собеседованиях. Что же, этот ответ всё ещё верен, и стандарт C++11 (8.3.2.5) запрещает ссылки на ссылки, указатели на ссылки и массивы ссылок. Но как быть, когда в шаблоне или при typedef получается смесь правых и левых ссылочных типов? Это способно поставить в тупик. Поэтому в этом месте следует разобраться с тем, как работают ссылки в C++11.

2.3. Свёртка ссылочных типов

Стандарт (8.3.2.6) определяет следующие правила свёртки ссылок, применимые для определений typedef и decltype, а также параметров шаблонов:

СОВЕТ

A& & становится A&

A& && становится A&

A&& & становится A&

A&& && становится A&&

Пусть определён шаблон

        template<typename T>
  void foo(T&& t);

Пусть теперь функция foo вызвана с аргументом x типа X, причём x является lvalue. Тогда T разрешается в X&, а реальным типом аргумента t будет X& &&, то есть (см. выше правила свёртки) X&. Если же x является rvalue, то T разрешается в X и реальным типом аргумента t будет X&&.

Стандарт также определяет функтор remove_reference (20.9.7.2), который позволяет получить не-ссылочный тип из ссылочного. Он работает похоже на static_cast и прочие привычные вещи. Аналогично (но наоборот) работает пара add_lvalue_reference/add_rvalue_reference.

        int *p = (std::remove_reference<int&>::type *)0;

(Я надеюсь, никто никогда не напишет такую строчку в реальном коде).

Теперь настала пора посмотреть, зачем же комитетом по стандартизации были введены все эти сложности.

2.4. Применение rvalue-ссылок

В стандарте С++98 условная реализация, соответствующая требованиям стандарта к функции std::swap (25.2.2), требовала использования копирования:

        template <class T>
  void swap(T& a, T& b)
  {
    T tmp = a; /* из tmp создаётся копия a, это может быть дорого */
    a = b;
    b = tmp;
  }

В условной реализации С++11 удовлетворяющая стандарту реализация библиотечной функции std::swap (20.2.2) может выглядеть, например, так:

        template<class T> 
  void swap(T& a, T& b) 
  { 
    T tmp(std::move(a)); /* в tmp перемещается значение a, это гораздо дешевле */
    a = std::move(b); 
    b = std::move(tmp);
  } 

В этой реализации нет операций копирования (без которых не обойтись в C++98). Функция std::move позволяет здесь реализовать семантику перемещения, поскольку она работает так: из любого аргумента std::move делает rvalue. Эта функция, в свою очередь, в удовлетворяющем стандарту (20.2.3) виде может быть определена так:

        template<class T> 
  typename remove_reference<T>::type&&
  std::move(T&& a) noexcept
  {
    typedeftypename remove_reference<T>::type&& RvalRef;
    returnstatic_cast<RvalRef>(a);
  } 

Методологически полезно проследить, что будет, если вызвать std::move с аргументом, имеющим тип X и являющимся lvalue.

1) T будет разрешено в X&

2) тип аргумента X& && a будет свёрнут в X& a

3) remove_reference<X&>::type&& будет означать X&&, который и будет значением RvalRef

Итак, получим эквивалент:

  X&& std::move(X& a) noexcept
  {  
    returnstatic_cast<X&&>(a);
  }

Что и требовалось.

Сама идея, что теперь обмен значениями может быть семантически выражен как обмен значениями, а не копирование (а значит, и оптимизирован соответствующим образом), после её осознания, приводит людей в лёгкую эйфорию. В конце концов, стремление к максимальной эффективности – нормальное стремление. Тем не менее, с функцией std::move следует быть осторожным.

ПРЕДУПРЕЖДЕНИЕ

Часто идея вернуть std::move(x) вместо x, когда реально нужно rvalue и хочется избежать лишних копирований, приводит к отключению оптимизаций возвращаемого значения в компиляторе, и результат ухудшается. Большинство таких трансформаций на современных компиляторах требует замеров и не должны выполняться preliminary:

  X foo()
  {
    X x;
    /* делаем что-то с x */return std::move(x); /* Лучше, чем просто "return x"? Не факт! */
  }

Итак, rvalue references позволяют (совместно с std::move) по-новому взглянуть на ссылки. Это позволяет более тонкому различать такие термины естественного языка, как "копирование", "присваивание значения", "присваивание результата" на языке C++. Это в свою очередь открывает как простор к оптимизациям, так и возможности более аккуратного выражения старых идиом. Хороший пример правильного использования rvalue references – это std::unique_ptr, который в новом стандарте пришёл на смену печально известному auto_ptr. Умение уместно использовать rvalue references – важная черта, отличающая программиста на C++11.

ПРИМЕЧАНИЕ

За кадром этого изложения осталось использование std::forward (20.2.3), предлагающееся для самостоятельного изучения, как упражнение на практическое применение rvalue refernces.

3. Lambda expressions и std::function

Ещё одной непросто воспринимаемой для программиста с опытом С++ концепцией являются lambda expressions. С одной стороны, кажется, что понять их чуть проще, в конце концов, все когда-нибудь писали функторы. С другой стороны, настоящее осознание мощи и гибкости этого инструмента также приходит только с опытом.

3.1. Lambda hello world

Следующий код с первого взгляда немного шокирует:

        int main()
  {
    auto func = [] () -> int { std::cout << "Hello world"; return 0; };
    return func(); 
  }

Тем не менее, это теперь легальный C++.

Здесь, согласно стандарту (5.1.2):

[] – capture specificator, определяет lambda-выражение, внутри этих скобок также можно задать захватываемый контекст.

() – argument list, здесь можно задать список параметров

-> int – означает, что возвращаемое значение имеет тип int.

Внутри {} – тело lambda-выражения.

Отдельно следует остановиться на спецификаторе auto (7.1.6.4). Его употребление вместо типа переменной означает, что тип переменной должен быть выведен из инициализирующего её выражения (есть также специальное назначение auto, когда он употребляется как тип функции, который должен быть в этом случае выведен из выражения, стоящего под return).

        auto x = newauto('a') /* выделенный тип char, x имеет тип (char*) */

Каким будет выведен тип func, пока не так существенно. Рассмотренный пример использования lambda-выражения, может быть переписан несколько проще.

        int main()
  {
    auto func = [] { std::cout << "Hello world"; return 0; };
    return func(); 
  }

По стандарту, если список аргументов не указан, то он пуст (5.1.2.4), и возвращаемый тип выражения выводится из return в его теле, если он указан и единственный (там же).

Лямбда-выражения в их простейшем виде хороши для объявления вместо несложных функторов. Стандарт (5.1.2.1) приводит простой пример:

        /* сортировать массив float по убыванию абсолютного значения */
        void abssort(float *x, unsigned N) 
  {
    std::sort(x, 
              x + N,
              [] (float a, float b) { return std::abs(a) < std::abs(b); });
  }

Тем не менее, в лямбда-выражениях есть и более интересные детали для рассмотрения, одна из которых – возможность захвата контекста.

3.2. Захват контекста

Список для захвата контекста пишется в квадратных скобках. Правила формирования списка для захвата контекста регулируются стандартом (5.1.2.8). Существует два специальных символа – ‘‘=’’ и ‘‘&’’. Переменные, захватываемые по значению, входят в список без модификаторов, захватываемые по lvalue reference – с модификатором ‘‘&’’.

[foo, &bar] { return (bar + foo * 2); };

Употребление ‘‘&’’ означает, что "может быть захвачена по ссылке любая переменная из контекста", тогда захватываемая переменная не должна предваряться & и всё равно будет захвачена по ссылке (по ссылке – значит, по lvalue-ссылке, говоря точно).

[&, foo] { return foo; }

Употребление ‘‘=’’ означает, что если имя захватываемой переменной не предварено символом ‘‘&’’, то она будет захвачена по значению, а не по ссылке (5.1.2.14).

Кроме того, употребление любого из спецсимволов отдельно, означает, что "захвачен this", тогда захватываемая переменная из контекста класса или структуры не должна предваряться this->. В случае ‘‘=’’, упоминание this – ошибка (5.1.2.8). Такая неоднозначность может несколько запутывать.

Важно помнить, что захват по ссылке – это по умолчанию захват по константной ссылке, и нужно ключевое слово mutable, чтобы это изменить (5.1.2.5). При этом круглые скобки списка параметров, если используется mutable, опускать нельзя:

        class Foo
  {
      int m_x;
    public:
      Foo () : m_x( 3 ) {}
      void func ()
        {
          /* рабочие примеры: */
          [=] { std::cout << m_x; } (); /* не this->m_x потому что есть = */
          [&, m_x] () mutable { m_x = 5; } (); /* не this->m_x потому что есть & *//* ошибки: */
          [&, m_x] { m_x = 5; } (); // error, references are const
          [&, &m_x] () mutable { m_x = 5; } (); // error: i preceded by & when & is the default
          [=, this]{ std::cout << m_x; } (); // error: this when = is the default
          [m_x, m_x]{ std::cout << m_x; } (); // error: m_x repeated
        }
  };

Кроме простых правил захвата контекста, есть и несколько более сложные, знание которых также полезно.

3.3. Элементы высшего пилотажа

Внутри lambda-выражения может быть использован тип переменной из контекста, для чего нет необходимости делать отдельно захват (5.2.1.18):

        void f(float x) 
{
  [=] { decltype(x) y1 = x; std::cout << y1; } ();
}

Также lambda-выражения могут быть вложенными. Это довольно странно, учитывая, что в C++ до сих пор нет вложенных функций, но тут можно выдвинуть такой аргумент, что lambda-выражение представляется скорее классом, чем функцией (см. связанный контекст), а вложенные классы в C++ есть.

        int main()
  {
    /* вложенное lambda-выражение */auto nested = [] (int x) 
                    { 
                      return [](int y) { return y * 2; }(x) + 3; 
                    };
    std::cout << m << endl;
  }

Поскольку lambda-выражения типизированы, их можно использовать вместе с шаблонами C++

        /* обращает каждый элемент в std::vector */
        template <typename T> 
  void negate_all(std::vector<T>& v)
  {
    for_each(v.begin(), v.end(), [] (T& n) { n = -n; } );
  }

Отдельной и интересной темой является обработка исключений внутри lambda-выражений. Мне не удалось набрать достаточно материала, чтобы делать выводы, но это место выглядит достаточно error-prone и мне кажется, здесь надо соблюдать существенную осторожность (пока Герб Саттер не порадует нас книгой на эту тему, я надеюсь).

Теперь пришло время разобраться с тем, как же типизированы lambda-выражения.

3.4. Осваиваем std::function

lambda-выражение с пустой спецификацией считается обычной функцией и может присваиваться указателям на функции в стиле C:

        int test(void)
  {
    typedefint (*fptr_t)();
    fptr_t fptr = [] { return 2; };
    return fptr();
  }

Пусть теперь есть необходимость создать lambda-выражение с захватом контекста. Можно создать его, используя auto переменную:

        int one_more_test(int x, int y)
  {
    auto f1 = [&x, &y] { return x + y; };
    return f1(); 
  }

Но что, если есть необходимость вернуть не результат вычисления lamda-выражения, а само выражение, чтобы использовать его (со связанным контекстом) где-то ещё? Это можно сделать, используя auto на результат функции, но можно выразить эту же идею более явно через std::function (20.8)

  std::function<int ()> harder_test(int x, int y)
  {
    return [&x, &y] { return x + y; };
  }

std::function дает возможность реализовать на C++ даже функции высшего порядка. Функция высшего порядка – это функция, которая берёт на вход lambda-выражение и возвращает lambda-выражение.

        int main()
  {
    /* результат работы g –  lambda выражение */auto g = [](int x) -> function<int (int)> 
       { return [=](int y) { return x + y; }; };
    /* параметр для h –  lambda выражение */auto h = [](const function<int (int)>& f, int z) 
       { return f(z) + 1; };
    /* теперь можно подать выход g на вход h */auto a = h(g(7), 8);
    std::cout << a << endl;
  }

Итак, lambda-expressions позволяют (совместно с std::function) привнести в C++ некоторые полезные особенности функциональных языков программирования (функции высшего порядка, захват контекста). Они предоставляют удобный синтаксис для объявления функторов и использования алгоритмов стандартной библиотеки. Также они частично совместимы со старыми указателями на функции. Освоение lambda-выражений позволяет сделать код короче, выразительней и проще для поддержки.

Список литературы

  1. ISO/IEC 9899-1999 Programming language C
  2. ISO/IEC 14882-1998 Programming language C++
  3. ISO/IEC 14882-2011 Programming language C++
  4. http://www2.research.att.com/~bs/C++0xFAQ.html
  5. http://gcc.gnu.org/projects/cxx0x.html
  6. http://herbsutter.com/elements-of-modern-c-style/
  7. http://thbecker.net/articles/rvalue_references/section_01.html
  8. http://plakhov.livejournal.com/152543.html
  9. http://accu.org/index.php/journals/227
  10. http://candrews.net/blog/2011/07/understanding-c-0x-lambda-functions/
  11. http://msdn.microsoft.com/en-us/library/dd293599.aspx
  12. http://www.cprogramming.com/c++11/c++11-lambda-closures.html
  13. http://www.cprogramming.com/c++11/c++11-auto-decltype-return-value-after-function.html