Языковые новшества C++17. Часть 4. Сборная солянка.

Эта статья является последней в цикле, посвящённом новшествам стандарта C++17 в части языка. Здесь мы рассмотрим несколько интересных и полезных мелочей, которые по тем или иным причинам не вошли в другие статьи цикла.

Нетипичные шаблоны

Всем C++ программистам хорошо известно, что параметром шаблона может быть не только какой-то шаблонный тип T, но и вполне конкретный тип size_t, к примеру. Такие параметры называются «шаблонные параметры, не являющиеся типом» (non-type template parameters).

Это отличный пример неудачного названия, потому что параметры типом как раз и являются. А вот аргументом для такого параметра будет что-то, что типом уже не является. Но даже тут есть заковыка: шаблон ведь тоже не является типом, но передан он может быть только в качестве аргумента «шаблонного шаблонного параметра» (template template parameter), а не «шаблонного параметра, не являющегося типом».

Так вот, имея в своём арсенале такие параметры, мы всегда могли написать что-то такое:

#include <iostream>

using namespace std;

template<size_t Constant>
void constantPrinter()
{
    cout << "Constant provided: " << Constant << "\n";
}

template<nullptr_t>
void constantPrinter()
{
    cout << "Null pointer provided.\n";
}

int main()
{
    constantPrinter<123>();
    constantPrinter<nullptr>();
};

Т.е. для каждого конкретного типа мы вынуждены были создавать отдельную функцию. В стандарте C++17 появилась возможность вместо конкретного типа указать auto или decltype(auto), что позволит передавать в качестве аргумента шаблона любой объект, тип которого может быть использован в качестве «шаблонного параметра, не являющегося типом». Список типов можно найти в temp.param/p4, а возможные аргументы описаны в temp.arg.nontype. Отличие auto от decltype(auto)заключается в том, каким образом из переданного аргумента будет вычисляться результирующий тип (я упоминал о разнице в этой статье). Таким образом, используя новый функционал, мы можем переписать предыдущий пример следующим образом:

template<auto Constant>
void constantPrinter()
{
    if constexpr(is_convertible_v<decltype(Constant), size_t>)
        cout << "Constant provided: " << Constant << "\n";
    else
        cout << "Null pointer provided.\n";
}

Конечно, этот пример просто высосан из пальца, но я действительно не знаю, где можно применить это нововведение (в оригинальном предложении есть некоторые примеры, но убедительными я назвать их не могу). Так что просто порадуемся, что у нас теперь есть такая возможность. И немного погрустим, что комитет продолжает использовать одни и те же ключевые слова для разного функционала. Ведь мы уже привыкли, что auto может быть всем, чем угодно, а тут auto жёстко ограничено.

Встроенные переменные

Не все его знают, но многие сталкивались с правилом одного определения (one definition rule), согласно которому (очень грубо) каждая используемая сущность должна иметь определение, и такое определение должно быть только одно. Именно из-за этого правила мы не можем помещать реализацию функции в заголовочный файл, который включается в двух и более файлах реализации. Ведь тогда получится, что реализация функции находится в двух разных единицах трансляции, а следовательно у неё 2 определения. Да и такой пример часто вводит новичков в ступор:

class SomeClass
{
public:
    static int myStaticVariable;
};

int main()
{
    SomeClass::myStaticVariable = 3;
};

На этапе компоновки, компоновщик будет ругаться на отсутствие определения myStaticVariable — всё это следствия вышеупомянутого правила. С другой стороны, в стандарте C++ есть методы обхода этого правила. Так, шаблоны могут полностью содержаться в заголовочном файле вместе с реализацией, и это им никак не мешает. Да и inline функции чувствуют себя в заголовках вполне комфортно. Всё это потому, что при условии идентичности определений, шаблонные реализации, а также inline функции, могут быть определены во множестве мест одновременно, и это уже дело компоновщика выбрать любое из этих определений (но только одно!).

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

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

class SomeClass
{
public:
    // Определение
    static inline int myStaticVariable{};
};

И всё, больше никаких ошибок компоновщика! Теперь у нас есть полноценное определение прямо в теле класса. Конечно, мы могли бы сделать и по старинке (т.е. добавить вынесенное определение), но до C++17 мы бы тогда были обязаны поместить такое определение в файл реализации. Теперь же мы можем это сделать и в заголовочном файле:

class SomeClass
{
public:
    // Объявление
    static int myStaticVariable;
};
// Определение
inline int SomeClass::myStaticVariable = 0;

Удобно, ничего не скажешь. Но изменения коснулись не только переменных, constexpr объекты тоже получили свою порцию. Теперь, если мы объявляем статический constexpr объект внутри класса:

class SomeClass
{
public:
    static constexpr int myStaticConst = 44;
};

, то такое объявление является одновременно и определением (в C++14 это было объявлением!), и это полностью эквивалентно явному указанию inline:

class SomeClass
{
public:
    // Определение
    static inline constexpr int myStaticConst = 44;
};

К сожалению, это правило работает только для constexpr, и недоступно простым переменным. Поэтому для них нам придётся указывать inline явно.

Байт

Наконец-то в C++ появляется тип byte, и это не просто псевдоним какого-то другого типа, это полноценный тип byte. Правда, он не настолько полноценный как, скажем, int. В чём разница? Разница в том, что int является фундаментальным типом языка, а std::byte введён как часть стандартной библиотеки, и определён он следующим образом (в <cstddef>):

enum class byte : unsigned char {};

Там же определены всевозможные операции, которые можно ожидать от byte (сдвиги и прочая работа с битами). Почему он добавлен таким образом, а не через введение нового ключевого слова? Судя по тому, что описано в P0298R3, авторы посчитали, что могут справиться и без введения нового ключевого слова; C++17, мол, имеет достаточно средств, чтобы сделать это. Безусловно, имеет, но если посмотреть на предложение внимательно, то можно заметить, что оно потребовало изменения некоторых ключевых параграфов стандарта, чтобы внедрить std::byte в язык. Более того, в будущем возможно потребуется дальнейшая адаптация далеко не библиотечной части стандарта, чтобы позволить использовать новоиспечённый byte в условных операторах. Учитывая все изменения, которые повлёк за собой byte, лично я не понимаю, почему было не добавить новое ключевое слово для фундаментального типа. Ведь отсутствие явного типа для объекта-байта является явным просчётом. Это ведь не что-то новое и современное — он должен был быть в языке изначально.

Более того, добавление этого типа в язык вовсе не означает, что он теперь станет повсеместно использоваться. И даже если забыть про то, что этот тип находится в пространстве имён std, а также требует подключения специального заголовка (<cstddef>), всё равно остаётся куча кода на языке C, который продолжит использовать в своих интерфейсах тип char. Поэтому применение std::byte на границе C и C++ кода повлечёт за собой постоянное конвертирование, что программисты не очень любят.

Таким образом, по моему мнению, будущее std::byte выглядит несколько туманным. Нет, я не сомневаюсь, что он найдёт своих пользователей и будет применяться. Просто вряд ли он будет применяться настолько же часто, насколько в программах есть потребность работы с байтами. Если бы этот тип добавили как в C++, так и в C, да ещё и через введение нового ключевого слова, у него было бы больше шансов. Сейчас же, на мой взгляд, его использование доставляет больше неудобств, чем от него исходит пользы. Подводя итог, можно сказать, что появление типа byte в языке это, безусловно, положительный момент, но учитывая то, сколько язык существует и то, как он в него добавлен это добавление может получится как мёртвому припарка.

Вложенные пространства имён

То, чего многие ждали, наконец свершилось: больше нет нужды явно «вкладывать» пространство имён друг в друга. Т.е. теперь, если у нас есть такой C++14-код:

namespace CoolApp
{
    namespace Model
    {
        namespace Instruments
        {
            class FakeGenerator
            {
                //...
            };
        }
    }
}

, то мы можем переписать его следующим образом:

namespace CoolApp::Model::Instruments
{
    class FakeGenerator
    {
        //...
    };
}

И всё, эти два листинга абсолютно эквивалентны! Никаких больше избыточных отступов и прочего визуального мусора. Это действительно стоящее изменение, которого многие ждали. Безусловно, тем кто использовал пространства имён редко, либо же вообще не использовал, это нововведение ничего не даст, но даже такие программисты могут начать использовать данный инструмент чаще — ведь теперь это действительно удобно. Также, не благодаря этому нововведению, но в согласии с ним, хочется надеяться на то, что стандартная C++ библиотека в конце концов обретёт нормальную иерархию пространств имён, и мы сможем точечно экспортировать её функционал.

Раскрытие пачки и using

Начнём сразу с примера (полный код которого может быть найден в этой фиксации). Допустим, у нас есть два класса-функтора, которые выводят что-то на экран:

class IntPrinter
{
public:
    void operator()(int i)
    {
        cout << "Int passed: " << i << "\n";
    }
};

class FloatPrinter
{
public:
    void operator()(float f)
    {
        cout << "Float passed: " << f << "\n";
    }
};

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

Printer<IntPrinter, FloatPrinter> printer;
printer(55);
printer(55.1f);

Очевидно, что для двух заранее известных классов мы можем сделать простой класс, но мы лёгких путей не ищем, поэтому нам нужно некоторое общее решение, которое сможет принимать сколь угодно много специализированных классов-принтеров. Исходя из этих требований, нам придётся воспользоваться шаблоном с переменным количеством параметров, а также рекурсией. Т.е. наш класс Printer мог бы выглядеть так:

template<typename... Ts>
class Printer;

template<typename T, typename... Ts>
class Printer<T, Ts...>: public T, public Printer<Ts...>
{
public:
    using T::operator();
    using Printer<Ts...>::operator();
};

template<>
class Printer<>
{
public:
    void operator()();
};

У этого кода несколько проблем. Первая проблема заключается в том, что у нас есть специализация-заглушка, в которой находится operator() без реализации (можно добавить реализацию со static_assert, но это всё равно не изменит того, что мы исключили возможность использования operator() без аргументов). Проблема вторая: рекурсия. Из-за рекурсии мы вынуждены иметь три объявления класса, и писать код, который только запутывает (а также может увеличить время компиляции). Да, в отсутствие альтернатив, решения с рекурсией всегда приходятся кстати, но с 2011 года всё больше и больше частей языка получают возможность обходится без рекурсии в шаблонах, когда речь идёт о раскрытии пачки параметро��.

С появлением C++17, вышеописанный случай также больше не нуждается в рекурсии. Теперь введение функций-членов из пространства базовых классов в пространство классов-наследников с помощью директивы using может раскрывать пачку шаблонных параметров. Т.е. реализация нашего класса Printer в C++17 станет такой:

template<typename... Ts>
class Printer: public Ts...
{
public:
    using Ts::operator()...;
};

И это всё! Это стало возможно за счёт того, что в стандарт добавили возможность использования using-декларации со списком (к примеру, using A::foo, B::foo;), а пачка параметров как раз раскрывается в список. Т.е. пример выше преобразуется в такой класс:

class Printer: public IntPrinter, public FloatPrinter
{
public:
    using IntPrinter::operator(), FloatPrinter::operator();
};

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

Инициализация в ветвлениях

Следующим «модным» нововведением является возможность определения объекта прямо в теле оператора ветвления if. Т.е. вместо такого кода:

mutex guard;
{
    lock_guard locker{guard};
    if(isContainerReady)
    {
        // Работаем с контейнером
    }
}

Мы теперь можем писать так:

mutex guard;
if(lock_guard locker{guard}; isContainerReady)
{
    // Работаем с контейнером
}

Т.е. теперь if может состоять из двух частей, разделённых точкой с запятой «;», где первая часть выступает в роли обычного определения объекта (либо же объектов), а вторая остаётся старым добрым ifом, т.е. в ней содержится условие. Все объекты, определённые внутри круглых скобок [else] if, ограничены областью видимости этого [else] if, а также всех нижеследующих else [if]:

if(int x = 0; 1)
{
    x = 2;
    // y здесь не виден!
}
else if(int y = 3; y)
{
    cout << "Y: " << y << ", X: " << x << "\n";
}
else 
{
    int z = 5;
    x = y = z;
}
// x, y, z здесь не видны!

Дальше, этот функционал присущ не только if, но и switch. Таким образом, оба оператора ветвления (selection statements) получили новый функционал, тогда как такой итерационный оператор как while остался не у дел; причина этой дискриминации для меня до сих пор остаётся загадкой. К примеру, вот довольно распространённый код с использованием while:

vector<string> lines;
ifstream fstream{"some/path"};
string line;
while(getline(fstream, line))
    lines.push_back(line);

, который можно было бы сделать таким:

while(string line; getline(fstream, line))
    lines.push_back(line);

На мой взгляд, это было бы вполне в духе рассматриваемого нововведения — чем пример с while хуже? Но нет, while, видимо, не так хорош, как if и switch. Не удивлюсь, что в C++20 они добавят то же самое расширение для while, это ведь так в духе комитета — выпускать недоработанный функционал. Кстати, взгляните на аналогичное предложение для C#, где while идёт первым же примером.

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

std::map<int, std::string> m;
std::mutex mx;
extern bool shared_flag; // guarded by mx

int demo()
{
    if (auto it = m.find(10); it != m.end()) { return it->size(); }
    
    if (char buf[10]; std::fgets(buf, 10, stdin)) { m[0] += buf; }
    
    if (std::lock_guard<std::mutex> lock(mx); shared_flag) { unsafe_ping(); shared_flag = false; }
    
    if (int s; int count = ReadBytesWithSignal(&s)) { publish(count); raise(s); }
    
    if (auto keywords = {"if", "for", "while"};
        std::any_of(keywords.begin(), keywords.end(), [&s](const char* kw) { return s == kw; })) {
        ERROR("Token must not be a keyword");
    }
}

Вас мотивирует подобный пример? Лично меня больше всего «мотивирует» последний if— это просто шедевр. Я бы за такое публичную порку вводил.

Новые атрибуты

После появление атрибутов в C++11, стандарт медленно но верно пополняется новыми экземплярами. Правда, на мой взгляд, только в этом стандарте появляются действительно стоящие атрибуты. Всего в C++17 добавлено три новых атрибута: [[maybe_unused]], [[nodiscard]] и [[fallthrough]]. Но прежде чем мы перейдём к рассмотрению каждого из них, хочется кратко упомянуть другие нововведения, которые коснулись атрибутов.

  • Атрибуты теперь можно применять к пространствам имён и членам перечислений. Подробнее в N4266.

  • Теперь можно использовать using в атрибутах, чтобы избавиться от повторения пространства имён для каждого атрибута в списке. Подробнее в P0028R4.

  • Неизвестные атрибуты больше не дают ошибку компиляции, а просто игнорируются (отличная новость для нестандартных атрибутов!). Подробнее в P0283R2.

Теперь к нашим баранам:

[[fallthrough]]

Это довольно простенький атрибут, который применяется в паре с оператором ветвления switch и служит для указания компилятору (или любой другой заинтересованной сущности), что код действительно должен «проваливаться» через нижеследующий case. Для лучшего понимания, давайте рассмотрим такой код:

ShapeType type{ShapeType::Triangle};
switch(type)
{
    case ShapeType::Square:
        processSquare();
    case ShapeType::Rectangle:
        processRectangle();
        break;
    case ShapeType::Triangle:
        processTriangle();
        break;
    case ShapeType::Circle:
        processCircle();
        break;
};

Т.к. квадрат является частным случаем прямоугольника, мы как для квадрата, так и для прямоугольника выполняем общий код, который находится в processRectangle. Но для квадрата нужна дополнительная обработка, поэтому для него мы выполняем ещё и processSquare. Чтобы достигнуть такого результата используя switch, мы просто не ставим break по окончании работы кода для квадрата, а «проваливаемся» в код для прямоугольника.

Хотя мой пример явно высосан из пальца, такая техника применяется повсеместно (просто я switch не использую, поэтому и нормальный пример в голову не приходит). Но помимо повсеместного применения подобной техники, в коде не менее часто встречаются ошибки, когда программист банально забыл поставить break и у него не было намерений, чтобы процесс исполнения «проваливался» через нижеследующие case. Поэтому различные анализаторы, а также некоторые компиляторы, обязательно выводят предупреждение, когда встречают подобный код. В связи с этим от языка требуется возможность указания, что программист намеренно написал такой код, и теперь такая возможность появилась.

Чтобы указать, что мы намеренно пропустили break можно использовать новый атрибут [[fallthrough]]:

ShapeType type{ShapeType::Triangle};
switch(type)
{
    case ShapeType::Square:
        processSquare();
        [[fallthrough]];
    case ShapeType::Rectangle:
        processRectangle();
        break;
    case ShapeType::Triangle:
        processTriangle();
        break;
    case ShapeType::Circle:
        processCircle();
        break;
};

Этот атрибут ставится перед каждым case, в который мы намерены «провалиться». Интересно заметить, что в отличии от других атрибутов, этот ни к чему не прикрепляется. Это обособленное и самодостаточное выражение, которое оканчивается «;». Т.е. это не аннотация нижеследующего case, а просто атрибут-выражение, которое, тем не менее, должно находится именно перед case (или default); вот такая вот особенность. Служит же данный атрибут одной лишь цели: показать анализирующей сущности, что генерация предупреждения здесь не нужна.

[[nodiscard]]

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

Не так давно Андрей Александреску, в своём великолепной докладе «Systematic Error Handling in C++», предложил взять сильные стороны обоих подходов и использовать их совместно. Скрестив исключения с кодами возврата, Александреску получил Expected, который с тех пор не выходит у многих людей из головы (что привело к появлению предложения по std::expected). Это весьма интересная тема, которая меня действительно волнует, но я не буду подробно здесь на этом останавливаться; мы рассмотрим её как-нибудь в другой раз, в отдельной статье. Здесь нам важно только одно: если из возвращённого объекта Expected не было извлечено значение, тогда он должен просигнализировать окружающей среде об этом. Как это можно было сделать? Либо добавить assert в деструкторе Expected, но тогда мы бы смогли узнать о нашей ошибке только в отладочной версии, либо кидать исключение из деструктора, что в общем-то тоже не самая лучшая идея.

Исходя из вышесказанного получается, что с Expected так и остаётся нерешённой вышеупомянутая проблема — слишком легко проигнорировать случайно. Но с появлением C++17, в котором появляется атрибут [[nodiscard]], эта ситуация меняется. Помечать этим атрибутом можно либо функцию, либо тип:

class [[nodiscard]] Expected
{
    //...
};

[[nodiscard]] int importantFunction()
{
    return 24;
}

Expected expectedFunction()
{
    return {};
}

int main()
{
    importantFunction();
    expectedFunction();
};

На оба вызова компилятор должен выдать предупреждение, что возвращённый из функции объект проигнорирован. Как вы можете видеть, если данным атрибутом пометить тип, тогда объект такого типа возвращённый из любой функции не должен быть проигнорирован. Если же атрибутом [[nodiscard]] мы пометим функцию, то возвращённый объект именно этой функции не должен быть проигнорирован — всё просто. Этот инструмент давно был нужен в C++, правда, и у него есть кое-какие ограничения. Давайте рассмотрим такой простой пример:

#include <optional>
#include <memory>

using namespace std;

class IStreamReader
{
public:
    [[nodiscard]] virtual optional<char> read() = 0;
};

class FakeStreamReader: public IStreamReader
{
public:
    optional<char> read() override
    {
        return '\0';
    }
};

int main()
{
    unique_ptr<IStreamReader> smartStream = make_unique<FakeStreamReader>();
    FakeStreamReader fakeStream;
    // #1. Есть предупреждение
    smartStream->read();
    // #2. Нет предупреждения
    fakeStream.read();
};

Во втором случае мы не получим предупреждения потому, что атрибуты в C++ (по крайней мере те, что есть на данный момент) не наследуются. И это, на мой взгляд, является ошибкой дизайна атрибутов в целом, а не просто недостатком конкретного атрибута [[nodiscard]] — у атрибутов должна быть возможность наследоваться.

В целом же данный атрибут является довольно полезным и надеюсь, что он будет активно применяться как в паре с Expected (как бы он не назывался), так и без него. За исключения вы или за коды возврата, но если функция возвращает что-то, что проигнорировано быть не должно, значит она должна быть помечена этим атрибутом.

[[maybe_unused]]

Как показывает практика, введение имени, которое нигде не используется, является довольно распространённым явлением. И лучшим доказательством этих слов является наличие таких конструкций как: __attribute__((unused)) в gcc, Q_UNUSED в Qt, ignore_unused в boost и т.д. Но зачем вообще вводить именованный объект, который нигде не используется? Давайте рассмотрим пару примеров.

Пример первый: предположим, что у нас есть некоторый класс реализующий функционал подробного протоколирования вызова функций, т.е. мы создаём этот объект на старте функции (идёт запись, что функция началась), а по завершении оной (в деструкторе) идёт запись о том, что функция завершилась. Это могло бы выглядеть так:

void processingFunction()
{
    TraceLogger logger{__FUNCTION__};
    //...
}

Имея такой код, компилятор скорее всего даст предупреждение, что объект logger нигде не используется. Если вы сознательный программист, то проект у вас собирается с ключом, который превращает предупреждения в ошибки, и на выходе вы имеете ошибку сборки. Раньше мы бы поступили как-то так (без сторонних средств):

TraceLogger logger{__FUNCTION__};
(void)logger;

И это бы успокоило компилятор, но согласитесь, выглядиn это не лучшим образом. Но тут к нам на помощь приходит новый атрибут:

[[maybe_unused]] TraceLogger logger{__FUNCTION__};

И компилятор больше не даёт предупреждения!

Другой пример: допустим у нас есть какая-то функция, которая принимает пару аргументов, но один из аргументов явно не используется в текущей версии программы, а служит лишь ограничителем, который мы используем только в отладочной версии:

void processingFunction(int important, int threshold)
{
    assert(important < threshold);
    // Используем important
    //...
}

Тут вырисовывается та же проблема: в отладочной версии нет предупреждений, но, собирая финальную версию, вы получите предупреждение (используя хороший компилятор). Проблема та же, то же и решение:

void processingFunction(int important, [[maybe_unused]] int threshold)

Данный атрибут используется исключительно в контексте декларации, но не ограничен двумя вышеприведёнными случаями. Он также может быть применён к декларации: класса, функции, перечисления (включая отдельные члены перечисления), а также к псевдонимам, декларируемым с использованием using/typedef.

Атрибут довольно простой и по его функционалу мне добавить больше нечего, зато есть что покритиковать, к чему и приступаю. Начну, пожалуй, с придирки. Мне совершенно не нравится его имя: maybe_unused не является чем-то, что я бы хотел видеть в коде — слишком длинно. Изначально его предлагали назвать unused, но видимо это с чем-то пересекалось и имя изменили. На мой взгляд, результат получился такой себе.

Дальше, в отличии от существующих решений, которые я привёл в начале этого раздела, [[maybe_unused]] может быть применён к декларациям, но ими только и ограничен. Получается, что данное решение никак не вытесняет существующие, а затрагивает лишь часть проблемы. Давайте рассмотрим банальный пример: есть такой фреймворк для написания тестов GTest, частью которого является GMock, в котором есть такие сущности как matchers (сопоставители). Суть их тут не важна, важно то, как создаются свои сопоставители; для их создания используется специальный макрос:

MATCHER(SomeMatcher, "")
{
    return true;
}

В недрах этого макроса определён объект с именем result_listener, и если мы не используем его в нашей реализации, то компилятор выдаст предупреждение. Знакомая проблема? Да! Можем мы её решить с помощью [[maybe_unused]]? К сожалению, нет. Ведь как мы помним, этот атрибут может быть применён только к объявлению, к которому у нас доступа нет. Эту проблему могут решить те, кто ответственны за разработку GMock, но станут ли они? А сколько по миру гуляет подобных макросов? Не счесть. И это, на мой взгляд, огромное упущение — [[maybe_unused]] должен покрывать такие случаи.

Давайте рассмотрим и другой пример, основанный на коде из раздела по [[nodiscard]]. Предположим, что у нас есть такой интерфейс:

class IStreamReader
{
public:
    [[nodiscard]] virtual optional<char> read() = 0;
    [[nodiscard]] virtual optional<char> peek() = 0;
};

Который мы используем для просмотра следующего символа в потоке (peek), с последующим извлечением оного, если он нам нужен (read). Тут всё вроде бы нормально, но иногда появляется такая ситуация, что нам нужно пропустить следующий символ в потоке. Т.е. не считать и использовать, а считать и выбросить. По уму, в интерфейсе неплохо бы иметь метод skip или ignore, но у нас нет такого, поэтому мы используем такой код: streamReader->read();. Проблема в том, что наш readпомечен атрибутом [[nodiscard]], а это значит, что мы получим предупреждение от компилятора на такой код. Логично было бы предположить, что [[maybe_unused]] мог бы тут нам помочь: [[maybe_unused]] streamReader->read();. Но, как мы уже выяснили ранее,— не поможет. Поэтому делаем по старинке: (void)streamReader->read();.

Всё это, лично для меня, делает данный атрибут, в сущности, бесполезным, потому что примеры, в которых [[maybe_unused]] работает, встречаются мне куда реже тех, в которых он не работает.

Выделение памяти с выравниванием

В C++11 добавили функционал, позволяющий изменять требования к выравниванию типа, но, как водится в комитете по стандартизации C++, забыли добавить это во все релевантные части языка. Напомню, что начиная с C++11 мы могли указать выравнивание для типа:

class alignas(32) SomeClass
{
    char value;
};

или для объекта в стеке:

alignas(64) SomeClass a;

Поэтому логично предположить, что следующий код должен всегда выводить true:

#include <memory>
#include <iostream>

using namespace std;

class alignas(32) SomeClass
{
    char value;
};

int main()
{
    auto some = make_unique<SomeClass>();
    bool isProperlyAligned = reinterpret_cast<size_t>(some.get()) % 32 == 0;
    cout << boolalpha << isProperlyAligned << "\n";
};

Но это заблуждение: до C++17 никакой гарантии у нас не было, потому что компилятор не обязан выравнивать выделенные в куче объекты, согласно указанному нами выравниванию! Более того, оператор new не имел перегрузки, в которую бы эта информация передавалась, поэтому если ваш компилятор всё-таки стабильно выводит true (MSVC и gcc это делают), то делает он это неким «магическим» способом, а не согласно букве стандарта. Теперь же, после принятия предложения P0035R4, все C++17-компиляторы обязаны будут вывести true.

Пару слов про обязанности компилятора. Строго говоря, всё, что мы указываем в alignas компилятор имеет право проигнорировать. Поэтому утверждать, что он что-то обязан, когда мы говорим об этом спецификаторе, неверно. Тем не менее, в данном тексте я исхожу из того, что компилятор подчиняется нашему запросу.

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

Итог

Когда я начинал писать серию статей, посвящённую C++17, я к этому стандарту относился довольно скептически: слишком мало интересных изменений, как мне показалось. Тем не менее, на описание самых значимых (по моему мнению) вещей у меня ушло 5 полновесных статей. Что довольно неплохо. И это я ещё не затрагивал стандартную библиотеку, в которой изменений тоже порядочно (я обязательно рассмотрю все значимые дополнения в библиотеке в другом цикле). Исходя из этого, хочется сказать, что C++17 получился довольно неплохим и, как мне кажется, язык двигается в правильном направлении. Конечно, все мы ждали концепций и модулей, поэтому и были расстроены, что стандарт вышел таким куцым. Но на деле получается, что это только завышенные ожидания не давали нам (мне?) принять тот факт, что стандарт в целом вышел неплохим.

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

Безусловно, итеративный процесс разработки проще и используется сейчас повсеместно, но одним из краеугольных камней итеративной разработки является тестирование. В частности модульное тестирование (unit testing). И именно этого, как мне кажется, не хватает комитету: слишком много вполне законных методов применения остаётся за бортом принятого функционала (отличный примером тому является последний раздел данной статьи), а такие вещи обычно и выявляются на этапе тестирования. Поэтому хотелось бы пожелать комитету больше терпения и вдумчивости по отношению ко всему вводимому в язык функционалу, а не только к тому, что вызывает основной ажиотаж (читай модули, концепции, рефлексия и т.д.).


Остальные статьи цикла:

Часть 1. Свёртка и выведение

Часть 2. Constexpr и привязки

Часть 3. Порядок и спокойствие