При работе над разными числодробилками, анализирующими последовательности, применяющими фильтры к картинкам, cчитающим свертки и проч, периодически возникает необходимость преобразовать массив целых чисел в массив чисел с плавающей точкой и наоборот.
На C++ это может выглядеть так
std::vector<float> to_floats(std::vector<int> input) {
std::vector<float> result(input.size());
std::transform(begin(input), end(input), begin(result), [](int x) -> float { return x; });
return result;
}
На Rust
fn to_floats(input: Vec<i32>) -> Vec<f32> {
input.into_iter().map(|x| x as f32).collect()
}
Компилируем плюсовый вариант с помощью clang или gcc и включенными оптимизациями. И видим посередине
mov qword ptr [rsp + 16], rax # 8-byte Spill
mov rbx, r15
sar rbx, 2
mov rdi, r15
mov qword ptr [rsp + 8], rcx # 8-byte Spill
call operator new(unsigned long)@PLT
mov rsi, qword ptr [rsp + 8] # 8-byte Reload
mov r14, rax
Выделяется память под новый вектор. Как и ожидалось.
Кстати, а call operator delete
для старого вектора нету. Это не ошибка кодогенерации. Вектор был передан by value, конструктор и деструктор такого параметра исполняются в контексте стек фрейма вызывающей стороны.
Для локальной переменной result
также не вызывается delete -- благодаря NRVO (named return value optimization), result
помещается сразу в стек фрейм вызывающей стороны, там же будет и деструктор с delete.
Скомпилируем Rust вариант
example::to_floats:
mov rax, rdi
mov rcx, qword ptr [rsi]
mov rdx, qword ptr [rsi + 8]
mov rsi, qword ptr [rsi + 16]
test rsi, rsi
je .LBB0_7
cmp rsi, 8
jae .LBB0_3
xor edi, edi
jmp .LBB0_6
.LBB0_3:
mov rdi, rsi
and rdi, -8
xor r8d, r8d
.LBB0_4:
movups xmm0, xmmword ptr [rcx + 4*r8]
movups xmm1, xmmword ptr [rcx + 4*r8 + 16]
cvtdq2ps xmm0, xmm0
cvtdq2ps xmm1, xmm1
movups xmmword ptr [rcx + 4*r8], xmm0
movups xmmword ptr [rcx + 4*r8 + 16], xmm1
add r8, 8
cmp rdi, r8
jne .LBB0_4
cmp rsi, rdi
je .LBB0_7
.LBB0_6:
xorps xmm0, xmm0
cvtsi2ss xmm0, dword ptr [rcx + 4*rdi]
movss dword ptr [rcx + 4*rdi], xmm0
lea r8, [rdi + 1]
mov rdi, r8
cmp rsi, r8
jne .LBB0_6
.LBB0_7:
mov qword ptr [rax], rcx
mov qword ptr [rax + 8], rdx
mov qword ptr [rax + 16], rsi
ret
Все поместилось в 40 строчек в отличие от С++... И тут нету аллокации памяти!
Ну действительно: sizeof(i32) == sizeof(f32)
и alignof(i32) == alignof(f32)
и аллокатор для входного и выходного вектора один и тот же. Владение вектором не разделено ни с кем. Ничего не мешет выполнить преобразование на месте.
Компиляторы C++, к сожалению, не могут себе позволить такую оптимизацию из-за объектной модели C++ с недеструктивным перемещением.
С++ язык достаточно низкоуровневый, так что если что-то не может произойти автоматически за счет оптимизаций компилятора, мы это что-то можем попытаться провернуть самостоятельно! Именно этим мы сейчас и займемся. Приготовьтесь увидеть самые чудовищные undefined и implementation defined способы!
Для начала немножко поменяем сигнатуру функции, чтоб
- Чуть точнее изложить наши намерения -- передать владение, а не сделать копию
- Немного сократить сгенерированный код
- Отстреливать себе ногу до тех пор пока мы не достигнем цели
Я также оставлю только Clang и переименую функцию: для достижения цели нам нужно научиться забирать владение буфером из вектора типа A и передавать его в вектор B. Как только мы это сделаем, дальнейшее преобразование -- это дело техники (да, как разработчики настоящих программ, а не абстрактного идеала описываемого стандартом, нам глубоко наплевать на strict aliasing):
void inplace_int_to_floats(void* data, size_t n) noexcept {
int* as_int = static_cast<int*>(data);
float* as_float = static_cast<float*>(data);
std::transform(as_int, as_int + n, as_float, [](int x) -> float { return x; });
}
Сосредоточимся на желаемой функции
template <class To, class From>
std::vector<To> transmute_move(std::vector<From>&& input) noexcept
requires (sizeof(To) == sizeof(From)) && (alignof(From) >= alignof(To))
&& std::is_trivially_destructible_v<To> // а на From тут не обязательно ограничение. Возможно создади утечку, но утечка это совершенно безопасно (с) Rust
&& (!std::is_same_v<To, bool>) && (!std::is_same_v<From, bool>) // потому что vector<bool> проклят
С ней вся наша конечная функция реализуется так
std::vector<float> to_floats(std::vector<int>&& input) noexcept {
std::vector<float> casted = transmute_move<float>(std::move(input));
inplace_int_to_floats(casted.data(), casted.size());
return casted;
}
Далее я буду опускать перечисление requires
ограничений
Что такое вектор? Да это ж три указателя! Резко врываемся, копируем, зануляем, уходим.
Проклятый vector<bool>
, устроенный совершенно по-другому, тут не рассматриваем
template <class To, class From>
std::vector<To> transmute_move(std::vector<From>&& input) noexcept
{
std::vector<To> result;
static_assert(sizeof(input) == sizeof(result));
std::memcpy(&result, &input, sizeof(input));
std::memset(&input, 0, sizeof(input)); // компиляторы любят удалять memset,
// когда считают что он не нужен/не влияет на обозреваемый эффект
// здесь memset не будет удален, поскольку его результат будет использоваться деструктором
// вектора input во внешнем стэк-фрейме
return result;
}
Результат выглядит не хуже чем у rustc
to_floats(std::vector<int, std::allocator<int> >&&): # @to_floats(std::vector<int, std::allocator<int> >&&)
mov rax, rdi
mov rcx, qword ptr [rsi + 16]
mov qword ptr [rdi + 16], rcx
movups xmm0, xmmword ptr [rsi]
movups xmmword ptr [rdi], xmm0
xorps xmm0, xmm0
movups xmmword ptr [rsi], xmm0
mov qword ptr [rsi + 16], 0
mov rcx, qword ptr [rdi]
mov rdi, qword ptr [rdi + 8]
mov rdx, rdi
sub rdx, rcx
sub rdi, rcx
je .LBB0_7
add rdi, -4
mov rsi, rcx
cmp rdi, 28
jb .LBB0_5
shr rdi, 2
inc rdi
mov r8, rdi
and r8, -8
lea rsi, [rcx + 4*r8]
xor r9d, r9d
.LBB0_3: # =>This Inner Loop Header: Depth=1
movups xmm0, xmmword ptr [rcx + 4*r9]
movups xmm1, xmmword ptr [rcx + 4*r9 + 16]
cvtdq2ps xmm0, xmm0
cvtdq2ps xmm1, xmm1
movups xmmword ptr [rcx + 4*r9], xmm0
movups xmmword ptr [rcx + 4*r9 + 16], xmm1
add r9, 8
cmp r8, r9
jne .LBB0_3
cmp rdi, r8
je .LBB0_7
.LBB0_5:
and rdx, -4
add rcx, rdx
.LBB0_6: # =>This Inner Loop Header: Depth=1
xorps xmm0, xmm0
cvtsi2ss xmm0, dword ptr [rsi]
movss dword ptr [rsi], xmm0
add rsi, 4
cmp rsi, rcx
jne .LBB0_6
.LBB0_7:
ret
На 9 строчек ассемблера больше сгенерировано. Из-за memset для деструктора и ссылки.
Способ простой, исполненный наплевательского отношения к приватным членам классов. И даже под санитайзером не падает. Но сломается, если вектор пользуется не std::allocator
.
Далее будем учитывать аллокатор, так что сигнатура transmute_move
слегка поменяется
template <class To, class From, class Alloc>
auto transmute_move(std::vector<From, Alloc>&& input) noexcept
{
using RebindAlloc = std::allocator_traits<Alloc>::template rebind_alloc<To>;
using RebindVector = std::vector<To, RebindAlloc>;
// это концептуальное требование к аллокаторам
// https://en.cppreference.com/w/cpp/named_req/Allocator
// если ваш аллокатор так не может -- у вас плохой аллокатор
static_assert(std::is_constructible_v<RebindAlloc, Alloc>);
RebindAlloc new_alloc { input.get_allocator() };
// Спасибо C++20: мы можем украсть данные и подставить нужный аллокатор
// нельзя просто взять и забрать старый аллокатор из input:
// например, он может заполнять свежевыделенную память специальным user-defined
// значением, зависящим от типа аллокатора и хранимым внутри его структуры.
// reinterpret_cast в этом случае приведет к получению неправильного значения
RebindVector result (
reinterpret_cast<RebindVector&&>(std::move(input)),
new_alloc
);
return result;
}
Тоже работает. Красиво, грубо, с нарушением всех норм приличия, с неопределенным поведением, полагающимся на реализацию move-конструктора вектора, но работает. Даже почти во всех случаях.
Сломается, если у Alloc
и RebindAlloc
разный layout: смещения полей data_, end_ и capacity_end_ указателей поедут и все красиво рванет.
Попробуем учесть и это.
Этот способ работает только с реализацией вектора из libstdc++ (gcc/clang default)
Упрощенно структура классов в ней следующая:
template <class T, class Alloc>
struct VectorBase : Alloc {
T* data_;
T* data_end_;
T* capacity_end_;
...
};
template<class T, class Alloc>
class vector: protected VectorBase<T, Alloc> {
....
}
Нам достаточно отнаследоваться от std::vector
, чтоб получить доступ ко всему.
template <class T, class Alloc>
struct ExposeVectorInternals : public std::vector<T, Alloc> {
using std::vector<T, Alloc>::vector;
template <class U, class A>
static std::vector<T, Alloc> transmute_impl(std::vector<U, A> &&from) noexcept {
// Честно и законно забираем данные из исходного вектора и перекладываем их в
// такой же, но другой
alignas (std::vector<U, A>) char buffer[sizeof(std::vector<U>)];
auto src_vec = new (buffer) std::vector<U, A>(std::move(from));
// но у этого другого деструктор вызываться автоматически не будет
auto src_data_ptr = reinterpret_cast<T*>(src_vec->data());
static_assert(std::is_constructible_v<Alloc, A>);
Alloc new_alloc { src_vec->get_allocator() };
// Создаем новый вектор, но наш, имеющий доступ к приватным полям
alignas (ExposeVectorInternals<T, Alloc>) char dst_buffer[sizeof(ExposeVectorInternals<T, Alloc>)];
auto dst = new (dst_buffer) ExposeVectorInternals<T, Alloc>( std::move(new_alloc) );
// у него также деструктор вызываться не будет
dst->_M_impl._M_start = src_data_ptr;
dst->_M_impl._M_finish = src_data_ptr + src_vec->size();
dst->_M_impl._M_end_of_storage = src_data_ptr + src_vec->capacity();
std::vector<T, Alloc> result;
dst->swap(result);
return result;
}
};
template <class To, class From, class Alloc>
auto transmute_move(std::vector<From, Alloc>&& input) noexcept
{
using RebindAlloc = std::allocator_traits<Alloc>::template rebind_alloc<To>;
return ExposeVectorInternals<To, RebindAlloc>::transmute_impl(std::move(input));
}
Работает! Не считая доступа к зарезервированным именам полей, совершенно честное решение. Но не идеальное...
- Полагается на отношения между size()/capacity() и внутренними указателями.
- Аллокатор при копировании себя может что-то аллоцировать и деаллоцировать в своем деструкторе. Например, это может быть какой-то уникальный строковый тэг. Тут создаются два промежуточных вектора, у которых не будет вызван деструктор -> возможны утечки
То же самое что и предыдущий способ, только без промежуточных буферов и с нарушением правил алиасинга. Но ничего страшного. Прокатит.
template <class T, class Alloc>
struct ExposeVectorInternals : public std::vector<T, Alloc> {
using std::vector<T, Alloc>::vector;
T*& expose_data() {
return this->_M_impl._M_start;
}
T*& expose_data_end() {
return this->_M_impl._M_finish;
}
T*& expose_capacity_end() {
return this->_M_impl._M_end_of_storage;
}
};
template <class To, class From, class Alloc>
auto transmute_move(std::vector<From, Alloc>&& input) noexcept
{
using RebindAlloc = std::allocator_traits<Alloc>::template rebind_alloc<To>;
static_assert(std::is_constructible_v<RebindAlloc, Alloc>);
RebindAlloc new_alloc { input.get_allocator() };
std::vector<To, RebindAlloc> result { std::move(new_alloc) };
using ExposeFrom = ExposeVectorInternals<From, Alloc>;
using ExposeTo = ExposeVectorInternals<To, RebindAlloc>;
// Наш наследничек не может получить доступ напрямую к полям другого вектора
// Но layout его и вектора одинаковый
// кастим и грабим
ExposeTo& to = reinterpret_cast<ExposeTo&>(result);
ExposeFrom& from = reinterpret_cast<ExposeFrom&>(input);
to.expose_data() = reinterpret_cast<To*>(std::exchange(from.expose_data(), nullptr));
to.expose_data_end() = reinterpret_cast<To*>(std::exchange(from.expose_data_end(), nullptr));
to.expose_capacity_end() = reinterpret_cast<To*>(std::exchange(from.expose_capacity_end(), nullptr));
return result;
}
Тоже работает. Красота!
К сожалению, не все реализации стандартной библиотеки такие. В libc++ VectorBase отнаследован приватно.
А что если я скажу вам, что в C++ есть способ совершенно легально ПРОИГНОРИРОВАТЬ модификаторы доступа к полям классов, не выполняя никаких страшных reinterpret_castов, С-style кастов и прочего ужаса.
Мы можем просто взять и напрямую получить доступ к интересующим нас трем полям вектора и сделать с ними все что угодно.
Как?!
С помощью явного инстанциирования шаблонов и member-pointers!
class A {
public:
int data;
private:
int secret;
};
В C++ помимо обычных указателей, есть еще указатели на поля/методы классов
int A::* ptr_data = &A::data; // Ok
int A::* ptr_secret = &A::secret; // не компилируется, ибо секрет приватный
A val {};
val.*ptr_data = 55; // доступ к полю есть
Если мы сможем откуда-нибудь достать указатель на приватное поле, мы получим к нему доступ.
Взять указатель на приватное поле класса в C++ вне этого самого класса в 99% нормальных случаев нельзя. Но есть один ненормальный случай: явное инстанциирование шаблонов
class A {
public:
int data;
private:
int secret;
};
template <auto ptr>
struct secret_pointer {
static constexpr auto secret = ptr;
};
// это компилируется -- явное инстанциирование шаблона
template struct secret_pointer<&A::secret>;
int main() {
// a вот тут нет
// using ptr = secret_pointer<&A::secret>;
}
Это совершенно правильное поведение. Класс A может в каком-нибудь из своих методов использовать этот шаблон с указателем на приватное поле. Почему бы и нет. Стандарт разрешает явно интсанциировать что угодно чтоб добавить определение тех или иных символов в вашу конкретную единицу трансляции. Все совершенно легитимно. Все равно ведь дальше инстанциирования ничего не пойдет, ведь мы же не может больше нигде к нему обратиться, да? Или нет?...
Ха! У нас же есть вычисления на этапе компиляции. И инстанциирование шаблонов их может запускать.
class A {
public:
int data;
void show() {
std::cout << secret << "\n";
}
private:
int secret;
};
// Объявим шаблон, куда
// путем зловещих манипуляций будем заталкивать указатель
// Этот шаблон не зависит от значения указателя (&A::secret), так что
// мы сможем обратиться к нему вне класса A.
// Главное заполнить значение
template <class StorageTag, class PointerType>
struct Secret {
static inline PointerType pointer = {};
};
// Добавляем шаблон, который будет знать значение указателя
// Этот шаблон будем явно инстанциировать
template <class StorageTag, auto PointerValue>
struct Expose {
// пишем в глобальную переменную от другого шаблона
// дело сделано
static const inline auto value = Secret<StorageTag, decltype(PointerValue)>::pointer = PointerValue;
};
class SecretTag; // объявляем тэг, по которому сможем записать и достать значение указателя
template struct Expose<SecretTag, &A::secret>;
int main() {
A val;
auto secret_pointer = Secret<SecretTag, int A::*>::pointer;
val.*secret_pointer = 42;
val.show();
}
Работает. Осталось дело техники. К сожалению, для явного инстанциирования шаблонов нужно явно подставлять все типы. Так что метод будет совсем не generic, в отличие от предыдущих.
А также под каждую реализацию вектора нужно будет добираться до приватных полей по-разному.
Написание кода c этим прекрасным способом чтоб сделать желаемый move для вектора из libc++ я оставляю тебе, мой читатель, в качестве домашнего задания. Ничего сложного:
- идем в исходники https://github.com/llvm-mirror/libcxx/blob/master/include/vector
- находим где живут три приватных поля
- вытаскиваем указатели на них описанным образом
- узнаем, что с приватным базовым классом становится чуть-чуть сложнее
- используем всемогущий C-каст, чтоб добраться до базового класса
- достаем поля
- вы великолепны