Малоосвещённые особенности 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)
{}
};
Довольно странный синтаксис, и он может использоваться не только в конструкторах, но и в определении любых функций.