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


  1. Kelbon
    08.03.2022 21:44
    +13

    В С++ мы избегаем вызова sizeof так как размер типа вычисляется компилятором автоматически

    Што, вызов sizeof?..

    Смысл new в С++ не просто в выделении памяти, а вызове конструктора и начале лайфтайма объекта, в С это бессмысленно, так что получилось сделать свой код несовместимым с С++ ))


    1. Bunikido Автор
      08.03.2022 21:53

       получилось сделать свой код несовместимым с С++ ))

      Но ведь главной целью не было сделать код совместимым с С++. Главной целью было повторить синтаксически и максимально похоже оператор new в С, с чем я считаю справился ( за исключением скобок ) - но это уже не критично.


      1. Zolg
        08.03.2022 22:02
        +7

        Не боитесь, что не сейчас, но когда-нибудь потом, за

        #define new(t,...)

        кто-нибудь захочет вас убить ? Возможно даже вы сами )


        1. Bunikido Автор
          08.03.2022 23:01

          Ну я не считаю что макросы с переменным количеством аргументов плохи, если вы это имели ввиду. Я считаю что если такая возможность языка имеется, и её можно применить, то её нужно применить. А что плохого вы видите в их использовании? Было бы интересно послушать о минусах/подводных камнях/недостатках и неудобства их использования, да и другим будет полезно


          1. Zolg
            08.03.2022 23:49
            +5

            Не в количестве переменных дело, а в дефайне new. А если хедер с этим окажется когда-нибудь включенным в плюсовый код, то редефайне. Вот радости-то будет.

            Вы добавили очень сомнительной полезности сахар и положили рядом граблю


            1. Bunikido Автор
              09.03.2022 00:02

              Ух это да. Но я думаю вряд-ли такое останется без внимания - компилятор сгенерирует столько ошибок, сколько было написано new, и тогда злополучная строка быстро обнаружится.

              Вы добавили очень сомнительной полезности сахар и положили рядом граблю

              Насчёт полезности честно не знаю - вкусовщина. Кому-то тип легче написать, а кому-то sizeof подтянуть. Всё ради эксперимента

              Кстати такая же неприятность может случиться вот с таким макросом:

              #define for(...)

              Проблем доставит ещё больше чем дефайн new.


            1. Serge78rus
              09.03.2022 00:09
              +1

              От включения заголовочника в код на C++ можно защититься:

              #ifndef __cplusplus
              // #define new, etc
              #endif
              

              Но с остальными Вашими доводами вполне согласен


      1. unsignedchar
        08.03.2022 22:10
        +5

        Придумать оператор new, который похож на new из С++, но немного другой - можно. Но зачем???

        Я ожидал увидеть самодельный аллокатор, а не вот это.


        1. Bunikido Автор
          08.03.2022 23:03

          Хм интересно. Может напишу об этом статью потом. Но здесь задумкой было повторить его синтаксически, а не технически.


          1. unsignedchar
            08.03.2022 23:28
            +2

            Но зачем? Неужели экономия букв исходников стоит того, чтобы превращать его в шараду?

            new(20);
            new(int);
            new(20,int);
            new(int,20);
              

            КМК, лучше честный calloc+sizeof.


            1. Bunikido Автор
              08.03.2022 23:55

              Конечно лучше, никто не спорит. Следует использовать инструменты которые присутствуют в языке. Я просто экспериментировал :)


  1. kovserg
    09.03.2022 00:38
    -6

    немного неудобно организована работа с памятью

    Да она вообще неправильно организована от слова совсем.
    Например вы не можете контролировать выделение/освобождение/выравнивание/учет памяти, не можете поменять метод выделения и ограничения на количество, даже просто определить сколько выделено и кем вы не можете. Не говоря уже о том что бы автоматически освобождать ресурсы которые понавыделял остановленный поток или подпрограмма и вообще сколько и она дочерних потоков запустила при жизни. Не говоря о передачи способа выделения памяти в подпрограмму.
    И в свете лютой многопоточности и многоступенчатого механизма кэшей каждый поток может хотеть иметь собственный пул память с разной иерархией скорости доступа. И в таких ситуациях вы вынуждены будете использовать кастомные аллокаторы и все эти эргономичные ухищрения c #define new вам не понадобятся, тем более что такой метод способен сломать больше чем кажется.

    ps: В C++ есть RAII и предлагается что все ресурсы программа которая их выделила, сама их и освободит. Но можно в C ничего подобного нет и не надо соблюдать эту условность. т.е. можно сделать «завещание» которое можно использовать для освобождения ресурсов при внезапной смерти потока/функции/fsm. Более того такой способ позволит ограничивать время исполнения кода, т.к. это такой же ресурс как и память например.


    1. saterenko
      09.03.2022 12:03
      +2

      "Скальпель вообще неправильный инструмент, им можно сильно порезать ногу, руку, голову... Его опасно носить без чехла в кармане... Можно случайно наступить на него и порезать ногу... Копать им неудобно, пилить брус долго, медведя не убьёшь, чешую с рыбы чистить сложно..."


      1. kovserg
        09.03.2022 12:26
        -2

        Тут как раз не скальпель, а каменный топор


        1. saterenko
          09.03.2022 13:24
          +1

          Некорректное утверждение, malloc более тонкий инструмент, который используется в реализации new, можете поизучать libstdc++.


  1. mapron
    09.03.2022 05:25
    +1

    Если честно, открывая статью, ожидал увидеть использование _Generic. А такое вот, ну не знаю даже, согласен с остальными, против макроса возражений нет, возражения нет что он мимикрирует под С++, имея другое поведение.


  1. napa3um
    09.03.2022 07:45
    +2

    Кажется, malloc(n * sizeof(int)) выглядит лучше, чем malloc(n, int). Прозрачнее и идиоматичнее, фактически на русском языке написано, что происходит, это почти что реализация именованных параметров в языке без их поддержки :). При этом sizeof - это не вызов функции, и умножение тоже не будет в рантайме производиться (если n - константа).


  1. Gordon01
    10.03.2022 13:09
    -1

    Слава богу в этот раз бред этого студента заминусовали.


    1. Bunikido Автор
      10.03.2022 14:19

      1. А что не так?

      2. Вы же понимайте что комментарии подобного рода автору вообще ничего не говорят о его ошибках?