Навіщо використовувати непрозору "ручку", яка вимагає лиття в загальнодоступному API, а не набір typesafe вказівника?


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:

двигун.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: Ну, оскільки бібліотека вже надає ручки, я не відчував потреби в розширенні. Дійсно, існує кілька підходів: від простого перетворення покажчика на ціле число до наявності "пулу" та використання ідентифікаторів як ключів. Ви навіть можете переключити підхід між налагодженням (пошук + пошук, для перевірки) та випуском (щойно перетворений покажчик для швидкості).
Матьє М.

Повторне використання цілого індексу таблиці фактично постраждає від проблеми ABA , коли 3вивільняється об'єкт (індекс ), створюється новий об'єкт і, на жаль, 3знову присвоюється індекс . Простіше кажучи, важко мати безпечний механізм життєдіяльності об'єктів в C, якщо посилання підрахунку (поряд з умовами про спільне право власності на об'єкти) не вноситься в явну частину дизайну API.
rwong

2
@rwong: Це лише питання наївної схеми; Ви можете легко інтегрувати лічильник епох, наприклад, так що коли буде вказана стара ручка, ви отримаєте невідповідність епохи.
Матьє М.

1
Пропозиція @JonathonReinhart: у вашому запитанні можна згадати "правило суворого згладжування", щоб допомогти спрямувати дискусію на більш важливі аспекти.
rwong

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);
    }
}

Коли у бібліотеці є два або більше типів структури, які мають однакову частину полів заголовка, як "тип" у наведеному вище, ці типи структур можна вважати спільними батьківськими структурами (як базовий клас у С ++).

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

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) { ... }

Але це необов'язкове рішення, тому що типи лиття потрібні незалежно від використання структури engine.

Висновок

У деяких випадках є причини, через які непрозорі ручки використовуються замість непрозорих названих конструкцій.


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

Але насправді уникнення, switchв першу чергу, використання "віртуальних функцій", мабуть, ідеально, і вирішує всю проблему.
Джонатан Райнхарт

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

Що ж, якщо ви хочете зробити це по-справжньому просто, ви можете повністю пропустити перевірку помилок!
Джонатан Райнхарт

На мою думку, простота дизайну є кращою, ніж деяка кількість перевірок помилок. У цьому випадку такі перевірки помилок існують лише у функціях API. Крім того, ви можете видалити типи типів за допомогою об'єднання, але пам’ятайте, що об'єднання природно небезпечно для типу.
Акіо Такахасі

2

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

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

Оскільки ви так чи інакше надавали б структурам своїм клієнтам, ви жертвуєте трохи безпекою типу (що все ще можна перевірити під час виконання) для набагато простішого API, хоча і такого, який вимагає кастингу.


5
"Ви можете змінювати внутрішні структури без .." - Ви також можете при підході до прямого декларування.
користувач253751

Чи не підхід "вперед-декларування" все ще вимагає від вас оголосити тип підписів? І чи не змінюються підписи цих типів, якщо ви змінюєте структури?
Роберт Харві

Переадресація вимагає лише оголосити ім'я типу - його структура залишається прихованою.
Ідан Ар'є

Тоді яка б користь від прямого оголошення, якщо воно навіть не застосовує структуру типу?
Роберт Харві

6
@RobertHarvey Пригадай - про це ми говоримо. Немає методів, тому крім імені та структури немає нічого іншого типу. Якщо ж застосовувати структуру було б бути ідентичні регулярної декларації. Сенс викриття імені без нав'язування структури полягає в тому, що ви можете використовувати цей тип у підписах функції. Звичайно, без структури ви можете використовувати лише покажчики на тип, оскільки компілятор не може знати його розмір, але оскільки не існує неявного введення покажчика в C за допомогою покажчиків, це досить добре для статичного введення, щоб захистити вас.
Ідан Ар'є

2

Дежавю

Як непрозора ручка краще, ніж названа непрозора структура?

Я зіткнувся з точно таким же сценарієм, лише з деякими тонкими відмінностями. У нас у SDK було багато таких речей:

typedef void* SomeHandle;

Моя проста пропозиція полягала в тому, щоб він відповідав нашим внутрішнім типам:

typedef struct SomeVertex* SomeHandle;

Стороннім сторонам, які використовують SDK, це не має ніякого значення. Це непрозорий тип. Кого хвилює? Це не впливає на сумісність ABI * або джерела, а використання нових версій SDK вимагає, щоб плагін все-таки перекомпілювався.

* Зауважте, що, як зазначає gnasher, насправді можуть бути випадки, коли розмір чогось на зразок покажчика на структуру та недійсність * насправді може бути різного розміру, і в цьому випадку це вплине на ABI. Як і він, я ніколи не стикався з цим на практиці. Але з цієї точки зору, друге може насправді покращити портативність у якомусь незрозумілому контексті, тож це ще одна причина надати перевагу другому, хоча й, мабуть, суперечить більшості людей.

Сторонні помилки

Крім того, у мене було навіть більше причин, ніж безпека типу для внутрішнього розвитку / налагодження. У нас вже було декілька розробників плагінів, які мали помилки у своєму коді, оскільки дві подібні ручки ( Panelі PanelNew, тобто) обидва використовували void*typedef для своїх ручок, і вони випадково переходили неправильними ручками в неправильні місця в результаті простого використання void*за все. Так це насправді спричиняло помилки на стороні тих, хто використовуєSDK. Їх помилки також коштували внутрішній команді розвитку величезний час, оскільки вони надсилатимуть звіти про помилки із скаргами на помилки в нашому SDK, і нам доведеться налагодити плагін і виявити, що це насправді викликано помилкою в плагіні, що передає неправильні ручки в неправильні місця (що легко дозволено навіть без попередження, коли кожна ручка є псевдонімом для 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тут і передаваючи в / з цілого числа без поважних причин, окрім подальшого приховування інформації, яка вже є 99 % приховано для користувача і не може завдати більше шкоди, ніж size_t.


1
Це невелика різниця: Відповідно до стандарту C, всі "вказівники на структуру" мають однакове представлення, так само всі "покажчик на об'єднання", так само "void *" і "char *", але порожнеча * і "pointer щоб структура "може мати різний розмірof () та / або різного представлення. На практиці я цього ніколи не бачив.
gnasher729

@ gnasher729 Це ж, можливо, я мушу кваліфікувати цю частину стосовно потенційної втрати переносимості під час трансляції на void*або size_tназад як іншу причину, щоб уникнути зайвих трансляцій. Я якось опустив це, оскільки я також ніколи не бачив цього на практиці, враховуючи націлені на нас платформи (які завжди були платформами настільних ПК: Linux, OSX, Windows).


1

Я підозрюю, що справжня причина - це інертність, це те, що вони завжди робили, і це працює, тож навіщо це міняти?

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

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

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

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


1

Я вважаю, що ставлення випливає з давньої філософії захищати API бібліотеки C від зловживань з боку початківців.

Зокрема,

  • Автори бібліотеки знають, що це вказівник на структуру, і деталі структури видно в коді бібліотеки.
  • Усі досвідчені програмісти, які використовують бібліотеку, також знають, що це вказівка ​​на деякі непрозорі структури;
    • Вони мали досить важкий болісний досвід, щоб знати, щоб не возитися з байтами, що зберігаються в цих структурах.
  • Недосвідчені програмісти не знають жодного.
    • Вони намагатимуться memcpyнепрозорі дані або збільшують байти чи слова всередині структури. Ідіть на злом.

Давним традиційним контрзаходом є:

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

Це просто сказати, що він має давні традиції; Я не мав особистої думки щодо того, правильно це чи неправильно.


3
За винятком смішного xor, моє рішення забезпечує таку безпеку. Клієнт залишається невідомим розміру та вмісту структури з додатковою перевагою безпеки типу. Я не бачу, як краще зловживати size_t для утримання покажчика.
Джонатан Райнхарт

@JonathonReinhart навряд чи клієнт насправді не знає про структуру. Питання більше: чи можуть вони отримати структуру і чи можуть вони повернути модифіковану версію до вашої бібліотеки. Не тільки з відкритим кодом, але в цілому. Рішення - сучасний розділ пам'яті, а не дурний XOR.
Móż

Про що ти говориш? Я тільки говорю, що ви не можете скласти жоден код, який намагається знеструмити вказівник на зазначену структуру, або зробити все, що вимагає знання її розміру. Звичайно, ви можете запам'ятати (, 0,) протягом усієї маси процесу, якщо цього хочете.
Джонатан Райнхарт

6
Цей аргумент дуже схоже на захист від Макіавеллі . Якщо користувач хоче передати сміття в мій API, я не можу їх зупинити. Введення такого небезпечного інтерфейсу навряд чи допомагає в цьому, оскільки це фактично полегшує випадкове використання API.
ComicSansMS

@ComicSansMS дякую вам за те, що ви згадали про "випадковість", тому що я тут справді намагаюся запобігти.
Джонатан Райнхарт
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.