1. Modelos
  2. Léxico
  3. Sintaxis
  4. Semántica
  5. Código
  6. Optimizar
  7. Ejecutar

Capítulo 00 · Modelos de ejecución

¿Compilado, interpretado o las dos cosas?

Un procesador solo entiende código máquina. Hay dos maneras de hacerle llegar un programa escrito como texto: traducirlo entero de antemano, o leerlo y representarlo sobre la marcha. Casi todos los lenguajes reales usan una mezcla de las dos.

Un procesador es asombrosamente literal. Lee unos pocos bytes de la memoria, los descodifica como una instrucción («suma estos dos registros», «salta si es cero»), la ejecuta y lee la siguiente. Eso es todo lo que hará jamás. Una línea como coin vidas = 3 no significa nada para él: alguien tiene que convertir el texto en esos bytes, o en acciones equivalentes.

Hay dos formas clásicas de hacerlo, y un congreso da una buena imagen de ambas. Un traductor recibe la víspera el texto escrito de la ponencia y entrega al público una copia impresa en su idioma: lento de preparar, pero luego cualquiera lo lee a toda velocidad, cuantas veces quiera. Un intérprete se sienta junto a quien habla y vierte cada frase según se pronuncia: no hay nada que preparar, pero cada frase cuesta esfuerzo cada vez, y si el ponente se contradice el intérprete solo lo nota al llegar a ese punto.

Dos maneras de ejecutar un programa

Definición (compilador e intérprete)

Un compilador es un programa que recibe un programa en un lenguaje fuente y produce un programa equivalente en un lenguaje destino, sin ejecutarlo. Un intérprete es un programa que recibe un programa y sus datos de entrada y produce directamente la salida del programa, llevando a cabo lo que este dice.

La diferencia está en cuándo se hace el trabajo. El compilador hace su análisis una vez, antes de ejecutar, y el resultado puede ejecutarse muchas veces; el intérprete hace un poco de análisis cada vez que se ejecuta un trozo del programa. Los compiladores pueden permitirse pensar mucho (comprobar tipos, optimizar) porque ese coste se paga una sola vez. Los intérpretes arrancan al instante, pueden ejecutar código escrito hace un segundo y ven los valores reales con los que trabaja el programa.

Las primeras implementaciones de lenguajes de alto nivel fueron intérpretes. El Short Code de John Mauchly (1949) permitía a los programadores del UNIVAC escribir fórmulas en vez de instrucciones de máquina, y se interpretaba a un coste unas cincuenta veces mayor que el del código escrito a mano. Los programadores no estaban dispuestos a pagar ese precio, y durante una década se dio por hecho que la programación automática siempre sería lenta. El sistema A-0 de Grace Hopper (1952) empezó a llamar «compilador» a la estrategia de traducir, y en 1957 el equipo de John Backus en IBM entregó FORTRAN, cuyo compilador producía código casi tan bueno como el de un experto. Ese resultado, más que cualquier argumento, es lo que hizo aceptables los lenguajes de alto nivel.

Es la implementación, no el lenguaje

Idea clave

«Compilado» e «interpretado» describen implementaciones, no lenguajes. Hay intérpretes de C (Cling, del CERN, ejecuta C++ de forma interactiva) y compiladores de Python (Cython, Nuitka y el compilador «justo a tiempo» de PyPy). Lo que se suele querer decir es cómo funciona la implementación más habitual.

Ni siquiera las implementaciones más habituales son puras. CPython, el Python estándar, compila tu fichero a bytecode antes de ejecutar una sola línea, y luego interpreta ese bytecode. Java se compila dos veces: una a bytecode en la máquina del programador, y otra, a código máquina, dentro de la máquina virtual mientras el programa se ejecuta. Los sistemas reales se sitúan en algún punto de un espectro.

El espectro

Intérpretes que recorren el árbol

El diseño más directo: analizar el programa hasta obtener un árbol sintáctico (capítulos 01 y 02) y evaluarlo recorriendo el árbol. Para calcular a + b * 2 se visita el nodo +, que pide a sus hijos sus valores, y así hacia abajo. John McCarthy describió Lisp en 1960 mediante una función eval que hace exactamente eso, y Steve Russell le sorprendió implementándola: fue el primer intérprete de Lisp. Las shells como Bash siguen funcionando así, y también lo hacía Ruby hasta la versión 1.8. Es sencillo y flexible, pero lento: cada nodo visitado cuesta seguir punteros y decidir de qué tipo de nodo se trata.

Bytecode y máquinas virtuales

El paso siguiente es compilar el árbol a una lista plana de instrucciones sencillas para una máquina imaginaria, el bytecode, e interpretar eso en su lugar. Un bucle lee una instrucción, salta al código que la trata y repite. El bytecode es compacto, aprovecha bien la caché y se despacha con facilidad, lo que hace este diseño varias veces más rápido que recorrer un árbol. El p-code de Pascal (años setenta) hizo famosa la idea; hoy la usan CPython, Ruby, Lua, PHP, la JVM, .NET y la BEAM de Erlang. Algunas máquinas virtuales son máquinas de pila (los operandos viven en una pila, como en la JVM, CPython y WebAssembly) y otras son máquinas de registros (las instrucciones nombran sus operandos, como en Lua 5 y en Dalvik, de Android).

Compiladores «justo a tiempo»

Un JIT (just-in-time) compila a código máquina mientras el programa se ejecuta. Empieza interpretando, cuenta qué funciones están calientes y compila esas, usando información que ningún compilador previo puede tener: los tipos que aparecieron de verdad, qué ramas se toman, qué objetos tienen qué forma. Si una suposición resulta falsa (una función que siempre recibió enteros recibe de pronto una cadena), el JIT tira el código compilado y vuelve al intérprete: es la desoptimización. Las técnicas vienen de Smalltalk y, sobre todo, del proyecto Self de finales de los ochenta y principios de los noventa; las mismas personas construyeron después HotSpot para Java (1999) y, con Lars Bak, el V8 de Google (2008), que hizo JavaScript lo bastante rápido para ejecutar aplicaciones enteras.

Compiladores de antemano

Traducirlo todo antes de ejecutar (ahead of time, AOT): C, C++, Rust, Go, Swift, Haskell, Fortran. El arranque es instantáneo y el rendimiento previsible, no hay ningún compilador ocupando memoria mientras el programa se ejecuta, y muchos errores se detectan antes de que nadie ejecute nada. El precio es que el compilador tiene que adivinar qué hará el programa, y el ejecutable solo funciona en el procesador y el sistema operativo para los que se construyó.

Transpiladores

Un transpilador (compilador de fuente a fuente) traduce a otro lenguaje de alto nivel en vez de a código máquina: TypeScript y Elm a JavaScript, el primer C++ (Cfront) a C, Nim a C. Así aprovecha todo lo que el lenguaje destino ya tiene, de sus optimizadores a sus depuradores.

Del texto al procesador en doce lenguajes. Las cajas de borde continuo ocurren antes de ejecutar (en la máquina del programador o en un servidor de compilación), las de borde discontinuo al ejecutar (en la máquina del usuario, cada vez) y la caja oscura es el hardware. Fíjate en cuántos lenguajes compilan algo aunque se les llame interpretados, y en cuántos lenguajes con JIT hay además un compilador previo.

Una docena de lenguajes, lado a lado

La misma historia en forma de tabla. «Se convierte en código máquina» indica cuándo aparece el código específico del procesador en la implementación más habitual; casi todas las filas tienen alternativas.

LenguajeModeloImplementación habitualQué se distribuyeSe convierte en código máquinaTipos
CAOTGCC, Clangejecutableantes de ejecutarestáticos
RustAOTrustc + LLVMejecutableantes de ejecutarestáticos
GoAOTgcejecutable con su runtimeantes de ejecutarestáticos
HaskellAOTGHCejecutableantes de ejecutarestáticos, inferidos
Javabytecode + JITjavac + HotSpotbytecode (.jar)al ejecutarestáticos
C#bytecode + JITRoslyn + .NETbytecode (.dll)al ejecutar (o antes, con Native AOT)estáticos
JavaScriptJITV8, SpiderMonkey, JavaScriptCorecódigo fuenteal ejecutardinámicos
Pythonbytecode + MVCPythoncódigo fuentenormalmente nunca (se ejecuta el intérprete; la 3.13 trae un JIT experimental)dinámicos
Rubybytecode + MVCRuby (YARV)código fuentecon YJIT, al ejecutardinámicos
PHPbytecode + MVZend Enginecódigo fuentecon el JIT de PHP 8, al ejecutardinámicos
Luabytecode + MVLua de PUC-Rio, LuaJITfuente o bytecodecon LuaJIT, al ejecutardinámicos
TypeScripttranspiladotscJavaScriptcomo JavaScriptestáticos, luego borrados
Bashinterpretadobashcódigo fuentenuncatodo es texto
WahooAOT a Wasmwahoo (Rust)WebAssembly (.wasm)cuando lo carga el motor Wasmestáticos

¿Por qué no compilar siempre?

Porque cada elección cambia una cosa por otra:

  • Arranque frente a velocidad punta. Un intérprete arranca al instante; un JIT necesita un calentamiento antes de alcanzar su máxima velocidad; un binario compilado de antemano es rápido desde la primera instrucción. Para una herramienta de línea de órdenes que dura 20 milisegundos, el arranque lo es todo. Para un servidor que funciona meses, lo es la velocidad punta.
  • Qué se sabe y cuándo. Un compilador AOT debe contemplar cualquier entrada que el programa pueda recibir. Un JIT ve las entradas que recibe de verdad y puede especializarse en ellas; por eso JavaScript, un lenguaje en el que a + b puede ser una suma, una concatenación de cadenas o una llamada a un método del usuario, consigue ejecutarse deprisa.
  • Portabilidad. El bytecode funciona allá donde funcione su máquina virtual: «escríbelo una vez, ejecútalo en cualquier parte» era el lema de Java. Un ejecutable nativo hay que reconstruirlo para cada plataforma.
  • Cuándo aparecen los errores. Un compilador con comprobador de tipos rechaza un programa con un error de tipos antes de ejecutarlo. Un intérprete con tipos dinámicos lo descubre en la línea donde ocurre, quizá en producción, quizá nunca.
  • Interactividad. Los intérpretes hacen naturales los REPL, los cuadernos y la programación en vivo, porque no hay un paso de compilación aparte.

La apuesta del JIT

Piensa en una función de JavaScript function suma(a, b) { return a + b; }. De antemano no se puede suponer nada sobre a y b. Ignition, el intérprete de V8, la ejecuta unos cientos de veces y anota que ambos argumentos fueron siempre enteros pequeños. El compilador optimizador emite entonces unas pocas instrucciones de máquina: comprobar que ambos son enteros pequeños, sumarlos, comprobar el desbordamiento, devolver. Si una comprobación falla alguna vez, la ejecución salta de vuelta al intérprete y el código optimizado se descarta. La apuesta suele salir bien porque los programas reales son mucho más regulares de lo que sus lenguajes les permiten ser.

Compiladores que se compilan a sí mismos

Un compilador no es más que un programa, así que puede escribirse en el mismo lenguaje que compila. La primera vez es el problema del huevo y la gallina, y se resuelve con el arranque (bootstrapping): se escribe un primer compilador sencillo en otro lenguaje, se usa para compilar uno mejor escrito en el lenguaje nuevo y, a partir de ahí, el lenguaje se compila a sí mismo. El compilador de Lisp 1.5, de 1962, estaba escrito en Lisp; el primer compilador de Rust se escribió en OCaml hasta que pudo compilarse a sí mismo en 2011; el de Go pasó de C a Go en 2015. En su conferencia del premio Turing de 1984, Reflexiones sobre confiar en la confianza, Ken Thompson mostró el lado inquietante de esto: se puede enseñar a un compilador a meter una puerta trasera en todo programa que compile, incluidas sus propias versiones futuras, sin dejar rastro en ningún código fuente.

Dónde está Wahoo

Wahoo se compila de antemano, pero no a código máquina: su destino es WebAssembly, un bytecode portable diseñado para que el navegador lo compile una vez más, de forma rápida y segura. Así que un programa en Wahoo pasa por dos compiladores: .wahoo → .wasm con el nuestro, y luego .wasm → código máquina con el motor de tu navegador (capítulo 06). Y en el playground el primer compilador es a su vez un módulo WebAssembly: Rust compilado a Wasm. Los capítulos siguientes lo abren, fase a fase, empezando por la más humilde: leer los caracteres.

Referencias

  1. G. M. Hopper (1952). “The Education of a Computer”. Proceedings of the ACM National Meeting.
  2. J. W. Backus et al. (1957). “The FORTRAN Automatic Coding System”. Proceedings of the Western Joint Computer Conference.
  3. J. McCarthy (1960). “Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I”. Communications of the ACM, 3(4).
  4. Y. Futamura (1971). “Partial Evaluation of Computation Process — An Approach to a Compiler-Compiler”. Systems, Computers, Controls, 2(5).
  5. K. Thompson (1984). “Reflections on Trusting Trust”. Communications of the ACM, 27(8).
  6. U. Hölzle (1994). Adaptive Optimization for Self: Reconciling High Performance with Exploratory Programming. Tesis doctoral, Universidad de Stanford.
  7. J. Aycock (2003). “A Brief History of Just-In-Time”. ACM Computing Surveys, 35(2).
  8. R. Nystrom (2021). Crafting Interpreters. Genever Benning.