Mi estilo personal de codificación en C a fines de 2023
(nullprogram.com)- 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 deconstystruct, 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
s8condataylen; en entornos Win32 y UTF-16 se usan tambiénc16ys16 - 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
oksolo 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
- Ejemplos:
- 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
iantes quessse reserva para nombres de tipos de cadena
- Para el tipo de tamaño se usa
sizeen vez deisize- Porque se considera que el tamaño signed es el valor predeterminado más importante
usizetiene un uso más acotado, principalmente al interactuar con interfaces externas
b32expresa 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
- Usa un tamaño de palabra natural en vez de
c16es 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
- Si se basa en
u8se usa para octets y principalmente datos UTF-8, mientras quebytese 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_ttambién se consideran un desperdicio innecesario
- Los nombres largos como
- 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
- Porque para que el lector entienda el contexto también se necesitaría el
Reglas para macros y assert
- Las macros con forma de función usan minúsculas
- Ejemplos:
countof(a),lengthof(s),new(a, t, n)
- Ejemplos:
- 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 camposnew - Porque si no tiene forma de llamada a función, no se expande como macro
- Se puede tener al mismo tiempo una macro
- Para GCC y Clang, la macro
assertusa la formawhile (!(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
libubsanproporciona 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-trapy 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
consten 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
constfue un cambio que aumentó la productividad al reducir la carga cognitiva y el ruido visual - Como pequeña excepción, se sigue prefiriendo
constcomo pista para colocar tablas estáticas en memoria de solo lectura cerca del código- Si hace falta, se elimina
constmediante casting
- Si hace falta, se elimina
- 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
restrictse 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
structhace 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
- Quitar la palabra clave
- 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
constystruct, 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
s8condataylen - Estructura de
s8:u8 *datasize len
- La macro
s8(s)envuelve un literal de cadena de C como cadenas8 s8se pasa y se devuelve por valor, como un fat pointers8también funciona bien como prefijo de funciones- Porque los nombres de la familia
strestán reservados - Ejemplos:
s8span,s8equals,s8compare,s8hash,s8trim,s8clone
- Porque los nombres de la familia
- 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
s16como tipo compatible con UTF-16- Tiene
c16 *dataysize len - Todavía no hay plena convicción sobre el enfoque de agregar
ua los literales en la macro
- Tiene
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 juntosvalue, el resultado del parseo, yok, 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
restrictoculto, o - si se inlinea, el overhead del valor de retorno deja de importar
- Porque la convención de llamada puede convertirlo en un parámetro de salida
- 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,
okse 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
__attributeantes que__attribute__- El sufijo
__final se considera excesivo e innecesario
- El sufijo
- 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,uptren vez deDWORD,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
- Se declaran directamente funciones como
- 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 en
- 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
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) * 3funciona 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 permiteinteger[pointer]con el mismo significado quepointer[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
countes 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.
_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;_Boollo normaliza a 1. Si se guarda en unint,if (need_free)pasa, peroif (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
_Boolestaba haciendo algo útil, se puede terminar con código incorrecto comoif ((flags & FLAG_ALLOCATED) == true).Además, al leer una estructura desde disco o al rellenarla con bytes arbitrarios, si un campo
_Boolno es 0 o 1, existe riesgo de comportamiento indefinido.(size)(sizeof(x)[y])también sorprendería a muchos, pero es lo mismo que(size)(sizeof ((x)[y])).sizeofno es una función, sino un operador unario, y la indexación y las llamadas a función tienen mayor precedencia quesizeof. Por eso prefiero dejar un espacio después desizeofy 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))).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_ten vez deuint.Esos tipos deberían estar definidos en el header adecuado, y puede que me equivoque porque hace mucho que no uso C.
intde 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 longsiempre es de 64 bits, así que basta con abandonarlong.charde 8 bits,shortde 16 bits,intde 32 bits,long longde 64 bits, y listo. En C hemos perdido una cantidad interminable de tiempo con el tamaño deint.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.
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
typedefpara 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.
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,i32en lugar deuint8_toint32_tpuede ahorrar algunos caracteres, pero puede confundir a otras personas al leer el códigoUsar 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.hpuede 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 todosu8oi32no buscan ahorrar teclas, sino reducir la carga sensorial al leerEl 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
u16se usan mucho y es poco probable que confundan a un programadorEl punto donde realmente se rompe es cuando dos programas distintos definen cada uno
u16y lo exponen en archivos de encabezado, y luego un tercer programa incluye ambos encabezados al mismo tiempoLos tipos de biblioteca con namespace terminan con formas como
libname_u32, y para ese punto uno acaba queriendo usar simplementeuint32_ten lugar del prefijolibname_u8oi32es, como mucho, teórica, y suena un poco a argumento de hombre de pajaPuede 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
boolde 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 memoriaAunque sean unos pocos bytes, no entiendo por qué pesimizarlo a propósito. ¿Qué se obtiene usando un tamaño mayor?
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”
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/
boolreal, el sanitizer avisa cuando el valor no es 0 parafalseni 1 paratrueNo 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
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
errnoy parámetros de salida, pero C no la tieneAun 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) { ... }El patrón de agregar
if (thing(...)) goto failen cada llamada a función tampoco parece gran cosa, aunque a la gente de Go parece gustarleO 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 enumeradoQuiero 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
constno tenga un rol práctico y que nunca haya ayudado a detectar errores. La gente suele confundir buffers de entrada y de salida, yconstlo deja en evidencia de inmediatoEso 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 autorPreferir 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
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p14...
constnunca los vas a dejar satisfechos, así que usaconstdonde haga falta, propágalo lo necesario e ignora las quejasSi lo quitan, lo vuelves a poner. Siempre son ellos los que se cansan primero. Llevo 25 años haciéndolo así y sigo aquí
staticpuede depender de la herramienta. Hace unos 15 años me pasé astaticpor defecto y a usarsize_ten todas partes, y todavía no he tenido problemasEs 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 llamó la atención la parte de mover las condiciones dentro de
doX()ydoZ(). No sé si siempre sea correcto; depende de dónde esté la abstracción y del modelo mental del códigoPor ejemplo,
deleteRecords();no es mejor queif 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 podarSi 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 fugasEstoy a favor de usar
typedefpara todas las estructuras, porque ayuda a la concisiónCreo que se puede usar
typedefcon generosidad. Eso sí, conviene hacertypedefsolo 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
typedefdel 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ónLa mayoría de las bases de código en C hacen esto mal: hacen
typedefdel puntero a función y aun así tienen que escribir manualmente las declaraciones de funciones ajustadas a esa definición de punteroTodaví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
typedefpara imitar clases con todos los campos privados, y usarstructpara estructuras de datos simplesA 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_tystruct stattypedefdel puntero en síCosas como SDL_net hacen justo eso, y no me gusta. En realidad es un puntero, pero lo dejan con
typedefcomo si fuera un tipo por valorEntiendo 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
int8en vez dei8Como 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
typedef float f32;ytypedef double f64;parecen una base peligrosa que asume quefloattiene 32 bits ydoubletiene 64 bitsOpenCV define
float16_t, CUDA implementa punto flotante de media precisión y los microcontroladores pueden implementarlo cada uno a su maneraC++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
_Floattypedef _Float32 f32;typedef _Float64 f64;https://gcc.gnu.org/onlinedocs/gcc/Floating-Types.html