Малоосвещённые особенности C++

Источник: Obscure C++ Features

На github: Перейти

Что на самом деле означают квадратные скобки

Доступ к элементу массива посредством выражения ptr[3] просто сокращённая форма написания *(ptr+3), которое с учётом арифметики указателей может быть записано как *(3+ptr). И, следовательно, выражение ptr[3] может быть записано как 3[ptr], что оказывается полностью валидным кодом.

Наиболее раздражающий синтаксический анализ (Most vexing parse)

Термин «Most vexing parse» придумал Скот Мейерс для неоднозначности, возникающей в синтаксисе декларации C++, приводящей к противоречивому поведению:

// Это:
// 1) Переменная типа std::string инициализируется значением std::string()?
// 2) Декларация функции, которая возвращает std::string и имеет один параметр,
//    являющийся указателем на функцию без параметров, и возвращающая std::string?

std::string foo(std::string());
// Это:
// 1) Переменная типа int инициализируется значением int(x)?
// 2) Декларация функции, которая возвращает int и имеет один аргумент,
//    являющийся int с именем x?

int bar(int(x));

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

// Круглые скобки разрешают неоднозначность

std::string foo((std::string()));

int bar((int(x)));

Причина неоднозначности во втором случае в том, что int y = 3; эквивалентно int(y) = 3;.

Лексемы операторов

Лексемы and, and_eq, bitand, bitor, compl, not, not_eq, or, or_eq, xor, xor_eq, <%, %>, <:, и :> могут быть использованы вместо символов &&, &=, &, |, ~, !, !=, ||, |=, ^, ^=, {, }, [, и ], соответственно. Это позволяет набирать указанные операторы на клавиатурах, не имеющих необходимых символов.

Размещающий new

Размещающий new это альтернативный синтаксис для оператора new. Размещающий new выполняется в месте, которое уже выделено под объект, имеющее корректный размер и корректное выравнивание. Это включает в себя настройку vtable и вызов конструктора.

#include <iostream>
using namespace std;

struct Test
{
    int data;
    Test() { cout << "Test::Test()" << endl; }
    ~Test() { cout << "Test::~Test()" << endl; }
};

int main()
{
    // Необходимо предварительно выделить нашу собственную память
    Test *ptr = (Test *)malloc(sizeof(Test));

    // Используем размещающий new
    new (ptr) Test;


    // Необходимо самостоятельно вызвать деструктор для размещённого объекта
    ptr->~Test();

    // Необходимо самостоятельно освободить память
    free(ptr);

    return 0;
}

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

Ответвление с использованием декларации переменной

C++ включает синтаксическое сокращение для одновременного объявления переменной и разветвления по её значению. Это выглядит как декларация переменной внутри условия оператора if или while, и одновременное присваивание ей значения.

struct Event                 { virtual ~Event() {} };
struct MouseEvent    : Event { int x, y; };
struct KeyboardEvent : Event { int key; };

void log(Event* event)
{
    if (MouseEvent* mouse = dynamic_cast<MouseEvent*>(event))
    {
        std::cout << "MouseEvent: " << mouse->x << ", " << mouse->y << std::endl;
    }
    else if (KeyboardEvent* keyboard = dynamic_cast<KeyboardEvent*>(event))
    {
        std::cout << "KeyboardEvent: " << keyboard->key << std::endl;
    }
    else
    {
        std::cout << "Event" << std::endl;
    }
}

Квалификаторы ссылки в методах

C++11 позволяет перегружать методы с использованием квалификатора ссылки, который находится в той же позиции, что и cv-квалификаторы (const и volatile квалификаторы). Это влияет на то, какой метод будет вызван для объекта, в зависимости от типа this, – является ли он lvalue или же rvalue:

#include <iostream>

struct Foo
{
    void bar() &  { std::cout << "lvalue" << std::endl; }
    void bar() && { std::cout << "rvalue" << std::endl; }
};

int main()
{
    Foo foo;
    foo.bar();     // foo - lvalue объект (напечатает "lvalue")
    Foo().bar();   // Анонимный объект - rvalue объект (напечатает "rvalue")
    return 0;
}

Полное по Тьюрингу шаблонное метапрограммирование

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

// Рекурсивный шаблон для общего случая:
template<int N>
struct factorial
{
    enum { value = N * factorial<N-1>::value };
};

// Специализация шаблона для базового случая:
template<>
struct factorial<0>
{
    enum { value = 1 };
};

enum { result = factorial<5>::value };  // 5 * 4 * 3 * 2 * 1 == 120

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

Можно создать переменную которая будет отвечать за тип, с помощью typedef, и переменную, которая будет отвечать за int с помощью enum. Получится структура данных встроенна в класс самой себя:

// Структура данных "список" целых чисел, времени компиляции:
template<int D, typename N>
struct node
{
    typedef N next;
    enum { data = D };
};

struct end {};

// Функция суммирования, времени компиляции:
template<typename L>
struct sum
{
    enum { value = L::data + sum<typename L::next>::value };
};

template<>
struct sum<end>
{
    enum { value = 0 };
};
                                        
// Структура данных встроена в тип:
typedef node<1, node<2, node<3, end>>> list123;
enum { total = sum<list123>::value };   // total == (1 + 2 + 3) == 6

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

Операторы «указатель на член» (Pointer-to-member operators)

Операторы «указатель на член» позволяют описать указатель на определённый член в любом экземпляре класса. Есть два оператора «указатель на член», это .* для значений, и ->* для указателей:

struct Test
{
    int num;
    void func() {}
};

// Обратите внимание на дополнительные "Test::" в типах указателей
int Test::*ptr_num = &Test::num;
void (Test::*ptr_func)() = &Test::func;

int main()
{
    Test t;
    Test *pt = new Test;

    // Вызовы члена функции класса:
    (t.*ptr_func)();
    (pt->*ptr_func)();

    // Присвоиение значений переменной являющейся членом данное класса
    t.*ptr_num = 1;
    pt->*ptr_num = 2;

    delete pt;
    return 0;
}

Эта возможность очень полезна, в частности для написания библиотек. К примеру, Boost::Python (библиотека для скрещивания C++ с объектами Python) использует указатели на члены для того, чтобы можно было обращаться к членам при упаковке объектов:

#include <iostream>
#include <boost/python.hpp>
using namespace boost::python;

struct World
{
    std::string msg;
    void greet() { std::cout << msg << std::endl; }
};

BOOST_PYTHON_MODULE(hello)
{
    class_<World>("World")
        .def_readwrite("msg", &World::msg)
        .def("greet", &World::greet);
}

При использовании указателя на член функцию, имейте в виду, что они отличаются от обычных указателей на функции. Преобразование типов между указателем на член функцию и указателем на обычную функцию не работает. Например, член функции в компиляторе Microsoft используют оптимизацию «соглашение о вызовах» называемую thiscall, которая кладёт параметр this в регистр ecx, в то время как обычная функция использует «соглашение о вызовах», которое передаёт все аргументы в стек.

Также, указатели на член функцию могут быть в четыре раза больше чем обычные указатели. Компилятору может понадобиться хранить адрес тела функции, смещение к правильному основанию (при множественном наследовании), индекс ещё одного смещения в vtable (при виртуальном наследовании), и, может быть, даже смещение vtable внутри самого объекта (для предварительного объявления классов).

#include <iostream>

struct A {};
struct B : virtual A {};
struct C {};
struct D : A, C {};
struct E;

int main()
{
    std::cout << sizeof(void (A::*)()) << std::endl;
    std::cout << sizeof(void (B::*)()) << std::endl;
    std::cout << sizeof(void (D::*)()) << std::endl;
    std::cout << sizeof(void (E::*)()) << std::endl;
    return 0;
}

// 32-bit MinGW GCC 5.3.0:  A = 16, B = 16, C = 16, E = 16
// 32-bit Visual C++ 2008:  A = 4,  B = 12, D = 8,  E = 16
// 32-bit Digital Mars C++: A = 4,  B = 4,  D = 4,  E = 4

Все указатели на члены функции в компиляторе Digital Mars одинакового размера, в связи с продуманной конструкцией, генерируемой «преобразователь» функций для применения правильных смещений вместо их хранения вместе с указателем.

Static methods on instances

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

struct Foo 
{
    static void foo() {}
};

// Эквивалентные вызовы
Foo::foo();                // только для статической функции
Foo().foo();               // для статической и функции экземпляра

Перегрузка ++ и --

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

struct Number 
{
    Number &operator++();       // Сгенерировать префиксный оператор ++
    Number operator++(int);     // Сгенерировать постфиксный оператор ++
};

Перегрузка операторов и порядок вычисления

Перегрузка операторов , (запятая), || (логические ИЛИ), и && (логическое И) немного сбивает с толку, потому что это разрушает нормальные правила вычисления. Обычно, оператор «запятая» гарантирует, что вся левая сторона будет вычислена до того, как начнёт вычисляться правая сторона. И оператор ||, и оператор && имеют упрощённое поведение, когда правая сторона вычисляется, только если это необходимо. Однако, перегруженные версии этих операторов это всего лишь вызовы функций, а вызовы функций вычисляются в не специфицированном порядке.

Перегрузка этих операторов это путь к некорректному использованию синтаксиса C++. Например, дана C++ реализация оператора печати в стиле Python 2, который не нуждается в круглых скобках:

#include <iostream>

namespace __hidden__
{
    struct print
    {
        bool space;
        print() : space(false) {}
        ~print() { std::cout << std::endl; }

        template<typename T>
        print &operator,(const T &t) 
        {
            if (space) std::cout << ' ';
            else space = true;
            std::cout << t;
            return *this;
        }
    };
}

#define print __hidden__::print(),

int main()
{
    int a = 1, b = 2;
    print "this is a test";
    print "the sum of", a, "and", b, "is", a + b;
    return 0;
}

Функции в качестве параметра шаблона

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

#include <map>

template<int(*f)(int)>
int memoize(int x)
{
    static std::map<int, int> cache;
    std::map<int, int>::iterator y = cache.find(x);
    if (y != cache.end()) return y->second;
    return cache[x] = f(x);
}

int fib(int n)
{
    if (n < 2) return n;
    return memoize<fib>(n-1) + memoize<fib>(n-2);
}

 

Шаблонный параметр типа

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

template<typename T>
struct Cache { ... };

template<typename T>
struct NetworkStore { ... };

template<typename T>
struct MemoryStore { ... };

template<typename Store, typename T>
struct CachedStore
{
    Store store;
    Cache<T> cache;
};

CachedStore<NetworkStore<int>, int> a;
CachedStore<MemoryStore<int>, int> b;

Структура CachedStore содержит cache и store которые хранят в себе данные одинакового типа. Однако при инстанцировании CachedStore необходимо дважды указывать один и тот же тип данных (int в коде выше), один раз для store и другой раз для CachedStore, и это не гарантирует, что типы данных будут согласованы. Хотелось бы указать тип данных единожды, чтобы можно было избежать рассогласования типов, но если опустить параметр типа для store, то компилятор выдаст ошибку:

// Этот код не компилируется, потому что у NetworkStore и MemoryStore
// отсутствуют параметры типа
CachedStore<NetworkStore, int> c;
CachedStore<MemoryStore, int> d;

Шаблонный параметр типа позволяет справиться с проблемой.

Примечание: необходимо использовать ключевое слово class для параметров типа, которые имеют свои параметры типа.

template<template<typename> class Store, typename T>
struct CachedStore2
{
    Store<T> store;
    Cache<T> cache;
};

CachedStore2<NetworkStore, int> e;
CachedStore2<MemoryStore, int> f;

Функциональные блоки try

Функциональные блоки try существуют для ловли исключений выброшенных при выполнении инициализационного списка конструктора. Блок вокруг списка инициализации нельзя обернуть обычным блоком try-catch, потому что список существует вне тела функции. Чтобы исправить это, C++ позволяет использовать try-catch блок в качестве тела метода:

int f() { throw 0; }

// Здесь нет способа поймать исключение выброшенное f()
struct A
{
    int a;
    A::A() : a(f()) {}
};

// Значение выброшенное из f() может быть поймано, если try-catch блок использует
// тело функции и список инициализации помещается после ключевого слова try
struct B
{
    int b;
    B::B() try : b(f())
    {}
    catch(int e)
    {}
};

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

Written on August 22, 2016