Files
printer-driver/docs/04-go-with-c-mindset.md
T
2026-09-30 10:22:42 +02:00

4.0 KiB
Raw Blame History

Go escrito con mentalidad de C

Experimento: el mismo generador de tickets ESC/POS escrito en Go de dos formas, midiendo el resultado (i9-9900K, Go 1.26, 30 líneas por ticket). Las dos versiones producen exactamente los mismos bytes.

ns/ticket bytes reservados/ticket reservas/ticket
Hábitos de JS (+= en strings, fmt.Sprintf, []*Item) ~9.600 14.378 126
Hábitos de C (un buffer reutilizado, strconv.Append*) ~550 0 0

Por qué

  • Los strings son inmutables. s += x crea un string nuevo y copia todo lo anterior en él. En un bucle, cada iteración copia más bytes que la anterior: el coste crece con el cuadrado del tamaño.
  • fmt.Sprintf reserva memoria para el resultado y usa reflexión. strconv.AppendInt escribe directamente en tu buffer.
  • Reutilizar el buffer: buf = buf[:0] pone la longitud a 0 pero conserva la capacidad, es decir, la memoria ya reservada. Tras el primer ticket no se vuelve a reservar nada. Es lo que hará el buffer de la fase 2.

Padding: el orden de los campos cambia el tamaño

El procesador exige que un int64 empiece en una dirección múltiplo de 8. El compilador rellena con huecos (padding) para cumplirlo:

  • {bool, int64, bool, int64, bool} ocupa 40 bytes.
  • {int64, int64, bool, bool, bool} ocupa 24 bytes.

Con un millón de elementos son 38 MB frente a 22 MB. En C pasa exactamente lo mismo.

Un slice es {puntero, longitud, capacidad}

header := make([]byte, 0, 16)
header = append(header, 0x1b, '@')
a := append(header, 'A')
b := append(header, 'B')   // a también termina en 'B'

Hay capacidad libre, así que append no copia: escribe en el mismo array. a y b comparten memoria. Quien piensa en punteros lo ve venir; quien piensa en arrays de JS, no.

Dinero en float64

10 × 0.10 da 0.9999999999999999. El number de JS es un float64. Para dinero se usan enteros en céntimos.

El precio de la versión rápida

BuildTicket devuelve una vista del buffer interno, que deja de ser válida en la siguiente llamada. Hay que documentar quién es el dueño de esa memoria. Es exactamente la regla de la API de la librería: documentar en cada función quién es dueño de cada puntero.

Tercera versión: una IA a partir de una spec escueta

Spec: "cabecera en negrita, una línea por producto, total, corte, precios con dos decimales, tests". El código resultante es idiomático (bytes.Buffer, fmt.Fprintf, dinero en céntimos) y pasa todos los criterios de aceptación.

ns/ticket bytes/ticket reservas/ticket
IA con spec escueta ~8.300 3.186 99

Casos que la spec no menciona:

Caso IA Estilo C Estilo JS
Descuento de −1,50 -1.-50 ❌ -1.+0 ❌ -1.50 ✅ (de casualidad)
é, ñ, € UTF-8 ❌ UTF-8 ❌ UTF-8 ❌
Nombre más largo que el papel (48/32 columnas) no lo trata no lo trata no lo trata
  • Negativos: en Go (y en C) el resto de un número negativo es negativo: -150 % 100 == -50. Las versiones con enteros lo formatean mal.
  • Codificación: la impresora no entiende UTF-8. é se envía como C3 A9 y en papel sale como dos símbolos extraños. Hay que convertir a la página de códigos de la impresora (fase 6).

Conclusión: el resultado nunca es mejor que la spec más la capacidad de quien lo revisa. Las decisiones que no se piden las toma el modelo "por defecto", y solo las detecta quien sabe que existen. Una spec de experto incluiría: presupuesto de reservas de memoria, quién es dueño del buffer, importes negativos, página de códigos y ancho de línea según el modelo de impresora.

Cuándo no merece la pena

Si el ticket se genera una vez por venta, 9 µs frente a 0,5 µs da igual. La versión rápida compensa en rutas calientes: miles de peticiones por segundo o bucles sobre millones de elementos. Primero se mide (go test -bench -benchmem, pprof) y después se optimiza.