Розумні вказівники у C

9 хв. читання

Я є палким прибічником C, однак іноді мені не вистачає певних високорівневих конструкцій.

Мені не дає спокою незручність роботи з пам'яттю у C. Мене легко відволікти, тому я часто забуваю звільнити виділену пам'ять чи закрити файли. Дізнавшись про розумні вказівники у C++, я одразу зрозумів, що мені це потрібно.

На першому семестрі у школі програмування ми вивчали мову C. Спочатку потрібно було виконували невеличкі проекти, розраховані на одну людину, а пізніше нам видали один великий проект: потрібно було об'єднатись у команду з чотирьох людей та розробити власну реалізацію командного рядка (POSIX bourne shell). Нас попередили, що оцінка може значно знизитись, якщо у програмі буде багато витоків пам'яті. Також обов'язковою умовою було використання компілятора gcc 4.9.

Я зовсім не був впевнений у якості свого коду, та й товариші по команді також викликали сумніви, тому потрібно було придумати якесь рішення цієї проблеми. Спершу я думав розробити обгортку для функції malloc для того, щоб реєструвати усю виділену пам'ять та звільняти її при виході з програми, однак такий спосіб не був досконалим.

Потім я прочитав більше про атрибути у gcc, зокрема про __attribute__ ((cleanup(f)). Відповідно до документації, цей атрибут спричиняє виклик функції f щодо змінної перед тим, як вона залишить поле зору. Це надихнуло мене на подальші дії.

Реалізація змінної, яка сама звільняє свої ресурси

Продовжуючи дослідження, я зрозумів, що таким чином можна зробити атрибут, з яким змінна може прибрати за собою після виходу з поля зору:

#define autofree __attribute__((cleanup(free_stack)))

__attribute__ ((always_inline))
inline void free_stack(void *ptr) {
    free(*(void **) ptr);
}

Використання досить очевидне:

int main(void) {
    autofree int *i = malloc(sizeof (int));
    *i = 1;
    return *i;
}

Однак на практиці виявилось, що використовувати таку конструкцію не дуже зручно, тому що у більшості випадків пам'ять виділяється під складні структури, що містять у собі поля-вказівники на динамічно виділені області пам'яті. Не можна було просто взяти і видалити таку структуру одним рядком.

Потрібно було щось схоже на деструктор

У свою чергу для реалізації деструктора потрібно було додати до виділеної пам'яті деякі метадані.

|---------------------------|---------------------- // -----|
| метадані  | вирівнювання? |  дані                         |
|---------------------------|---------------------- // -----|
^                           ^ вказівник, який реально повертається 
 `- початок виділеного        (вирівнювання на слово)
    блоку

Такі от метадані

Довелось зробити дві функції: одну для виділення пам'яті, іншу для звільнення. Таким чином з'явились smalloc та sfree:

struct meta {
    void (*dtor)(void *);
    void *ptr;
};

static struct meta *get_meta(void *ptr) {
    return ptr - sizeof (struct meta);
}

__attribute__((malloc))
void *smalloc(size_t size, void (*dtor)(void *)) {
    struct meta *meta = malloc(sizeof (struct meta) + size);
    *meta = (struct meta) {
        .dtor = dtor,
        .ptr  = meta + 1
    };
    return meta->ptr;
}

void sfree(void *ptr) {
    if (ptr == NULL)
        return;
    struct meta *meta = get_meta(ptr);
    assert(ptr == meta->ptr); // ptr shall be a pointer returned by smalloc
    meta->dtor(ptr);
    free(meta);
}

Обидві досить прості: smalloc виділяє пам'ять під метадані та основні дані. Також вона ініціалізує метадані та зберігає деструктор, і повертає вказівник на непронініціалізовану область пам'яті, призначену для використання користувачем.

sfree поводиться точно як free: нічого не робить, якщо отримає NULL, або звільняє пам'ять. Єдина відмінність полягає у тому, що перед цим вона викликає деструктор, вказівник на який свого часу був переданий у smalloc.

Маючи ці дві функції, я зміг переписати макрос autofree:

#define smart __attribute__((cleanup(sfree_stack)))

__attribute__ ((always_inline))
inline void sfree_stack(void *ptr) {
    sfree(*(void **) ptr);
}

Воно ставало все більш схожим на розумний вказівник, тому я перейменував autofree у smart.

Оскільки sfree запускає деструктор, вона є універсальним деалокатором, її робота нагадує дії ключового слова delete у C++.

Таким чином для будь-якої області пам'яті, виділеної за допомогою smalloc я міг завжди використати sfree, не звертаючи увагу на те, якою має бути функція-деструктор. Це був значний крок вперед.

З іншого боку, це означало, що треба створювати деструктор навіть для невеликих простих типів, що було досить незручно. Тому я вирішив дозволити використання NULL у якості параметру для вказівника на деструктор у випадку простих типів.

Також я додав можливість додавати до метаданих блок, яким користувач може розпоряджатись на власний розсуд. Це дозволило б ряд цікавих застосувань типу масивів змінної довжини або розширення до стандартних бібліотечних структур.

На шляху до unique_ptr та shared_ptr

Усе, про що було сказано вище, заклало основу для створення unique_ptr та shared_ptr. Фактично, unique_ptr уже було реалізовано.

Тепер треба було зробити shared_ptr — однак це виявилось не так просто. Підрахунок посилань має здійснюватись у потокобезпечний спосіб, що можна реалізувати двома шляхами: або замками (locks), або атомарними операціями (atomics).

Замки дуже прості для використання, однак реалізовані таким чином вказівники неможливо буде використовувати для обробки сигналів (signal handlers). Воно то не дуже й потрібно, але все ж таки.

Імплементація без використання замків має свої вади: вона потребує механізму для порівняння та заміни значення, яке стосується контрольованого вказівника, однак сам вказівник використовується як є, без обгортки. Це неправильно, але можна погодитись, якщо прийняти, що ніяке видалення не буде відбуватись асинхронно (лишенько, відчуваю, багатьом ця фраза не сподобається). Менше з тим, якщо ви можете запропонувати більш правильне вирішення цієї проблеми, можете надіслати pull request.

Наразі я зробив лічильник на основі atomic integer у метаданих, а також функцію sref для того, щоб з ним працювати. Кожного разу, коли її викликають для вказівника, що спільно використовується (shared pointer), внутрішній лічильник посилань збільшується на одиницю. Відповідно, кожний виклик sfree зменшує лічильник.

Ще треба було якось відрізняти унікальні вказівники та вказівники зі спільним використанням, для цього було введено ще одне окреме поле у метадані.

Врешті решт полів у метаданих стало дуже багато. Це і розмір, і вказівник на деструктор, і додані дані, а тепер ще й тип вказівника. На початку концепція виглядала досить просто, але тепер, коли для кожного виділення пам'яті потрібно вказувати по п'ять параметрів, виникає бажання повернутися до старих добрих malloc та free.

Макроси приходять на допомогу

Один з можливих варіантів вирішення проблеми — створення кількох варіантів smalloc з різними параметрами. Як мінімум, деструктор та визначені користувачем метадані є опціональними. Однак виникає проблема з тим, що треба якось відрізняти одні набори параметрів від інших.

Кращим варіантом є залишити усі додаткові параметри та завернути все у макрос. Макрос має підраховувати кількість параметрів та передавати їх до відповідного варіанту smalloc.

Ще довелось додати пару макросів для створення unique_ptr та smart_ptr (щоб викликати smalloc з правильним параметром), і на цьому бібліотеку було завершено: якщо мені потрібен унікальний вказівник, я використовую unique_ptr(sizeof (T), dtor); Для іншого типу вказівника я пишу shared_ptr(sizeof (T), &data, sizeof (data)); В цілому це виглядає досить пристойно.

За порадою користувача /u/matthieum з reddit я згодом змінив тип розміну даних у макросах, що зробило їх ще простішими. Також мені вдалось реалізувати розумні масиви, які використовують параметри типу int[9].

Підсумки

Таким чином, розумні вказівники стали доступними для всіх. Було дуже цікаво погратися з мовою і реалізувати речі, які здавались неможливими. Визнаю, що цей проект ще має дозріти, перш ніж його можна буде використовувати у справжній розробці.

Все оформлено у репозиторії на github, там же можна знайти інструкцію з встановлення та приклади використання. Бажаю успіхів!

Від перекладача: мені здається, для повноти статті варто вказати ще кілька прикладів з репозиторію проекту, просто щоб краще розуміти, що воно таке

Ось просте використання uniqueptr

#include <stdio.h>
#include <csptr smart_ptr.h="">

int main(void) {
    // some_int is an unique_ptr to an int with a value of 1.
    smart int *some_int = unique_ptr(int, 1);

    printf("%p = %d\
", some_int, *some_int);

    // some_int is destroyed here
    return 0;
}

А ось те ж саме, але з деструктором:

#include <unistd.h>
#include <fcntl.h>
#include <csptr smart_ptr.h="">

struct log_file {
    int fd;
    // ...
};

void cleanup_log_file(void *ptr, void *meta) {
    (void) meta;
    close(((struct log_file *) ptr)->fd);
}

int main(void) {
    smart struct log_file *log = unique_ptr(struct log_file, {
            .fd = open("/dev/null", O_WRONLY | O_APPEND),
            // ...
        }, cleanup_log_file);

    write(log->fd, "Hello", 5);

    // cleanup_log_file is called, then log is freed
    return 0;
}
Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 4.8K
Приєднався: 10 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація