На днях я экспериментировал с языком С, и придумал одну интересную концепцию для удобства выделения памяти ( точнее перенял идею С++ но реализовал её средствами языка С ). Я про операторы 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. Всё же это два разных языка, похожих лишь синтаксически. В комментариях жду только конструктивную критику, а не пустых слов, направленных на то, чтобы оскорбить автора. Также рекомендую всем соблюдать "Хабраэтикет".