На днях я экспериментировал с языком С, и придумал одну интересную концепцию для удобства выделения памяти ( точнее перенял идею С++ но реализовал её средствами языка С ). Я про операторы new и delete, которые захотел повторить. В этой статье я расскажу о новом malloc, как я к этому пришёл, зачем это нужно, и как оно работает.
Зачем?
В С ( по моему личному мнению ) немного неудобно организована работа с памятью - где-то идёт работа с байтами, а где-то с ячейками. Простой пример:
*u++ = 1;
Сначала когда я только начинал изучать основы С, и не совсем был знаком с указателями и с арифметикой указателей, мне казалось что код выше корректно будет заполнять только массивы, ячейки которых занимают один байт. Потому что тогда я ещё не знал про то что когда мы выполняем арифметические операции над указателями (+,-) то работа ведётся не в байтах а в ячейках, т.е. u++ не означает сдвинуть указатель на один байт. Инкремент здесь говорит о том что полученный адрес сравняется с адресом следующей ячейки памяти после той на которую сейчас указывает u. Но почему же у меня тогда возникла ассоциация именно с байтами? Всё просто - sizeof. Оператор sizeof даёт размер элемента в байтах. malloc(), calloc(), realloc() принимают размеры в байтах. Так у новичка складывается ошибочное мнение что весь С построен на работе с байтами. Таким образом такой новичок долго будет думать почему его код по типу такого не работает:
int array[5] = {1, 2, 3, 4, 5};
int *u = array+sizeof(int);
printf("%d", *u); /* ожидание: 2. реальность: 5. */
Конечно сам С в этом не виноват - такая реализация передвижения по массиву выглядит довольно удобной ( если не смотреть на работу с байтами в других случаях ) и часто используется на практике. Так вот:
malloc(), calloc(), realloc() принимают размеры в байтах.
Решил я сделать что-то похожее на new в С++. Оператор принимает не число байт, а тип данных под который выделяется память:
int *a = new int; //выделилась память под одну ячейку типа int
int *b = new int[20]; //выделилась память под 20 ячеек типа int
Гораздо компактнее чем вызов malloc()
/calloc()
:
int *a = malloc(sizeof(int)); //выделилась память под одну ячейку типа int
int *b = calloc(20, sizeof(int)); //выделилась память под 20 ячеек типа int
В С++ мы избегаем вызова sizeof так как размер типа вычисляется компилятором автоматически.
Макросы с переменным количеством аргументов
Моя реализация оператора new для языка С использует макрос с переменным количеством аргументов. О нём я рассказывал в своей первой статье, и внёс её в список самых редко-встречаемых конструкций языка С. Эта статья получила много критики, но лишь небольшая часть из неё была конструктивной и действительно полезной. Спасибо всем кто пишет про недочёты/ошибки в моих статьях, если таковые были замечены.
В случае макроса с переменным количеством аргументов - его объявление схоже с объявлением функции с переменным количеством аргументов
#define vamacro(m, ...) /* макрос с переменным количеством параметров */
void vafunc(int m, ...); /* функция с перменным количеством параметров */
Чтобы получить значения аргументов в том виде, в котором они переданы в макрос в компиляторе gnu С complier (gcc) существует слово __VA_ARGS__. Оно заменяется препроцессором при сборке на аргументы, переданные в область переменного количества аргументов. Это пожалуй единственная для некоторых людей неочевидная вещь в моей реализации которая может показаться непонятной.
Реализация
#define new(t,...) calloc(#__VA_ARGS__[0]!='\0'?__VA_ARGS__:1,sizeof(t))
Простое однострочное решение. Объявляется макрос который принимает один аргумент t, за которым следует троеточие ( обозначает область переменного количества параметров ).
new в С++ принимает тип данных, и new в моей реализации тоже принимает тип данных первым аргументом. А пользоваться этим макросом можно так
int *a = new(int); //выделилась память под одну ячейку типа int
int *b = new(int,20); //выделилась память под 20 ячеек типа int
Единственное отличие от С++ заключается в том что скобки здесь необходимы чтобы сделать макровызов.
Разбор кода
Разбирать буду пошагово, объясняя каждую операцию которая может вызвать неуверенность.
Сразу после объявления имени можно заметить вызов calloc()
в тексте для подстановки. Поэтому чтобы использовать этот макрос необходимо предварительно подключить в программу файл <stdlib.h>
или <malloc.h>
calloc()
принимает количество элементов, и размер одного элемента в байтах. Размер вычислить просто - зная что в макрос первым будет всегда передаваться тип данных можно воспользоваться оператором sizeof
для получения его размера. Но вот гораздо больше вопросов здесь вызывает первый аргумент
#__VA_ARGS__[0]!='\0'?__VA_ARGS__:1
Как он работает? Когда макрос вызывается без второго аргумента то на уровне макроса это означает что calloc()
должен выделить память под один элемент. Но вот если во второй аргумент было передано число N, то calloc()
должен выделить память под N элементов. Выражением #__VA_ARGS__[0]!='\0'
вычисляется было ли передано число во второй параметр, или же он пуст.
Здесь применён значок #
. Он преобразует формальный параметр в фактический, и создаёт из него строковой литерал, т.е. оборачивает в двойные кавычки. Он всегда образует правильную строковую константу ("
заменяется на \"
, а \
на \\
). При переводе второго аргумента в строку если второй аргумент был пустой, то образуется пустая строка: "\0"
. Но в случае если аргумент не пустой (содержит число), то строка будет непустая. Это проверяется благодаря [0]!='\0'
, если первый символ строки это символ конца строки, то тогда второй аргумент не был передан. В этом случае программа берёт часть тернарного оператора, стоящую после :
и calloc()
выделяет память под один элемент. Иначе calloc()
выделяет память под N элементов, где N это второй аргумент переданный в макрос.
Подводные камни
Конечно эта реализация имеет минусы. Самый очевидный и заметный из них - нет никаких проверок на то что было передано в первый и второй аргумент, а значит и гарантии на то что всё правильно сработает. А если был написан ещё и третий аргумент, то в calloc()
передастся сразу 3 аргумента, и возникнет ошибка во время компиляции. С точки зрения макроса в его текущем виде все вызовы ниже корректны
new(1,int);
new(int,int);
new('a',"s");
new(new(new(8)));
new(errno);
Конечно же при компиляции после замены возникнет множество ошибок. Но препроцессор уже в этом не виноват - он просто выполнил свою работу, а именно заменил макрос на текст для подстановки, интерпретировал все спецсимволы, заменил все формальные параметры на фактические и завершил свою работу, непосредственно передав эстафету компилятору.
delete
Если есть new, то должен быть и delete. Но тут всё уж очень просто
#define delete(arg) free(arg)
Заключение
В заключение хотелось бы сказать что не стоит превращать С в С++, так как в последнее время С++ стал немного оказывать влияние и на язык С, особенно заметно в стандарте С2X. Всё же это два разных языка, похожих лишь синтаксически. В комментариях жду только конструктивную критику, а не пустых слов, направленных на то, чтобы оскорбить автора. Также рекомендую всем соблюдать "Хабраэтикет".
Комментарии (20)
kovserg
09.03.2022 00:38-6немного неудобно организована работа с памятью
Да она вообще неправильно организована от слова совсем.
Например вы не можете контролировать выделение/освобождение/выравнивание/учет памяти, не можете поменять метод выделения и ограничения на количество, даже просто определить сколько выделено и кем вы не можете. Не говоря уже о том что бы автоматически освобождать ресурсы которые понавыделял остановленный поток или подпрограмма и вообще сколько и она дочерних потоков запустила при жизни. Не говоря о передачи способа выделения памяти в подпрограмму.
И в свете лютой многопоточности и многоступенчатого механизма кэшей каждый поток может хотеть иметь собственный пул память с разной иерархией скорости доступа. И в таких ситуациях вы вынуждены будете использовать кастомные аллокаторы и все эти эргономичные ухищрения c #define new вам не понадобятся, тем более что такой метод способен сломать больше чем кажется.
ps: В C++ есть RAII и предлагается что все ресурсы программа которая их выделила, сама их и освободит. Но можно в C ничего подобного нет и не надо соблюдать эту условность. т.е. можно сделать «завещание» которое можно использовать для освобождения ресурсов при внезапной смерти потока/функции/fsm. Более того такой способ позволит ограничивать время исполнения кода, т.к. это такой же ресурс как и память например.saterenko
09.03.2022 12:03+2"Скальпель вообще неправильный инструмент, им можно сильно порезать ногу, руку, голову... Его опасно носить без чехла в кармане... Можно случайно наступить на него и порезать ногу... Копать им неудобно, пилить брус долго, медведя не убьёшь, чешую с рыбы чистить сложно..."
mapron
09.03.2022 05:25+1Если честно, открывая статью, ожидал увидеть использование _Generic. А такое вот, ну не знаю даже, согласен с остальными, против макроса возражений нет, возражения нет что он мимикрирует под С++, имея другое поведение.
napa3um
09.03.2022 07:45+2Кажется,
malloc(n * sizeof(int))
выглядит лучше, чемmalloc(n, int)
. Прозрачнее и идиоматичнее, фактически на русском языке написано, что происходит, это почти что реализация именованных параметров в языке без их поддержки :). При этом sizeof - это не вызов функции, и умножение тоже не будет в рантайме производиться (если n - константа).
Kelbon
Што, вызов sizeof?..
Смысл new в С++ не просто в выделении памяти, а вызове конструктора и начале лайфтайма объекта, в С это бессмысленно, так что получилось сделать свой код несовместимым с С++ ))
Bunikido Автор
Но ведь главной целью не было сделать код совместимым с С++. Главной целью было повторить синтаксически и максимально похоже оператор new в С, с чем я считаю справился ( за исключением скобок ) - но это уже не критично.
Zolg
Не боитесь, что не сейчас, но когда-нибудь потом, за
#define new(t,...)
кто-нибудь захочет вас убить ? Возможно даже вы сами )
Bunikido Автор
Ну я не считаю что макросы с переменным количеством аргументов плохи, если вы это имели ввиду. Я считаю что если такая возможность языка имеется, и её можно применить, то её нужно применить. А что плохого вы видите в их использовании? Было бы интересно послушать о минусах/подводных камнях/недостатках и неудобства их использования, да и другим будет полезно
Zolg
Не в количестве переменных дело, а в дефайне new. А если хедер с этим окажется когда-нибудь включенным в плюсовый код, то редефайне. Вот радости-то будет.
Вы добавили очень сомнительной полезности сахар и положили рядом граблю
Bunikido Автор
Ух это да. Но я думаю вряд-ли такое останется без внимания - компилятор сгенерирует столько ошибок, сколько было написано new, и тогда злополучная строка быстро обнаружится.
Насчёт полезности честно не знаю - вкусовщина. Кому-то тип легче написать, а кому-то sizeof подтянуть. Всё ради эксперимента
Кстати такая же неприятность может случиться вот с таким макросом:
Проблем доставит ещё больше чем дефайн new.
Serge78rus
От включения заголовочника в код на C++ можно защититься:
Но с остальными Вашими доводами вполне согласен
unsignedchar
Придумать оператор new, который похож на new из С++, но немного другой - можно. Но зачем???
Я ожидал увидеть самодельный аллокатор, а не вот это.
Bunikido Автор
Хм интересно. Может напишу об этом статью потом. Но здесь задумкой было повторить его синтаксически, а не технически.
unsignedchar
Но зачем? Неужели экономия букв исходников стоит того, чтобы превращать его в шараду?
КМК, лучше честный calloc+sizeof.
Bunikido Автор
Конечно лучше, никто не спорит. Следует использовать инструменты которые присутствуют в языке. Я просто экспериментировал :)