понедельник, 23 июля 2018 г.

[C++] Что такое warning C4291 и как с ним бороться

Самой яркой особенностью языка программирования C++ перед привычной мне Java является необходимость обеспечивать ручное управление памятью и на этом пути разработчика поджидает множество интересных особенностей. Например, если мы переопределяем оператор new, снабдив его нужными исключительно нам аргументами (так называемая class-specific placement allocation functions), то необходимо подобным же образом переопределить и оператор delete (имеется ввиду, что нужно определить оператор delete, имеющий сигнатуру void operator delete(void *ptr, user-defined-args...), иначе при возникновении исключительной ситуации в конструкторе класса будет не понятно какой оператор delete следует вызывать, чтобы освободить уже выделенную для создаваемого объекта данного класса память. Произойдет утечка памяти.

Предупреждение компилятора C4291


К счастью компилятор MSVC информирует разработчика об опасности, выбрасывая исключение C4291 no matching operator delete found; memory will not be freed if initialization throws an exception (код должен компилироваться с флагами компиляции /EHsc /W1). Например:

[1/2] Building CXX object src\memory\CMakeFiles\placement-new-delete.dir\PlacementNewDelete.cpp.obj
..\src\memory\PlacementNewDelete.cpp(67): warning C4291: 'void *MyClassA::operator new(size_t,MyAllocator &)': no matching operator delete found; memory will not be freed if initialization throws an exception
..\src\memory\PlacementNewDelete.cpp(32): note: see declaration of 'MyClassA::operator new'
[2/2] Linking CXX executable src\memory\placement-new-delete.exe

Подробно предупреждение компилятора C4291 описано в одноименной статье на MSDN.

Проблема заключается в том, что реализовать соответствующий оператор delete может быть не так то и просто. Как нетрудно заметить, оператор new в любом случае принимает аргумент size - размер требуемой памяти в байтах, оператор же delete такого параметра не принимает, а он может использоваться при выделении памяти и быть необходим, чтобы вернуть ее операционной системе.

Использование аллокатора для размещения объектов в памяти


Рассмотрим такой паттерн: использование для выделения памяти стороннего аллокатора, реализующего два метода: allocate и deallocate, каждый из которых принимает на вход параметр size_t size. Такой код довольно часто встречается в реализации JIT-компилятора в наборе компонентов для построения виртуальных машин Eclipse OMR, из чего я сделал вывод, что паттерн довольно распространен.



Использоваться аллокатор может следующим образом:



Второй оператор delete необходим для уничтожения объекта с помощью стандартной конструкции delete ptr;. В данном коде проблем нет: какое-то количество байт аллоцируется в операторе new класса MyClassA, а при возникновении исключительной ситуации в его конструкторе - срабатывает оператор delete, который возвращает память. Размер выделенной памяти совпадает с результатом выражения sizeof(MyClassA), в чем можно убедиться, взглянув на вывод программы:

MyClassA::operator new(size_t size, MyAllocator& allocator)
size: 24 sizeof(MyClassA): 24
MyClassA::operator delete(void *ptr, MyAllocator& allocator)
sizeof(MyClassA): 24

Однако что делать, если у класса MyClassA есть наследники? Предположим, что у нас есть класс MyClassB, наследуемый от MyClassA и мы, как разработчики приложения на ООП языке, хотим воспользоваться всеми примуществами полиморфизма: использовать объекты дочернего класса через ссылку (хм, точнее - указатель) на объект базового класса:



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

MyClassA::operator new(size_t size, MyAllocator& allocator)
size: 32 sizeof(MyClassA): 24
MyClassA::operator delete(void *ptr, MyAllocator& allocator)
sizeof(MyClassA): 24

Что-то здесь не так: объект класса MyClassB занимает 32 байта (именно такое значение передается в качестве значения первого параметра в оператор new), а выражение sizeof(MyClassA) возвращает меньшее значение - 24. Если оставить все как есть, то в программе будет утечка памяти: в операторе delete будет освобождаться меньше памяти, нежели выделено под объект.

Переопределение оператора delete в наследниках класса


Самым первым на ум приходит следующий вариант решения: переопределить оператор delete в классе MyClassB таким образом, чтобы использовалась конструкция

allocator.deallocate(ptr, sizeof(MyClassB));

Данное решение довольно простое, но требует, переопределения операторов operator delete(void *ptr, MyAllocator& allocator) и operator delete(void *ptr, size_t size) в каждом наследнике класса MyClassA, а таких наследников может быть довольно много (в Eclipse OMR, например, в каталоге compiler/optimizer содержится 33 наследника класса TR::Optimizer, что и понятно: современные компиляторы реализуют богатый набор алгоритмов оптимизации). В каждый наследник по два оператора, отличающихся только параметром sizeof(MyClassXXX) - отличное поле для копипасты, а значит и возможности забыть в один прекрасный момент поменять MyClassXXX на MyClassYYY, а то и вообще забыть переопределить злополучные операторы, что вполне возможно при вовлечении в команду одного-двух новых разработчиков. Лучше конечно как-то сохранить размер выделяемой памяти...

Сохранение размера объекта в блоке выделенной памяти


Из-за того, что операторы new и delete на самом деле статические, хоть их декларация и разрешает опускать ключевое слово static, в них нельзя обращаться к полям объекта (что вполне логично: на этапе вызова оператора new никакого объекта еще нет). Возникает вопрос: куда же записать размер объекта в операторе new, чтобы затем извлечь его оттуда в операторе delete? Можно поступить следующим образом: при выполнении оператора new выделить чуть больше памяти, так чтобы ее хватило еще и на запись размера объекта, записать в начало блока размер объекта и вернуть указатель на ячейку памяти сразу за этим размером:

0xAA...00 - размер объекта (8 байт на 64-х битной системе), 0xAA...08 возвращаемый указатель, данные объекта

В операторе delete необходимо поступить наоборот: в качестве значения параметра ptr будет передан указатель на начало данных объекта. Из данного указателя необходимо вычесть место, выделенное под хранение размера объекта (size_of(size_t)), по данному адресу будет находиться размер объекта и именно данный адрес необходимо передать в метод освобождения памяти. Размер освобождаемой памяти необходимо увеличить на объем памяти, необходимый для хранения размера объекта.

Переопределенные в классе MyClassB операторы delete можно невозбранно удалить.

Получится вот такой код (я добавил метод getI() для демонстрации правильности размещения объекта в памяти, для его срабатывания необходимо закомментировать строчку throw "Fail"; в конструкторе класса MyClassA).



Результат работы программы при возникновении исключения в коде конструктора класса MyClassA:

MyClassA::operator new(size_t size, MyAllocator& allocator)
size: 32 allocated: 40
MyClassA::operator delete(void *ptr, MyAllocator& allocator)
size: 32 allocated: 40

Напомню, что размер объекта класса MyClassB (sizeof(MyClassB)) равен 32 байтам (см. вывод программы в предыдущем параграфе), мы аллоцируем 40 байт, т.к. восемь из них нужны для хранения размера объекта.

А в OMR нужно будет открыть Pull Reques конечно же...

Понравилось сообщение - подпишитесь на блог

Комментариев нет:

Отправить комментарий

Любой Ваш комментарий важен для меня, однако, помните, что действует предмодерация. Давайте уважать друг друга!