Зачем использовать непрозрачный «дескриптор», который требует приведения в открытом API, а не типобезопасный структурный указатель?

27

Я оцениваю библиотеку, публичный API которой в настоящее время выглядит следующим образом:

libengine.h

/* Handle, used for all APIs */
typedef size_t enh;


/* Create new engine instance; result returned in handle */
int en_open(int mode, enh *handle);

/* Start an engine */
int en_start(enh handle);

/* Add a new hook to the engine; hook handle returned in h2 */
int en_add_hook(enh handle, int hooknum, enh *h2);

Обратите внимание, что enhэто универсальный дескриптор, используемый в качестве дескриптора для нескольких различных типов данных ( движки и хуки ).

Внутренне, большинство из этих API, конечно, приводят «дескриптор» к внутренней структуре, которую они сделали malloc:

engine.c

struct engine
{
    // ... implementation details ...
};

int en_open(int mode, *enh handle)
{
    struct engine *en;

    en = malloc(sizeof(*en));
    if (!en)
        return -1;

    // ...initialization...

    *handle = (enh)en;
    return 0;
}

int en_start(enh handle)
{
    struct engine *en = (struct engine*)handle;

    return en->start(en);
}

Лично я ненавижу прятать вещи за typedefs, особенно когда это ставит под угрозу безопасность типов. (Учитывая enh, как я узнаю, к чему это на самом деле относится?)

Поэтому я отправил запрос на извлечение, предложив следующее изменение API (после изменения всей библиотеки для соответствия):

libengine.h

struct engine;           /* Forward declaration */
typedef size_t hook_h;    /* Still a handle, for other reasons */


/* Create new engine instance, result returned in en */
int en_open(int mode, struct engine **en);

/* Start an engine */
int en_start(struct engine *en);

/* Add a new hook to the engine; hook handle returned in hh */
int en_add_hook(struct engine *en, int hooknum, hook_h *hh);

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

libengine.c

struct engine
{
    // ... implementation details ...
};

int en_open(int mode, struct engine **en)
{
    struct engine *_e;

    _e = malloc(sizeof(*_e));
    if (!_e)
        return -1;

    // ...initialization...

    *en = _e;
    return 0;
}

int en_start(struct engine *en)
{
    return en->start(en);
}

Я предпочитаю это по следующим причинам:

Тем не менее, владелец проекта отказался от запроса на извлечение (перефразировал):

Лично мне не нравится идея разоблачения struct engine. Я все еще думаю, что нынешний путь чище и дружелюбнее.

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

Посмотрим, что другие подумают об этом пиаре.

Эта библиотека в настоящее время находится в стадии приватного бета-тестирования, поэтому не нужно беспокоиться о коде пользователя (пока). Кроме того, я немного запутал имена.


Чем непрозрачный дескриптор лучше именованной непрозрачной структуры?

Примечание: я задал этот вопрос в Code Review , где он был закрыт.

Джонатон Рейнхарт
источник
1
Я изменил название на то, что, как мне кажется, более четко выражает суть вашего вопроса. Не стесняйтесь вернуться, если я неверно истолковал это.
Ixrec
1
@Ixrec Это лучше, спасибо. После написания всего вопроса у меня закончились умственные способности, чтобы придумать хорошее название.
Джонатон Рейнхарт

Ответы:

33

Мантра «просто лучше» стала слишком догматичной. Простое не всегда лучше, если оно усложняет другие вещи. Сборка проста - каждая команда намного проще, чем команды языков более высокого уровня - и все же программы сборки более сложны, чем языки более высокого уровня, которые делают то же самое. В вашем случае унифицированный тип дескриптора enhупрощает типы за счет усложнения функций. Так как обычно типы проектов имеют тенденцию расти сублинейной скоростью по сравнению с его функциями, по мере того, как проект становится больше, вы обычно предпочитаете более сложные типы, если это может упростить функции - поэтому в этом отношении ваш подход кажется правильным.

Автор проекта обеспокоен тем, что ваш подход - « разоблачениеstruct engine ». Я бы объяснил им, что это не разоблачает саму структуру - только тот факт, что есть структура с именем engine. Пользователь библиотеки уже должен знать об этом типе - он должен знать, например, что первый аргумент en_add_hookэтого типа, а первый аргумент другого типа. Таким образом, это на самом деле делает API более сложным, потому что вместо наличия «сигнатурной» функции этих документов необходимо документировать ее где-то еще, и потому что компилятор больше не может проверять типы для программиста.

Следует отметить одну вещь - ваш новый API делает пользовательский код немного более сложным, поскольку вместо написания:

enh en;
en_open(ENGINE_MODE_1, &en);

Теперь им нужен более сложный синтаксис для объявления своего дескриптора:

struct engine* en;
en_open(ENGINE_MODE_1, &en);

Решение, однако, довольно простое:

struct _engine;
typedef struct _engine* engine

и теперь вы можете прямо написать:

engine en;
en_open(ENGINE_MODE_1, &en);
Идан Арье
источник
Я забыл упомянуть, что библиотека претендует на то, чтобы следовать стилю кодирования Linux , которому я также и следую. Там вы увидите, что структуры определения типов просто во избежание записи structявно не приветствуются.
Джонатон Рейнхарт
@JonathonReinhart он определяет тип указателя, чтобы структурировать, а не саму структуру.
фрик с трещоткой
@JonathonReinhart и, читая эту ссылку, я вижу, что для «полностью непрозрачных объектов» это разрешено. (глава 5, правило а)
чокнутый урод
Да, но только в исключительно редких случаях. Я искренне верю, что это было добавлено, чтобы избежать переписывания всего кода mm для работы с pte typedefs. Посмотрите на код блокировки вращения. Это полностью специфично для архива (нет общих данных), но они никогда не используют typedef.
Джонатон Рейнхарт
8
Я бы предпочел typedef struct engine engine;и использовал engine*: введено наименьшее имя, и это делает очевидным, что он похож на ручку FILE*.
дедупликатор
16

Здесь, кажется, с обеих сторон путаница:

  • использование подхода с дескриптором не требует использования одного типа дескриптора для всех дескрипторов
  • разоблачение structимени не раскрывает его детали (только его существование)

Преимущества использования дескрипторов, а не простых указателей, в языке, подобном C, заключаются в том, что передача указателя позволяет напрямую манипулировать указателем (включая вызовы free), тогда как передача дескриптора требует, чтобы клиент прошел через API для выполнения любого действия. ,

Тем не менее, подход с использованием единственного типа дескриптора, определенного через a typedef, не является безопасным по типу и может вызвать много неприятностей.

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

typedef struct {
    size_t id;
} enh;

typedef struct {
    size_t id;
} oth;

Теперь нельзя случайно пройти 2 как ручку и нельзя случайно передать ручку метле, где ожидается ручка для двигателя.


Поэтому я отправил запрос на извлечение, предлагая следующее изменение API (после изменения всей библиотеки для соответствия)

Это ваша ошибка: прежде чем приступить к серьезной работе над библиотекой с открытым исходным кодом, свяжитесь с автором (-ами) / сопровождающим (-ями) для обсуждения изменений заранее . Это позволит вам обоим договориться о том, что делать (или не делать), и избегать ненужной работы и разочарований, которые в результате этого возникают.

Матье М.
источник
1
Спасибо. Вы не вдавались в то, что делать с ручками, хотя. Я реализовал настоящий API, основанный на дескрипторах , где указатели никогда не отображаются, даже через typedef. Он участвует в ~ дорогостоящего поиск данных на входе каждого вызова API - так же, как способ Linux просматривает struct fileиз int fd. Это, конечно, излишне для пользовательской библиотеки IMO.
Джонатон Рейнхарт
@JonathonReinhart: Ну, поскольку библиотека уже предоставляет дескрипторы, я не чувствовал необходимости расширяться. На самом деле существует несколько подходов: от простого преобразования указателя до целого числа до «пула» и использования идентификаторов в качестве ключей. Вы даже можете переключаться между Debug (ID + lookup, для проверки) и Release (только преобразованный указатель, для скорости).
Матье М.
Повторное использование индекса целочисленной таблицы фактически будет страдать от проблемы ABA , когда объект (индекс 3) освобождается, затем создается новый объект и, к сожалению, ему 3снова назначается индекс . Проще говоря, трудно создать безопасный механизм жизни объектов в C, если подсчет ссылок (вместе с соглашениями об общих правах собственности на объекты) не включен в явную часть разработки API.
Rwong
2
@ rwong: Это проблема только в наивной схеме; например, вы можете легко интегрировать счетчик эпох, чтобы при указании старого дескриптора вы получали несоответствие эпох.
Матье М.
1
Предложение @JonathonReinhart: вы можете упомянуть «строгое правило алиасинга» в своем вопросе, чтобы помочь дискуссии перейти к более важным аспектам.
Руонг
3

Вот ситуация, когда нужна непрозрачная ручка;

struct SimpleEngine {
    int type;  // always SimpleEngine.type = 1
    int a;
};

struct ComplexEngine {
    int type;  // always ComplexEngine.type = 2
    int a, b, c;
};

int en_start(enh handle) {
    switch(*(int*)handle) {
    case 1:
        // treat handle as SimpleEngine
        return start_simple_engine(handle);
    case 2:
        // treat handle as ComplexEngine
        return start_complex_engine(handle);
    }
}

Когда библиотека имеет два или более структурных типа, которые имеют одинаковую часть заголовка полей, как, например, «тип» в приведенном выше примере, можно считать, что эти структурные типы имеют общую родительскую структуру (например, базовый класс в C ++).

Вы можете определить часть заголовка как «struct engine», вот так;

struct engine {
    int type;
};

struct SimpleEngine {
    struct engine base;
    int a;
};

struct ComplexEngine {
    struct engine base;
    int a, b, c;
};

int en_start(struct engine *en) { ... }

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

Вывод

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

Акио Такахаши
источник
Я думаю, что использование объединения делает это безопаснее, чем опасными приведениями к полям, которые могут быть перемещены. Проверьте эту суть, которую я собрал, показывая полный пример.
Джонатон Рейнхарт
Но на самом деле, избегать, switchво-первых, использования «виртуальных функций», вероятно, идеально, и решает всю проблему.
Джонатон Рейнхарт
Ваш дизайн в гисте сложнее, чем я предполагал. Конечно, это делает приведение к минимуму, безопасным и умным, но вводит больше кода и типов. На мой взгляд, это кажется слишком сложным, чтобы получить типобезопасность. Я и, возможно, автор библиотеки решили следовать за KISS, а не за безопасностью типов.
Акио Такахаши
Ну, если вы хотите, чтобы все было действительно просто, вы также можете полностью исключить проверку ошибок!
Джонатон Рейнхарт
На мой взгляд, простота дизайна предпочтительнее, чем некоторое количество ошибок. В этом случае такие проверки ошибок существуют только в функциях API. Кроме того, вы можете удалить приведение типов с помощью объединения, но помните, что объединение естественно небезопасно.
Акио Такахаши
2

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

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

Так как вы все равно будете предоставлять структуры своим клиентам, вы жертвуете немного безопасностью типов (которую все еще можно проверить во время выполнения) для намного более простого API, хотя и того, которое требует приведения.

Роберт Харви
источник
5
«Вы можете изменять внутренние структуры без ...», - вы также можете сделать это с помощью метода предварительной декларации.
user253751
Разве подход "прямого объявления" все еще требует, чтобы вы объявляли сигнатуры типов? И не изменятся ли эти типовые сигнатуры, если вы измените структуру?
Роберт Харви
Прямое объявление требует от вас только объявления имени типа - его структура остается скрытой.
Идан Арье
Тогда какая польза от предварительного объявления, если оно даже не обеспечивает структуру типов?
Роберт Харви
6
@RobertHarvey Помните - это C, о котором мы говорим. Нет никаких методов, так что кроме имени и структуры нет ничего другого для типа. Если бы это действительно обеспечивало структуру, это было бы идентично регулярному объявлению. Смысл раскрытия имени без применения структуры состоит в том, что вы можете использовать этот тип в сигнатурах функций. Конечно, без структуры вы можете использовать только указатели на тип, так как компилятор не может знать его размер, но поскольку в C нет неявного приведения указателей с использованием указателей, этого достаточно для статической типизации, чтобы защитить вас.
Идан Арье
2

Дежавю

Чем непрозрачный дескриптор лучше именованной непрозрачной структуры?

Я столкнулся с точно таким же сценарием, только с некоторыми тонкими различиями. В нашем SDK было много таких вещей:

typedef void* SomeHandle;

Мое простое предложение состояло в том, чтобы он соответствовал нашим внутренним типам:

typedef struct SomeVertex* SomeHandle;

Для третьих лиц, использующих SDK, это не должно иметь никакого значения. Это непрозрачный тип. Какая разница? Он не влияет на совместимость с ABI * или исходными текстами, и для использования новых выпусков SDK требуется перекомпиляция плагина в любом случае.

* Обратите внимание, что, как указывает gnasher, на самом деле могут быть случаи, когда размер чего-то вроде указателя на struct и void * может фактически иметь другой размер, и в этом случае это повлияет на ABI. Как и он, я никогда не сталкивался с этим на практике. Но с этой точки зрения, то второй может реально улучшить переносимость в какой-то неясной контексте, так что это еще одна причина, в пользу второго, хотя, возможно, спорный вопрос для большинства людей.

Ошибки третьих сторон

Кроме того, у меня было даже больше причин, чем безопасность типов для внутренней разработки / отладки. У нас уже было несколько разработчиков плагинов, у которых были ошибки в их коде, потому что два одинаковых дескриптора ( Panelи PanelNew, т.е.) оба использовали void*typedef для своих дескрипторов, и они случайно передавали неправильные дескрипторы в неправильные места в результате простого использования SDK. Их ошибки также стоят огромного времени для внутренней команды разработчиков, так как они отправляют отчеты об ошибках с жалобами на ошибки в нашем SDK, и нам придется отлаживать плагин и обнаруживать, что это на самом деле было вызвано ошибкой в ​​плагине, передающей неправильные дескрипторы. в неправильные места (что легко разрешается даже без предупреждения, когда каждый дескриптор является псевдонимом илиvoid* для всего. Так что это на самом деле вызывает ошибки на стороне тех, кто используетvoid*size_t ). Таким образом, мы без необходимости тратили свое время на предоставление услуг по устранению неполадок третьим сторонам из-за ошибок, вызванных с их стороны нашим стремлением к концептуальной чистоте в сокрытии всей внутренней информации, даже простых имен нашей внутренней structs.

Хранение Typedef

Разница заключается в том, что я предлагаю , что мы делаем стики до typedefсих пор, не иметь клиент запись , struct SomeVertexкоторые будут влиять на совместимость источника для будущих плагин релизов. Хотя мне лично нравится идея не определять тип structв C, с точки зрения SDK, это typedefможет помочь, поскольку все дело в непрозрачности. Так что я бы предложил ослабить этот стандарт только для публичного API. Для клиентов, использующих SDK, не должно иметь значения, является ли дескриптор указателем на структуру, целое число и т. Д. Для них важно только то, что два разных дескриптора не имеют псевдонимов одного типа данных, так что они не неправильно передать в неправильную ручку в неправильное место.

Тип информации

Больше всего важно избегать кастинга для вас, внутренних разработчиков. Этот вид эстетики сокрытия всех внутренних имен от SDK - это концептуальная эстетика, которая сопряжена со значительными затратами на потерю всей информации о типах и требует от нас ненужного разбрасывания бросков в наши отладчики для получения критической информации. В то время как программист на C должен в значительной степени привыкнуть к этому на C, требовать этого без необходимости просто напрашивается на неприятности.

Концептуальные Идеалы

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

Пользовательские настройки

Даже со строгой точки зрения конечных пользователей, использующих API, они предпочли бы ошибочный API или API, который работает хорошо, но предоставляет какое-то имя, о котором они вряд ли могли бы заботиться взамен? Потому что это практический компромисс. Потеря информации о типах без необходимости вне общего контекста увеличивает риск ошибок, и из-за крупномасштабной кодовой базы в команде в течение нескольких лет закон Мерфи, как правило, вполне применим. Если вы чрезмерно увеличите риск ошибок, скорее всего, вы, по крайней мере, получите еще несколько ошибок. Это не займет много времени в большой команде, чтобы обнаружить, что все мыслимые человеческие ошибки в конечном итоге превратятся из потенциала в реальность.

Так что, возможно, это вопрос к пользователям. «Вы бы предпочли более шумный SDK или тот, который предоставляет некоторые внутренние непрозрачные имена, которые вам никогда не понадобятся?» И если кажется, что этот вопрос выдвигает ложную дихотомию, я бы сказал, что требуется больше опыта для всей команды в очень крупномасштабной обстановке, чтобы оценить тот факт, что более высокий риск ошибок в конечном итоге в конечном итоге приведет к появлению реальных ошибок. Не имеет значения, насколько разработчик уверен в том, чтобы избежать ошибок. В общекорпоративных условиях это помогает больше думать о самых слабых звеньях и, по крайней мере, о самых простых и быстрых способах предотвращения их срабатывания.

Предложение

Поэтому я бы предложил здесь компромисс, который все же даст вам возможность сохранить все преимущества отладки:

typedef struct engine* enh;

... даже ценой уничтожения шрифтов struct, это действительно нас убьет? Вероятно, нет, поэтому я рекомендую также немного прагматизма с вашей стороны, но в большей степени разработчику, который предпочел бы сделать экспоненциальную сложность отладки, используя size_tздесь, и приведение к / от целочисленного значения без веской причины, за исключением дальнейшего скрытия информации, которая уже % скрыт от пользователя и не может причинить больше вреда, чем size_t.


источник
1
Это крошечная разница: в соответствии со стандартом C все «указатель на структуру» имеют одинаковое представление, так же как и все «указатель на объединение», так же как и «void *» и «char *», кроме void * и «указателя» для структуры "может иметь разные sizeof () и / или другое представление. На практике я такого никогда не видел.
gnasher729
@ gnasher729 То же самое, может быть, я должен квалифицировать эту часть в отношении потенциальной потери переносимости при приведении к void*или size_tобратно как еще одну причину, чтобы избежать излишнего приведения. Я как бы пропустил это, поскольку никогда не видел его на практике, учитывая платформы, на которые мы нацеливались (которые всегда были настольными платформами: linux, OSX, Windows).
1
Мы закончили сtypedef struct uc_struct uc_engine;
Джонатон Рейнхарт
1

Я подозреваю, что настоящая причина в инерции, это то, что они всегда делали, и это работает, так зачем это менять?

Основная причина, которую я вижу, заключается в том, что непрозрачный дескриптор позволяет дизайнеру поместить за ним что угодно, не только структуру. Если API возвращает и принимает несколько непрозрачных типов, они все выглядят одинаково для вызывающей стороны, и никогда не возникает проблем компиляции или перекомпиляции, если изменяется мелкий шрифт. Если en_NewFlidgetTwiddler (handle ** newTwiddler) изменится, чтобы вернуть указатель на Twiddler вместо дескриптора, API не изменится, и любой новый код будет молча использовать указатель там, где раньше он использовал дескриптор. Кроме того, нет никакой опасности, что ОС или что-либо еще тихо «исправит» указатель, если он проходит через границы.

Недостатком этого, конечно, является то, что вызывающий абонент может вводить в него все что угодно. У вас есть 64-битная вещь? Вставьте его в 64-битный слот в вызове API и посмотрите, что произойдет.

en_TwiddleFlidget(engine, twiddler, flidget)
en_TwiddleFlidget(engine, flidget, twiddler)

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

Мос
источник
1

Я считаю, что это связано с давней философией защиты API библиотеки C от злоупотреблений со стороны новичков.

Особенно,

  • Авторы библиотеки знают, что это указатель на структуру, а детали структуры видимы для кода библиотеки.
  • Все опытные программисты, которые используют библиотеку, также знают, что это указатель на некоторые непрозрачные структуры;
    • У них было достаточно тяжелый, мучительный опыт, чтобы знать, чтобы не связываться с байтами, хранящимися в этих структурах.
  • Неопытные программисты не знают ни того, ни другого.
    • Они будут пытаться memcpyизменить непрозрачные данные или увеличить байты или слова внутри структуры. Иди взломать.

Давняя традиционная контрмера заключается в том, чтобы:

  • Замаскируйте тот факт, что непрозрачный дескриптор на самом деле является указателем на непрозрачную структуру, которая существует в том же пространстве процесса-памяти.
    • Чтобы сделать это, утверждая, что это целочисленное значение, имеющее то же число бит, что и void*
    • Чтобы быть более осмотрительным, маскируйте биты указателя, например,
      struct engine* peng = (struct engine*)((size_t)enh ^ enh_magic_number);

Это просто сказать, что у него есть давние традиции; У меня не было личного мнения о том, правильно это или неправильно.

rwong
источник
3
За исключением смешного xor, мое решение также обеспечивает эту безопасность. Клиент по-прежнему не знает о размере или содержимом структуры с дополнительным преимуществом безопасности типов. Я не понимаю, как лучше использовать size_t для удержания указателя.
Джонатон Рейнхарт
@JonathonReinhart маловероятно, что клиент на самом деле не будет знать о структуре. Вопрос в следующем: могут ли они получить структуру и могут ли они вернуть измененную версию в вашу библиотеку. Не только с открытым исходным кодом, но в более общем плане. Решением является современное разбиение памяти, а не глупый XOR.
Móż
О чем ты говоришь? Все, что я говорю, - это то, что вы не можете скомпилировать любой код, который пытается разыменовать указатель на указанную структуру, или сделать что-либо, что требует знания его размера. Конечно, вы можете использовать memset (, 0,) в куче всего процесса, если вы действительно этого хотите.
Джонатон Рейнхарт
6
Этот аргумент очень похож на защиту от Макиавелли . Если пользователь хочет передать мусор моему API, я не смогу его остановить. Внедрение подобного небезопасного интерфейса вряд ли поможет в этом, так как на самом деле облегчает случайное неправильное использование API.
ComicSansMS
@ComicSansMS спасибо за то, что вы упомянули «случайный», потому что это то, что я действительно пытаюсь здесь предотвратить.
Джонатон Рейнхарт