4 puntos por GN⁺ 2023-10-10 | 1 comentarios | Compartir por WhatsApp
  • En 2023 cambió bastante mi forma de escribir código C, y el estilo se reorganizó alrededor de nombres de tipos cortos, evitar cadenas terminadas en nulo, devolver estructuras y compilar en una sola unidad de traducción
  • Los alias cortos como u8, i32, size, s8, y la eliminación de const y struct, son decisiones para reducir el ruido visual y la carga cognitiva en declaraciones repetitivas
  • Las cadenas se manejan no como terminadas en nulo, sino como un fat pointer s8 con data y len; en entornos Win32 y UTF-16 se usan también c16 y s16
  • En el diseño de funciones se prefiere devolver estructuras antes que usar parámetros de salida, con un patrón donde el valor de retorno se inicializa en cero y ok solo se establece en el momento de éxito
  • Se priorizan reglas locales legibles incluso para macros, assert, declaraciones Win32 y ensamblador inline, pero al contribuir a otros proyectos se sigue el estilo de ese proyecto

Expresar la intención con nombres de tipos cortos

  • Se usan alias cortos para enteros básicos, caracteres y tipos del tamaño de punteros
    • Ejemplos: u8, c16, b32, i32, u32, u64, f32, f64, uptr, byte, size, usize
  • Como estos nombres aparecen con frecuencia en todo el programa, la concisión aporta beneficios directos para la lectura y la revisión
  • Ya no se usa el sufijo _t; ahora se siente como un elemento visualmente distractor
  • Para el prefijo de tipos signed se prefiere i antes que s
    • s se reserva para nombres de tipos de cadena
  • Para el tipo de tamaño se usa size en vez de isize
    • Porque se considera que el tamaño signed es el valor predeterminado más importante
    • usize tiene un uso más acotado, principalmente al interactuar con interfaces externas
  • b32 expresa la intención de “booleano de 32 bits”
    • Usa un tamaño de palabra natural en vez de _Bool
    • En la práctica, se asume que a menudo está en un registro o dentro del padding de una estructura
    • Cuando la memoria realmente importa, los booleanos se compactan en una variable flags
  • c16 es el tipo para caracteres UTF-16 necesario en Win32
    • Si se basa en char16_t, ayuda a que depuradores como GDB muestren los datos como caracteres
    • El nombre oficial del tipo en Win32 es wchar_t, pero se prefiere explicitar UTF-16
  • u8 se usa para octets y principalmente datos UTF-8, mientras que byte se distingue para memoria cruda y como tipo especial de aliasing
  • Preocuparse por sistemas que no soportan tipos de ancho fijo se considera poco práctico
    • Los nombres largos como int_fast32_t también se consideran un desperdicio innecesario
  • Cuando se muestran fragmentos de código aislados, no se usan estos alias por sí solos
    • Porque para que el lector entienda el contexto también se necesitaría el typedef

Reglas para macros y assert

  • Las macros con forma de función usan minúsculas
    • Ejemplos: countof(a), lengthof(s), new(a, t, n)
  • Para constantes se sigue prefiriendo ALL_CAPS, pero en macros con forma de función se considera que las minúsculas son más legibles
  • Las macros con forma de función tienen menos problemas de namespace que las macros comunes
    • Se puede tener al mismo tiempo una macro new() y variables o campos new
    • Porque si no tiene forma de llamada a función, no se expande como macro
  • Para GCC y Clang, la macro assert usa la forma while (!(c)) __builtin_unreachable()
  • Este enfoque de assert no requiere separar configuraciones de build
    • No hace falta tener definiciones distintas para builds de debug y release
    • Si opera o no se controla por la presencia de Undefined Behavior Sanitizer, es decir, UBSan
    • libubsan proporciona salida de diagnóstico con nombre de archivo y número de línea
    • En builds de release se convierte en una pista práctica de optimización
  • Para activar assertions en builds de release, se pone UBSan en modo trap con -fsanitize-trap y se activa como mínimo -fsanitize=unreachable
  • En teoría también sería posible con -funreachable-traps, pero al momento de escribir esto está roto en varias versiones recientes de GCC

Cosas que se reducen en las declaraciones

  • No se usa const en parámetros
    • Se considera que no tiene un rol práctico en la optimización
    • No se recuerdan casos en los que haya detectado, o habría detectado, errores
    • Un buen nombre de parámetro se considera suficiente como documentación del prototipo
  • Eliminar const fue un cambio que aumentó la productividad al reducir la carga cognitiva y el ruido visual
  • Como pequeña excepción, se sigue prefiriendo const como pista para colocar tablas estáticas en memoria de solo lectura cerca del código
    • Si hace falta, se elimina const mediante casting
  • Para punteros nulos se usa el literal 0
    • Es un estilo usado desde hace unos 7 años
    • Aunque existe una posible falla teórica, no se han visto casos reales en cientos de miles de líneas de código
  • restrict se usa solo cuando hace falta
    • El código se organiza evitando usar parámetros de salida en loops, o evitando directamente los parámetros de salida
  • No se usa inline
    • Porque todo se compila como una sola unidad de traducción
  • Todas las estructuras llevan typedef
    • Quitar la palabra clave struct hace que el código sea más fácil de leer
    • Las estructuras recursivas tienen una forward declaration justo encima, y en los campos se usan nombres cortos
  • Todas las funciones excepto el entry point se declaran como static
    • Porque se asume la compilación en una sola unidad de traducción
  • Gracias a los nombres de tipos cortos y a eliminar const y struct, resulta cómodo poner el tipo de retorno y el nombre de la función en la misma línea
  • Hubo una época en que los nombres de tipos se escribían con mayúscula inicial, pero finalmente se abandonó

Cadenas con s8 en vez de terminación nula

  • Uno de los cambios más productivos fue rechazar por completo las cadenas terminadas en nulo y pasar a usar el tipo de cadena s8 con data y len
  • Estructura de s8:
    • u8 *data
    • size len
  • La macro s8(s) envuelve un literal de cadena de C como cadena s8
  • s8 se pasa y se devuelve por valor, como un fat pointer
  • s8 también funciona bien como prefijo de funciones
    • Porque los nombres de la familia str están reservados
    • Ejemplos: s8span, s8equals, s8compare, s8hash, s8trim, s8clone
  • Para comparar literales se usa una forma como s8equals(tagname, s8("body"))
  • También se probó un enfoque que une el tamaño y el arreglo en una sola allocation mediante un flexible array member, pero la falta de flexibilidad pesa más que sus ventajas
  • Hubo casos en que se pensó que un programa simple no necesitaba un tipo de cadena, pero en general esa fue una evaluación equivocada
  • También se usa s16 como tipo compatible con UTF-16
    • Tiene c16 *data y size len
    • Todavía no hay plena convicción sobre el enfoque de agregar u a los literales en la macro

Devolución de estructuras y forma de inicializar

  • Se prefiere devolver estructuras antes que usar parámetros de salida
    • En la práctica es una forma de devolver múltiples valores, aunque no haya destructuring
  • El ejemplo i32parse(s8) devuelve juntos value, el resultado del parseo, y ok, el estado
  • El costo de copias adicionales no se considera un problema importante en la práctica
    • Porque la convención de llamada puede convertirlo en un parámetro de salida restrict oculto, o
    • si se inlinea, el overhead del valor de retorno deja de importar
  • Este enfoque reduce la tentación de señalar errores con valores in-band, como un retorno nulo especial
  • Se prefiere el patrón de crear al inicio de la función un valor de retorno inicializado en cero y usarlo en todos los return
    • En caso de error, se devuelve de inmediato en estado inicializado en cero
    • En la ruta de éxito, ok se establece en true justo antes de devolver
  • Salvo para datos estáticos y macros s8/s16, también se reduce el uso de initializers
    • También se evitan los designated initializers, y se inicializa mediante asignaciones
  • Inicializar con asignaciones es legible, y entre cada asignación hay un sequence point que aporta un orden explícito
  • En inicializaciones donde el orden de llamadas puede afectar el resultado, como en funciones de generación de números aleatorios, no hace falta pensar en los casos de valores posibles

Declaraciones Win32 y ensamblador inline

  • Se prefiere __attribute antes que __attribute__
    • El sufijo __ final se considera excesivo e innecesario
  • En programación de sistemas Win32 no se incluye windows.h; se escriben directamente los prototipos necesarios
    • Porque por lo general no hacen falta muchas declaraciones y definiciones
    • Reduce el tiempo de build y ensucia menos el namespace
    • Encaja más limpiamente con tipos personalizados como u32, b32, uptr en vez de DWORD, BOOL, ULONG_PTR
  • Los ejemplos de declaraciones Win32 usan la macro W32(r) __declspec(dllimport) r __stdcall
    • Se declaran directamente funciones como ExitProcess, GetStdHandle, VirtualAlloc, WriteConsoleA, WriteConsoleW
  • En ensamblador inline, los paréntesis externos se tratan como llaves
    • Como en if, se deja un espacio antes del paréntesis de apertura
    • Cada línea de constraint empieza con dos puntos
  • Como ejemplo donde se puede ver este estilo en un programa pequeño está wordhist.c
  • Como ejemplo un poco más grande está asmint.c, una implementación de un mini lenguaje de programación

1 comentarios

 
GN⁺ 2023-10-10
Opiniones de Hacker News
  • Parece que consideró que #define sizeof(x) (size)sizeof(x) no necesitaba paréntesis externos, pero hay una excepción muy menor.
    El casteo tiene mayor precedencia que la multiplicación, así que sizeof(x) * 3 funciona de forma segura como (size)sizeof(x) * 3.
    Sin embargo, en (size)sizeof(x)[y], la indexación de arreglo se aplica antes que el casteo, por lo que se convierte en (size)(sizeof(x)[y]), no en ((size)sizeof(x))[y].
    En código real no habría motivo para indexar sizeof(x), pero como C permite integer[pointer] con el mismo significado que pointer[integer], esta macro puede compilar y comportarse mal por falta de paréntesis.
    Más de fondo, también me cuesta estar de acuerdo con el argumento de que un signed size sea mejor. El autor dice que los tamaños sin signo son una fuente de defectos, pero el código presentado también tiene un bug que corrompe memoria si count es negativo.
    Con enteros sin signo, una cantidad negativa no se puede representar y, si hay overflow, se vuelve un número positivo muy grande que cae en las validaciones existentes. Personalmente prefiero usar enteros sin signo, pero en lo posible con un wrapper de validación de rango que detenga la ejecución ante un overflow.

    • La semántica de _Bool, en cambio, sí me gusta.
      Porque una expresión que funciona bien en if (flags & FLAG_ALLOCATED) se puede extraer a una variable booleana como _Bool need_free = flags & FLAG_ALLOCATED;.
      flags & FLAG_ALLOCATED, cuando está activado, puede ser un valor distinto de cero arbitrario y no necesariamente 1; _Bool lo normaliza a 1. Si se guarda en un int, if (need_free) pasa, pero if (need_free == true) puede fallar.
      También tiene desventajas. Durante una refactorización, si se pasa por alto que la conversión implícita a _Bool estaba haciendo algo útil, se puede terminar con código incorrecto como if ((flags & FLAG_ALLOCATED) == true).
      Además, al leer una estructura desde disco o al rellenarla con bytes arbitrarios, si un campo _Bool no es 0 o 1, existe riesgo de comportamiento indefinido.
    • De hecho, (size)(sizeof(x)[y]) también sorprendería a muchos, pero es lo mismo que (size)(sizeof ((x)[y])).
      sizeof no es una función, sino un operador unario, y la indexación y las llamadas a función tienen mayor precedencia que sizeof. Por eso prefiero dejar un espacio después de sizeof y usar paréntesis en el operando solo cuando haga falta.
      https://en.cppreference.com/w/c/language/operator_precedence
      Para escribir bien la macro, sería #define sizeof(x) ((size)(sizeof (x))).
    • Buena observación. La lección es que, si una definición de macro no se expande a un solo token, siempre hay que envolverla entre paréntesis. Las reglas de precedencia de C son realmente complejas.
  • Definir tus propios tipos parece ir un paso demasiado lejos.
    Incluso alguien que ya conoce los tipos de C tendría que aprender un sistema peculiar aparte para entender un programa. Es razonable explicitar los tamaños, así que entiendo usar uint32_t en vez de uint.
    Esos tipos deberían estar definidos en el header adecuado, y puede que me equivoque porque hace mucho que no uso C.

    • En la práctica, el int de C es de 32 bits.
      No en targets de 16 bits, pero ¿de verdad vas a portar un programa de 5 MB a 16 bits? Esa preocupación casi nunca vale la pena.
      El problema es long. En algunas máquinas es de 32 bits y en otras de 64 bits, lo que resulta confuso. Por suerte, long long siempre es de 64 bits, así que basta con abandonar long.
      char de 8 bits, short de 16 bits, int de 32 bits, long long de 64 bits, y listo. En C hemos perdido una cantidad interminable de tiempo con el tamaño de int.
    • El autor sí lo acotó como su estilo personal de programación. Sinceramente, los tipos estándar son demasiado verbosos, y pienso que habría sido bueno que esta lista concisa que propone se hubiera adoptado antes.
    • Dicho medio en broma, una parte considerable de programar consiste en lidiar con el sistema de tipos de otros.
      Para quienes usan C con frecuencia, las abreviaturas que aparecen aquí son familiares, y para ser un sistema de tipos personalizado, es bastante elegante. Recuerda a Rust.
    • Esos tipos están en stdint.h.
      Siempre me sorprende ver que muchos proyectos recrean este archivo trabajosamente.
      Traducir los tipos estándar a nombres propios resulta molesto para quien lee. Una vez pregunté en un proyecto de C++ por qué usaban tantos typedef para colecciones, referencias y objetos compuestos, y me respondieron que así era más fácil de entender.
      Más tarde vi que esa persona tenía pegada junto al monitor una chuleta de typedefs.
    • Definir tipos enteros propios tiene sentido en plataformas con recursos limitados.
      Es común ver tipos como dim_t, que según el uso pueden ser de 32 o 64 bits. Incluso en plataformas de 64 bits, en estructuras con compresión de punteros se usan con frecuencia enteros de 32 bits.
      Por ejemplo, si se asigna un heap propio y solo se guardan offsets de 32 bits, en cargas de trabajo de menos de 4 GB se reduce a la mitad el uso de memoria, y además mejora la localidad de caché, lo que aumenta el rendimiento.
  • Abandonar las convenciones establecidas de C por preferencias personales parece un poco excesivo
    Usar u8, i32 en lugar de uint8_t o int32_t puede ahorrar algunos caracteres, pero puede confundir a otras personas al leer el código
    Usar un tipo de cadena personalizado en lugar de cadenas terminadas en nulo también da la sensación de aumentar la dificultad de colaborar, considerando que C fue creado alrededor de ese tipo de cadenas
    Escribir directamente los prototipos de la API Win32 sin incluir windows.h puede reducir el tiempo de compilación, pero se siente como tomar un sendero en el bosque cuando ya hay una autopista bien mantenida. Gran parte de esto parece más una preferencia personal que código C fácil de manejar para todos

    • u8 o i32 no buscan ahorrar teclas, sino reducir la carga sensorial al leer
      El argumento de “cantidad de teclas” que siempre aparece en el debate entre verbosidad y concisión tiene muchas fallas. Es falsa la creencia de que la concisión solo sirve para tipear más rápido y que la verbosidad siempre es mejor para leer
      La verbosidad también tiene ventajas para la comprensión, pero la concisión también las tiene, y ninguna de las dos es claramente la ganadora. Son solo compromisos distintos
    • Nombres como u16 se usan mucho y es poco probable que confundan a un programador
      El punto donde realmente se rompe es cuando dos programas distintos definen cada uno u16 y lo exponen en archivos de encabezado, y luego un tercer programa incluye ambos encabezados al mismo tiempo
      Los tipos de biblioteca con namespace terminan con formas como libname_u32, y para ese punto uno acaba queriendo usar simplemente uint32_t en lugar del prefijo libname_
    • La posibilidad de que un programador C competente se confunda al ver u8 o i32 es, como mucho, teórica, y suena un poco a argumento de hombre de paja
      Puede que le moleste, pero no se confundirá. Como dijo Rich Hickey, todo es difícil de leer antes de aprender a leerlo
  • Dicen que usar booleanos de 32 bits puede parecerle a un principiante un desperdicio de memoria; entonces supongo que yo también soy principiante
    He visto algunos casos donde no es peor que un bool de 8 bits, pero no veo casos donde sea realmente mejor. Si hay booleanos adyacentes en una estructura, o si una variable booleana de una función se desplaza de los registros a la pila, igual se desperdicia memoria
    Aunque sean unos pocos bytes, no entiendo por qué pesimizarlo a propósito. ¿Qué se obtiene usando un tamaño mayor?

    • Depende por completo de la arquitectura y del CPU, pero según experiencias anteriores, un caso claro fue el procesamiento numérico
      Había un valor condicional al inicio de una estructura por ciclo, seguido por 512, 1024 o 2048 valores de muestra, y un junior, para ahorrar espacio, empaquetó la estructura e hizo que el valor condicional fuera de 8 bits, un byte
      Ese código “mejorado” redujo el throughput unas 10 veces en chips Intel y generó un BUS ERROR en la arquitectura RISC SPARC
      Al empaquetar el encabezado de la estructura, el arreglo de datos quedó sin alinear; Intel tuvo que traer silenciosamente dos palabras de 32 bits y combinarlas, mientras que SPARC se enojó correctamente con los datos no alineados
      Si no se trata de almacenamiento de archivos a largo plazo, sino de cálculos en pipeline donde importa el throughput, a veces conviene ajustar los datos a la alineación de la arquitectura en lugar de empaquetarlos para “ahorrar espacio”
    • Por lo general, la optimización fácil es rellenar los campos de la estructura para que queden en límites de 32 bits
      Casi todos los compiladores lo hacen, así que basta con buscar “alineación/padding de estructuras”. Si el compilador de todos modos va a dejar espacio vacío, es mejor usar esa memoria directamente; de lo contrario, se puede perder rendimiento
      Más precisamente, cada campo debería estar en una dirección divisible por su propio tamaño o por el tamaño de la línea de palabra, y la estructura completa también debería rellenarse hasta ser múltiplo del tamaño del campo más grande. En la práctica, normalmente significa alineación de 32 bits
      Referencia: http://www.catb.org/esr/structure-packing/
    • Si se usa el tipo bool real, el sanitizer avisa cuando el valor no es 0 para false ni 1 para true
    • La mayoría de las arquitecturas de computadoras están optimizadas para accesos alineados de 32 bits o más. Lo que se obtiene suele ser, aunque no siempre, rendimiento
    • Me da curiosidad cuál sería un ejemplo de función donde una variable booleana se derrame a la pila y esos 3 bytes importen
  • No estoy de acuerdo con el argumento sobre devolver estructuras y usar parámetros de salida
    Hace mucho más difícil componer funciones que pueden devolver errores y multiplica los tipos por todas partes. En la práctica, casi cualquier función puede fallar, así que, sobre todo si se maneja incluso la falta de memoria, es más importante tener un estilo predecible de devolución de errores

    • Casi nadie maneja la falta de memoria
      Es muy difícil y aporta muy poco. En ese punto aparecen problemas mucho más distintos que una elección de estilo de programación
    • Si fuera posible desempaquetar estructuras de forma significativa, podríamos obtener la semántica habitual de errno y parámetros de salida, pero C no la tiene
      Aun así, componer valores opcionales en C siempre fue algo doloroso. Si no se usan excepciones, en muchos lenguajes las excepciones y las mónadas parecen las dos grandes opciones, pero ninguna encaja en C ni con la filosofía de la mayoría de los programadores C
      Para llamadas simples uno a uno se podrían intentar macros, pero tienen límites. Aunque C++ sea terrible, C++ optional es más agradable de usar que if(foo(x,y, out1, out2) != WHATEVER_LIBRARY_OK) { ... }
    • Lo correcto sería devolver opciones o tipos suma, pero en C es realmente engorroso escribirlos
      El patrón de agregar if (thing(...)) goto fail en cada llamada a función tampoco parece gran cosa, aunque a la gente de Go parece gustarle
      O bien está thread_local mylibrary_errno, que dentro de una biblioteca podría ser realmente la forma correcta, y en los límites se puede convertir a un valor de retorno enumerado
  • Quiero decir que con “signed sizes are the way” ya era suficiente para dejar de leer
    Un tamaño con signo es una fuga de abstracción muy sorprendente y una receta para el desastre
    También cuesta aceptar que const no tenga un rol práctico y que nunca haya ayudado a detectar errores. La gente suele confundir buffers de entrada y de salida, y const lo deja en evidencia de inmediato
    Eso de dejar todas las funciones como static, salvo los puntos de entrada, también puede hacer que al depurar no encuentres variables o funciones y termines maldiciendo al autor
    Preferir devolver estructuras facilita que por error se devuelva un puntero de pila y se abra un gran agujero de seguridad. Si pasas un buffer de salida, la semántica de propiedad queda clara
    Este consejo quizá funcione más o menos para quienes escriben código de sistema en 64 bits, pero en el mundo embebido de 32 bits puede meterte en problemas rápidamente

    • Bjarne Stroustrup escribió una nota detallada defendiendo los tamaños con signo
      https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p14...
    • A quienes odian const nunca los vas a dejar satisfechos, así que usa const donde haga falta, propágalo lo necesario e ignora las quejas
      Si lo quitan, lo vuelves a poner. Siempre son ellos los que se cansan primero. Llevo 25 años haciéndolo así y sigo aquí
      static puede depender de la herramienta. Hace unos 15 años me pasé a static por defecto y a usar size_t en todas partes, y todavía no he tenido problemas
  • Es interesante que mi experiencia haya ido en otra dirección
    https://dlang.org/blog/2023/10/02/crafting-self-evident-code...
    El artículo está escrito alrededor de D, pero los principios también aplican a C

    • Me pareció una lectura entretenida
      Me llamó la atención la parte de mover las condiciones dentro de doX() y doZ(). No sé si siempre sea correcto; depende de dónde esté la abstracción y del modelo mental del código
      Por ejemplo, deleteRecords(); no es mejor que if let x = deadRecords() deleteRecords(x);. Lo segundo se ve más desordenado, pero tiene el valor de mostrar al frente que no es borrar, sino podar
      Si renombras la función con criterio, como pruneDeadProjects(), puede estar bien, pero simplemente mover la condición dentro de la función puede volver peligroso el contexto y convertirse en una abstracción con fugas
  • Estoy a favor de usar typedef para todas las estructuras, porque ayuda a la concisión
    Creo que se puede usar typedef con generosidad. Eso sí, conviene hacer typedef solo del objeto en sí, no de los punteros. Si necesitas un puntero, siempre puedes escribir (type *)
    En particular, con los punteros a función no deberías hacer typedef del puntero, sino de la función misma. Así también puedes usar ese typedef en las declaraciones de funciones para obtener verificación de tipos de parámetros, y no tienes que corregir todas las declaraciones cuando cambia la firma de la función
    La mayoría de las bases de código en C hacen esto mal: hacen typedef del puntero a función y aun así tienen que escribir manualmente las declaraciones de funciones ajustadas a esa definición de puntero
    Todavía no me convence usar estructuras como tipo de retorno. Prefiero dejar un código numérico de error como valor de retorno y recibir el resto de los valores devueltos mediante parámetros de salida

    • Para estructuras opacas, prefiero usar typedef para imitar clases con todos los campos privados, y usar struct para estructuras de datos simples
      A las clases se debería acceder solo mediante funciones, y a las estructuras se debería poder acceder directamente
      Esto se acerca bastante a la convención estándar de C/POSIX. Por ejemplo, es la diferencia entre pthread_t y struct stat
    • Coincido en que no hay que hacer typedef del puntero en sí
      Cosas como SDL_net hacen justo eso, y no me gusta. En realidad es un puntero, pero lo dejan con typedef como si fuera un tipo por valor
      Entiendo la intención, pero es una forma bastante incómoda
  • Muchas partes de este artículo me parecen razonables
    Empecé a escribir un SO bare-metal para Arm64 y, aunque todavía está en una etapa temprana, estoy haciendo cosas parecidas. Uso cadenas Pascal y también cambié los nombres de tipos. Eso sí, con estilo int8 en vez de i8
    Como decidí rápidamente que no pensaba portar software real, no necesito seguir las funciones ni las convenciones de la biblioteca estándar de C. Eso me permite experimentar con más libertad
    C es un lenguaje tan viejo que todavía carga, incluso en los nombres de funciones, con el legado de una época en la que cada byte era valioso. Es agradable apartarse de eso, y lo de este artículo, junto con varios pequeños cambios de nombres, se siente como una limpieza bastante prolija

    • Que no tengas que seguir las funciones ni las convenciones de la biblioteca estándar de C, ¿significa que es solo un hobby y que no será algo grande y profesional como gnu?
    • Nunca se me había ocurrido que también se ahorraran bytes en los símbolos
  • typedef float f32; y typedef double f64; parecen una base peligrosa que asume que float tiene 32 bits y double tiene 64 bits
    OpenCV define float16_t, CUDA implementa punto flotante de media precisión y los microcontroladores pueden implementarlo cada uno a su manera
    C++23 introduce tipos de punto flotante de ancho fijo, pero no sé cómo forzar eso en C. Parece mejor tener una macro que verifique en tiempo de compilación que no haya pérdida de datos
    En general, como dicen otros, aunque no sea tan conciso, quizá sea mejor dejar algunas cosas con los valores predeterminados por legibilidad
    [0] https://docs.opencv.org/4.x/df/dc9/classcv_1_1float16__t.htm...
    [1] https://docs.nvidia.com/cuda/cuda-math-api/group__CUDA__MATH...
    [2] https://en.cppreference.com/w/cpp/types/floating-point