¿Por qué utilizar un "controlador" opaco que requiere la conversión en una API pública en lugar de un puntero de estructura segura?


27

Estoy evaluando una biblioteca cuya API pública actualmente se ve así:

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

Tenga en cuenta que enhes un identificador genérico, que se utiliza como un identificador para varios tipos de datos diferentes ( motores y ganchos ).

Internamente, la mayoría de estas API, por supuesto, arrojan el "identificador" a una estructura interna que han hecho malloc:

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

Personalmente, odio esconder cosas detrás de typedefs, especialmente cuando compromete la seguridad del tipo. (Dado un enh, ¿cómo sé a qué se refiere realmente?)

Así que envié una solicitud de extracción, sugiriendo el siguiente cambio de API (después de modificar toda la biblioteca para que se ajuste):

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

Por supuesto, esto hace que las implementaciones internas de la API se vean mucho mejor, eliminando los moldes y manteniendo la seguridad del tipo desde / para la perspectiva del consumidor.

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

Prefiero esto por las siguientes razones:

Sin embargo, el propietario del proyecto rechazó la solicitud de extracción (parafraseado):

Personalmente no me gusta la idea de exponer el struct engine. Sigo pensando que la forma actual es más limpia y amigable.

Inicialmente, utilicé otro tipo de datos para el identificador de enlace, pero luego decidí cambiar para usar enh, por lo que todo tipo de identificadores comparten el mismo tipo de datos para simplificarlo. Si esto es confuso, ciertamente podemos usar otro tipo de datos.

Veamos qué piensan los demás sobre este PR.

Esta biblioteca se encuentra actualmente en una etapa beta privada, por lo que no hay mucho código de consumidor de qué preocuparse (todavía). Además, he ofuscado un poco los nombres.


¿Cómo es mejor un mango opaco que una estructura opaca con nombre?

Nota: Hice esta pregunta en Code Review , donde se cerró.


1
He editado el título de algo que creo que expresa más claramente el núcleo de su pregunta. Siéntete libre de revertirlo si lo he malinterpretado.
Ixrec

1
@ Ixrec Eso es mejor, gracias. Después de escribir toda la pregunta, me quedé sin capacidad mental para encontrar un buen título.
Jonathon Reinhart

Respuestas:


33

El mantra "simple es mejor" se ha vuelto demasiado dogmático. Simple no siempre es mejor si complica otras cosas. El ensamblaje es simple: cada comando es mucho más simple que los comandos de idiomas de nivel superior, y sin embargo, los programas de ensamblaje son más complejos que los lenguajes de nivel superior que hacen lo mismo. En su caso, el tipo de mango uniforme enhsimplifica los tipos a costa de hacer que las funciones sean complejas. Dado que, por lo general, los tipos de proyectos tienden a crecer en una tasa sub-lineal en comparación con sus funciones, a medida que el proyecto se hace más grande, generalmente prefiere tipos más complejos si puede simplificar las funciones, por lo que su enfoque parece ser el correcto.

El autor del proyecto está preocupado porque su enfoque es " exponer elstruct engine ". Les habría explicado que no está exponiendo la estructura en sí misma, solo el hecho de que hay una estructura llamada engine. El usuario de la biblioteca ya necesita conocer ese tipo; necesita saber, por ejemplo, que el primer argumento de en_add_hookes de ese tipo, y el primer argumento es de un tipo diferente. Por lo tanto, en realidad hace que la API sea más compleja, porque en lugar de tener el documento de "firma" de la función de estos tipos, debe documentarse en otro lugar, y porque el compilador ya no puede verificar los tipos para el programador.

Una cosa que debe tenerse en cuenta: su nueva API hace que el código de usuario sea un poco más complejo, ya que en lugar de escribir:

enh en;
en_open(ENGINE_MODE_1, &en);

Ahora necesitan una sintaxis más compleja para declarar su identificador:

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

La solución, sin embargo, es bastante simple:

struct _engine;
typedef struct _engine* engine

y ahora puedes escribir directamente:

engine en;
en_open(ENGINE_MODE_1, &en);

Olvidé mencionar que la biblioteca afirma seguir el estilo de codificación de Linux , que también es lo que sigo. Allí, verá que las estructuras de definición de texto solo para evitar la escritura structse desaconsejan expresamente.
Jonathon Reinhart

@JonathonReinhart está escribiendo el puntero para estructurar no la estructura en sí.
monstruo de trinquete

@JonathonReinhart y realmente leyendo ese enlace veo que para "objetos totalmente opacos" está permitido. (Capítulo 5 regla a)
monstruo de trinquete

Sí, pero solo en los casos excepcionalmente raros. Sinceramente, creo que se agregó para evitar reescribir todo el código mm para tratar con los pte typedefs. Mira el código de bloqueo de giro. Es completamente específico del arco (sin datos comunes) pero nunca usan un typedef.
Jonathon Reinhart

8
Preferiría typedef struct engine engine;usar engine*: Se introdujo un nombre menos, y eso hace obvio que es un identificador similar FILE*.
Deduplicador

16

Parece haber una confusión en ambos lados aquí:

  • el uso de un enfoque de controlador no requiere el uso de un solo tipo de controlador para todos los controladores
  • exponer el structnombre no expone sus detalles (solo su existencia)

Hay ventajas en el uso de identificadores en lugar de punteros desnudos, en un lenguaje como C, porque entregar el puntero permite la manipulación directa del puntero (incluidas las llamadas a free), mientras que entregar un identificador requiere que el cliente pase por la API para realizar cualquier acción. .

Sin embargo, el enfoque de tener un solo tipo de identificador, definido a través de a, typedefno es seguro y puede causar muchos dolores.

Mi sugerencia personal sería, por lo tanto, avanzar hacia mangos seguros de tipo, que creo que los satisfaría a ambos. Esto se logra de manera bastante simple:

typedef struct {
    size_t id;
} enh;

typedef struct {
    size_t id;
} oth;

Ahora, uno no puede pasar accidentalmente 2como una manija ni puede pasar accidentalmente una manija a una escoba donde se espera una manija al motor.


Así que envié una solicitud de extracción, sugiriendo el siguiente cambio de API (después de modificar toda la biblioteca para que se ajuste)

Ese es su error: antes de realizar un trabajo significativo en una biblioteca de código abierto, comuníquese con los autores / encargados del mantenimiento para analizar el cambio por adelantado . Les permitirá a ambos ponerse de acuerdo sobre qué hacer (o no hacer) y evitar el trabajo innecesario y la frustración que resulta de ello.


1
Gracias. Sin embargo, no entraste qué hacer con los mangos. Implementé una API basada en un identificador real , donde los punteros nunca están expuestos, incluso a través de un typedef. Involucraba una búsqueda ~ costosa de los datos en la entrada de cada llamada a la API, muy similar a la forma en que Linux busca struct filedesde un int fd. Esto es ciertamente excesivo para una IMO de biblioteca de modo de usuario.
Jonathon Reinhart

@JonathonReinhart: Bueno, dado que la biblioteca ya proporciona identificadores, no sentí la necesidad de expandirme. De hecho, existen múltiples enfoques, desde simplemente convertir el puntero al número entero hasta tener un "grupo" y usar las ID como claves. Incluso puede cambiar el enfoque entre Depurar (ID + búsqueda, para validación) y Release (puntero recién convertido, para velocidad).
Matthieu M.

La reutilización del índice de la tabla de enteros en realidad sufrirá el problema ABA , donde 3se libera un objeto (índice ), luego se crea un nuevo objeto y desafortunadamente se le asigna un índice 3nuevamente. En pocas palabras, es difícil tener un mecanismo seguro de vida útil de los objetos en C a menos que el recuento de referencias (junto con las convenciones sobre la propiedad compartida de los objetos) se convierta en una parte explícita del diseño de la API.
rwong

2
@rwong: Es solo un problema en el esquema ingenuo; puede integrar fácilmente un contador de época, por ejemplo, para que cuando se especifique un identificador antiguo, obtenga una falta de coincidencia de época.
Matthieu M.

1
Sugerencia de @JonathonReinhart: puede mencionar "regla de alias estricta" en su pregunta para ayudar a dirigir la discusión hacia los aspectos más importantes.
rwong

3

Aquí hay una situación en la que se necesita un asa opaca;

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

Cuando la biblioteca tiene dos o más tipos de estructura que tienen una misma parte de encabezado de campos, como "tipo" en lo anterior, estos tipos de estructura pueden considerarse que tienen una estructura principal común (como una clase base en C ++).

Puede definir la parte del encabezado como "motor de estructura", de esta manera;

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

Pero es una decisión opcional porque los tipos de conversión son necesarios independientemente del uso del motor de estructura.

Conclusión

En algunos casos, hay razones por las que se utilizan controladores opacos en lugar de estructuras con nombre opacas.


Creo que el uso de una unión hace que esto sea más seguro en lugar de lanzamientos peligrosos a los campos que podrían moverse. Echa un vistazo a esta esencia que reuní mostrando un ejemplo completo.
Jonathon Reinhart

Pero, en realidad, evitar el switchuso de "funciones virtuales" es probablemente ideal y resuelve todo el problema.
Jonathon Reinhart

Su diseño en esencia es más complejo de lo que sugerí. Seguramente, hace que la conversión sea menos segura y segura, pero introduce más código y tipos. En mi opinión, parece ser demasiado complicado obtener una escritura segura. Yo, y tal vez el autor de la biblioteca, decido seguir KISS en lugar de escribir con seguridad.
Akio Takahashi

Bueno, si quieres que sea realmente simple, ¡también puedes omitir por completo la verificación de errores!
Jonathon Reinhart

En mi opinión, se prefiere la simplicidad del diseño que cierta cantidad de verificaciones de errores. En este caso, tales comprobaciones de errores solo existen en las funciones API. Además, puede eliminar los tipos de letra mediante la unión, pero recuerde que la unión es naturalmente insegura.
Akio Takahashi

2

El beneficio más obvio del enfoque de controladores es que puede modificar las estructuras internas sin romper la API externa. De acuerdo, todavía tiene que modificar el software del cliente, pero al menos no está cambiando la interfaz.

La otra cosa que hace es proporcionar la capacidad de elegir entre muchos tipos diferentes posibles en tiempo de ejecución, sin tener que proporcionar una interfaz API explícita para cada uno. Algunas aplicaciones, como las lecturas de sensores de varios tipos de sensores diferentes donde cada sensor es ligeramente diferente y genera datos ligeramente diferentes, responden bien a este enfoque.

Como de todos modos proporcionaría las estructuras a sus clientes, sacrifica un poco la seguridad de los tipos (que aún se puede verificar en tiempo de ejecución) para obtener una API mucho más simple, aunque una que requiere conversión.


55
"Puede modificar las estructuras internas sin ..." - también puede hacerlo con el enfoque de declaración directa.
user253751

¿El enfoque de "declaración directa" todavía no requiere que declare las firmas de tipo? ¿Y esas firmas tipo no cambian aún si cambias las estructuras?
Robert Harvey

La declaración directa solo requiere que declares el nombre del tipo; su estructura permanece oculta.
Idan Arye

Entonces, ¿cuál sería el beneficio de la declaración directa si ni siquiera aplica la estructura tipográfica?
Robert Harvey

66
@RobertHarvey Recuerda: estamos hablando de C No hay métodos, por lo que aparte del nombre y la estructura, no hay nada más en el tipo. Si hiciera cumplir la estructura, habría sido idéntico a la declaración regular. El punto de exponer el nombre sin forzar la estructura es que puede usar ese tipo en las firmas de funciones. Por supuesto, sin la estructura, solo puede usar punteros para el tipo, ya que el compilador no puede saber su tamaño, pero dado que no hay una conversión de puntero implícita en C el uso de punteros es lo suficientemente bueno como para que la escritura estática lo proteja.
Idan Arye

2

Deja Vu

¿Cómo es mejor un mango opaco que una estructura opaca con nombre?

Encontré exactamente el mismo escenario, solo que con algunas diferencias sutiles. Teníamos, en nuestro SDK, muchas cosas como esta:

typedef void* SomeHandle;

Mi mera propuesta fue hacer que coincida con nuestros tipos internos:

typedef struct SomeVertex* SomeHandle;

Para terceros que usan el SDK, no debería hacer ninguna diferencia. Es un tipo opaco. ¿A quien le importa? No tiene ningún efecto sobre ABI * o la compatibilidad de fuente, y el uso de nuevas versiones del SDK requiere que el complemento se vuelva a compilar de todos modos.

* Tenga en cuenta que, como señala Gnasher, en realidad puede haber casos en los que el tamaño de algo como puntero para estructurar y anular * en realidad puede ser un tamaño diferente, en cuyo caso afectaría a ABI. Como él, nunca lo he encontrado en la práctica. Pero desde ese punto de vista, el segundo en realidad podría mejorar la portabilidad en un contexto oscuro, por lo que esa es otra razón para favorecer al segundo, aunque probablemente sea discutible para la mayoría de las personas.

Errores de terceros

Además, tenía incluso más causas que la seguridad de tipo para el desarrollo / depuración interna. Ya teníamos una serie de desarrolladores de complementos que tenían errores en su código porque dos identificadores similares ( Panely PanelNew, es decir, ambos) usaban un void*typedef para sus identificadores, y accidentalmente pasaban los identificadores incorrectos a los lugares incorrectos como resultado de simplemente usar void*para todo. Por lo tanto, en realidad estaba causando errores en el lado de aquellos que usaban SDK. Sus errores también le costaron al equipo de desarrollo interno un tiempo enorme, ya que enviarían informes de errores quejándose de errores en nuestro SDK, y tendríamos que depurar el complemento y descubrir que en realidad fue causado por un error en el complemento que pasa por los controladores incorrectos a los lugares equivocados (que se permite fácilmente sin siquiera una advertencia cuando cada identificador es un alias paravoid*o size_t) Así que estábamos desperdiciando innecesariamente nuestro tiempo proporcionando un servicio de depuración para terceros debido a los errores causados ​​por su parte por nuestro deseo de pureza conceptual al ocultar toda la información interna, incluso los simples nombres de nuestro interno structs.

Manteniendo el Typedef

La diferencia es que estaba proponiendo que nos atenemos a la imagen typedeffija, para que los clientes no escriban, struct SomeVertexlo que afectaría la compatibilidad de origen para futuras versiones de complementos. Si bien personalmente me gusta la idea de no escribir structen C, desde la perspectiva del SDK, typedefpuede ayudar, ya que todo el punto es la opacidad. Entonces, sugiero que se relaje este estándar solo para la API expuesta públicamente. Para los clientes que usan el SDK, no debería importar si un identificador es un puntero a una estructura, un entero, etc. Lo único que les importa es que dos identificadores diferentes no alias el mismo tipo de datos para que no Pase incorrectamente el asa incorrecta al lugar equivocado.

Clasificar información

Donde más importa evitar el casting es para ti, los desarrolladores internos. Este tipo de estética de ocultar todos los nombres internos del SDK es una estética conceptual que conlleva el costo significativo de perder toda la información de tipo, y que requiere que rocíemos innecesariamente los moldes en nuestros depuradores para obtener información crítica. Si bien un programador en C debería estar acostumbrado a esto en C, exigirlo innecesariamente es solo pedir problemas.

Ideales conceptuales

En general, debe tener cuidado con los tipos de desarrolladores que ponen alguna idea conceptual de la pureza muy por encima de todas las necesidades prácticas diarias. Eso impulsará la mantenibilidad de su base de código en el terreno al buscar algún ideal utópico, haciendo que todo el equipo evite la loción bronceadora en un desierto por temor a que no sea natural y pueda causar una deficiencia de vitamina D mientras la mitad del equipo muere de cáncer de piel.

Preferencia de usuario final

Incluso desde el punto de vista estricto del usuario final de aquellos que usan la API, ¿preferirían una API defectuosa o una API que funcione bien pero exponga algún nombre que difícilmente podrían importarles a cambio? Porque esa es la compensación práctica. Perder información de tipo innecesariamente fuera de un contexto genérico aumenta el riesgo de errores, y desde una base de código a gran escala en un entorno de todo el equipo durante varios años, la ley de Murphy tiende a ser bastante aplicable. Si aumenta excesivamente el riesgo de errores, es probable que al menos obtenga algunos errores más. En un entorno de equipo grande, no lleva mucho tiempo descubrir que todo tipo de error humano imaginable eventualmente pasará de ser potencial a ser realidad.

Entonces, tal vez sea una pregunta para los usuarios. "¿Preferiría un SDK con errores o uno que exponga algunos nombres opacos internos que nunca le interesarán?" Y si esa pregunta parece presentar una falsa dicotomía, diría que se necesita más experiencia en todo el equipo en un entorno de gran escala para apreciar el hecho de que un mayor riesgo de errores finalmente manifestará errores reales a largo plazo. Poco importa cuánto confía el desarrollador en evitar errores. En un entorno de todo el equipo, es más útil pensar en los enlaces más débiles y, al menos, en las formas más fáciles y rápidas para evitar que se tropiecen.

Propuesta

Así que sugeriría un compromiso aquí que aún le dará la capacidad de retener todos los beneficios de depuración:

typedef struct engine* enh;

... incluso a costa de descartar struct, ¿eso realmente nos matará? Probablemente no, por lo que también recomiendo un poco de pragmatismo de su parte, pero más aún para el desarrollador que preferiría hacer la depuración exponencialmente más difícil al usar size_taquí y enviar a / desde entero sin una buena razón, excepto para ocultar aún más la información que ya es 99 % oculto para el usuario y no puede hacer más daño que size_t.


1
Es una pequeña diferencia: de acuerdo con el Estándar C, todos los "punteros a estructuras" tienen una representación idéntica, así que todos los "punteros a la unión", al igual que "void *" y "char *", pero un vacío * y un "puntero" a struct "puede tener diferentes tamaños de () y / o diferentes representaciones. En la práctica, nunca he visto esto.
gnasher729

@ gnasher729 mismo, tal vez debería calificar esa parte con respecto a la posible pérdida de portabilidad en la fundición hasta void*o size_tida y vuelta como otra razón para evitar la proyección de manera superflua. Lo omití un poco, ya que tampoco lo he visto en la práctica, dadas las plataformas a las que apuntamos (que siempre fueron plataformas de escritorio: Linux, OSX, Windows).


1

Sospecho que la verdadera razón es la inercia, eso es lo que siempre han hecho y funciona, ¿por qué cambiarlo?

La razón principal por la que puedo ver es que el mango opaco le permite al diseñador poner algo detrás, no solo una estructura. Si la API regresa y acepta múltiples tipos opacos, todos se ven iguales para la persona que llama y nunca hay problemas de compilación o recompilaciones si la letra pequeña cambia. Si en_NewFlidgetTwiddler (handle ** newTwiddler) cambia para devolver un puntero al Twiddler en lugar de un manejador, la API no cambia y cualquier código nuevo usará silenciosamente un puntero donde antes estaba usando un manejador. Además, no hay peligro de que el sistema operativo o cualquier otra cosa "repare" silenciosamente el puntero si cruza los límites.

La desventaja de eso, por supuesto, es que la persona que llama puede alimentar cualquier cosa. ¿Tienes una cosa de 64 bits? Empújelo en la ranura de 64 bits en la llamada API y vea qué sucede.

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

Ambos compilan pero apuesto a que solo uno de ellos hace lo que quieres.


1

Creo que la actitud se deriva de una filosofía de mucho tiempo para defender una API de biblioteca C del abuso por parte de principiantes.

En particular,

  • Los autores de la biblioteca saben que es un puntero a la estructura, y los detalles de la estructura son visibles para el código de la biblioteca.
  • Todos los programadores experimentados que usan la biblioteca también saben que es un puntero a algunas estructuras opacas;
    • Tenían suficiente experiencia dolorosa y dura para saber que no debían meterse con los bytes almacenados en esas estructuras.
  • Los programadores sin experiencia tampoco lo saben.
    • Intentarán con memcpylos datos opacos o aumentarán los bytes o palabras dentro de la estructura. Ve a hackear.

La contramedida tradicional desde hace mucho tiempo es:

  • Enmascarar el hecho de que un controlador opaco es en realidad un puntero a una estructura opaca que existe en el mismo espacio de memoria de proceso.
    • Para hacerlo afirmando que es un valor entero que tiene el mismo número de bits que un void*
    • Para ser más circunspecto, enmascarar los bits del puntero también, por ejemplo
      struct engine* peng = (struct engine*)((size_t)enh ^ enh_magic_number);

Esto es solo para decir que tiene una larga tradición; No tenía una opinión personal sobre si era correcto o incorrecto.


3
Excepto por el ridículo xor, mi solución también brinda esa seguridad. El cliente desconoce el tamaño o el contenido de la estructura, con el beneficio adicional de la seguridad de tipo. No veo usar cómo abusar de un size_t para mantener un puntero es mejor.
Jonathon Reinhart

@JonathonReinhart es extremadamente improbable que el cliente realmente desconozca la estructura. La pregunta es más: ¿pueden obtener la estructura y pueden devolver una versión modificada a su biblioteca? No solo con código abierto, sino en general. La solución es la partición de memoria moderna, no el tonto XOR.
Más

¿De qué estás hablando? Todo lo que digo es que no puede compilar ningún código que intente desreferenciar un puntero a dicha estructura, o hacer algo que requiera el conocimiento de su tamaño. Claro, usted podría memset (, 0,) sobre el montón de todo el proceso, si realmente lo desea.
Jonathon Reinhart

66
Este argumento se parece mucho a la protección contra Maquiavelo . Si el usuario quiere pasar basura a mi API, no hay forma de que pueda detenerlos. La introducción de una interfaz de tipo inseguro como esta difícilmente ayuda con eso, ya que en realidad facilita el uso accidental de la API.
ComicSansMS

@ComicSansMS gracias por mencionar "accidental", ya que eso es lo que realmente estoy tratando de evitar aquí.
Jonathon Reinhart
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.