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.
En este capítulo
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
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
«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.
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.
| Lenguaje | Modelo | Implementación habitual | Qué se distribuye | Se convierte en código máquina | Tipos |
|---|---|---|---|---|---|
| C | AOT | GCC, Clang | ejecutable | antes de ejecutar | estáticos |
| Rust | AOT | rustc + LLVM | ejecutable | antes de ejecutar | estáticos |
| Go | AOT | gc | ejecutable con su runtime | antes de ejecutar | estáticos |
| Haskell | AOT | GHC | ejecutable | antes de ejecutar | estáticos, inferidos |
| Java | bytecode + JIT | javac + HotSpot | bytecode (.jar) | al ejecutar | estáticos |
| C# | bytecode + JIT | Roslyn + .NET | bytecode (.dll) | al ejecutar (o antes, con Native AOT) | estáticos |
| JavaScript | JIT | V8, SpiderMonkey, JavaScriptCore | código fuente | al ejecutar | dinámicos |
| Python | bytecode + MV | CPython | código fuente | normalmente nunca (se ejecuta el intérprete; la 3.13 trae un JIT experimental) | dinámicos |
| Ruby | bytecode + MV | CRuby (YARV) | código fuente | con YJIT, al ejecutar | dinámicos |
| PHP | bytecode + MV | Zend Engine | código fuente | con el JIT de PHP 8, al ejecutar | dinámicos |
| Lua | bytecode + MV | Lua de PUC-Rio, LuaJIT | fuente o bytecode | con LuaJIT, al ejecutar | dinámicos |
| TypeScript | transpilado | tsc | JavaScript | como JavaScript | estáticos, luego borrados |
| Bash | interpretado | bash | código fuente | nunca | todo es texto |
| Wahoo | AOT a Wasm | wahoo (Rust) | WebAssembly (.wasm) | cuando lo carga el motor Wasm | está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 + bpuede 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
- G. M. Hopper (1952). “The Education of a Computer”. Proceedings of the ACM National Meeting.
- J. W. Backus et al. (1957). “The FORTRAN Automatic Coding System”. Proceedings of the Western Joint Computer Conference.
- J. McCarthy (1960). “Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I”. Communications of the ACM, 3(4).
- Y. Futamura (1971). “Partial Evaluation of Computation Process — An Approach to a Compiler-Compiler”. Systems, Computers, Controls, 2(5).
- K. Thompson (1984). “Reflections on Trusting Trust”. Communications of the ACM, 27(8).
- U. Hölzle (1994). Adaptive Optimization for Self: Reconciling High Performance with Exploratory Programming. Tesis doctoral, Universidad de Stanford.
- J. Aycock (2003). “A Brief History of Just-In-Time”. ACM Computing Surveys, 35(2).
- R. Nystrom (2021). Crafting Interpreters. Genever Benning.