Подтвердить что ты не робот

C наилучшей практикой использования памяти стека для неполных структур

Есть моменты, когда я хочу иметь неполную структуру (только один файл C знает о своих членах) поэтому я могу определить API для любых манипуляций и поэтому разработчики не могут легко манипулировать им вне API.

Проблема с этим, это часто означает, что вам нужна функция-конструктор, которая выделяет данные и освобождает их после (используя malloc и free).

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

Итак, мне было интересно, что может быть переносным способом сохранить члены локально в исходном файле C и по-прежнему использовать распределение стека.

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

Пример простого генератора случайных чисел (для краткости включают только новые/бесплатные методы).

Заголовок: rnd.h

struct RNG;
typedef struct RNG RNG;

struct RNG *rng_new(unsigned int seed);
void        rng_free(struct RNG *rng);

Источник: rnd.c

struct RNG {
    uint64_t X;
    uint64_t Y;
};

RNG *rng_new(unsigned int seed)
{
    RNG *rng = malloc(sizeof(*rng));
    /* example access */
    rng->X = seed;
    rng->Y = 1;
    return rng;
}

void rng_free(RNG *rng)
{
    free(rng);
}

Другой источник: other.c

#include "rnd.h"
void main(void)
{
    RND *rnd;

    rnd = rnd_new(5);

    /* do something */

    rnd_free(rnd);
}

Возможные решения

У меня было 2 идеи, как это можно было бы сделать, оба чувствуют себя немного клочьями.

Объявить только размер (в заголовке)

Добавьте эти определения в заголовок.

Заголовок: rnd.h

#define RND_SIZE      sizeof(uint64_t[2])
#define RND_STACK_VAR(var) char _##var##_stack[RND_SIZE]; RND *rnd = ((RND *)_##var##_stack)

void rnd_init(RND *rnd, unsigned int seed);

Чтобы обеспечить синхронизацию размеров.

Источник: rnd.c

#include "rnd.h"

struct RNG {
    uint64_t X;
    uint64_t Y;
};

#define STATIC_ASSERT(expr, msg) \
    extern char STATIC_ASSERTION__##msg[1]; \
    extern char STATIC_ASSERTION__##msg[(expr) ? 1 : 2]

/* ensure header is valid */
STATIC_ASSERT(RND_SIZE == sizeof(RNG))

void rng_init(RNG *rng, unsigned int seed)
{
    rng->X = seed;
    rng->Y = 1;
}

Другой источник: other.c

#include "rnd.h"

void main(void)
{
    RND_STACK_VAR(rnd);

    rnd_init(rnd, 5);

    /* do something */

    /* stack mem, no need to free */
}

Сохранение размера в синтаксисе для больших членов struct может быть проблемой, но для небольшой структуры это не такая проблема.

Условно скрыть элементы struct (в заголовке)

Использование устаревшего атрибута GCC, однако, если есть более переносимый способ сделать это, это будет хорошо.

Заголовок: rnd.h

#ifdef RND_C_FILE
#  define RND_HIDE /* don't hide */
#else
#  define RND_HIDE __attribute__((deprecated))
#endif

struct RNG {
    uint64_t X RND_HIDE;
    uint64_t Y RND_HIDE;
};

Источник: rnd.c

#define RND_C_FILE
#include "rnd.h"

void main(void)
{
    RND rnd;

    rnd_init(&rnd, 5);

    /* do something */

    /* stack mem, no need to free */
}

Таким образом, вы можете использовать RND как регулярную структуру, определенную в стеке, просто не получите доступ к своим членам без какого-либо предупреждения/ошибки. Но его GCC только.

4b9b3361

Ответ 1

Вы можете выполнить это в стандартном C способом, подобным вашему первому примеру, хотя не без большой боли, чтобы избежать нарушений псевдонимов.

Теперь давайте просто посмотрим, как определить тип. Чтобы он был полностью непрозрачным, нам нужно использовать VLA, который принимает размер от функции во время выполнения. В отличие от размера, выравнивание не может выполняться динамически, поэтому мы должны максимально выровнять тип. Я использую спецификаторы выравнивания C11 от stdalign.h, но вы можете подставить свои любимые расширения выравнивания компилятора, если хотите. Это позволяет свободно изменять тип, не нарушая ABI, точно так же, как типичный непрозрачный тип с кучей.

//opaque.h
size_t sizeof_opaque();
#define stack_alloc_opaque(identifier) \
    alignas(alignof(max_align_t)) char (identifier)[sizeof_opaque()]

//opaque.c
struct opaque { ... };
size_t sizeof_opaque(void) { return sizeof(struct opaque); }

Затем, чтобы создать экземпляр blackbox нашего типа faux, пользователь будет использовать stack_alloc_opaque(blackbox);

Прежде чем мы сможем пойти дальше, нам нужно определить, как API будет иметь возможность взаимодействовать с этим массивом, маскирующимся как структура. Предположительно, мы также хотим, чтобы наш API принимал выделенную кучу struct opaque* s, но при вызове функции наш объект стека распадается на char*. Есть несколько возможных вариантов:

  • Заставить пользователя скомпилировать с эквивалентом -Wno-incompatible-pointer-types
  • Принудительно использовать пользователя вручную при каждом вызове, например func((struct opaque*)blackbox);
  • Курорт переопределяет stack_alloc_opaque() для использования идентификатора throwaway для массива, а затем назначает его указателю struct opaque внутри макроса. Но теперь наш макрос имеет несколько операторов, и мы загрязняем пространство имен идентификатором, о котором пользователь не знает.

Все они довольно нежелательны по-своему, и ни одна из них не затрагивает основную проблему: while char* может быть псевдонимом любого типа, обратное неверно. Несмотря на то, что наш char[] идеально выровнен и имеет размер для struct opaque, переинтерпретация его как одного через листинг указателя verboten. И мы не можем использовать союз, чтобы сделать это, потому что struct opaque является неполным типом. К сожалению, это означает, что единственным безопасным решением является:

  • У каждого метода в нашем API есть char* или typedef до char*, а не struct opaque*. Это позволяет API принимать оба типа указателей, теряя при этом все видимость безопасности типов. Хуже того, любые операции в API потребуют memcpy ввода аргумента функции и возврата из локального struct opaque.

Это довольно чудовищно. Даже если мы пренебрегаем строгим псевдонимом, единственный способ поддерживать один и тот же API для объектов кучи и стека в этой ситуации - это первый элемент (не делайте этого).

В отношении игнорирования стандарта есть еще одна вещь:

  • alloca

Это плохое слово, но я бы отказался не упоминать об этом. В отличие от char VLA, и как malloc, alloca возвращает указатель void на нетипизированное пространство. Поскольку он имеет примерно ту же семантику, что и malloc, его использование не требует какой-либо гимнастики, указанной выше. API кучи и стека может радостно жить бок о бок, отличающийся только распределением объектов (de). Но alloca нестандартно, возвращенные объекты имеют немного другое время жизни, чем VLA, и его использование почти повсеместно обескуражено. К сожалению, это хорошо подходит для этой проблемы.

Насколько я вижу, существует только одно правильное решение (# 4), только одно чистое решение (# 5) и нет хорошего решения. Способ определения остальной части API зависит от того, какой из них вы выберете.

Ответ 2

В некоторых случаях это имеет мало смысла с точки зрения управления памятью, особенно если структура мала, а ее выделено и освобождено.

Я не вижу здесь проблемы. В вашем примере кто-то, вероятно, будет использовать только один RND для жизни своей программы или, по крайней мере, небольшого числа из них.

И если структура выделяется и освобождается много, то она не делает разницы в производительности, независимо от того, выполняет ли ваша библиотека все выделения и освобождения, или делает ли их код.

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

Кроме того, им придется выделять память, которая правильно выровнена для вашей структуры (т.е. они не могут просто пойти char foo[SIZE_OF_RND]; RND *rng = (RND *)foo; из-за проблем с выравниванием). Ваш пример RND_STACK_VAR игнорирует эту проблему.

Возможно, вы могли бы опубликовать SIZE_OF_RND, который является фактическим размером, плюс некоторое пособие для выравнивания. Затем ваша "новая" функция использует некоторые хаки для поиска правильного местоположения выравнивания в этой памяти и возвращает указатель.

Если он чувствует kludgey, это потому, что это так. И нет ничего, что помешало бы им просто писать байты внутри RND в любом случае. Я просто использовал бы ваше первое предложение RND_new() и т.д., Если бы не была очень сильная причина, почему он не подходит.

Ответ 3

Третье решение: разделите файл заголовка на общедоступную и частную части и объявите структуру в публичной части и определите ее в частной, неэкспортируемой.

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

Ответ 4

Вот еще один способ сделать это. Как и в вашем первом решении, важно сохранить синхронизацию размера или действительно плохие вещи.


main.c

#include <stdio.h>
#include "somestruct.h"

int main( void )
{
    SomeStruct test;

    InitSomeStruct( &test );
    ShowSomeStruct( &test );
}

somestruct.h

#define SOME_STRUCT_SIZE ((sizeof(int) * 2 + sizeof(long double) - 1) / sizeof(long double))

typedef struct
{
    union
    {
        long double opaque[SOME_STRUCT_SIZE];

#ifdef _SOME_STRUCT_SOURCE_
        struct
        {
            int a;
            int b;
        };
#endif

    };
}
    SomeStruct;

void InitSomeStruct( SomeStruct *someStruct );
void ShowSomeStruct( SomeStruct *someStruct );

somestruct.c

#include <stdio.h>
#define _SOME_STRUCT_SOURCE_
#include "somestruct.h"

void InitSomeStruct( SomeStruct *someStruct )
{
    someStruct->a = 55;
    someStruct->b = 99;
}

void ShowSomeStruct( SomeStruct *someStruct )
{
    printf( "a=%d b=%d\n", someStruct->a, someStruct->b );
}

Ответ 5

В дизайне API на основе C нет смысла не иметь стандартных функций распределения и инициализации по умолчанию, собранных вместе, готовых к использованию - как в С++. Не предлагая его как средство по умолчанию для "получения" экземпляра объекта, все это слишком простое использование неинициализированного хранилища. Если придерживаться этого правила, нет необходимости раскрывать любые размеры.

Распределение и инициализация атомных стеков отлично подходит для классов, которые не требуют уничтожения. Для таких объектов функция factory alloca является жизнеспособной опцией в дополнение к функции malloc на основе

Использование классов, требующих уничтожения, менее очевидно, так как "инстинкт" с выделенными alloca переменными не должен освобождать их. По крайней мере, если придерживаться "factory" API для построения и уничтожения объектов, довольно легко обеспечить, чтобы политика и код проверяли, что происходит разрушение, или объект протекает. Память объектов alloca не будет просачиваться, но ее можно забыть, и ее ресурсы (включая дополнительную память!) Могут, конечно же, протечь.

Предположим, что у нас есть интерфейс для 24-битного арифметического типа, написанный в стиле C-интерфейсы и реализации.

#ifndef INT24_INCLUDED
#define INT24_INCLUDED
#define T Int24_T
typedef struct T *T;
extern T Int24_new(void);
extern void Int24_free(T**);
extern void Int24_set_fromint(T, int);
extern void Int24_add(T a, T b);
extern int Int24_toint(T);
...
#undef T
#endif

Функция Int24_new возвращает новое 24-битное целое число, выделенное в куче, и ничего не нужно делать, чтобы уничтожить его, освободив его:

struct T {
  int val:24;
};    

T Int24_new(void) {
  T int24 = malloc(sizeof(struct T));
  int24->val = 0;
  return int24;
}

void Int24_free(T ** int24) {
  assert(int24);
  free(*int24);
  *int24 = NULL;
}

Мы можем иметь макрос Int24_auto, который делает то же самое, но выделяет в стеке. Мы не можем называть alloca() внутри функции, так как момент, когда мы ее возвращаем, это свисающий указатель - возврат из функции "освобождает" память. Использование Int24_free на таком объекте было бы ошибкой.

#define Int24_auto() Int24_auto_impl(alloca(sizeof(struct T)))
T Int24_auto_impl(void * addr) {
  T int24 = addr;
  int24->val = 0;
  return int24;
}

Использование простое, об уничтожении не нужно забывать, но API несовместим: мы не должны free объекты, полученные через Int24_auto.

void test(void) {
  Int24_T a = Int24_auto();
  Int24_T b = Int24_auto();
  Int24_set_fromint(a, 1);
  Int24_set_fromint(b, 2);
  Int24_add(a, b);
  assert(Int24_toint(a) == 3);
}

Если мы можем жить с накладными расходами, желательно добавить флаг к реализации, который позволяет методу free уничтожить экземпляр, не рассматривая его так, как если бы он был выделен в куче.

struct T {
  int val:24;
  int is_auto:1;
};    

T Int24_new(void) {
  T int24 = malloc(sizeof(struct T));
  int24->val = 0;
  int24->is_auto = 0;
  return int24;
}

#define Int24_auto() Int24_auto_impl(alloca(sizeof(struct T)))
T Int24_auto_impl(void * addr) {
  T int24 = addr;
  int24->val = 0;
  int24->is_auto = 1;
  return int24;
}

void Int24_free(T ** int24) {
  assert(int24);
  if (!(*int24)->is_auto) free(*int24);
  *int24 = NULL;
}

Это означает, что использование, связанное с кучей и стеком, будет непротиворечивым:

void test(void) {
  Int24_T a = Int24_auto();
  ...
  Int24_free(&a);
  a = Int24_new();
  ...
  Int24_free(&a);
}

Мы можем, конечно, иметь API, который возвращает размер непрозрачного типа и предоставляет методы init и release, которые строят объект на месте и разрушают его соответственно. Использование таких методов является более подробным и требует большей осторожности. Предположим, что у нас есть тип массива:

#ifndef ARRAY_INCLUDED
#define ARRAY_INCLUDED
#define T Array_T
typedef struct T *T;
extern size_t Array_alloc_size(void);
extern void Array_init(T, int length, int size);
extern void Array_release(T);
...
#undef T
#endif

Это позволяет гибко выбирать распределитель, который мы хотим, за счет 1 или 2 дополнительных строк кода для каждого используемого объекта.

void test(void) {
  Array_T a = alloca(Array_alloc_size());
  Array_init(a, 10, sizeof(int));
  ...
  Array_release(a);

  a = malloc(Array_alloc_size());
  Array_init(a, 5, sizeof(void*));
  ...
  Array_release(a);
  free(a);
}

Я бы счел, что такой API слишком подвержен ошибкам, особенно, что он делает некоторые виды будущих изменений в реализации более громоздкими. Предположим, что мы должны оптимизировать наш массив, выделив все хранилище за один раз. Для этого нужно, чтобы метод alloc_size принимал те же параметры, что и init. Это кажется совершенно глупым, если методы new и auto factory могут позаботиться об этом за один раз и сохранить двоичную совместимость, несмотря на изменения в реализации.