wandres.dev
DIESTRO · Funciones a fondo

inline, static y restrict: promesas al optimizador

Las tres palabras con las que transfieres conocimiento al compilador: qué promete cada una, qué código habilita a generar, y qué comportamiento indefinido desatas si mientes.

⏱ 18 min

Un compilador optimiza lo que puede demostrar. Hay hechos que tú conoces y él no puede deducir: que esta función no la llama nadie fuera del archivo, que estos dos punteros nunca apuntan al mismo sitio, que este cuerpo debe estar disponible en todas las unidades de traducción. inline, static y restrict son el canal por el que le entregas ese conocimiento. No son adornos de rendimiento: son contratos, y romperlos no te da código más lento, te da comportamiento indefinido.

🎯 Al terminar esta lección sabrás
  • Entender que el efecto normativo de inline es de enlazado, no de optimización.
  • Usar static para linkage interno y ver qué optimizaciones desbloquea.
  • Aplicar restrict como promesa de no aliasing y leer el código que habilita.
  • Diagnosticar qué ocurre exactamente cuando una de las tres promesas es falsa.

inline: una regla de enlazado disfrazada de optimización

Casi todo el mundo cree que inline significa “incrusta esta función”. Esa es la parte no vinculante: el estándar la describe como una sugerencia que el compilador puede ignorar, y con -O2 la ignora constantemente porque su heurística de coste decide mejor que tú. Con -O0 no incrusta nada, y aun así inline cambia el significado del programa. Lo que sí es normativo es lo que hace con el enlazado.

En C —y aquí difiere de C++, fuente inagotable de confusión— una definición marcada solo inline es una definición inline que no proporciona una definición externa. Si otra unidad de traducción necesita llamarla de verdad, el enlazador no la encuentra. El patrón correcto exige designar exactamente una unidad que emita el símbolo:

// utiles.h
inline int maximo(int a, int b) { return a > b ? a : b; }

// utiles.c
#include "utiles.h"
extern inline int maximo(int a, int b);   // emite AQUI la definicion externa

Por eso el idioma abrumadoramente dominante en C es otro: static inline en la cabecera. Cada unidad recibe su propia copia con linkage interno, el compilador tiene el cuerpo a la vista para incrustarlo y no hay ni símbolo duplicado ni símbolo ausente. Es lo que verás en el kernel de Linux y en prácticamente toda cabecera moderna.

💡
Lo que de verdad habilita la incrustación

Incrustar no es posible si el compilador no ve el cuerpo, y en compilación separada solo ve lo que está en la cabecera. De ahí que static inline sea el vehículo real: no porque contenga la palabra inline, sino porque pone el cuerpo delante del compilador. La otra vía es el LTO: con -flto el optimizador trabaja sobre el programa entero al enlazar y puede incrustar a través de archivos sin que muevas una línea de código. Si necesitas forzarlo de verdad existe [[gnu::always_inline]], pero es una herramienta de casos extremos: casi siempre significa que estás peleando con una heurística que acierta más que tú.

static: menos símbolos, más optimización

Ya sabes que static a nivel de archivo da linkage interno. Lo que quizá no habías conectado es la consecuencia para el optimizador: si un símbolo es interno, el compilador ve todos sus usos. Y ver todos los usos le permite razonar de formas que con un símbolo exportado le están prohibidas, porque cualquier otro archivo podría llamarlo.

🔎

Análisis completo

Conoce todos los sitios de llamada, así que puede incrustar sin dejar copia fuera de línea.

🧬

Especialización

Si siempre la llamas con un argumento constante, puede clonar la función y propagar esa constante dentro.

🗑️

Eliminación

Si no la usa nadie, la borra entera; y avisa con -Wunused-function, cosa imposible con un símbolo público.

🎛️

Convención libre

Al no ser visible fuera, puede saltarse la ABI y pasar argumentos como le convenga.

El mismo razonamiento sube una escala cuando construyes bibliotecas compartidas. Un símbolo global de una .so puede ser interpuesto: otro objeto cargado antes puede exportar ese mismo nombre y quedarse con todas las llamadas. Como el compilador no puede descartarlo, ni siquiera las llamadas internas de tu biblioteca a su propia función pueden resolverse directamente; tienen que pasar por la tabla de saltos del enlazador dinámico. Marcar static lo que no salga del archivo, y compilar con visibilidad oculta lo que no forme parte de la API, elimina esa indirección de golpe y reduce además el tiempo de carga:

gcc -std=c23 -O2 -fvisibility=hidden -shared -fPIC lib.c -o libx.so
nm -D libx.so | grep ' T '        # solo deberian quedar los simbolos publicos

Existe además un tercer uso de static que casi nadie conoce y que es puro contrato con el optimizador: dentro de un parámetro de tipo array.

void procesar(int datos[static 4]);   // PROMETO: no nulo y al menos 4 elementos
void copiar4(int destino[static 4], const int origen[static 4]);

No es documentación decorativa. El compilador puede asumir que el puntero nunca es nulo —y eliminar comprobaciones tuyas que dependan de ello— y puede precargar los cuatro elementos sin esperar. Si le pasas NULL o un array de tres, es comportamiento indefinido.

restrict: la promesa de no aliasing

restrict es el más potente y el más malinterpretado. Califica un puntero y promete que, durante su vida, el objeto al que apunta solo se accede a través de ese puntero o de punteros derivados de él. No dice “es rápido”: dice “nadie más toca esto”.

Por qué importa se ve mejor con lo que ocurre sin él. En este bucle, el compilador no puede saber si dst se solapa con a o con b:

void sumar_vec(size_t n, float *dst, const float *a, const float *b) {
    for (size_t i = 0; i < n; i++)
        dst[i] = a[i] + b[i];
}

Como dst[0] podría ser a[1], escribir en dst[i] puede invalidar los valores de a y b que ya había leído. Está obligado a recargar en cada vuelta y no puede vectorizar sin más; en el mejor caso emite dos versiones del bucle y una comprobación de solapamiento en tiempo de ejecución, pagando código e instrucciones. Añade la promesa y el problema desaparece:

void sumar_vec(size_t n, float *restrict dst,
               const float *restrict a, const float *restrict b) {
    for (size_t i = 0; i < n; i++)
        dst[i] = a[i] + b[i];        // ahora si: SIMD limpio, sin recargas
}

Dos precisiones que evitan casi todos los usos incorrectos. La primera: restrict tiene alcance de bloque, y para un parámetro ese bloque es la ejecución de la función; lo que prometes no es una propiedad eterna de esos punteros, sino que durante esta llamada nadie más accede a esos objetos. La segunda, la que más sorprende: const no es una promesa equivalente. Declarar const float *a solo significa que tú no escribirás a través de a; el compilador debe seguir asumiendo que otro puntero no calificado puede modificar ese mismo objeto. const documenta e impide errores tuyos; restrict es lo único que informa sobre aliasing.

El caso más famoso de la biblioteca estándar lo declara así: memcpy marca origen y destino como restrict, mientras que memmove no lo hace. Esa única diferencia en la firma es la razón por la que copiar regiones solapadas con memcpy es comportamiento indefinido y con memmove es correcto. La firma no describe la implementación: describe el contrato.

flowchart LR
A[Punteros sin calificar] --> B[Puede haber solapamiento]
B --> C[Recargar en cada iteracion o duplicar el bucle]
D[Punteros con restrict] --> E[El programador garantiza que no hay solapamiento]
E --> F[Vectorizacion y reordenacion libres]
style C fill:#f9e2af,color:#11111b
style F fill:#a6e3a1,color:#11111b

Qué pasa si mientes

Aquí está la asimetría que hace de este nivel algo serio. Estas tres palabras no degradan a “menos óptimo” cuando la promesa es falsa: caen en territorios muy distintos de gravedad.

Promesa rota Consecuencia
inline sin definición externa Error de enlazado: símbolo indefinido. Ruidoso e inmediato
static que necesitabas fuera Error de enlazado. También ruidoso
datos[static 4] con menos elementos Comportamiento indefinido silencioso
restrict con punteros que sí se solapan Comportamiento indefinido silencioso y dependiente de las optimizaciones

Hay un cuarto contrato de la misma familia que casi nadie declara y que sin embargo está activo por defecto: el aliasing estricto por tipos. C asume que dos punteros de tipos incompatibles nunca designan el mismo objeto, así que escribir en un float * no puede afectar a lo que se lee por un int *. Quien reinterpreta bytes con un cast entre punteros —el viejo truco de la raíz cuadrada inversa— rompe esa regla exactamente igual que rompería restrict, con los mismos síntomas. La forma correcta de reinterpretar es memcpy entre objetos, que el compilador reconoce y no cuesta nada con optimizaciones; la vía de escape es -fno-strict-aliasing, y compilarlo así es admitir que tu código depende de un contrato roto.

Las dos últimas filas son las peligrosas, y la de restrict es probablemente la clase de bug más difícil de diagnosticar de todo C. El programa funciona con -O0, porque sin optimizar el compilador recarga igualmente; y produce resultados incorrectos con -O2 en producción, porque allí sí usó la promesa. No hay aviso, no hay fallo de segmentación, no hay ninguna señal: solo números erróneos. Un bug que aparece y desaparece con el nivel de optimización debe llevarte de inmediato a sospechar de un contrato roto, aquí o en el aliasing estricto de tipos.

La disciplina que evita casi todos estos desastres es sencilla de enunciar: cada vez que escribas restrict, [static N] o un cast entre tipos de puntero, escribe al lado el comentario que dice por qué es cierto. Si no puedes redactar esa frase, no tienes la prueba, y entonces no tienes derecho a la palabra. Y verifica siempre con el mismo ritual: la salida numérica debe ser idéntica con -O0 y con -O2.

gcc -std=c23 -O2 -fopt-info-vec prog.c     # que bucles logro vectorizar
clang -std=c23 -O2 -Rpass=loop-vectorize prog.c
gcc -std=c23 -O2 -fsanitize=undefined prog.c   # caza el nulo del array [static N]
El trato de C: rendimiento a cambio de obligaciones de prueba

Estas tres palabras revelan el pacto que define el lenguaje entero. Un compilador solo puede optimizar lo que consigue demostrar, y en compilación separada su capacidad de demostración es lamentable: no sabe si otro archivo llama a esta función, no puede probar que dos punteros no se solapan —el problema del aliasing es, en general, indecidible—, no sabe si el cuerpo de una función estará disponible en otra unidad. Ante esa ignorancia estructural, un lenguaje tiene dos salidas. Puede volverse conservador y generar siempre el código seguro, que es lo que hace C sin estas palabras, pagando recargas y vectorizaciones perdidas. O puede aceptar afirmaciones no verificadas del programador y optimizar como si fueran ciertas: eso es restrict, eso es datos[static 4], eso es en el fondo todo el comportamiento indefinido de C. Cada UB del estándar es exactamente esto —una obligación de prueba que el lenguaje delega en ti sin comprobarla— y el rendimiento legendario de C es la contrapartida de esa delegación masiva. Por eso el contraste con Rust es tan iluminador: Rust obtiene la información de aliasing gratis de su sistema de préstamos, la comprueba en compilación y se la pasa al mismo backend de LLVM como si fuera restrict en todas partes. No es que sea “más rápido que C”: es que el compilador puede demostrar lo que en C solo puedes prometer. Cuando escribes restrict no estás pidiendo velocidad, estás firmando un teorema que nadie va a revisar. Fírmalo solo cuando puedas demostrarlo tú.

⚔️ Firma solo lo que puedas demostrar
  1. Pon una función inline en una cabecera sin extern inline en ningún .c y provoca el error de enlazado; arréglalo de las dos formas y compara.
  2. Compila sumar_vec con y sin restrict usando -O2 -fopt-info-vec (o -Rpass=loop-vectorize) y localiza en el ensamblador las instrucciones SIMD que aparecen solo en una versión.
  3. Reescribe un cast entre float * e int * usando memcpy y comprueba con -O2 -S que el ensamblador generado es el mismo.
  4. Llama a sumar_vec con restrict pasando el mismo array como dst y como a; compara el resultado con -O0 y con -O2 y explica la diferencia.
  5. Declara void procesar(int datos[static 4]), llámala con NULL y compila con -fsanitize=undefined; lee el diagnóstico.
  6. Toma un módulo tuyo, marca static todo lo que no sea API pública y comprueba con nm cuántos símbolos desaparecieron del objeto.