Lenguaje de programación

Un lenguaje de programación (Programming Language) es un lenguaje formal creado para que las personas describan las tareas que debe realizar una computadora y l...

ClasificaciónLenguaje formal de la informática, medio de desarrollo de la ingeniería de software
PropósitoRepresentación de programas informáticos y procedimientos computacionales
Componentes principalesSintaxis, semántica, sistema de tipos, modelo de ejecución
Formas de implementaciónCompilador, intérprete, máquina virtual
Paradigmas principalesProgramación imperativa, programación orientada a objetos, programación funcional, programación lógica, programación declarativa
Clasificaciones principalesLenguaje de programación de bajo nivel, lenguaje de programación de alto nivel, lenguaje de programación de propósito general, lenguaje específico de dominio
EstandarizaciónOrganismos de normalización como ISO/IEC JTC 1/SC 22 y Ecma International
Campos relacionadosCompilador, teoría de lenguajes de programación, semántica formal, análisis estático de programas

Un lenguaje de programación (Programming Language) es un lenguaje formal creado para que las personas describan las tareas que debe realizar una computadora y las expresen de una forma que esta pueda comprender y ejecutar. Los lenguajes de programación están diseñados para representar diversos modelos computacionales, como algoritmos, estructuras de datos, flujo de control, abstracción y procesamiento paralelo, y constituyen una tecnología fundamental sobre la que se basa el desarrollo de software. Los lenguajes de programación modernos se utilizan en prácticamente todos los campos de la computación, entre ellos el software de sistemas, las aplicaciones web, las aplicaciones móviles, la inteligencia artificial, los sistemas embebidos, el desarrollo de videojuegos y la computación científica. Cada lenguaje presenta características diferentes en cuanto a sintaxis, forma de ejecución, gestión de memoria, sistema de tipos, rendimiento y productividad, según su propósito y filosofía de diseño. Por lo general, los lenguajes de programación se ejecutan tras ser transformados en código máquina o en una representación intermedia mediante un compilador o un intérprete. En informática, han sido estudiados y desarrollados desde diversas perspectivas, como el diseño de lenguajes, la sintaxis (Syntax), la semántica (Semantics), el sistema de tipos (Type System), los compiladores, el entorno de ejecución (Runtime) y las bibliotecas estándar.

Historia

La historia de los lenguajes de programación no comenzó repentinamente con la aparición de las computadoras electrónicas. Sus orígenes pueden encontrarse en los intentos de representar los procedimientos de cálculo mediante símbolos y reglas determinados, y de hacer que las máquinas ejecutaran automáticamente dichos procedimientos. Los lenguajes de programación actuales se han convertido en sistemas de notación para transmitir instrucciones a las máquinas y han evolucionado hasta constituir sistemas formales capaces de representar algoritmos, datos, abstracciones, cambios de estado, relaciones lógicas y modelos computacionales.

El cálculo mecánico y la formación del concepto de programa

A comienzos del siglo XIX, el telar de Jacquard desarrollado por Joseph Marie Jacquard controlaba los patrones de tejido mediante la disposición de agujeros perforados en tarjetas. Aunque las tarjetas del telar de Jacquard no contenían instrucciones destinadas al cálculo numérico, se consideran un precedente importante que influyó en métodos posteriores de control mediante programas, ya que permitían modificar el funcionamiento de una máquina de acuerdo con patrones registrados en un soporte externo.

Charles Babbage diseñó a partir de la década de 1830 la máquina analítica, un dispositivo mecánico de cálculo de propósito general. La máquina analítica fue concebida con una unidad denominada Mill para realizar operaciones, un almacén denominado Store para conservar valores, dispositivos de entrada y salida, e instrucciones de operaciones y datos transmitidas mediante tarjetas perforadas. Aunque la máquina real no llegó a completarse, poseía una estructura semejante a la de las computadoras modernas de propósito general, pues separaba la unidad de cálculo de la memoria y podía realizar diversos cálculos de acuerdo con instrucciones externas.

En 1843, Ada Lovelace tradujo al inglés una descripción de la máquina analítica escrita por Luigi Menabrea y añadió notas más extensas que el texto original. La nota G incluía una tabla que organizaba la secuencia de operaciones, el desplazamiento de variables, los resultados intermedios y los procesos repetitivos necesarios para calcular los números de Bernoulli mediante la máquina analítica. Esta tabla suele presentarse como el primer programa detallado publicado para una máquina. Sin embargo, también existen registros de procedimientos de cálculo escritos por Babbage para la máquina analítica alrededor de 1837, por lo que resulta más preciso considerar a Lovelace como la persona que explicó sistemáticamente el primer algoritmo para una máquina ampliamente publicado, en lugar de afirmar sin condiciones que fue la primera programadora de la historia.

Lovelace consideró que la máquina analítica podría procesar objetos expresables mediante reglas determinadas, más allá del cálculo numérico. Esta fue una perspectiva temprana que entendía un programa no como el resultado de un cálculo específico, sino como un procedimiento general de manipulación de símbolos. Sin embargo, debido a que la propia máquina analítica nunca se completó, en aquella época no pudo formarse un sistema de programación que permitiera ejecutar realmente el programa en una máquina o utilizarlo de manera repetida.

Formalización matemática de la computabilidad

Durante la década de 1930, antes de la difusión de las computadoras reales, se realizaron investigaciones destinadas a definir matemáticamente el cálculo y los algoritmos. Alonzo Church desarrolló el cálculo lambda, un sistema computacional basado en la abstracción y aplicación de funciones, mientras que Alan Turing presentó en un artículo de 1936 la máquina de Turing, que utilizaba una cinta con símbolos registrados y un conjunto finito de transiciones de estado.

Turing definió no solo máquinas destinadas a realizar un cálculo específico, sino también la máquina de Turing universal, capaz de recibir como entrada la descripción de otra máquina de Turing y simular su funcionamiento. Este concepto se convirtió en una base teórica de las computadoras de propósito general, que almacenan instrucciones y datos en una misma memoria y pueden realizar diversas tareas sustituyendo el programa almacenado. Aunque el cálculo lambda y la máquina de Turing empleaban formas de representación diferentes, se demostró que eran equivalentes con respecto al conjunto de funciones computables. Estos modelos computacionales ejercieron una influencia duradera sobre las llamadas a funciones, la recursión, las reglas de evaluación, la teoría de tipos y la semántica formal de los lenguajes de programación posteriores.

Las primeras computadoras electrónicas y el lenguaje máquina

Las primeras computadoras electrónicas de la década de 1940 estaban muy alejadas de la forma actual de escribir y ejecutar código fuente. Algunas computadoras configuraban sus procedimientos de cálculo mediante la conexión de cables y la modificación de interruptores y cableados, por lo que cambiar un programa requería reconfigurar físicamente la máquina. Posteriormente, a medida que se extendió el modelo de programa almacenado, en el que las instrucciones se registraban en forma numérica en la memoria o en cintas perforadas, los programas y el hardware comenzaron a separarse progresivamente.

Los primeros programas se escribían en lenguaje máquina, compuesto por instrucciones numéricas interpretadas directamente por la unidad central de procesamiento. Los programadores debían registrar manualmente los códigos de operación, las direcciones de memoria y las posiciones de salto, y cuando cambiaba el modelo de computadora, normalmente también era necesario reescribir la mayor parte del sistema de instrucciones y del programa. Un pequeño error en el cálculo de una dirección o en la introducción de una sola instrucción podía provocar el funcionamiento incorrecto de todo el programa, y leer y mantener su estructura resultaba extremadamente difícil.

Para reducir estos problemas apareció el lenguaje ensamblador, que asignaba nombres simbólicos como ADD, LOAD y JMP a las instrucciones de máquina. El ensamblador convertía estas instrucciones simbólicas en lenguaje máquina y calculaba las direcciones de memoria. Aunque el lenguaje ensamblador era más legible que el lenguaje máquina numérico, seguía estando estrechamente vinculado al conjunto de instrucciones de un procesador concreto, y la lógica de los programas todavía debía escribirse de acuerdo con los registros y la estructura de memoria de la máquina.

El primer lenguaje de programación

La respuesta a cuál fue el primer lenguaje de programación depende del criterio utilizado para definir un lenguaje de programación.

  • Como primer algoritmo detallado publicado para una máquina se suele mencionar el procedimiento de cálculo de Ada Lovelace para la máquina analítica.
  • Como primer lenguaje de programación de alto nivel diseñado se suele mencionar Plankalkül.
  • Como uno de los primeros sistemas de notación de alto nivel utilizados en una computadora electrónica real se suele mencionar Short Code.
  • Como uno de los primeros sistemas de programación automática y compilación se suele mencionar A-0 System.
  • Como primer lenguaje compilado de alto nivel práctico y ampliamente utilizado se suele mencionar FORTRAN.

Konrad Zuse diseñó Plankalkül entre 1942 y 1945. Plankalkül fue concebido para representar no solo valores numéricos, sino también arreglos, valores lógicos, datos compuestos semejantes a registros, instrucciones condicionales y estructuras repetitivas. Se considera el primer diseño de un lenguaje de programación de alto nivel porque intentaba representar los procedimientos de resolución de problemas en un nivel superior, en lugar de enumerar directamente instrucciones específicas de una máquina. Sin embargo, debido a la guerra y a las condiciones de la posguerra, no fue implementado en las computadoras de Zuse de la época y la información relacionada no se dio a conocer hasta mucho después.

Short Code fue uno de los primeros lenguajes de alto nivel, propuesto alrededor de 1949 y utilizado a comienzos de la década de 1950. Permitía representar expresiones matemáticas de forma simbólica en lugar de utilizar instrucciones de máquina, pero un intérprete ejecutaba las instrucciones directamente, en vez de que un compilador generara la totalidad del código máquina en el sentido habitual. Era más sencillo de escribir que el lenguaje máquina, pero su velocidad de ejecución era baja y su capacidad expresiva estaba limitada.

El A-0 System, desarrollado por Grace Hopper entre 1951 y 1952, era un sistema de programación automática que permitía especificar mediante símbolos subrutinas escritas previamente, localizar las rutinas de código máquina necesarias y combinarlas en un programa. En aquella época fue denominado compilador y es ampliamente presentado como el primer compilador. Sin embargo, según las clasificaciones modernas, también poseía características más próximas a un enlazador o cargador que organizaba y conectaba subrutinas que a un compilador capaz de traducir un lenguaje completo de alto nivel. Aun así, constituyó una etapa importante en el desarrollo posterior de los compiladores, porque demostró que una máquina podía automatizar parte del proceso de escritura de programas.

Década de 1950: aplicación práctica de los lenguajes de alto nivel y los compiladores

Durante la década de 1950 comenzaron a utilizarse de manera práctica los lenguajes de programación de alto nivel, que permitían a los programadores escribir programas mediante expresiones más cercanas a la estructura matemática o administrativa del problema que al sistema de instrucciones de la máquina, mientras un traductor las convertía en lenguaje máquina. En este periodo se formaron importantes familias de lenguajes para responder a necesidades diferentes, como el cálculo científico, el procesamiento simbólico y el tratamiento de datos administrativos.

FORTRAN

En 1954, un equipo de investigación de IBM dirigido por John Backus comenzó a desarrollar FORTRAN —Formula Translation—, un sistema de traducción de fórmulas destinado al IBM 704. Para que los lenguajes de alto nivel fueran aceptados ampliamente en aquella época, debían producir un rendimiento de ejecución cercano al del lenguaje máquina escrito directamente por una persona. Por ello, en el desarrollo de FORTRAN, el diseño del compilador optimizador fue considerado tan importante como la propia sintaxis del lenguaje.

FORTRAN, distribuido comercialmente en 1957, permitió describir cálculos científicos y de ingeniería mediante fórmulas matemáticas, variables, bucles y bifurcaciones condicionales. Los programadores ya no necesitaban administrar directamente la disposición de los registros ni las instrucciones detalladas de la máquina, y el compilador analizaba las fórmulas escritas para generar código máquina eficiente. Aunque FORTRAN no fue el primer lenguaje de alto nivel, se considera el primer lenguaje compilado de alto nivel ampliamente adoptado en entornos industriales y de investigación reales. El éxito de FORTRAN demostró que los lenguajes de alto nivel y los compiladores podían resultar prácticos sin sacrificar considerablemente el rendimiento.

Más tarde, el nombre oficial del lenguaje comenzó a escribirse como Fortran. Fortran continuó revisándose e incorporó operaciones con arreglos, estructuras de control, módulos, tipos de datos definidos por el usuario, funciones orientadas a objetos y capacidades de procesamiento paralelo. A pesar de ser uno de los primeros lenguajes, continúa utilizándose en la computación moderna de alto rendimiento, el análisis numérico y las simulaciones meteorológicas y físicas.

FLOW-MATIC y COBOL

A diferencia del cálculo científico, en las tareas empresariales de nóminas, contabilidad, inventario y administración resultaba más importante leer y clasificar grandes cantidades de registros y generar informes que procesar fórmulas complejas. Grace Hopper sostuvo que los responsables de estas tareas debían poder utilizar instrucciones semejantes a palabras inglesas en lugar de símbolos matemáticos.

FLOW-MATIC, desarrollado por el equipo de Hopper, fue uno de los primeros lenguajes que representó procedimientos de procesamiento de datos mediante palabras de estilo inglés como INPUT, OUTPUT y COMPARE. FLOW-MATIC demostró la posibilidad de crear lenguajes de procesamiento de datos administrativos diferenciados de los lenguajes centrados en las matemáticas e influyó en los esfuerzos por desarrollar un lenguaje administrativo común que pudiera utilizarse en computadoras de distintos fabricantes.

En 1959, el Gobierno de Estados Unidos, diversos fabricantes de computadoras y asociaciones de usuarios formaron CODASYL para desarrollar COBOL. En 1960 se publicó la especificación COBOL 60. COBOL adoptó palabras clave extensas semejantes a frases en inglés, una división explícita para definir los datos y una estructura de procesamiento de archivos centrada en registros. Su objetivo era constituir un lenguaje administrativo independiente de las instrucciones de las máquinas de un fabricante específico, y posteriormente fue utilizado ampliamente en sistemas centrales de información de bancos, aseguradoras, organismos gubernamentales y grandes empresas. COBOL también contribuyó a que la especificación de los lenguajes de programación y el cumplimiento de los estándares por parte de los compiladores recibieran una mayor importancia.

LISP

John McCarthy desarrolló a finales de la década de 1950 LISP, un lenguaje destinado a la inteligencia artificial y al cálculo simbólico. A diferencia de FORTRAN, centrado en arreglos numéricos, LISP trataba los símbolos y las listas como sus datos fundamentales. Se basaba en conceptos como la definición de funciones, la recursión, las expresiones condicionales, las estructuras dinámicas de datos y la recuperación automática de memoria, y permitía representar el propio código de un programa como datos en forma de listas.

El artículo sobre LISP publicado en 1960 explicó sistemáticamente cómo procesar funciones recursivas y expresiones simbólicas mediante una computadora. El modelo de cálculo basado en funciones de LISP recibió la influencia del cálculo lambda y posteriormente ejerció una influencia duradera sobre los lenguajes de programación funcional, los sistemas de macros, la administración automática de memoria, los entornos interactivos de desarrollo y la investigación en inteligencia artificial.

ALGOL

A diferencia de FORTRAN, que se originó en un entorno específico de IBM, investigadores europeos y estadounidenses trataron de crear un lenguaje de notación algorítmica independiente de las máquinas y de los países. Como resultado apareció ALGOL 58 en 1958 y en 1960 se publicó el informe oficial de su revisión, ALGOL 60.

ALGOL 60 proporcionó una estructura de bloques delimitada por begin y end, variables locales válidas únicamente dentro de un bloque, procedimientos recursivos y estructuras sistemáticas de control. La sintaxis del lenguaje fue descrita mediante una gramática formal de la familia de la notación de Backus-Naur, que posteriormente se convirtió en un método estándar para las especificaciones de lenguajes de programación y el diseño de analizadores sintácticos. Aunque el uso industrial de ALGOL fue más limitado que el de FORTRAN o COBOL, influyó en la sintaxis y la estructura de muchos lenguajes, entre ellos Pascal, C (lenguaje de programación) y Simula.

Década de 1960: estructuración, educación y aparición del concepto de objeto

Durante la década de 1960, el uso de las computadoras se extendió más allá de los centros de investigación y las instalaciones militares hacia las empresas y las universidades. A medida que aumentó el tamaño de los programas, adquirió importancia no solo desarrollar sistemas de notación que sustituyeran al lenguaje máquina, sino también estructurar los programas para que pudieran ser comprendidos, modificados y desarrollados mediante la colaboración de varias personas.

Las estructuras de bloques y las variables locales introducidas por los lenguajes de la familia ALGOL proporcionaron una base para separar partes de un programa en unidades independientes. El movimiento de la programación estructurada, que buscaba reducir el uso indiscriminado de instrucciones de salto y organizar los programas alrededor de la ejecución secuencial, la selección, la repetición y las llamadas a procedimientos, también influyó en el diseño de los lenguajes. Los lenguajes posteriores comenzaron a conceder importancia no solo a la expresión sintáctica, sino también a las funciones que limitaban y aclaraban la estructura de los programas.

BASIC y la programación interactiva

En 1963, John Kemeny y Thomas Kurtz comenzaron a desarrollar en Dartmouth College un lenguaje y un entorno informático de tiempo compartido que pudieran ser aprendidos fácilmente por estudiantes de especialidades no técnicas. Como resultado, el 1 de mayo de 1964 se ejecutó el primer programa en BASIC en el sistema de tiempo compartido de Dartmouth.

BASIC utilizaba instrucciones sencillas semejantes al inglés, números de línea y un modelo de ejecución interactivo. En lugar de entregar tarjetas perforadas y esperar durante mucho tiempo para obtener los resultados, los usuarios podían introducir programas desde un terminal y comprobar inmediatamente sus resultados. Posteriormente, durante las décadas de 1970 y 1980, numerosas computadoras personales incluyeron de manera predeterminada diferentes intérpretes de BASIC, por lo que BASIC se convirtió en uno de los lenguajes de programación con los que el público tuvo su primer contacto.

Simula y la programación orientada a objetos

Los noruegos Ole-Johan Dahl y Kristen Nygaard desarrollaron Simula para simular sistemas complejos del mundo real. La primera versión de Simula era un lenguaje de simulación basado en ALGOL y, posteriormente, Simula 67 sistematizó los conceptos correspondientes a clases, objetos, herencia, métodos virtuales y despacho dinámico.

Los objetos de Simula combinaban los datos y los comportamientos que procesaban dichos datos en una sola unidad. Los objetos se utilizaban para representar actores individuales o componentes de sistemas reales, mientras que las clases definían sus estructuras comunes. Este modelo se convirtió posteriormente en una base fundamental de lenguajes orientados a objetos como Smalltalk, C++, Java y C#. Simula se considera generalmente el primer lenguaje de programación orientado a objetos.

Década de 1970: establecimiento de los principales paradigmas de programación

Durante la década de 1970 se establecieron en forma de lenguajes independientes diversos paradigmas de programación que continúan utilizándose en la actualidad. C apareció en la programación de sistemas, Pascal en la educación y la programación estructurada, Smalltalk en los entornos orientados a objetos, Prolog en la programación lógica y ML en la programación funcional estática.

Pascal

Niklaus Wirth presentó Pascal en 1970, basándose en la estructura de ALGOL 60. Pascal expresó los principios de la programación estructurada mediante una sintaxis clara y una comprobación estricta de tipos, y proporcionó arreglos, registros, conjuntos, punteros y tipos de datos definidos por el usuario.

Pascal fue utilizado ampliamente en la enseñanza de la programación, pero no fue diseñado desde el principio como un lenguaje exclusivamente educativo. Wirth consideraba que un lenguaje debía poseer principios básicos sencillos y, al mismo tiempo, permitir escribir programas grandes de manera eficiente. Pascal influyó posteriormente en Modula-2, Oberon, Object Pascal y diversos entornos educativos y comerciales de desarrollo.

C y UNIX

Dennis Ritchie desarrolló C (lenguaje de programación) en Bell Labs entre 1971 y 1973, bajo la influencia de BCPL y B (lenguaje de programación). C fue diseñado como un lenguaje de programación de sistemas para implementar el primer sistema operativo UNIX.

Los sistemas operativos anteriores estaban escritos principalmente en lenguaje ensamblador, pero cuando una parte considerable de UNIX fue reescrita en C, resultó más sencillo portar el sistema operativo a otras arquitecturas de computadoras. C proporcionaba punteros, acceso directo a la memoria, operaciones de bits y un modelo de ejecución sencillo, lo que permitía conservar un control cercano al hardware y, al mismo tiempo, realizar programación de alto nivel mediante funciones, tipos de datos y estructuras.

C mantuvo pequeño el propio lenguaje y separó en bibliotecas funciones como la entrada y salida, las cadenas y la administración de memoria. Esta estructura resultó adecuada para implementar sistemas operativos, compiladores, bases de datos, software embebido y entornos de ejecución de diversos lenguajes. Posteriormente, la sintaxis y el modelo de ejecución de C influyeron directa o indirectamente en numerosos lenguajes, como C++, Objective-C, Java, C#, JavaScript, Go (lenguaje de programación), Rust y Wave (lenguaje de programación).

Smalltalk

En 1972, un equipo de investigación de Xerox PARC en el que participaron Alan Kay, Dan Ingalls y Adele Goldberg, entre otros, desarrolló una de las primeras versiones de Smalltalk. Smalltalk no ofrecía únicamente un lenguaje, sino un entorno integrado que combinaba un lenguaje orientado a objetos, una máquina virtual, un navegador de código fuente, un depurador y una interfaz gráfica de usuario.

En Smalltalk, casi todos los elementos, incluidos los números, las cadenas, las clases y el propio entorno de ejecución, se trataban como objetos, y los cálculos se expresaban mediante el envío de mensajes entre objetos. Los desarrolladores podían modificar los objetos y el código dentro del sistema en ejecución y comprobar inmediatamente los resultados. El modelo de objetos, la máquina virtual, el método interactivo de desarrollo y el entorno gráfico de Smalltalk ejercieron una gran influencia no solo sobre los lenguajes orientados a objetos posteriores, sino también sobre los entornos modernos de desarrollo integrado y las interfaces de usuario.

Prolog

En 1972, Alain Colmerauer y su equipo desarrollaron en Marsella, Francia, la primera implementación de Prolog. Prolog representaba los programas en forma de hechos, reglas y consultas que debían resolverse, en lugar de escribirlos como una secuencia de instrucciones que debían ejecutarse.

El entorno de ejecución de Prolog utiliza la unificación lógica y el retroceso para buscar soluciones que satisfagan determinadas condiciones. Este enfoque formó un paradigma independiente denominado programación lógica y fue utilizado en la investigación sobre procesamiento del lenguaje natural, representación del conocimiento, sistemas expertos, sistemas basados en reglas y resolución de restricciones.

ML y los lenguajes funcionales estáticos

Robin Milner diseñó ML como metalenguaje para escribir procedimientos de demostración en el sistema de demostración de teoremas LCF. ML era un lenguaje funcional de orden superior que trataba las funciones como valores y proporcionaba un sistema de tipos estático y polimórfico en el que el compilador podía inferir la mayoría de los tipos sin que el programador tuviera que escribirlos explícitamente.

La inferencia de tipos y el polimorfismo paramétrico de ML demostraron que era posible reducir la verbosidad del código sin abandonar una seguridad de tipos estricta. La coincidencia de patrones, los tipos de datos algebraicos y el sistema de módulos influyeron posteriormente en Haskell, OCaml, F#, Scala y numerosos lenguajes modernos de tipado estático.

SQL y los lenguajes declarativos

A medida que se desarrollaron las bases de datos relacionales, también aparecieron lenguajes declarativos independientes destinados al procesamiento de datos. SEQUEL, desarrollado durante la década de 1970 por Donald Chamberlin y Raymond Boyce en IBM, fue posteriormente renombrado como SQL.

Los usuarios de SQL describen las condiciones y la forma de los datos que desean obtener, en lugar de indicar detalladamente el orden en el que deben buscarse y combinarse. El sistema de gestión de bases de datos determina las rutas reales de acceso y el plan de ejecución. Aunque SQL posee un propósito diferente al de los lenguajes de programación de propósito general, consolidó en sistemas prácticos de gran escala un modelo de lenguaje que declara los resultados en lugar de los procedimientos necesarios para obtenerlos.

Década de 1980: computadoras personales, modularidad y expansión de la orientación a objetos

Durante la década de 1980, la difusión de las computadoras personales amplió considerablemente la cantidad de usuarios de los lenguajes de programación. BASIC fue utilizado ampliamente en las primeras computadoras personales, mientras que los compiladores de Pascal y C también se difundieron en la educación y el desarrollo de software comercial. La aparición de entornos de desarrollo que ofrecían un editor integrado, un compilador y un depurador en un único producto aceleró el proceso de escritura y ejecución de programas.

A medida que aumentó el tamaño de los programas, dejó de ser suficiente organizar el código como una simple colección de procedimientos. Adquirieron importancia las funciones destinadas a especificar interfaces públicas entre módulos, ocultar implementaciones y combinar de manera segura componentes compilados por separado. Modula-2 y Oberon de Wirth, junto con diversos lenguajes de la familia Pascal, abordaron la modularidad y la comprobación de interfaces desde el propio lenguaje.

C++

Bjarne Stroustrup comenzó a desarrollar “C with Classes” en Bell Labs en 1979. Este lenguaje trataba de combinar las clases y la abstracción mediante objetos de Simula con el rendimiento y la capacidad de control de sistemas de C. Alrededor de 1983, su nombre fue cambiado a C++, y en 1985 se publicaron la primera implementación comercial y un libro sobre el lenguaje.

C++ incorporó gradualmente no solo clases y herencia, sino también sobrecarga de funciones y operadores, constructores y destructores, plantillas, gestión de excepciones y programación genérica. Evolucionó como un lenguaje multiparadigma capaz de combinar distintas formas de programación, mientras conservaba en gran medida la compatibilidad con C. Posteriormente fue utilizado ampliamente en grandes aplicaciones, componentes de sistemas operativos, motores de videojuegos, navegadores y software de alto rendimiento.

Perl y los lenguajes de script

En 1987, Larry Wall publicó Perl para automatizar la generación de informes, el procesamiento de texto y las tareas de administración de sistemas en entornos UNIX. Perl combinaba características de C, los shells y herramientas y lenguajes como sed y awk.

Perl integró profundamente en el lenguaje las expresiones regulares y las funciones de procesamiento de cadenas, y permitió transformar archivos y textos mediante programas cortos. Posteriormente desempeñó un papel importante en la programación web inicial basada en CGI y en la automatización de sistemas. También influyó en el modelo de construcción de ecosistemas lingüísticos organizados alrededor de grandes repositorios de módulos de extensión.

Haskell

Durante la década de 1980 se investigaron diversos lenguajes funcionales de evaluación perezosa, pero las diferencias de sintaxis y funciones entre ellos dificultaban compartir resultados de investigación y bibliotecas. Para resolver este problema, un comité comenzó a diseñar Haskell a finales de la década de 1980.

Haskell adoptó como características principales la programación funcional pura, que separa los efectos secundarios mediante los tipos y la estructura, la evaluación perezosa, los tipos de datos algebraicos, la coincidencia de patrones y las clases de tipos. Haskell se convirtió no solo en un lenguaje de uso comercial, sino también en una base para experimentar con nuevos sistemas de tipos y capacidades de abstracción dentro de la investigación sobre lenguajes de programación.

Década de 1990: Internet, máquinas virtuales y crecimiento de los lenguajes dinámicos

Durante la década de 1990, el aumento del rendimiento de las computadoras personales, la difusión de los sistemas operativos gráficos e Internet transformaron considerablemente el entorno que debían abordar los lenguajes de programación. Adquirieron importancia la capacidad de distribuir programas a través de redes, ejecutar el mismo código en distintos sistemas operativos y desarrollar rápidamente aplicaciones que cambiaban con frecuencia.

Python

A finales de la década de 1980, Guido van Rossum concibió en el CWI de los Países Bajos un lenguaje que heredara las ventajas del lenguaje educativo ABC y, al mismo tiempo, pudiera ampliarse con facilidad para su uso en entornos UNIX y en programación práctica de sistemas. Comenzó su implementación a finales de 1989 y publicó Python a comienzos de la década de 1990.

Python representaba la estructura de bloques mediante sangría y proporcionaba tipado dinámico, administración automática de memoria, gestión de excepciones, módulos y una amplia variedad de tipos de datos incorporados. Su estructura, que permitía conectarlo con facilidad a módulos externos escritos en C, contribuyó a ampliar su campo de uso desde scripts sencillos hasta cálculo científico, servidores web, automatización e inteligencia artificial.

Java

James Gosling y un equipo de investigadores de Sun Microsystems estudiaron a comienzos de la década de 1990 una plataforma de software destinada a dispositivos de consumo conectados a redes y a sistemas embebidos. Inicialmente utilizaron C++, pero desarrollaron un nuevo lenguaje y un entorno de ejecución para resolver problemas de portabilidad, estabilidad, administración de memoria y seguridad.

Java, publicado en 1995, adoptó una estructura en la que el código fuente se compilaba a bytecode, en lugar de a código máquina para una CPU específica, y posteriormente se ejecutaba en la Java Virtual Machine. Al ofrecer conjuntamente administración automática de memoria, verificación en tiempo de ejecución y bibliotecas estándar, enfatizó la independencia de plataforma que permitía ejecutar un mismo programa en distintos sistemas operativos. Java comenzó con los applets web y amplió su campo de uso hacia servidores, software empresarial, dispositivos móviles y grandes sistemas distribuidos.

JavaScript

En 1995, Brendan Eich, de Netscape, diseñó un lenguaje destinado a responder a las acciones del usuario y modificar dinámicamente el contenido de la pantalla dentro de los documentos web. El lenguaje fue desarrollado inicialmente con otros nombres, pero finalmente se incorporó a Netscape Navigator con el nombre de JavaScript.

Aunque su nombre se parece al de Java, JavaScript es un lenguaje independiente y utiliza tipado dinámico, objetos basados en prototipos y funciones de primera clase. Inicialmente se utilizaba principalmente para procesar scripts breves insertados en páginas web, pero el aumento del rendimiento de los motores de los navegadores, la estandarización de ECMAScript y la aparición de Node.js y de los ecosistemas de módulos lo convirtieron en un lenguaje de propósito general utilizado tanto en el frontend como en el servidor.

PHP y Ruby

Rasmus Lerdorf creó en 1994 herramientas CGI para administrar el registro de visitantes de su sitio web. Estas herramientas pasaron por PHP/FI y evolucionaron hasta convertirse en PHP. PHP se difundió rápidamente en el desarrollo de sitios web dinámicos gracias a su modelo de inserción de código del lado del servidor dentro de documentos HTML y a su sencilla estructura de despliegue.

Yukihiro Matsumoto diseñó Ruby combinando influencias de Perl, Smalltalk y LISP, con especial atención a la expresividad y la comodidad de escritura para el desarrollador. Ruby comenzó a desarrollarse en 1993 y fue publicado en 1995. Proporcionaba un modelo de objetos coherente que trataba todos los valores como objetos, además de bloques y funciones de metaprogramación. Posteriormente se hizo ampliamente conocido en el desarrollo de aplicaciones web con la aparición de Ruby on Rails.

Década de 2000: plataformas administradas y lenguajes multiparadigma

Durante la década de 2000, las máquinas virtuales y los entornos de ejecución administrados se convirtieron en tecnologías centrales para el desarrollo de software de gran escala. Los lenguajes comenzaron a entenderse no únicamente como una sintaxis de código fuente, sino como ecosistemas completos que incluían recolectores de basura, bibliotecas estándar, gestión de paquetes, depuradores, compiladores JIT y plataformas de distribución.

C# y .NET

Microsoft desarrolló C# para la plataforma .NET bajo la dirección de Anders Hejlsberg. La primera implementación ampliamente distribuida se presentó en 2000 junto con la iniciativa .NET.

C# utilizaba una sintaxis de la familia de C y un modelo orientado a objetos, al tiempo que integraba administración automática de memoria, información de tipos en tiempo de ejecución, propiedades, delegados y eventos. Posteriormente incorporó genéricos, LINQ, expresiones lambda, funciones asíncronas, coincidencia de patrones y registros, y evolucionó hasta convertirse en un lenguaje que admite conjuntamente programación orientada a objetos, funcional y declarativa.

Scala

Martin Odersky comenzó a desarrollar Scala en la EPFL en 2001 y publicó una implementación para la JVM en 2004. Scala combinó la programación orientada a objetos y la programación funcional dentro de un único sistema de tipos estático, manteniendo la interoperabilidad con la Java Virtual Machine y las bibliotecas de Java.

Scala proporcionaba funciones de orden superior, coincidencia de patrones, estructuras de datos inmutables, traits y una potente inferencia de tipos. La estrategia de introducir nuevas capacidades lingüísticas aprovechando el ecosistema de una plataforma existente se repitió posteriormente en diversos lenguajes modernos destinados a la JVM y a .NET.

Década de 2010: concurrencia, seguridad de memoria y desarrollo a gran escala

Desde finales de la década de 2000, el desarrollo de los procesadores pasó de centrarse en el aumento de la velocidad de un único núcleo a depender cada vez más de los procesadores multinúcleo y el procesamiento paralelo. Al mismo tiempo, el aumento de la escala de los servicios de Internet y la expansión de las infraestructuras en la nube convirtieron la concurrencia, el despliegue estable, la compilación rápida y la administración de grandes bases de código en problemas fundamentales del diseño de lenguajes.

Go

Robert Griesemer, Rob Pike y Ken Thompson, de Google, comenzaron a diseñar Go (lenguaje de programación) en 2007 para resolver los prolongados tiempos de compilación, las relaciones de dependencia complejas y los problemas de concurrencia que aparecían durante el desarrollo de grandes infraestructuras de software. El proyecto Go fue publicado en 2009 y Go 1 se presentó en 2012.

Go proporcionaba una sintaxis relativamente pequeña, compilación rápida, recolección de basura, un sistema de paquetes, goroutines y canales. En lugar de aumentar el número de funciones del lenguaje, adoptó un enfoque orientado a simplificar el desarrollo de software de servidor y de red en equipos mediante la integración de herramientas estándar, un formateador, pruebas, módulos y procedimientos de compilación.

Rust

Rust, iniciado por Graydon Hoare y desarrollado posteriormente por Mozilla y una comunidad abierta, tenía como objetivo crear un lenguaje de programación de sistemas que ofreciera seguridad de memoria y seguridad de concurrencia sin un recolector de basura. Con la publicación de Rust 1.0 en 2015 se proporcionaron conjuntamente un lenguaje estable, una biblioteca estándar y Cargo, una herramienta de gestión de paquetes y compilación.

Rust comprueba durante la compilación las reglas de uso de la memoria mediante la propiedad, los préstamos y el análisis de tiempos de vida. Busca impedir en la fase de compilación diversas clases de errores, como las condiciones de carrera de datos y el acceso a memoria liberada, al tiempo que proporciona un control detallado y un rendimiento cercanos a los de C y C++. También adoptó un sistema de ediciones para desarrollar gradualmente la sintaxis del lenguaje mientras conserva la compatibilidad con el código existente.

Kotlin

JetBrains inició el proyecto Kotlin en 2010 y lo presentó públicamente en 2011. Kotlin fue diseñado para ser compatible con el código Java existente y con el ecosistema de la JVM, al tiempo que proporcionaba características modernas como seguridad frente a valores nulos, inferencia de tipos, funciones de extensión, clases de datos y corrutinas.

Kotlin 1.0 fue publicado en 2016 y posteriormente se amplió como lenguaje multiplataforma dirigido no solo a la JVM, sino también a JavaScript, código nativo y WebAssembly.

Swift

Apple desarrolló Swift para modernizar el entorno de desarrollo basado en Objective-C y lo presentó en 2014. Swift proporcionaba tipado estático, inferencia de tipos, tipos por valor, valores opcionales, genéricos y abstracciones centradas en protocolos.

Inicialmente se utilizó principalmente para el desarrollo de aplicaciones de iOS y macOS, pero posteriormente fue publicado como software de código abierto y se amplió hacia diversas plataformas, incluidas Linux y Windows, así como hacia los campos de servidores y sistemas embebidos.

TypeScript

A medida que aumentó el tamaño de las aplicaciones web, creció la necesidad de comprobar durante el desarrollo la estructura y los errores del código JavaScript de tipado dinámico. TypeScript, publicado por Microsoft en 2012, fue diseñado como un superconjunto de JavaScript que permitía añadir anotaciones estáticas de tipos opcionales sin abandonar el código existente.

TypeScript utiliza un sistema de tipos estructural y análisis de flujo de control, y genera JavaScript convencional como resultado de la compilación. Se convirtió en uno de los ejemplos más exitosos de un sistema de tipos gradual que añade una capa de análisis estático sobre un entorno de ejecución y un ecosistema ya existentes, sin reemplazarlos.

WebAssembly

En 2019, el World Wide Web Consortium adoptó la especificación principal de WebAssembly como estándar web. WebAssembly no es un lenguaje convencional de alto nivel destinado a ser escrito directamente por personas, sino un bytecode de bajo nivel seguro y portátil y un formato de instrucciones para máquinas virtuales generado a partir de lenguajes como C, C++ y Rust.

Gracias a ello, los navegadores web obtuvieron un destino común capaz de ejecutar eficientemente no solo JavaScript, sino también el resultado de compilación de diversos lenguajes. Posteriormente, WebAssembly comenzó a utilizarse fuera de los navegadores, en servidores, complementos, entornos aislados y entornos de ejecución portátiles, ampliando nuevamente la relación entre los lenguajes de programación y las plataformas de ejecución.

Desde la década de 2020

Los lenguajes de programación de la década de 2020 son cada vez más difíciles de clasificar mediante un único paradigma o método de ejecución. Los lenguajes modernos combinan según las necesidades modelos procedurales, orientados a objetos, funcionales, declarativos y de concurrencia. Los lenguajes de tipado estático están reforzando la inferencia de tipos y las herramientas interactivas, mientras que los lenguajes dinámicos también están incorporando comprobación opcional de tipos y funciones de análisis estático.

Las principales direcciones consideradas en el diseño de lenguajes son las siguientes.

  • Seguridad de memoria y reducción del comportamiento indefinido
  • Ejecución asíncrona y concurrencia estructurada
  • Representación explícita de los valores nulos y la gestión de errores
  • Inferencia de tipos y sistemas de tipos graduales
  • Ampliación de los datos inmutables y las expresiones funcionales
  • Estandarización de los gestores de paquetes, los sistemas de compilación y los formateadores
  • Ejecución multiplataforma compatible con diversos sistemas operativos y arquitecturas de CPU
  • Interoperabilidad con los ecosistemas existentes de la ABI de C, la JVM, .NET, JavaScript y WebAssembly
  • Computación heterogénea que utiliza conjuntamente CPU, GPU y aceleradores
  • Desarrollo público de las especificaciones y las implementaciones de los lenguajes, junto con la gobernanza comunitaria

También en la estructura de los compiladores se está extendiendo el uso de múltiples etapas de representaciones intermedias, en lugar de un flujo sencillo que traduce directamente un único lenguaje fuente a un único lenguaje máquina. Tecnologías de infraestructura para compiladores como LLVM y MLIR permiten reutilizar frontends de lenguajes, optimizadores y backends para CPU y GPU, y tienen como objetivo reducir el coste de desarrollo de lenguajes específicos de dominio destinados a determinados equipos o campos computacionales.

Por otra parte, la aparición de nuevos lenguajes no provoca la desaparición inmediata de los lenguajes existentes. Fortran y COBOL continúan utilizándose en sistemas científicos y administrativos construidos durante varias décadas, mientras que C y C++ mantienen amplios ecosistemas relacionados con los sistemas operativos y el software de alto rendimiento. Java y C# continúan incorporando funciones alrededor de sus plataformas administradas, y Python y JavaScript también conservan sus ecosistemas existentes de código y paquetes mientras desarrollan el rendimiento de sus lenguajes y entornos de ejecución y sus capacidades de análisis de tipos.

Por lo tanto, a lo largo de la historia de los lenguajes de programación se han propuesto distintos modelos computacionales y métodos de abstracción como respuesta al rendimiento y la estructura de las computadoras de cada época, la variedad de sus usuarios, el tamaño del software que se pretendía desarrollar y las necesidades de seguridad y productividad. Los lenguajes de programación modernos continúan evolucionando mediante la combinación de estos linajes históricos, y los lenguajes de distintos niveles, desde los lenguajes de bajo nivel cercanos al control de la máquina hasta los lenguajes declarativos y específicos de dominio cercanos a la expresión humana de los problemas, se utilizan de manera complementaria.

Clasificación y componentes

Los lenguajes de programación no pueden dividirse claramente mediante un único criterio. Un mismo lenguaje puede ser de alto nivel y de tipado estático, admitir conjuntamente programación orientada a objetos, procedimental y funcional, y utilizar tanto código nativo como una máquina virtual como destinos de ejecución. Por ello, la clasificación de los lenguajes de programación debe entenderse no como una lista de categorías mutuamente excluyentes, sino como un sistema multidimensional que describe desde distintas perspectivas el nivel de abstracción, el método de ejecución, el sistema de tipos, los paradigmas de programación, el propósito de uso y el método de administración de recursos de cada lenguaje. El sistema de clasificación de la computación de la ACM también utiliza una estructura que permite aplicar varias clasificaciones a un mismo objeto de estudio, al incluir como categorías independientes los lenguajes funcionales, orientados a objetos, lógicos y de restricciones, de flujo de datos, paralelos y distribuidos, y multiparadigma.

Los principales criterios de clasificación pueden resumirse de la siguiente manera.

CriterioEjemplos de clasificaciónObjeto de la distinción
Nivel de abstracciónLenguaje máquina, lenguaje ensamblador, lenguaje de alto nivelEn qué medida se representan directamente los detalles del hardware
Método de ejecuciónCompilación anticipada, interpretación, bytecode, JITCómo se transforma el código fuente en una forma ejecutable
Sistema de tiposTipado estático o dinámico, tipos explícitos o inferidosCuándo y cómo se comprueba la validez de los valores y las operaciones
Relación entre tiposTipado nominal, tipado estructuralCon qué criterio se determina la compatibilidad entre tipos diferentes
ParadigmaImperativo, orientado a objetos, funcional, lógico, de flujo de datosCómo se representan el cálculo y la estructura de los programas
Propósito de usoLenguaje de propósito general, lenguaje específico de dominioAlcance del área de problemas para la que fue diseñado el lenguaje
Administración de recursosAdministración manual, recolección de basura, administración basada en propiedadCómo se administra el ciclo de vida de la memoria y los recursos externos
Modelo de concurrenciaHilos, memoria compartida, paso de mensajes, canales, corrutinasCómo se representan la ejecución y la sincronización de varias tareas

Clasificación según el nivel de abstracción

El lenguaje máquina es la representación binaria de las instrucciones que interpreta directamente un procesador. Cada instrucción representa un código de operación y operandos, registros o direcciones de memoria pertenecientes a una arquitectura específica de conjunto de instrucciones. El lenguaje máquina permite controlar el hardware de la forma más directa, pero resulta difícil de leer y escribir para las personas, y aunque el programa sea el mismo, es necesario reconstruir la representación de sus instrucciones cuando cambia el procesador de destino.

El lenguaje ensamblador representa las instrucciones de máquina mediante símbolos como MOV, ADD y JMP, en lugar de números. Permite utilizar etiquetas, constantes y macros, por lo que resulta más fácil de leer y administrar que el lenguaje máquina. Sin embargo, se clasifica como lenguaje de bajo nivel porque trata directamente estructuras de la arquitectura de destino, como los registros, las convenciones de llamada, los métodos de direccionamiento de memoria y el conjunto de instrucciones.

Un lenguaje de programación de alto nivel representa los programas mediante conceptos más cercanos a la resolución de problemas, como algoritmos, estructuras de datos, funciones, objetos y módulos, en lugar de centrarse en instrucciones de hardware. El grado de abstracción varía entre lenguajes que, como C, permiten manipular directamente direcciones de memoria y punteros, y lenguajes como Python o Haskell, que delegan una parte considerable de la ejecución y de la representación de los datos en la implementación del lenguaje. Por ello, la distinción entre lenguajes de bajo y alto nivel se asemeja más a una escala continua determinada por el grado de dependencia del hardware y de abstracción que a una división absoluta en dos niveles.

En este contexto, la expresión alto nivel no indica la calidad ni la dificultad del lenguaje. Representa el grado en que se abstrae el funcionamiento concreto del hardware, y un lenguaje de alto nivel no tiene que ser necesariamente fácil de utilizar ni lento durante la ejecución. Un compilador puede transformar las estructuras de un lenguaje de alto nivel en código máquina eficiente mediante representaciones intermedias y etapas de optimización. LLVM proporciona una estructura en la que analiza y optimiza la representación intermedia generada por el frontend de un lenguaje y posteriormente la transforma en código máquina para diversas arquitecturas de destino.

Clasificación según el método de ejecución

Los lenguajes de programación suelen dividirse en lenguajes compilados e interpretados, pero esta distinción se refiere más al método utilizado por una implementación concreta para ejecutar un programa que a una propiedad absoluta del propio lenguaje. Según la implementación, un mismo lenguaje puede compilarse anticipadamente a código nativo, transformarse en bytecode para una máquina virtual, compilarse a código máquina durante la ejecución o interpretarse directamente a partir de su estructura sintáctica. Python, por ejemplo, posee distintas implementaciones, como CPython, PyPy, Jython e IronPython. PyPy utiliza compilación JIT, mientras que Jython e IronPython emplean respectivamente los entornos de ejecución de la JVM y .NET.

La compilación anticipada, o compilación AOT, transforma el código fuente en código máquina u otro formato ejecutable para el sistema de destino antes de ejecutar el programa. En las implementaciones habituales de C y C++, se crea un archivo ejecutable o una biblioteca mediante etapas de preprocesamiento, análisis sintáctico y semántico, generación de una representación intermedia, optimización, generación de código específico para el destino, ensamblado y enlace. Sin embargo, en las herramientas reales, varias de estas etapas pueden combinarse en una sola orden o puede omitirse la generación de ciertos archivos intermedios.

Un intérprete lee la representación del programa durante la ejecución y realiza operaciones de acuerdo con su significado. Esto no significa necesariamente que el intérprete ejecute directamente y línea por línea el código fuente original. También puede transformar primero el código fuente en tokens, un árbol sintáctico, instrucciones internas o bytecode, y posteriormente interpretar ese formato intermedio.

En un método que utiliza bytecode y una máquina virtual, el código fuente se transforma en un conjunto virtual de instrucciones, en lugar de las instrucciones de una CPU física. El código fuente Java se compila normalmente en archivos de clase y bytecode para la JVM, mientras que la JVM define una estructura de ejecución y un conjunto de instrucciones que incluyen la pila, el montón, el área de métodos y el depósito de constantes. Este método sitúa una capa de ejecución común entre el lenguaje y el hardware físico, lo que permite utilizar el mismo formato de programa en distintos sistemas operativos y procesadores.

La compilación JIT transforma durante la ejecución una representación intermedia o el código ejecutado con frecuencia en código máquina para el sistema actual. Puede realizar optimizaciones adaptadas a las relaciones reales entre llamadas, la forma de los valores y la frecuencia de repetición utilizando información recopilada durante la ejecución, aunque el tiempo y la memoria necesarios para compilar pasan a formar parte del propio proceso de ejecución. Las implementaciones modernas pueden utilizar conjuntamente un intérprete, un compilador JIT y un compilador AOT. Por ello, la compilación y la interpretación no son métodos completamente separados, sino estrategias de implementación que pueden combinarse dentro de un mismo sistema de ejecución.

Clasificación según el sistema de tipos

Un sistema de tipos es un conjunto de reglas que define las clases de valores que pueden utilizarse en un programa, las operaciones permitidas sobre cada valor y las relaciones existentes entre los valores. Por ejemplo, los enteros y las cadenas admiten operaciones diferentes, y también pueden aplicarse condiciones de tipo específicas a los parámetros y los valores de retorno de una función. La especificación de Go describe un tipo como un conjunto de determinados valores y un conjunto de operaciones y métodos aplicables a dichos valores, mientras que en Haskell una expresión representa un valor y posee al mismo tiempo un tipo estático.

Los lenguajes de tipado estático comprueban antes de ejecutar el programa las relaciones de tipo de las expresiones, las variables y las llamadas a funciones. C, C++, Rust, Java, Haskell y Go se basan principalmente en la comprobación estática de tipos. Esta comprobación permite detectar ciertos errores antes de la ejecución y proporciona al compilador información sobre la representación de los valores y las estructuras de llamada que puede utilizar durante la optimización. Sin embargo, incluso en un lenguaje estático puede ser necesario comprobar por separado condiciones que solo se determinan durante la ejecución, como los límites de los arreglos, las entradas externas y la carga dinámica.

En los lenguajes de tipado dinámico, los valores poseen información de tipo durante la ejecución y, en muchos casos, la validez de una operación se comprueba cuando esta se aplica realmente a un valor. Python, Ruby y JavaScript son ejemplos representativos. El tipado dinámico permite asociar sucesivamente valores de distintas clases a un mismo nombre y manipular con naturalidad estructuras creadas durante la ejecución, pero ciertos errores de tipo pueden no aparecer hasta que se ejecute realmente la ruta de código correspondiente. En el modelo de datos de Python, todos los datos de un programa se representan mediante objetos o relaciones entre objetos, y cada objeto posee identidad, tipo y valor.

La distinción entre tipado estático y dinámico es independiente de si el programador escribe directamente las anotaciones de tipo. Un lenguaje estático que admite inferencia de tipos puede determinar los tipos analizando las expresiones y sus relaciones de uso, aunque el programador no los especifique todos. Haskell, ML, Rust, Kotlin y Swift utilizan ampliamente la inferencia de tipos. A la inversa, también es posible añadir a un lenguaje dinámico una capa de análisis estático que no modifica directamente el comportamiento durante la ejecución, como las anotaciones de TypeScript o las indicaciones de tipos de Python.

La compatibilidad entre tipos también puede dividirse en tipado nominal y tipado estructural. Un sistema de tipos nominal determina la compatibilidad principalmente mediante el nombre de una declaración de tipo o una relación explícita de herencia o implementación. Un sistema de tipos estructural compara la estructura real de los tipos, como sus campos y métodos, en lugar de sus nombres. TypeScript utiliza subtipado estructural y determina la compatibilidad entre tipos a partir de los miembros que componen un objeto.

Los sistemas de tipos también pueden clasificarse según propiedades como los genéricos y el polimorfismo paramétrico, el subtipado, los tipos suma y producto, los tipos de datos algebraicos, los tipos dependientes y el tipado gradual. Las expresiones tipado fuerte y tipado débil se utilizan con significados diferentes según la bibliografía y los usuarios, como conversiones implícitas, seguridad de memoria, comprobaciones durante la ejecución o posibilidad de eludir el sistema de tipos. Por ello, cuando se necesita una clasificación precisa, resulta más apropiado describir directamente características concretas, como la comprobación estática o dinámica, las conversiones explícitas o implícitas y la seguridad de memoria. Los tipos y el polimorfismo no forman una única relación de fuerza o debilidad, sino que están compuestos por diversas reglas independientes que definen la clasificación, la abstracción y la compatibilidad de los valores.

Clasificación según el paradigma de programación

Un paradigma de programación representa los conceptos y las estructuras mediante los que se expresa el cálculo. La mayoría de los lenguajes modernos no siguen estrictamente un único paradigma, sino que son lenguajes multiparadigma que admiten varias formas de programación. Por ejemplo, C++ admite conjuntamente programación procedimental, orientada a objetos, genérica y expresiones funcionales, mientras que Python y JavaScript también permiten utilizar objetos, funciones y estructuras de control imperativas. La clasificación de lenguajes de la ACM también trata por separado los lenguajes funcionales, orientados a objetos, lógicos y de restricciones, de flujo de datos, concurrentes y distribuidos, y multiparadigma.

La programación imperativa expresa el cálculo como una secuencia de instrucciones que modifican el estado del programa. Asigna valores a variables y controla el flujo de ejecución mediante condiciones, bucles y llamadas a funciones. La programación procedimental organiza los programas imperativos dividiéndolos en unidades invocables, como funciones, procedimientos y subrutinas. C, Pascal y FORTRAN se clasifican como lenguajes procedimentales representativos.

La programación orientada a objetos organiza los datos y las operaciones que los procesan en unidades denominadas objetos. En los lenguajes basados en clases, una clase define la estructura y el comportamiento de los objetos, y las relaciones entre estos se expresan mediante encapsulación, herencia, polimorfismo y despacho dinámico. Java, C#, C++ y Smalltalk son ejemplos representativos, aunque cada lenguaje define de forma diferente los objetos, las clases y la herencia. También existen lenguajes como JavaScript, que proporcionan declaraciones de clases pero utilizan internamente un modelo de objetos basado en prototipos. La especificación de ECMAScript define los cálculos y la manipulación de objetos del lenguaje, al tiempo que separa como responsabilidad del entorno anfitrión las funciones de entrada y salida, como los archivos y las redes.

La programación funcional trata las funciones como unidades centrales del cálculo y como valores, y organiza los programas mediante la composición de funciones, la recursión, las funciones de orden superior y las estructuras de datos inmutables. Los lenguajes funcionales puros enfatizan las funciones que devuelven el mismo resultado para una misma entrada y el control de los efectos secundarios. Haskell organiza el lenguaje alrededor de expresiones, funciones y tipos estáticos, mientras que LISP, ML, F# y numerosos lenguajes modernos también admiten conceptos fundamentales de la programación funcional. Esta ha sido estudiada como un modelo computacional capaz de sustituir o complementar la programación tradicional de estilo von Neumann, centrada en las instrucciones y en el estado de la memoria.

La programación lógica describe hechos, reglas y consultas, y permite que el entorno de ejecución busque mediante inferencia lógica soluciones que satisfagan determinadas condiciones. Prolog es su ejemplo representativo y utiliza unificación, retroceso y descripciones centradas en relaciones. La programación con restricciones describe las condiciones que deben satisfacer las variables y permite que un solucionador busque valores posibles. Ambos métodos se caracterizan por declarar las relaciones y las condiciones que deben cumplirse, en lugar de escribir directamente una secuencia concreta de instrucciones.

La programación declarativa es una categoría amplia que describe el resultado o las relaciones deseadas en lugar de especificar el proceso de cálculo mediante instrucciones detalladas. Los lenguajes funcionales y lógicos también pueden incluirse en la categoría declarativa. Asimismo, lenguajes como SQL, en los que se describen las condiciones y la forma de los datos requeridos y se delegan al sistema el orden real de acceso y el plan de ejecución, se clasifican como lenguajes declarativos.

La programación de flujo de datos representa las dependencias de datos entre operaciones mediante un grafo y ejecuta una operación cuando se encuentran disponibles sus entradas necesarias. Se utiliza con frecuencia en entornos de programación visual, procesamiento de señales, cálculo paralelo y sistemas de grafos para aprendizaje automático. La programación reactiva organiza el programa alrededor de valores que cambian con el tiempo, flujos de acontecimientos y relaciones de propagación entre valores.

La programación concurrente aborda estructuras en las que varios cálculos se ejecutan de manera superpuesta, así como la comunicación y sincronización entre ellos. Según el lenguaje, puede proporcionar hilos del sistema operativo y memoria compartida, actores y paso de mensajes, corrutinas y funciones asíncronas o comunicación basada en canales. Go define la comunicación mediante canales como uno de los principales mecanismos de sincronización entre goroutines y especifica en su modelo de memoria la relación de orden entre un envío a un canal y la recepción correspondiente.

Clasificación según el propósito de uso

Un lenguaje de programación de propósito general está diseñado para escribir distintas clases de software sin limitarse a un único problema. C, C++, Java, Python, Rust y Go pertenecen a esta categoría. Sin embargo, incluso un lenguaje de propósito general puede utilizarse con mayor frecuencia en determinados campos, como software de sistemas, aplicaciones empresariales, servidores web o automatización, según sus objetivos de diseño y su ecosistema.

Un lenguaje específico de dominio está diseñado para expresar de manera concisa y precisa un área concreta de problemas. SQL representa consultas y operaciones sobre datos relacionales; las expresiones regulares, patrones de cadenas; los lenguajes de sombreado, operaciones gráficas de GPU; y los lenguajes de descripción de hardware, circuitos digitales. Un lenguaje específico de dominio puede ejecutarse como un programa independiente o incluirse dentro de cadenas, bibliotecas, macros o sintaxis incorporada de otro lenguaje.

Según el ámbito de uso, también se distinguen lenguajes de programación de sistemas, lenguajes de script, lenguajes de shell, lenguajes de consulta de datos, lenguajes de cálculo científico, lenguajes educativos, lenguajes de descripción de hardware, lenguajes de sombreado y lenguajes de programación concurrente y paralela. Estas denominaciones indican principalmente el propósito de diseño y las prácticas de uso del lenguaje, en lugar del conjunto completo de sus capacidades, por lo que un mismo lenguaje puede pertenecer a varios ámbitos.

Componentes fundamentales de un lenguaje

Un lenguaje de programación no está compuesto únicamente por su gramática. Para funcionar como un sistema computacional completo deben definirse conjuntamente los caracteres y tokens que forman el código fuente, la estructura de las construcciones, el comportamiento representado por cada una de ellas, los valores y tipos, los ámbitos de los nombres, el flujo de control, los medios de abstracción y los métodos de gestión de memoria y errores. El sistema de clasificación de la ACM presenta como componentes principales de los lenguajes los tipos y las estructuras de datos, las estructuras de control, las funciones y los procedimientos, las clases y los objetos, los módulos y paquetes, el polimorfismo, la recursión, las estructuras de concurrencia y la administración dinámica de almacenamiento.

Estructura léxica y tokens

La estructura léxica define las unidades gramaticales más pequeñas que componen el código fuente. Incluye identificadores, palabras clave, literales, operadores, delimitadores, comentarios y reglas para procesar espacios en blanco y saltos de línea. Un analizador léxico lee una secuencia de caracteres de entrada y la transforma en una secuencia de tokens que puede procesar el analizador sintáctico.

El significado de los espacios en blanco varía según el lenguaje. En los lenguajes de la familia de C, la mayoría de los espacios sirven únicamente para separar tokens, mientras que en Python los cambios de sangría se transforman en tokens INDENT y DEDENT que representan la estructura de bloques. La referencia de Python distingue entre el análisis léxico, que procesa la entrada de caracteres, y el análisis sintáctico, que procesa los tokens resultantes.

Sintaxis

La sintaxis define en qué orden y estructura deben combinarse los tokens para formar un programa válido. Las reglas sintácticas incluyen la forma de las declaraciones de variables, las definiciones de funciones, las expresiones, las instrucciones condicionales, los bucles, las declaraciones de tipos y las declaraciones de módulos. Las especificaciones de los lenguajes suelen describir la sintaxis mediante notaciones gramaticales como BNF, EBNF y PEG, o mediante formas equivalentes.

Las reglas sintácticas determinan únicamente la estructura externa de un programa. Por ejemplo, aunque a + b sea una expresión sintácticamente válida, la posibilidad de aplicar la suma a a y b y el resultado de la operación se determinan mediante las reglas de tipos y la semántica.

Semántica

La semántica define qué significa un programa sintácticamente válido y cómo se comporta. Los estándares de los lenguajes suelen especificar la semántica junto con la sintaxis. El estándar de COBOL también define la sintaxis y la semántica del lenguaje para permitir programas independientes de las máquinas.

La semántica estática aborda las reglas que pueden comprobarse antes de ejecutar el programa. Incluye si un nombre ha sido declarado, si el ámbito de una variable es válido, si los argumentos y el tipo de retorno de una llamada a función son correctos y si una estructura de control aparece en una posición permitida. Las reglas de comprobación de tipos y resolución de nombres que resultan difíciles de expresar únicamente mediante la sintaxis pertenecen a la semántica estática.

La semántica dinámica define los cambios de estado y los valores calculados por cada expresión e instrucción durante la ejecución del programa. Incluye el orden de evaluación de los operadores, las llamadas a funciones, el lanzamiento de excepciones, la creación de objetos, el acceso a la memoria y el orden de las ejecuciones concurrentes. La semántica puede explicarse mediante lenguaje natural o definirse mediante métodos matemáticos, como la semántica operacional, la semántica denotacional y la semántica axiomática.

Valores y modelo de datos

El modelo de datos define qué constituye un valor en un lenguaje y qué clases de valores pueden crearse y combinarse. Puede incluir valores básicos, como enteros, números reales, valores lógicos, caracteres y cadenas, además de arreglos, tuplas, registros, estructuras, listas, conjuntos, mapas, funciones, clases y objetos.

La relación entre valores y objetos varía según el lenguaje. Python modela todos los datos mediante objetos o relaciones entre objetos y define que cada objeto posee identidad, tipo y valor. En Haskell, las expresiones representan valores y a cada expresión se le asigna un tipo estático. C define objetos, valores y regiones de almacenamiento mediante un modelo de memoria cercano al hardware. Estas diferencias entre modelos de datos afectan a la asignación y la copia, la comparación de identidad, la mutabilidad y la disposición de la memoria.

Nombres, enlaces y ámbitos

Los identificadores de un programa hacen referencia a elementos como variables, funciones, tipos y módulos. La conexión entre un identificador y el elemento real se denomina enlace. Los enlaces pueden formarse durante la compilación, el enlace, la carga del programa o la ejecución.

El ámbito es la región del código fuente en la que puede hacerse referencia a un nombre determinado, mientras que el tiempo de vida es el periodo de ejecución durante el que existen realmente un valor, un objeto o una región de almacenamiento. Ambos conceptos están relacionados, pero no son idénticos. Un objeto que ha salido de un ámbito puede seguir existiendo mediante otra referencia y, a la inversa, aunque un nombre permanezca dentro de su ámbito, el recurso al que hacía referencia puede haber dejado de ser válido.

Un lenguaje puede proporcionar ámbitos globales, de módulo, de función, de bloque y de clase. En las funciones anidadas puede crearse una clausura que capture variables locales de una función externa, y el orden de búsqueda de nombres y el método de captura dependen del modelo de ejecución del lenguaje.

Expresiones y sentencias

Una expresión es un elemento del lenguaje que calcula o representa un valor. Los literales, las referencias a variables, la aplicación de operadores, las llamadas a funciones, las expresiones condicionales y la creación de objetos pueden ser expresiones. Una sentencia o instrucción representa una operación que afecta al flujo de ejecución o al estado del programa, como la asignación, la repetición, la bifurcación, el retorno y la gestión de excepciones.

Algunos lenguajes tratan incluso las condiciones y los bloques como expresiones que generan valores, mientras que otros separan estrictamente las expresiones que producen valores de las sentencias que únicamente realizan operaciones. La referencia del lenguaje Python también define por separado la sintaxis y la semántica de las expresiones, mientras que Haskell trata las expresiones como las unidades computacionales centrales del lenguaje.

Flujo de control

El flujo de control determina qué partes de un programa se ejecutan y en qué orden. La ejecución secuencial, las bifurcaciones condicionales, la repetición, las llamadas y retornos de funciones, la recursión y la gestión de excepciones son estructuras básicas de control.

Según el lenguaje, también pueden proporcionarse estructuras avanzadas de control, como la coincidencia de patrones, las corrutinas, los generadores, las funciones asíncronas, las continuaciones y la elección no determinista. Estas estructuras no son únicamente funciones de comodidad sintáctica, sino que determinan el estado del programa, la pila de llamadas, el orden de evaluación de los valores y el método de propagación de los errores.

Funciones y abstracción

Las funciones, los procedimientos y las subrutinas dividen los cálculos repetidos en unidades con nombre. Los parámetros, los valores de retorno, las variables locales, las convenciones de llamada y los métodos de paso de valores son elementos principales del sistema de funciones. Cuando una función puede utilizarse como argumento o valor de retorno de otra función, se dice que el lenguaje admite funciones de primera clase. Una función que recibe o devuelve otras funciones se denomina función de orden superior.

Además de las funciones, los lenguajes proporcionan abstracción de datos y comportamientos mediante clases, interfaces, traits, protocolos, clases de tipos y tipos abstractos de datos. El polimorfismo permite aplicar una misma interfaz o fragmento de código a valores de distintos tipos y se divide en categorías como sobrecarga, polimorfismo de subtipos y polimorfismo paramétrico.

Módulos y paquetes

Un módulo reúne declaraciones relacionadas, como valores, funciones, tipos y clases, dentro de un espacio de nombres y una unidad de distribución. Los módulos permiten distinguir los elementos expuestos al exterior de la implementación interna y proporcionan una estructura para importar funciones de otros módulos. Los módulos de Haskell definen valores, tipos de datos, sinónimos de tipos y clases, exportan determinados elementos al exterior e incluyen mediante declaraciones de importación elementos de otros módulos dentro de su ámbito.

Un paquete suele utilizarse como una unidad de distribución de mayor tamaño que reúne uno o varios módulos, recursos y metadatos. Sin embargo, el significado exacto y la relación entre módulos y paquetes varían según el lenguaje y su sistema de herramientas.

Administración de memoria y recursos

Un programa debe asignar memoria para almacenar valores y objetos y recuperar la memoria que ya no necesita. En C y C++, el programador puede asignar y liberar directamente la memoria o utilizar las reglas de tiempo de vida de los objetos. Java, C#, Go y Python utilizan principalmente la recolección de basura, mediante la cual el entorno de ejecución localiza y recupera los objetos que han dejado de ser accesibles.

Rust utiliza reglas de propiedad, préstamo y tiempo de vida comprobadas por el compilador para proporcionar seguridad de memoria sin un recolector de basura. Los recursos se liberan cuando el propietario de un valor sale de su ámbito, y durante la compilación se restringen situaciones como una referencia que sobrevive al objeto original o la creación de referencias mutables incompatibles.

Además de la memoria, existen recursos que deben liberarse explícitamente, como archivos, conexiones de red, bloqueos y recursos de GPU. Los lenguajes administran su tiempo de vida mediante destructores, construcciones de limpieza, administradores de contexto, ejecución diferida y métodos que combinan la adquisición de recursos con su inicialización.

Gestión de errores y excepciones

Los lenguajes proporcionan métodos para representar y propagar los fallos. Pueden utilizar códigos de retorno tradicionales y estados globales de error, o lanzar excepciones que interrumpen el flujo actual de llamadas y se transmiten a un controlador superior. Algunos lenguajes de tipado estático representan los valores correctos y los errores mediante tipos suma o tipos de resultado, obligando al llamador a tratar mediante el sistema de tipos la posibilidad de que se produzca un error.

El método de gestión de errores afecta a los tipos de retorno de las funciones, al flujo de control, a la liberación de recursos y a la estabilidad del programa. Incluso los lenguajes que proporcionan excepciones pueden tratar de forma diferente los errores recuperables, los defectos de programación y las situaciones de interrupción del sistema.

Concurrencia y ejecución asíncrona

Un modelo de concurrencia define cómo se crean varias tareas, cómo intercambian datos y cómo coordinan su orden de ejecución. En los lenguajes que utilizan hilos y memoria compartida, son importantes los bloqueos, las operaciones atómicas y las reglas de orden de memoria. En los modelos de paso de mensajes o de canales, el proceso de comunicación mediante el intercambio de valores constituye el centro de la sincronización.

Las corrutinas y async·await permiten que una tarea devuelva el control de ejecución mientras espera una operación de entrada y salida u otra tarea asíncrona, y que posteriormente continúe desde el punto en el que fue suspendida. Estas capacidades no implican necesariamente una ejecución paralela real, ya que varias tareas pueden ejecutarse alternativamente en un solo hilo o combinarse con varios hilos.

Biblioteca estándar y entorno de ejecución

Una biblioteca estándar proporciona mediante interfaces comunes funciones utilizadas con frecuencia, como cadenas, colecciones, archivos, operaciones matemáticas, tiempo, redes y concurrencia. La especificación central de un lenguaje y su biblioteca estándar están estrechamente relacionadas, pero no son lo mismo. La referencia de Python también distingue las reglas del propio lenguaje de las funciones proporcionadas por los elementos incorporados y los módulos estándar.

El entorno de ejecución es la capa de software que admite las funciones del lenguaje mientras se ejecuta un programa. Puede encargarse de la asignación de memoria, la recolección de basura, la comprobación dinámica de tipos, la gestión de excepciones, la administración de hilos, las llamadas a métodos virtuales y la reflexión. Puede existir una máquina virtual independiente, como la JVM, que define la memoria de ejecución y un conjunto virtual de instrucciones, o una estructura como la de C, que utiliza un entorno de ejecución relativamente pequeño y bibliotecas del sistema.

Un lenguaje también puede delegar la entrada y salida y las funciones del sistema operativo en el entorno de ejecución, en lugar de definirlas directamente. La especificación de ECMAScript define el cálculo y el modelo de objetos del lenguaje, pero la entrada de datos externos y la salida de resultados son proporcionadas por entornos anfitriones, como navegadores y runtimes de servidor. Por ello, el entorno real de desarrollo está compuesto por una combinación de la especificación del lenguaje, el entorno de ejecución, la biblioteca estándar y las API del sistema anfitrión.

Sistemas de procesamiento del lenguaje y herramientas

Un sistema de procesamiento del lenguaje puede incluir un analizador léxico, un analizador sintáctico, un analizador semántico, un compilador, un intérprete, un optimizador, un generador de código, un ensamblador, un enlazador y un cargador. Una cadena de herramientas de compilación real analiza el código fuente para crear un árbol sintáctico abstracto, lo transforma en una representación intermedia de nivel inferior, realiza optimizaciones y generación de código específica para el destino, y enlaza los archivos objeto y las bibliotecas generados para producir un programa ejecutable.

El entorno de desarrollo incluye depuradores, analizadores estáticos, formateadores, linters, herramientas de prueba, generadores de documentación, sistemas de compilación y gestores de paquetes. Estas herramientas no forman parte de la sintaxis del lenguaje, pero influyen considerablemente en su utilidad práctica, en la estabilidad de su ecosistema y en la portabilidad y reproducibilidad del código.

Distinción entre lenguaje, especificación e implementación

Un lenguaje de programación debe entenderse distinguiendo entre la especificación del lenguaje, sus implementaciones, la biblioteca estándar y el ecosistema de desarrollo. Una especificación del lenguaje define qué programas son válidos y qué significado posee cada uno. Una implementación es el software, como un compilador, intérprete o máquina virtual, que permite ejecutar realmente los programas definidos por la especificación.

Un mismo lenguaje puede poseer varias implementaciones, y cada una puede diferir en su estructura interna, rendimiento, destinos de ejecución y funciones adicionales. Además de CPython, Python posee implementaciones como Jython para la JVM, IronPython para .NET y PyPy, que utiliza un compilador JIT. A la inversa, un mismo entorno de ejecución puede admitir varios lenguajes. En la JVM pueden ejecutarse Kotlin, Scala y Clojure, además de Java, mientras que en .NET pueden ejecutarse C#, F# y Visual Basic sobre un lenguaje intermedio y un entorno de ejecución comunes.

Por ello, al describir las características de un lenguaje de programación, en lugar de asignarle una única denominación, como «lenguaje compilado», «lenguaje orientado a objetos» o «lenguaje de tipado estático», deben examinarse conjuntamente su sintaxis y semántica, sistema de tipos, paradigmas, modelo de ejecución, administración de memoria, estructuras de concurrencia y principales implementaciones. La combinación de estos elementos determina la forma de los programas que pueden expresarse mediante el lenguaje, los métodos de desarrollo, la seguridad, el rendimiento y sus campos de aplicación.

Paradigmas de programación

Un paradigma de programación es una perspectiva y un método fundamentales para comprender y expresar los programas y los cálculos. Algunos paradigmas describen un programa como una secuencia de instrucciones que modifican el estado de la memoria, mientras que otros lo describen mediante la evaluación de funciones, el intercambio de mensajes entre objetos, las relaciones lógicas, las dependencias de datos o el flujo de acontecimientos. Un paradigma es un modelo de diseño que determina en qué unidades se divide un programa, dónde se sitúan el estado y el flujo de control, y de qué manera se abstrae un problema.

Los lenguajes de programación y los paradigmas de programación no mantienen una correspondencia uno a uno. Un mismo lenguaje puede admitir varios paradigmas, y un mismo paradigma puede implementarse con sintaxis y modelos de ejecución diferentes según el lenguaje. C se utiliza principalmente como lenguaje procedimental e imperativo, pero también permite otras formas de organización mediante punteros a funciones y tipos abstractos de datos. C++ admite conjuntamente programación procedimental, orientada a objetos, genérica y expresiones funcionales, mientras que Python y JavaScript también permiten combinar código imperativo, objetos, funciones de primera clase y funciones de orden superior. Por ello, en lugar de definir un lenguaje concreto mediante un solo paradigma, resulta más preciso examinar en torno a qué paradigmas fue diseñado y hasta qué nivel admite cada uno.

Los principales paradigmas de programación pueden organizarse de la siguiente manera.

ParadigmaPerspectiva central del cálculoElementos representativos
Programación imperativaSecuencia de instrucciones que modifican el estadoVariables, asignaciones, condiciones, bucles
Programación procedimentalDescomposición de instrucciones en procedimientos invocablesFunciones, procedimientos, subrutinas
Programación orientada a objetosCooperación entre objetos con estado y comportamientoObjetos, mensajes, clases, interfaces
Programación funcionalEvaluación y composición de funcionesFunciones puras, funciones de orden superior, valores inmutables
Programación lógicaInferencia de resultados a partir de hechos y reglasRelaciones, reglas, consultas, unificación
Programación con restriccionesDescripción de las condiciones que deben satisfacer los valoresVariables, dominios, restricciones, búsqueda de soluciones
Programación de flujo de datosEjecución de operaciones según dependencias de datosNodos, conexiones, tokens, grafos de datos
Programación reactivaPropagación de cambios que ocurren a lo largo del tiempoFlujos de eventos, señales, suscripciones, propagación
Programación concurrenteProgreso e interacción de varios cálculosHilos, mensajes, canales, actores
Programación genéricaAlgoritmos independientes de tipos e implementacionesParámetros de tipo, restricciones, conceptos
Programación orientada a aspectosSeparación de intereses que atraviesan varios módulosAspectos, puntos de unión, consejos, tejido

Estas clasificaciones no son mutuamente excluyentes. Un programa funcional puede ser al mismo tiempo declarativo y concurrente, y dentro de un lenguaje orientado a objetos pueden utilizarse algoritmos procedimentales y estructuras de datos genéricas. Modelos como el flujo de datos, la programación reactiva y los actores pueden implementarse como paradigmas de un lenguaje completo, pero también pueden utilizarse como modelos parciales de programación proporcionados por una biblioteca o un framework específico.

Programación imperativa

La programación imperativa representa un programa como una secuencia de instrucciones que modifican el estado de una computadora. El programa lee el estado actual, registra los resultados de los cálculos en variables o en la memoria y selecciona la siguiente instrucción que debe ejecutarse según determinadas condiciones. Un programa imperativo habitual está compuesto por variables, instrucciones de asignación, condiciones, bucles, llamadas a funciones e instrucciones de entrada y salida.

El modelo fundamental de la programación imperativa está estrechamente relacionado con la estructura tradicional de las computadoras de programa almacenado, que leen valores de la memoria, realizan operaciones y vuelven a escribir los resultados en ella. En su conferencia del Premio Turing de 1977, John Backus analizó que los lenguajes imperativos existentes reflejaban fuertemente el modelo computacional de von Neumann, en el que los valores almacenados se modifican uno por uno, y propuso como alternativa una forma de programación centrada en la composición de funciones. Esta discusión se convirtió en uno de los principales fundamentos teóricos para explicar la diferencia entre la programación imperativa y la funcional.

El siguiente código muestra una estructura típica para calcular una suma de manera imperativa.

int total = 0;

for (int i = 0; i < count; ++i) {
    total += values[i];
}

En este código, los valores de total e i cambian continuamente durante la ejecución. Para comprender el resultado del programa es necesario seguir cómo se modifica el estado de las variables después de ejecutar cada instrucción.

La programación imperativa se corresponde de manera natural con el funcionamiento del hardware y permite controlar detalladamente el orden de ejecución, el uso de la memoria y los efectos secundarios. Se utiliza ampliamente en campos donde el proceso de ejecución y la administración de recursos son importantes, como los sistemas operativos, los controladores de dispositivos, los motores de videojuegos, los sistemas embebidos y el software de alto rendimiento. Sin embargo, cuando se modifica un estado compartido desde varias ubicaciones, puede resultar difícil razonar sobre el comportamiento del código, y en entornos concurrentes pueden aparecer condiciones de carrera y problemas de sincronización relacionados con el estado mutable.

Programación procedimental

La programación procedimental es una forma de dividir los programas imperativos en unidades invocables denominadas funciones, procedimientos o subrutinas. Cada procedimiento realiza una tarea concreta, recibe valores mediante parámetros y devuelve un resultado o modifica el estado del programa.

La programación procedimental descompone un problema grande en varios procedimientos pequeños. Por ejemplo, un programa que procesa archivos puede dividirse en procedimientos para abrir el archivo, leer la cabecera, interpretar los datos, guardar el resultado y gestionar los errores. Cada procedimiento puede proporcionar al llamador únicamente la interfaz necesaria y ocultar los detalles internos de su implementación.

C, Pascal, FORTRAN y BASIC se clasifican como lenguajes procedimentales representativos. Incluso en los lenguajes modernos orientados a objetos y multiparadigma, los algoritmos incluidos dentro de métodos o funciones ordinarias suelen escribirse de forma procedimental.

La esencia de la programación procedimental no consiste simplemente en utilizar funciones. Consiste en organizar el flujo de ejecución del programa mediante relaciones claras entre llamadas a procedimientos y separar las tareas, las entradas y las salidas correspondientes a cada uno. Una descomposición adecuada en procedimientos mejora la reutilización y la capacidad de prueba del código, pero si el programa depende excesivamente de un estado mutable compartido, puede aumentar el acoplamiento entre los procedimientos.

Programación estructurada

La programación estructurada es un enfoque que busca organizar el flujo de control de un programa mediante estructuras claras, como la ejecución secuencial, la selección y la repetición, y reducir las bifurcaciones no estructuradas que saltan a posiciones arbitrarias. Su objetivo es representar el flujo del programa mediante bloques y procedimientos anidables, facilitando la comprensión y verificación local de su comportamiento.

Investigadores como Edsger Dijkstra consideraron que, a medida que aumentaba el tamaño de los programas, adquiría mayor importancia utilizar estructuras que permitieran a una persona razonar con precisión sobre las rutas de ejecución. 《Notes on Structured Programming》 de Dijkstra es una de las principales obras que sistematizó la descomposición gradual de los programas y su construcción mediante estructuras limitadas de control.

La programación estructurada suele explicarse simplemente como una forma de evitar el uso de la instrucción goto, pero su esencia no consiste en prohibir una sintaxis específica. Su fundamento es dividir un programa en unidades pequeñas que puedan comprenderse de una sola vez y aclarar los puntos de entrada y salida, las precondiciones y los resultados de cada unidad. Los bloques, las funciones, las instrucciones condicionales, los bucles y las estructuras de gestión de excepciones de los lenguajes modernos están influidos en gran medida por la programación estructurada.

Programación orientada a objetos

La programación orientada a objetos es un paradigma que organiza los programas como conjuntos de objetos con estado y comportamiento y mediante las interacciones entre ellos. Un objeto agrupa en una misma unidad los datos que administra y las operaciones que se aplican a dichos datos. Otros objetos interactúan con él a través de interfaces públicas o mensajes, de modo que no necesitan conocer directamente su representación interna concreta.

Los orígenes de la programación orientada a objetos se encuentran en Simula, desarrollado durante la década de 1960. Simula introdujo conceptos equivalentes a objetos, clases y herencia para representar actores y procesos del mundo real en simulaciones de eventos discretos. Simula 67 sistematizó conceptos como clases, objetos y procedimientos virtuales, que posteriormente se convirtieron en elementos centrales de los lenguajes orientados a objetos.

Smalltalk amplió durante la década de 1970 los objetos y el paso de mensajes hasta convertirlos en los principios fundamentales de todo el programa. En Smalltalk, casi todos los elementos, incluidos los números, las colecciones, las clases y el propio entorno de desarrollo, se tratan como objetos, y el cálculo se expresa mediante el envío de mensajes entre ellos. Smalltalk ejerció una gran influencia no solo en los lenguajes orientados a objetos, sino también en el desarrollo de las interfaces gráficas de usuario y los entornos interactivos de programación.

En la programación orientada a objetos suelen considerarse importantes los siguientes conceptos.

  • Un objeto es una unidad que posee identidad, estado y comportamiento dentro de un programa.
  • La encapsulación separa el estado y la implementación internos de un objeto de su interfaz externa.
  • El paso de mensajes o la llamada a métodos es la forma en que un objeto solicita una operación a otro.
  • El polimorfismo permite utilizar implementaciones diferentes mediante una misma interfaz.
  • La herencia es un método para definir un tipo nuevo a partir de las características de otro tipo o clase existente.
  • La composición es un método para construir comportamientos mayores combinando varios objetos.

No todos los lenguajes orientados a objetos proporcionan estos elementos de la misma forma. Java y C# se centran en clases e interfaces explícitas, mientras que C++ combina clases, tipos por valor y plantillas. El modelo de objetos de JavaScript se basa tradicionalmente en enlaces de prototipos, y la sintaxis class del lenguaje también está construida internamente sobre un comportamiento basado en prototipos. Go no proporciona herencia tradicional de clases, pero admite diseños orientados a objetos mediante métodos, interfaces y composición.

En el diseño orientado a objetos, la herencia es una herramienta, no un elemento central obligatorio. Los objetivos más fundamentales son dividir claramente las responsabilidades de los objetos, proporcionar interfaces estables al exterior y administrar las dependencias entre objetos. Las jerarquías de herencia excesivamente profundas y el uso amplio de estado mutable compartido pueden aumentar el acoplamiento del programa, por lo que el diseño orientado a objetos moderno suele combinar interfaces, composición, objetos inmutables e inyección de dependencias.

Programación funcional

La programación funcional es un paradigma que representa los cálculos mediante la evaluación y la composición de funciones. Una función no se trata simplemente como un procedimiento que agrupa instrucciones, sino como un valor de primera clase que puede almacenarse en variables, pasarse como argumento y devolverse como resultado, del mismo modo que otros valores.

Entre los fundamentos teóricos de la programación funcional se encuentra el cálculo lambda establecido por Alonzo Church. Los primeros LISP introdujeron en lenguajes de programación reales conceptos centrales de la programación funcional, como las funciones, la recursión y el procesamiento de listas. Posteriormente, los lenguajes de la familia ML desarrollaron la inferencia estática de tipos y los tipos de datos algebraicos, mientras que Haskell estableció un modelo representativo de lenguaje funcional puro centrado en funciones puras, evaluación perezosa y clases de tipos. Haskell surgió durante la década de 1980 a partir de la discusión sobre la necesidad de un lenguaje abierto y común para distintos lenguajes funcionales de evaluación perezosa.

Los conceptos considerados importantes en la programación funcional incluyen los siguientes.

  • Las funciones de primera clase permiten almacenar y transmitir funciones como cualquier otro valor.
  • Las funciones de orden superior reciben funciones como argumentos o devuelven otras funciones como resultado.
  • Una función pura no modifica estados externos y devuelve el mismo resultado para la misma entrada.
  • La transparencia referencial es la propiedad por la cual una expresión puede sustituirse por su valor sin cambiar el significado del programa.
  • La inmutabilidad utiliza la creación de valores nuevos en lugar de modificar valores ya existentes.
  • La recursión representa cálculos repetitivos mediante una función que se llama a sí misma.
  • La composición de funciones combina funciones pequeñas para construir cálculos más complejos.
  • La coincidencia de patrones divide el cálculo según la estructura de un valor y extrae sus elementos internos.

El siguiente ejemplo muestra una expresión funcional típica que procesa una colección mediante la composición de funciones.

sumOfSquares =
    sum
    . map (\x -> x * x)
    . filter even

Esta expresión no describe directamente en qué orden debe recorrerse la colección ni qué valores deben asignarse a variables temporales. Representa la relación de transformación consistente en seleccionar los valores pares, elevar cada uno al cuadrado y sumar los resultados.

Organizar un programa alrededor de funciones puras y valores inmutables puede reducir la influencia de una función sobre estados externos, lo que facilita razonar localmente sobre los resultados del código y probar cada parte de manera independiente. La reducción del estado mutable compartido también resulta favorable para limitar las condiciones de carrera de datos en programas concurrentes.

Sin embargo, no todos los lenguajes funcionales permiten únicamente funciones puras. LISP, OCaml, F# y Scala combinan funciones funcionales con estado mutable y estructuras de control imperativas. Haskell también permite expresar entrada y salida y cambios de estado, pero separa estos efectos de las expresiones puras mediante los tipos y la estructura del cálculo. La programación funcional puede entenderse no como la eliminación completa de los efectos secundarios, sino como una forma de controlar claramente dónde y cómo se producen.

Los lenguajes funcionales también difieren según su estrategia de evaluación. La evaluación estricta calcula los argumentos de una función antes de llamarla, mientras que la evaluación no estricta o perezosa puede posponer un cálculo hasta que su resultado sea realmente necesario. La evaluación perezosa permite expresar de manera natural estructuras de datos infinitas y composiciones de cálculos, aunque en algunos casos puede dificultar la predicción del momento real de evaluación y del uso de memoria.

Programación declarativa

La programación declarativa abarca enfoques que describen el resultado, la relación o las restricciones deseadas, en lugar de enumerar directamente la secuencia concreta de instrucciones necesaria para realizar un cálculo. Mientras que un programa imperativo se centra en «en qué orden debe calcularse», un programa declarativo se centra en «qué resultado debe cumplirse».

La programación declarativa no se refiere a un único modelo de ejecución. En un sentido amplio, puede incluir la programación funcional, lógica, con restricciones, basada en reglas y los lenguajes de consulta de datos. Sin embargo, los lenguajes funcionales también pueden contener funciones imperativas y los entornos de ejecución de los lenguajes declarativos utilizan internamente algoritmos imperativos complejos, por lo que la frontera entre programación declarativa e imperativa no siempre es clara.

Las consultas SQL son un ejemplo representativo de programación declarativa.

SELECT name
FROM users
WHERE active = TRUE
ORDER BY name;

Esta consulta no especifica en qué orden deben leerse los archivos de datos, qué índice debe utilizarse ni mediante qué algoritmos deben realizarse el filtrado y la ordenación. El usuario describe la forma del resultado deseado, y el optimizador de consultas del sistema de gestión de bases de datos determina el plan real de ejecución.

Los programas declarativos delegan al sistema los detalles del método de ejecución, lo que permite simplificar la expresión y que el entorno de ejecución elija distintas estrategias de optimización según la situación. Sin embargo, como el proceso de ejecución queda determinado por la implementación, al analizar problemas de rendimiento puede ser necesario comprender por separado los planes internos de ejecución y las reglas de optimización.

Programación lógica

La programación lógica representa un programa como un conjunto de hechos, reglas y consultas. El programador describe qué relaciones son verdaderas, y el entorno de ejecución utiliza inferencia lógica para encontrar soluciones que satisfagan una consulta.

Robert Kowalski describió un algoritmo como la combinación de lógica y control. La lógica representa las relaciones y el significado que deben cumplirse en el problema, mientras que el control determina en qué orden y mediante qué método se exploran dichas relaciones. Esta distinción se convirtió en una base importante para comprender por separado el significado de un problema y su estrategia de ejecución dentro de la programación lógica.

Prolog es el lenguaje representativo de la programación lógica. Un programa Prolog define hechos y reglas y posteriormente realiza consultas.

parent(alice, bob).
parent(bob, carol).

ancestor(X, Y) :-
    parent(X, Y).

ancestor(X, Y) :-
    parent(X, Z),
    ancestor(Z, Y).

En este ejemplo, la regla ancestor define la relación entre padres y antepasados sin escribir una secuencia de cálculo concreta mediante instrucciones ordinarias. El entorno de ejecución utiliza unificación y retroceso para buscar los valores de las variables que satisfacen las condiciones.

La programación lógica se ha utilizado en procesamiento del lenguaje natural, representación del conocimiento, demostración de teoremas, sistemas expertos, análisis de programas y sistemas basados en reglas. Los lenguajes lógicos restringidos, como Datalog, también se utilizan en consultas de bases de datos, análisis estático, políticas de control de acceso y representación de relaciones en sistemas distribuidos.

Programación con restricciones

La programación con restricciones es un paradigma en el que, en lugar de escribir un procedimiento que calcule directamente los valores de las variables, se describen las condiciones que estas deben satisfacer y se permite que un solucionador de restricciones busque una solución posible.

Por ejemplo, en un problema de planificación pueden definirse como variables las horas de inicio y finalización de cada tarea y establecerse restricciones como las siguientes.

  • La tarea A debe completarse antes que la tarea B.
  • Dos tareas no pueden utilizar el mismo recurso al mismo tiempo.
  • Todas las tareas deben finalizar antes del plazo establecido.
  • Determinadas tareas solo pueden ejecutarse dentro de un intervalo permitido.

El solucionador reduce los dominios, es decir, los posibles valores de cada variable, propaga las restricciones y, cuando es necesario, selecciona candidatos y realiza una búsqueda con retroceso. La programación con restricciones resulta adecuada para planificación, asignación de recursos, optimización combinatoria, diseño de circuitos, problemas de configuración y verificación.

La programación lógica con restricciones combina la programación lógica y la programación con restricciones, permitiendo representar en un mismo programa relaciones lógicas y restricciones numéricas, de conjuntos o temporales.

Programación basada en reglas

La programación basada en reglas representa mediante reglas las operaciones que deben ejecutarse cuando se cumplen determinadas condiciones. Una regla habitual está compuesta por una parte condicional y una parte de conclusión.

Condiciones:
    La cuenta del usuario está bloqueada y
    han transcurrido 30 minutos desde el último fallo de autenticación.

Acción:
    Desbloquear la cuenta.

Un motor de reglas examina los hechos y el estado actuales, localiza las reglas aplicables y las ejecuta de acuerdo con prioridades y políticas de resolución de conflictos. Los métodos basados en reglas se utilizan en sistemas expertos, políticas empresariales, control de acceso, procesamiento de eventos y sistemas de comportamiento para videojuegos.

La programación basada en reglas puede parecerse a la programación lógica, pero no utiliza necesariamente un modelo de inferencia basado en lógica formal. Algunos sistemas de reglas incluyen operaciones imperativas que modifican directamente el estado, y el orden de ejecución de las reglas también puede influir en el resultado.

Programación genérica

La programación genérica es un paradigma destinado a escribir algoritmos y estructuras de datos que no dependen de un tipo o implementación concretos y a especificar las operaciones y propiedades requeridas por dichos algoritmos.

Por ejemplo, un algoritmo de ordenación puede escribirse no solo para arreglos de enteros, sino para aplicarse a diversos tipos de datos que puedan compararse e intercambiarse. Lo que requiere el algoritmo no es un nombre concreto de tipo, sino la condición de que los elementos puedan compararse y desplazarse.

La programación genérica fue sistematizada durante la década de 1980 mediante las investigaciones de David Musser y Alexander Stepanov. Posteriormente, la Standard Template Library de C++ proporcionó un sistema representativo de programación genérica en el que iteradores, contenedores y algoritmos se separaban, pero podían combinarse mediante parámetros de tipo y polimorfismo en tiempo de compilación. En la programación genérica también se considera importante que un algoritmo abstraído pueda especializarse para tipos concretos sin introducir costes innecesarios durante la ejecución.

Los métodos utilizados para expresar programación genérica varían según el lenguaje.

  • C++ utiliza plantillas y concepts.
  • Java y C# utilizan tipos genéricos y restricciones.
  • Rust utiliza genéricos y trait bounds.
  • Haskell utiliza polimorfismo paramétrico y clases de tipos.
  • Go utiliza parámetros de tipo y restricciones basadas en interfaces.
  • Los lenguajes de la familia ML utilizan funciones polimórficas y sistemas de módulos.

La programación genérica no consiste simplemente en sustituir nombres de tipos por parámetros dentro del código. Es un método de diseño que identifica conceptos comunes entre distintos tipos y algoritmos y expresa dichos conceptos mediante los requisitos mínimos necesarios.

Programación de flujo de datos

La programación de flujo de datos representa un programa mediante un grafo de nodos de operaciones y dependencias de datos que conectan dichos nodos. Cada operación se ejecuta cuando se encuentran disponibles las entradas que necesita, y el resultado del cálculo se transmite a la siguiente operación conectada.

Los programas imperativos tradicionales ejecutan secuencialmente la instrucción señalada por el contador de programa, mientras que en el modelo de flujo de datos la disponibilidad de los datos determina qué operaciones pueden ejecutarse. Como las operaciones que no dependen unas de otras pueden ejecutarse simultáneamente, el modelo de flujo de datos constituye una base natural para representar cálculos paralelos. Las investigaciones de Jack Dennis sobre arquitecturas de flujo de datos concretaron un modelo computacional en el que las operaciones se activan según sus dependencias de datos, en lugar de hacerlo según el orden de las instrucciones.

Un programa de flujo de datos puede representarse de la siguiente manera.

Entrada A ─┐
           ├─ Multiplicación ─┐
Entrada B ─┘                  │
                              ├─ Suma ─ Resultado
Entrada C ────────────────────┘

La multiplicación se ejecuta cuando A y B están disponibles, mientras que la suma se ejecuta cuando se encuentran disponibles el resultado de la multiplicación y C. El flujo de control no se especifica por separado, sino que se deriva de las relaciones de dependencia entre los datos.

Los métodos de flujo de datos se utilizan ampliamente en entornos gráficos de programación, procesamiento digital de señales, sistemas de streaming, sistemas de compilación, hojas de cálculo, grafos de operaciones para GPU y frameworks de aprendizaje automático. Sin embargo, para representar bucles, bifurcaciones condicionales y estado mutable dentro de un grafo de flujo de datos son necesarios nodos y tokens adicionales, dependencias de control o estructuras de administración de estado.

Programación dirigida por eventos

La programación dirigida por eventos es un método en el que el flujo de ejecución del programa queda determinado por acontecimientos como entradas del usuario, mensajes de red, temporizadores, modificaciones de archivos y señales de sensores. El programa registra controladores de eventos o callbacks que responden a acontecimientos específicos, y el entorno de ejecución invoca dichos controladores cuando se produce el evento correspondiente.

Las interfaces gráficas de usuario son sistemas representativos dirigidos por eventos. El programa no controla directamente cuándo pulsará el usuario un botón, sino que define la operación que debe ejecutarse cuando se produzca el evento de clic.

button.addEventListener("click", () => {
    saveDocument();
});

Los programas dirigidos por eventos resultan adecuados para servidores, interfaces de usuario, videojuegos, sistemas embebidos y servicios distribuidos que deben reaccionar a entradas externas. Sin embargo, cuando los callbacks se anidan en varios niveles y modifican un estado compartido, puede resultar difícil comprender el orden de ejecución y la propagación de errores. Las promesas, los futures, las corrutinas y async·await se utilizan para representar de forma más estructurada la conexión entre acontecimientos asíncronos y la transmisión de sus resultados.

La programación dirigida por eventos y la programación reactiva están estrechamente relacionadas, pero no son el mismo concepto. Un programa dirigido por eventos suele ejecutar imperativamente un controlador registrado cuando se produce un acontecimiento. Un programa reactivo concede mayor importancia a declarar las relaciones de dependencia entre valores y acontecimientos que cambian con el tiempo y a permitir que los cambios se propaguen automáticamente a través de dichas relaciones.

Programación reactiva

La programación reactiva es un paradigma que representa valores y acontecimientos que cambian con el tiempo y organiza el programa para que los cambios de entrada se propaguen automáticamente hacia los cálculos relacionados. Modela los cambios continuos de valores o los acontecimientos asíncronos como flujos o señales y crea nuevos flujos mediante operaciones de filtrado, transformación y combinación.

Una hoja de cálculo puede considerarse un ejemplo sencillo de cálculo reactivo. Cuando cambia el valor de una celda, se recalculan automáticamente los resultados de las fórmulas que hacen referencia a ella. El programador no escribe directamente una secuencia de instrucciones para actualizar cada celda, sino que define las relaciones de dependencia entre los valores.

La programación reactiva funcional combina la programación funcional con el cálculo reactivo. Las primeras investigaciones sobre FRP representaron comportamientos que cambian continuamente a lo largo del tiempo y acontecimientos discretos mediante abstracciones componibles. Posteriormente, FRP evolucionó como un método para construir de forma declarativa interfaces de usuario, animaciones, control robótico y sistemas interactivos.

Las bibliotecas reactivas modernas suelen proporcionar operaciones como las siguientes.

  • map transforma cada acontecimiento o valor en otro valor.
  • filter deja pasar únicamente los acontecimientos que cumplen una condición.
  • merge combina varios flujos en uno solo.
  • combine crea un nuevo valor a partir de los valores más recientes de varios flujos.
  • debounce reduce a uno solo los acontecimientos repetidos durante un periodo breve.
  • buffer agrupa acontecimientos durante un periodo o hasta alcanzar una cantidad determinada.
  • subscribe conecta un controlador que recibe los resultados del flujo.

En los programas reactivos también deben administrarse el control de presión cuando un productor genera datos más rápidamente de lo que puede procesarlos un consumidor, el tiempo de vida de las suscripciones, la propagación de errores y los planificadores de ejecución.

Programación concurrente

La programación concurrente es un paradigma para construir programas en los que dos o más cálculos progresan de manera independiente durante un mismo intervalo de tiempo. No es necesario que varias tareas se ejecuten exactamente en el mismo instante; existe concurrencia incluso cuando se alternan en un solo procesador, siempre que sus ciclos de vida se superpongan.

La programación paralela se centra en ejecutar realmente varios cálculos al mismo tiempo para aumentar el rendimiento o la velocidad de procesamiento. La programación distribuida aborda sistemas en los que varias computadoras con memorias y dominios de fallo independientes colaboran a través de una red.

Los tres conceptos están relacionados, pero no son idénticos.

ConceptoProblema central
ConcurrenciaCómo organizar y coordinar correctamente varias tareas
ParalelismoCómo ejecutar cálculos simultáneamente utilizando varios recursos computacionales
DistribuciónCómo coordinar varios sistemas en presencia de redes y fallos parciales

Los modelos de concurrencia se dividen en varios tipos según si comparten estado, cómo realizan la comunicación y de qué forma definen el orden de ejecución de las tareas.

Memoria compartida e hilos

En un modelo de memoria compartida, varios hilos acceden a los mismos valores de memoria. La comunicación entre hilos se realiza mediante variables y estructuras de datos compartidas, y se utilizan bloqueos, semáforos, variables de condición y operaciones atómicas para impedir condiciones de carrera cuando varios hilos modifican simultáneamente el mismo estado.

La memoria compartida permite una comunicación eficiente porque no es necesario transmitir explícitamente los datos, pero el resultado del programa puede variar según diferencias muy pequeñas en el orden de ejecución. Para evitar interbloqueos, livelocks, inversión de prioridades, condiciones de carrera y problemas de visibilidad de memoria, es necesario comprender el modelo de memoria definido por el lenguaje y el entorno de ejecución.

Communicating Sequential Processes

Communicating Sequential Processes es un modelo en el que procesos secuenciales que se ejecutan de forma independiente interactúan mediante comunicación explícita. Tony Hoare propuso en un artículo de 1978 la entrada y salida y la comunicación sincronizada entre procesos como componentes fundamentales de los programas paralelos.

En CSP, cada proceso administra su propio estado local y envía y recibe valores a través de canales. La comunicación no solo transmite datos, sino que también actúa como mecanismo de sincronización entre los procesos.

Las goroutines y los canales de Go constituyen un modelo de programación representativo influido por CSP. Sin embargo, el modelo de ejecución y los canales de Go no implementan directamente el CSP formal original, sino que proporcionan funciones del lenguaje y planificación del runtime adaptadas a la programación práctica de sistemas.

Modelo de actores

El modelo de actores representa un sistema concurrente como un conjunto de actores independientes. Cuando un actor recibe un mensaje, puede realizar las siguientes operaciones.

  • Modificar su estado interno.
  • Enviar mensajes a otros actores.
  • Crear actores nuevos.
  • Determinar el comportamiento con el que procesará el siguiente mensaje.

Los actores no acceden directamente al estado interno de otros actores, sino que se comunican mediante mensajes asíncronos. El modelo de actores se originó en el modelo de cálculo concurrente propuesto en 1973 por Carl Hewitt, Peter Bishop y Richard Steiger.

El modelo de procesos de Erlang y Elixir, Akka y diversos frameworks de actores distribuidos recibieron la influencia del concepto de actor. El estado aislado y el paso de mensajes de los actores reducen las condiciones de carrera de memoria compartida y resultan adecuados para representar componentes de sistemas distribuidos.

Sin embargo, deben tratarse el retraso, el orden, la duplicación y la pérdida de mensajes, así como los fallos de los actores, y las modificaciones consistentes de estado que abarcan varios actores requieren protocolos independientes. El uso de actores no resuelve automáticamente los problemas de fallos y consistencia de los sistemas distribuidos.

Corrutinas y concurrencia estructurada

Una corrutina es una unidad de cálculo que puede suspender su ejecución y reanudarse posteriormente desde la misma posición. Se utiliza para implementar generadores, funciones asíncronas y tareas cooperativas, y a menudo se proporciona como una unidad de ejecución más ligera que un hilo del sistema operativo.

async y await permiten expresar operaciones asíncronas mediante una estructura semejante al código secuencial ordinario. Mientras una función espera el resultado de una operación de entrada y salida, puede suspenderse y devolver el control a un ejecutor o bucle de eventos.

La concurrencia estructurada es un enfoque que vincula el tiempo de vida de las tareas concurrentes creadas con los bloques y la estructura de llamadas del código. El trabajo padre administra la finalización, los fallos y la cancelación de los trabajos hijos, lo que reduce la aparición de tareas que permanecen fuera de la estructura del programa y de ejecuciones asíncronas difíciles de rastrear.

Las corrutinas no garantizan por sí mismas el paralelismo. Es posible ejecutar cooperativamente varias corrutinas en un solo hilo o ejecutarlas en paralelo sobre varios hilos o ejecutores de tareas.

Programación orientada a aspectos

La programación orientada a aspectos es un paradigma que busca separar en unidades independientes los intereses transversales que aparecen repetidamente a lo largo de varios módulos y clases. El registro, la comprobación de permisos, las transacciones, la medición del rendimiento y la recuperación de errores son ejemplos de intereses transversales que se insertan comúnmente en varias partes de un programa.

En las estructuras procedimentales u orientadas a objetos habituales, estas funciones pueden repetirse en varias clases y funciones y mezclarse con la lógica empresarial principal. La programación orientada a aspectos define estos intereses transversales como aspectos y aplica sus comportamientos en puntos de unión específicos del programa. Las investigaciones de Gregor Kiczales y otros sistematizaron la programación orientada a aspectos como un método para mejorar la separación de intereses.

Sus principales conceptos son los siguientes.

  • Un punto de unión es una posición de un programa, como una llamada a función o la creación de un objeto, en la que puede aplicarse un comportamiento adicional.
  • Un pointcut especifica qué puntos de unión deben seleccionarse.
  • Un consejo es el comportamiento que debe ejecutarse antes, después o alrededor del punto seleccionado.
  • El tejido es el proceso de combinar el comportamiento de un aspecto con el programa de destino.

Los aspectos permiten separar la lógica principal de las funciones auxiliares repetidas, pero pueden dificultar el seguimiento del flujo real de control porque se insertan comportamientos desde ubicaciones que no aparecen directamente en el código fuente. Los lenguajes y frameworks modernos también proporcionan funciones semejantes mediante atributos, anotaciones, decoradores, proxies, middleware y plugins de compilador, en lugar de utilizar un lenguaje completo orientado a aspectos.

Metaprogramación

La metaprogramación es una forma de escribir programas que analizan, generan o transforman otros programas como si fueran datos. A menudo se considera una técnica de construcción de programas que puede utilizarse dentro de varios paradigmas, en lugar de un paradigma central completamente independiente.

La metaprogramación aparece en formas como las siguientes.

  • Las macros del preprocesador sustituyen o generan código fuente.
  • Las macros sintácticas transforman estructuras de sintaxis abstracta.
  • Las plantillas generan código a partir de tipos y constantes.
  • La reflexión examina durante la ejecución los tipos y la estructura de un programa.
  • Los generadores de código producen código fuente a partir de esquemas o definiciones de interfaces.
  • Los plugins de compilador analizan o modifican declaraciones y expresiones.
  • La evaluación parcial calcula previamente las entradas conocidas y especializa un programa.

Las macros de los lenguajes de la familia LISP permiten tratar la estructura sintáctica de un programa como una estructura de datos del propio lenguaje. Las plantillas de C++ se utilizan no solo para programación genérica, sino también para cálculo y generación de código durante la compilación. Las macros de Rust proporcionan macros declarativas basadas en patrones y macros procedimentales que procesan árboles sintácticos.

La metaprogramación permite reducir código repetitivo y añadir nuevas abstracciones al lenguaje, pero el código generado y el flujo real de ejecución pueden no resultar evidentes en el código fuente original. Por ello, deben considerarse conjuntamente los mensajes de error, la depuración, el soporte de herramientas, el tiempo de compilación y la legibilidad del código.

Programación multiparadigma

Los lenguajes modernos de propósito general suelen admitir varios paradigmas simultáneamente. Incluso dentro de una misma aplicación pueden combinarse paradigmas diferentes según la naturaleza del problema.

Por ejemplo, un motor de videojuegos puede estructurarse de la siguiente manera.

  • Utiliza programación imperativa para generar instrucciones de renderizado y controlar dispositivos.
  • Utiliza diseño orientado a objetos o a datos para los componentes de los sistemas del juego y sus herramientas.
  • Utiliza operaciones funcionales para las transformaciones de colecciones y el procesamiento de recursos.
  • Utiliza modelos de flujo de datos y concurrencia para el planificador de tareas.
  • Utiliza programación de flujo de datos para los grafos de sombreadores.
  • Utiliza programación dirigida por eventos y reactiva para la interfaz del editor.
  • Utiliza metaprogramación para serialización y generación de código.

El propósito de la programación multiparadigma no consiste en utilizar todas las formas posibles dentro de un mismo programa. Consiste en seleccionar el modelo que exprese con mayor claridad la estructura de cada problema y establecer fronteras claras entre los distintos modelos.

Al combinar paradigmas deben mantenerse de forma coherente la propiedad del estado y los datos, la propagación de errores, los límites de concurrencia, la dirección de las llamadas y los niveles de abstracción. Es posible utilizar transformaciones funcionales dentro de una jerarquía orientada a objetos, pero si se modifica el estado interno de los objetos de manera impredecible pueden perderse las ventajas de ambos modelos. Al combinar flujos reactivos con callbacks imperativos también deben definirse claramente la creación y eliminación de suscripciones, el orden de ejecución y las reglas de gestión de errores.

Paradigmas y diseño de software

Los paradigmas de programación no se limitan a la elección de una sintaxis determinada. Influyen en la manera de dividir un programa en unidades y de definir las relaciones entre dichas unidades.

En el paradigma imperativo, los elementos centrales son los cambios de estado y el orden de las instrucciones. En el paradigma orientado a objetos, lo son las responsabilidades y la cooperación entre objetos. En el paradigma funcional, lo son las transformaciones de valores y la composición de funciones. En los paradigmas lógico y de restricciones, lo son las relaciones y condiciones que deben cumplirse. En el paradigma de flujo de datos, el centro son las dependencias entre operaciones, mientras que en el paradigma concurrente lo son la comunicación y la sincronización entre tareas independientes.

Un mismo problema puede expresarse de manera diferente según el paradigma. Una operación que transforma y suma los valores de una colección que satisfacen una condición puede representarse de la siguiente forma.

Imperativa:
    Recorrer los elementos, comprobar la condición
    y actualizar una variable.

Funcional:
    Componer filter, map y reduce.

Orientada a objetos:
    Solicitar al objeto colección operaciones de transformación y agregación.

Lógica:
    Describir las relaciones y condiciones de los elementos que deben incluirse en el resultado.

De flujo de datos:
    Conectar nodos de filtrado, transformación y agregación.

Paralela:
    Dividir la entrada, calcularla en varias tareas
    y combinar los resultados.

Ninguna representación es siempre superior a las demás. La forma adecuada varía según el tamaño y la frecuencia de modificación de la entrada, los requisitos de rendimiento, la gestión de errores, la experiencia del equipo, el código existente y el entorno de ejecución.

Los paradigmas de programación tampoco forman una secuencia evolutiva en la que unos sustituyen por completo a otros. Los lenguajes procedimentales no desaparecieron con la aparición de los lenguajes orientados a objetos, ni la programación funcional reemplazó por completo a la programación imperativa. Los paradigmas nuevos permiten abordar desde perspectivas distintas problemas difíciles de expresar mediante los métodos existentes y se combinan con los paradigmas anteriores hasta ser absorbidos por los lenguajes modernos y el diseño de software.

Por ello, comprender los paradigmas posee un significado más amplio que memorizar la sintaxis de cada lenguaje. Es necesario poder modelar un mismo cálculo de varias formas y comparar cómo se representan en cada una el estado, el control, los datos, la abstracción y la concurrencia. Esta comprensión proporciona una base para seleccionar estructuras de programa y funciones del lenguaje adecuadas para cada problema sin quedar limitado a un lenguaje concreto.

Ejecución e implementación

El código fuente escrito en un lenguaje de programación no constituye por sí mismo un conjunto de instrucciones que la unidad central de procesamiento pueda ejecutar. Una implementación del lenguaje lee el código fuente, analiza su sintaxis y su significado y transforma el programa en lenguaje máquina, bytecode, una representación intermedia o una estructura interna que pueda procesar un intérprete. El programa transformado se ejecuta realmente con la ayuda del cargador del sistema operativo, una máquina virtual o el entorno de ejecución del lenguaje.

Este proceso varía considerablemente según el lenguaje y la implementación. Las implementaciones habituales de C y C++ compilan el código fuente en archivos objeto y los enlazan para crear ejecutables nativos. Java y Kotlin generan normalmente bytecode para la JVM, mientras que C# y F# utilizan el lenguaje intermedio común de .NET. CPython transforma el código fuente Python en objetos de código que contienen bytecode interno y los ejecuta en una máquina virtual. Los motores JavaScript suelen analizar el código fuente y utilizar conjuntamente un intérprete y un compilador JIT. Por ello, el proceso que va desde el código fuente hasta la ejecución no es un procedimiento único y fijo, sino un sistema de implementación capaz de seleccionar y combinar varias etapas.

El proceso tradicional de compilación nativa puede resumirse de la siguiente manera.

Código fuente
    ↓
Preprocesamiento
    ↓
Análisis léxico
    ↓
Tokens
    ↓
Análisis sintáctico
    ↓
Árbol sintáctico abstracto
    ↓
Análisis semántico y comprobación de tipos
    ↓
Representación intermedia
    ↓
Optimización
    ↓
Generación de código de destino
    ↓
Lenguaje ensamblador o lenguaje máquina
    ↓
Archivo objeto
    ↓
Enlace
    ↓
Archivo ejecutable o biblioteca
    ↓
Cargador del sistema operativo y runtime
    ↓
Ejecución

No todas las implementaciones separan claramente estas etapas ni las ejecutan en el mismo orden. Algunos lenguajes no poseen preprocesador, ciertas implementaciones generan bytecode directamente a partir del árbol sintáctico abstracto y algunos compiladores analizan el código fuente durante la ejecución o dividen la representación intermedia en múltiples niveles. El controlador de Clang también distingue conceptualmente el preprocesamiento, la compilación, el ensamblado y el enlace, aunque durante la ejecución real puede integrar varias etapas en un mismo proceso o conectarlas mediante tuberías y archivos temporales.

Código fuente y unidades de traducción

El código fuente es el texto original de un programa escrito por un programador según la sintaxis de un lenguaje de programación determinado. Generalmente está compuesto por uno o varios archivos, y cada archivo se procesa junto con su codificación de caracteres, saltos de línea, ruta, módulo o estructura de paquetes.

Un compilador no tiene que leer necesariamente todo el proyecto de una sola vez. En C y C++ se utiliza ampliamente el método tradicional de tratar como unidad de traducción un único archivo fuente después del preprocesamiento. Los archivos objeto generados a partir de varias unidades de traducción son combinados posteriormente por el enlazador en un archivo ejecutable o una biblioteca. En Java, un archivo class suele contener la representación binaria de una clase o interfaz, y la JVM puede cargar individualmente durante la ejecución las clases necesarias.

Los sistemas de módulos de los lenguajes modernos no tratan necesariamente un archivo fuente y una unidad lógica de programa como la misma cosa. Un módulo puede estar compuesto por varios archivos y un archivo puede contener múltiples tipos y declaraciones. El compilador puede analizar las dependencias entre módulos para determinar el orden de procesamiento o almacenar únicamente las interfaces públicas en un archivo intermedio separado para reducir el coste de volver a analizar otros módulos.

Preprocesamiento

El preprocesamiento es una etapa que transforma una parte del código fuente antes del análisis sintáctico principal. No todos los lenguajes utilizan un preprocesador independiente, pero en C y C++ las directivas de preprocesamiento y las macros ocupan una posición importante dentro del procesamiento del lenguaje.

Un preprocesador de la familia de C realiza normalmente las siguientes tareas.

  • Incluye el contenido de los archivos especificados mediante la directiva #include.
  • Expande las macros definidas mediante #define.
  • Incluye o excluye ciertas partes del código según directivas de compilación condicional.
  • Sustituye macros predefinidas proporcionadas por la implementación.
  • Rastrea las ubicaciones lógicas del código fuente y la información de diagnóstico.

El resultado del preprocesamiento puede considerarse una secuencia de tokens en la que se han combinado el código fuente y las cabeceras originalmente separados en varios archivos y se han expandido las macros. Posteriormente, el analizador sintáctico y el analizador semántico del compilador trabajan sobre este resultado. Clang puede integrar estrechamente el preprocesamiento y el análisis sintáctico, por lo que no necesita crear necesariamente un archivo completo con el resultado del preprocesamiento en el disco antes de ejecutar la siguiente etapa.

Las macros del preprocesador transforman tokens sin comprender la estructura sintáctica ordinaria del lenguaje. Por ello, el ámbito, los tipos y el orden de evaluación del código generado por una macro pueden comportarse de manera diferente a lo esperado. Los lenguajes modernos también proporcionan funciones semejantes mediante módulos, expresiones constantes, genéricos, atributos, macros sintácticas y plugins de compilador que comprenden la estructura del lenguaje en lugar de utilizar un preprocesador.

Análisis léxico

El análisis léxico es el proceso de transformar los caracteres del código fuente en una secuencia de tokens a los que el lenguaje puede asignar significado. El componente encargado del análisis léxico se denomina lexer o tokenizador.

Puede considerarse el siguiente código fuente.

int result = value + 10;

El lexer lo divide aproximadamente en los siguientes tokens.

Palabra clave(int)
Identificador(result)
Operador(=)
Identificador(value)
Operador(+)
Literal entero(10)
Delimitador(;)

El lexer distingue identificadores, palabras clave, literales numéricos y de cadena, operadores, delimitadores y comentarios. También puede interpretar la codificación de caracteres del código fuente y procesar secuencias de escape, uniones de líneas y el significado de los espacios. En Python, los aumentos y reducciones de la sangría se transforman en tokens INDENT y DEDENT que representan la estructura de bloques, por lo que los espacios poseen significado gramatical. La documentación oficial de Python también explica que el analizador léxico transforma la entrada en un flujo de tokens antes de entregarla al analizador sintáctico.

Los tokens pueden almacenar no solo su clase y texto original, sino también información como el nombre del archivo, la línea, la columna y las posiciones inicial y final. Esta información de ubicación se utiliza en mensajes de error, navegación de código, refactorización y generación de información de depuración.

Análisis sintáctico

El análisis sintáctico es el proceso de comprobar si una secuencia de tokens se ajusta a la gramática del lenguaje y construir las relaciones estructurales entre dichos tokens. El componente que realiza esta tarea se denomina analizador sintáctico o parser.

Por ejemplo, la siguiente expresión posee una estructura diferente según la precedencia de los operadores.

a + b * c

El parser no la interpreta simplemente como cinco tokens enumerados de izquierda a derecha, sino como la siguiente estructura jerárquica.

Suma
├── a
└── Multiplicación
    ├── b
    └── c

La regla según la cual la multiplicación se ejecuta antes que la suma se refleja en la estructura sintáctica. Los paréntesis, la asociatividad de los operadores, las llamadas a funciones, las condiciones, los bucles, las declaraciones y las expresiones de tipo también son estructurados por el parser según la gramática.

Un parser puede implementarse mediante análisis descendente recursivo, LR, LALR, LL, PEG y otros métodos. Algunos analizadores se generan automáticamente a partir de definiciones gramaticales y otros son escritos manualmente por los implementadores. El parser de Clang recibe tokens del lexer, comprueba la estructura gramatical del código fuente y genera el AST junto con el análisis semántico.

Cuando el código fuente no se ajusta a la gramática, el parser informa de un error sintáctico. Un parser práctico intenta no detenerse inmediatamente tras el primer error, sino estimar la ubicación del problema y recuperarse hasta la siguiente sentencia o declaración para mostrar varios errores en una sola compilación. Sin embargo, la estructura posterior a un token incorrecto puede estimarse de forma incompleta, por lo que el primer error también puede causar varios diagnósticos posteriores.

Árbol sintáctico abstracto

Un árbol sintáctico abstracto es una representación interna de la estructura gramatical de un programa en forma de árbol. Generalmente se abrevia como AST.

Un AST se diferencia de un árbol sintáctico concreto que conserva todos los caracteres del código fuente original. Omite ciertos elementos que determinan la estructura pero no son necesarios directamente para el significado de la ejecución, como algunos paréntesis o puntos y comas, y representa principalmente las relaciones significativas entre declaraciones, expresiones, tipos y sentencias.

Puede utilizarse como ejemplo el siguiente código.

int square(int value) {
    return value * value;
}

El AST puede poseer aproximadamente la siguiente estructura.

Declaración de función: square
├── Tipo de retorno: int
├── Parámetros
│   └── value: int
└── Cuerpo de la función
    └── return
        └── Multiplicación
            ├── Referencia a value
            └── Referencia a value

El AST se utiliza como base para la comprobación de tipos, la resolución de nombres, el análisis estático, la generación de código, la refactorización y la navegación de código. El compilador puede añadir a los nodos del AST información como tipos resueltos, referencias a declaraciones, ubicaciones del código fuente, valores constantes y permisos de acceso. Clang genera un AST como resultado del análisis sintáctico y semántico y administra mediante ASTContext los nodos de larga duración, incluidos tipos y declaraciones.

Algunas herramientas de desarrollo deben conservar los comentarios, espacios y posiciones exactas de los tokens del texto original, por lo que mantienen un árbol sintáctico concreto o información de tokens además del AST. Los formateadores automáticos y los transformadores de código fuente deben tratar no solo el significado de ejecución, sino también la notación original y la posición de los comentarios.

Análisis semántico

Que un código sea sintácticamente correcto no significa que también lo sea semánticamente. El análisis semántico comprueba a partir del AST si el programa satisface las reglas de semántica estática del lenguaje y calcula la información necesaria para las etapas posteriores.

El análisis semántico incluye normalmente las siguientes tareas.

  • Resolución de nombres que conecta nombres con declaraciones.
  • Comprobación del ámbito de variables, funciones y tipos.
  • Determinación de los tipos de expresiones y declaraciones.
  • Comprobación de conversiones implícitas de tipos.
  • Resolución de sobrecarga de funciones.
  • Comprobación de restricciones de genéricos o plantillas.
  • Comprobación del control de acceso.
  • Comprobación de relaciones de herencia e interfaces.
  • Comprobación de validez de instrucciones de retorno y flujos de control.
  • Evaluación de expresiones constantes.
  • Comprobación del orden de inicialización y de las reglas de tiempo de vida.
  • Diagnóstico de estructuras prohibidas por el lenguaje.

Por ejemplo, el siguiente código constituye sintácticamente una expresión válida de llamada a función, pero puede producir un error durante el análisis semántico si la función solo admite parámetros enteros.

calculate("text")

El analizador semántico compara el tipo de "text" con el tipo del parámetro de calculate y determina la validez de la llamada. En un lenguaje como C++ que admite sobrecarga, debe seleccionar entre varias funciones con el mismo nombre aquella que mejor se ajuste a los tipos de los argumentos y a las reglas de conversión.

Clang denomina Sema al componente responsable del análisis semántico. Este determina si el código fuente es semánticamente válido y genera la mayoría de los diagnósticos de compilación. El parser y Sema trabajan estrechamente, por lo que pueden resolver nombres y tipos mientras se construye la estructura sintáctica.

Después del análisis semántico, el AST contiene mucha más información que un simple árbol gramatical. Los identificadores quedan conectados con sus declaraciones reales, se asignan tipos a las expresiones y se concretan las conversiones implícitas y los destinos de las llamadas. A partir de esta información, el compilador puede transformar el comportamiento del programa en una representación de nivel inferior.

Análisis estático y diagnósticos

Un compilador puede analizar no solo errores que infringen las reglas del lenguaje, sino también código que puede ejecutarse pero probablemente contiene defectos. El análisis estático es un método que busca posibles problemas examinando el código fuente o una representación intermedia sin ejecutar directamente el programa.

El análisis estático puede detectar problemas como los siguientes.

  • Uso de valores no inicializados.
  • Código inalcanzable.
  • Condiciones siempre verdaderas o siempre falsas.
  • Posible uso de memoria liberada o referencias null.
  • Fugas de recursos.
  • Posibles accesos fuera de los límites de un arreglo.
  • Orden incorrecto de bloqueos.
  • Posibles condiciones de carrera de datos.
  • Valores de retorno ignorados.
  • Incompatibilidades entre cadenas de formato y argumentos.
  • Operaciones que pueden provocar comportamiento indefinido.

Un analizador estático puede utilizar grafos de flujo de control, análisis de flujo de datos, ejecución simbólica e interpretación abstracta. Como generalmente resulta imposible predecir por completo todas las entradas y rutas reales de ejecución, los resultados pueden incluir advertencias que no corresponden a defectos reales o rutas que el analizador no pudo demostrar como seguras.

Los diagnósticos del compilador pueden dividirse en errores, advertencias e información de referencia. Un error indica un problema que impide crear un programa válido y obliga a detener la compilación, mientras que una advertencia señala una estructura sospechosa aunque esté permitida por el lenguaje. Un buen sistema de diagnósticos muestra no solo la ubicación del problema, sino también las declaraciones relacionadas y posibles métodos de corrección.

Representación intermedia

Una representación intermedia es un formato interno que representa un programa entre el AST del lenguaje fuente y las instrucciones de la máquina de destino. Generalmente se abrevia como IR.

Los compiladores utilizan representaciones intermedias para reducir la complejidad que surgiría al conectar directamente un lenguaje fuente con una arquitectura de destino. Los frontends de distintos lenguajes pueden generar un IR común, un optimizador común puede procesarlo y varios backends pueden producir código para diferentes CPU o máquinas virtuales.

C ─────────┐
C++ ───────┤
Rust ──────┼─ Representación intermedia común ─┬─ x86-64
Fortran ───┤                                   ├─ AArch64
Swift ─────┘                                   ├─ RISC-V
                                               └─ WebAssembly

No es necesario que exista una sola representación intermedia. Los compiladores modernos pueden utilizar gradualmente varios IR con distintos niveles de abstracción.

  • Un IR de alto nivel conserva relativamente muchas estructuras del lenguaje fuente, como funciones, objetos, arreglos, excepciones y corrutinas.
  • Un IR de nivel intermedio representa el flujo de control, el flujo de datos y los accesos a memoria de una forma fácil de analizar.
  • Un IR de bajo nivel utiliza estructuras cercanas a registros y operaciones de máquina, aunque todavía puede no depender de una CPU específica.
  • Un IR de máquina contiene información específica del destino para la selección de instrucciones y la asignación de registros.

LLVM IR es una representación intermedia de bajo nivel con tipos, diseñada principalmente alrededor de la forma estática de asignación única. Se utiliza como representación común del código en toda la estrategia de compilación de LLVM y actúa como frontera entre el frontend, la optimización y el backend.

Forma estática de asignación única

La forma estática de asignación única es una representación intermedia en la que cada nombre de variable recibe un valor una sola vez. Se abrevia como SSA.

Puede considerarse el siguiente código imperativo.

x = 10
x = x + 1
x = x * 2

En SSA se asigna un nombre nuevo a cada asignación.

x1 = 10
x2 = x1 + 1
x3 = x2 * 2

En esta estructura queda claro dónde se define y dónde se utiliza cada valor. El compilador puede seguir fácilmente el flujo de valores y realizar optimizaciones como propagación de constantes, eliminación de código no utilizado y eliminación de subexpresiones comunes.

En un punto en el que se combinan varios flujos de control puede utilizarse una estructura equivalente a una función φ que selecciona uno de los valores procedentes de diferentes rutas.

if condition:
    x1 = 10
else:
    x2 = 20

x3 = φ(x1, x2)

La forma y las reglas de transformación del LLVM IR real se definen de manera más estricta, y el compilador mantiene la estructura SSA durante la optimización o la reduce a una representación específica de la máquina de destino.

Bajada de nivel

La bajada de nivel es el proceso de transformar estructuras de alto nivel de un lenguaje en operaciones más simples y de menor nivel. El término procede del inglés lowering.

Por ejemplo, las siguientes funciones de un lenguaje de alto nivel pueden no tener una instrucción única equivalente en la máquina de destino.

  • Objetos y métodos virtuales.
  • Clausuras.
  • Coincidencia de patrones.
  • Gestión de excepciones.
  • Corrutinas y async·await.
  • Interpolación de cadenas.
  • Iteradores y bucles basados en rangos.
  • Creación de objetos administrados mediante recolección de basura.
  • Funciones genéricas.
  • Operaciones de tipado dinámico.

El compilador transforma estas estructuras en operaciones más básicas, como llamadas a funciones, bifurcaciones, asignación de memoria, máquinas de estados, punteros a funciones, tablas de saltos y comprobaciones durante la ejecución.

Una corrutina puede transformarse en una máquina de estados que conserva los puntos de suspensión y el estado de las variables locales. Una clausura puede transformarse en una combinación de un objeto de entorno que almacena las variables capturadas y una función invocable. Una llamada a un método virtual puede reducirse a una estructura que obtiene la dirección de la función a partir de la información de tipo del objeto y realiza una llamada indirecta.

La bajada de nivel no tiene que producirse de una sola vez. Puede realizarse varias veces, desde un IR cercano al lenguaje fuente hacia un IR común de alto nivel y posteriormente hacia un IR de bajo nivel y un IR de máquina específico del destino. El uso de IR por etapas permite que cada optimización y transformación utilice información apropiada para su propio nivel.

Optimización

La optimización del compilador es una transformación que mejora propiedades como el tiempo de ejecución, el tamaño del código, los accesos a memoria y el consumo de energía mientras conserva el significado observable del programa.

El optimizador analiza qué calcula el programa y determina si puede obtenerse el mismo resultado con menos operaciones o instrucciones más eficientes. Sin embargo, las transformaciones solo pueden realizarse dentro de los límites del comportamiento observable permitido por el lenguaje, su modelo de memoria, sus reglas de coma flotante, sus excepciones y su comportamiento indefinido.

Entre las optimizaciones representativas se encuentran las siguientes.

  • El plegado de constantes calcula anticipadamente operaciones conocidas durante la compilación.
  • La propagación de constantes transmite valores determinados como constantes hacia sus usos posteriores.
  • La eliminación de código no utilizado elimina operaciones que no afectan al resultado y código inalcanzable.
  • La eliminación de subexpresiones comunes reutiliza un resultado anterior cuando se repite el mismo cálculo.
  • El inlining de funciones sustituye una llamada a función por el código de su cuerpo.
  • El movimiento de código invariante de bucle desplaza fuera del bucle operaciones que producen el mismo resultado en cada iteración.
  • El desenrollado de bucles duplica varias veces el cuerpo del bucle para reducir el número de bifurcaciones.
  • La vectorización combina la misma operación aplicada a varios elementos en instrucciones SIMD.
  • El análisis de escape determina si un objeto sale de una función o hilo para optimizar su lugar de asignación.
  • La sustitución escalar divide objetos pequeños o valores agrupados en valores individuales.
  • La optimización de llamada de cola reutiliza el marco actual de llamada en determinadas llamadas a funciones.
  • La simplificación de bifurcaciones elimina o combina condiciones y saltos innecesarios.

La optimización no termina en una única etapa. Una transformación puede crear oportunidades para otra, por lo que varios pases de optimización se repiten o se ejecutan en un orden determinado. Por ejemplo, después del inlining desaparece el límite de una llamada, lo que puede permitir la propagación de constantes y la eliminación de código no utilizado.

Análisis de alias

En los lenguajes que utilizan punteros o referencias es importante determinar si dos expresiones diferentes señalan la misma posición de memoria. Este proceso se denomina análisis de alias.

Si el compilador no puede descartar que dos punteros señalen el mismo valor, debe asumir que una escritura realizada a través de uno puede modificar el resultado de una lectura a través del otro. Esto limita la reordenación de operaciones, la vectorización y el mantenimiento de valores en registros.

La información procedente del sistema de tipos, las reglas de propiedad, calificadores como restrict y referencias inmutables puede reducir las posibilidades de alias y proporcionar más información para optimizar. En cambio, las operaciones arbitrarias con punteros y las llamadas a funciones externas pueden dificultar la inferencia de los efectos sobre la memoria.

Optimización basada en perfiles

La optimización basada en perfiles utiliza durante la compilación información recopilada a partir de ejecuciones reales del programa. Se abrevia como PGO.

El proceso habitual es el siguiente.

Compilación de un programa instrumentado
    ↓
Ejecución con entradas representativas
    ↓
Recopilación de frecuencias de llamadas y probabilidades de bifurcación
    ↓
Nueva compilación utilizando el perfil

El compilador puede distinguir funciones y bifurcaciones ejecutadas con frecuencia de rutas de error raras y ajustar el inlining, la disposición del código, el orden de las bifurcaciones y el uso de registros. Si el perfil no representa adecuadamente los patrones reales de uso, el efecto esperado puede disminuir o el rendimiento puede empeorar en determinados trabajos.

Optimización en tiempo de enlace

La optimización en tiempo de enlace analiza conjuntamente las representaciones intermedias de varios módulos durante el enlace después de compilar por separado las unidades de traducción. Se abrevia como LTO.

En la compilación separada habitual, el compilador de una unidad de traducción no conoce el cuerpo de una función definida en otro archivo. Con LTO, el enlazador o un plugin de enlace combina los IR de varios módulos o intercambia información resumida para realizar inlining entre módulos, eliminación de código global no utilizado y análisis de todo el programa.

Procesar todo el IR al mismo tiempo puede requerir mucha memoria y tiempo. Por ello, en programas grandes también se utilizan estructuras distribuidas o ThinLTO que emplean resúmenes de módulos y backends paralelos.

Generación de código de destino

La representación intermedia optimizada se transforma en instrucciones del procesador o de la máquina virtual de destino. Este proceso se denomina generación de código.

Un generador de código nativo realiza las siguientes tareas.

  • Selecciona instrucciones de destino para las operaciones de la representación intermedia.
  • Determina los modos de direccionamiento admitidos por el procesador de destino.
  • Asigna registros virtuales a registros físicos reales.
  • Derrama a la pila o a la memoria valores para los que no hay registros suficientes.
  • Ajusta el orden de las instrucciones al procesador de destino.
  • Coloca argumentos y valores de retorno según la convención de llamada.
  • Construye el marco de pila.
  • Determina la ubicación de bifurcaciones, constantes y datos globales.
  • Genera información adicional necesaria para excepciones y depuración.

Selección de instrucciones

La selección de instrucciones es el proceso de transformar operaciones del IR en instrucciones reales del procesador de destino.

Incluso para una misma operación, las instrucciones disponibles varían según el procesador. Algunos procesadores pueden leer un valor de memoria y realizar una operación mediante una sola instrucción, mientras que otros deben cargar primero el valor en un registro y utilizar después una instrucción de operación separada.

El generador de código selecciona una combinación adecuada considerando el coste de las instrucciones de destino y las funciones disponibles. Si la CPU admite SIMD, operaciones especiales de bits o instrucciones criptográficas, puede utilizar estas instrucciones en lugar de una combinación de operaciones ordinarias.

Asignación de registros

Una representación intermedia puede utilizar más registros virtuales que los registros disponibles en el procesador real. La asignación de registros es el proceso de colocar valores virtuales en una cantidad limitada de registros físicos.

Cuando el número de valores simultáneamente vivos supera la cantidad de registros disponibles, algunos valores se almacenan temporalmente en la memoria de la pila. Esto se denomina derrame de registros. Como el acceso a registros suele ser más rápido que el acceso a memoria, es importante mantener en registros los valores utilizados con frecuencia y reducir las operaciones innecesarias de almacenamiento y carga.

La convención de llamada define qué registros debe conservar el llamador y cuáles debe conservar la función llamada. El generador de código asigna los registros respetando estas reglas.

Planificación de instrucciones

Los procesadores modernos pueden superponer la ejecución de varias instrucciones, pero las dependencias entre datos y las limitaciones de las unidades de ejecución afectan al rendimiento. La planificación de instrucciones ajusta el orden de las instrucciones mientras conserva su significado para reducir las detenciones de la canalización y los conflictos de recursos.

Aunque la CPU de destino pueda reorganizar dinámicamente las instrucciones durante la ejecución, una disposición apropiada del código por parte del compilador puede ampliar el conjunto de instrucciones que el procesador puede ejecutar en paralelo.

Ensamblado y archivos objeto

Cuando el generador de código produce lenguaje ensamblador en forma textual, el ensamblador lo transforma en lenguaje máquina. El compilador también puede utilizar un ensamblador interno y crear directamente un archivo objeto sin generar un archivo intermedio de ensamblador.

Un archivo objeto no es un ejecutable completo, sino un binario intermedio que contiene lenguaje máquina, datos, símbolos, información de reubicación y distintos metadatos.

Un archivo objeto habitual contiene elementos como los siguientes.

  • Código máquina ejecutable.
  • Constantes de solo lectura.
  • Datos globales inicializados.
  • Información sobre el tamaño de los datos globales no inicializados.
  • Símbolos exportados o referenciados.
  • Información de reubicación para posiciones cuya dirección real aún no se ha determinado.
  • Información para gestión de excepciones y desenrollado de pila.
  • Información de depuración.
  • Información de plataforma y arquitectura.

Según la plataforma se utilizan formatos de objetos y ejecutables como ELF, PE/COFF y Mach-O. Cada formato divide el código y los datos en secciones o segmentos y proporciona al enlazador y al cargador la información necesaria.

Símbolos y reubicación

Un símbolo representa el nombre y las propiedades de un elemento que debe identificarse durante el enlace, como una función, una variable global o datos estáticos. Un archivo objeto registra los símbolos que define y los símbolos que debe importar del exterior.

Puede llamarse a una función externa desde un archivo como en el siguiente ejemplo.

extern int calculate(int value);

int run(void) {
    return calculate(10);
}

Cuando este archivo se compila por separado, puede no conocerse la dirección final en memoria de calculate. El compilador y el ensamblador dejan un valor temporal y una entrada de reubicación en el lugar de la llamada. El enlazador localiza otro archivo objeto o biblioteca que defina calculate y refleja la dirección real o el método para calcularla.

La reubicación es el proceso de modificar las posiciones dependientes de direcciones dentro del código y los datos de acuerdo con su disposición final. Existen distintos métodos de reubicación, como direcciones absolutas, direcciones relativas a la posición actual de la instrucción, tablas globales de desplazamiento y tablas de enlace de procedimientos.

Enlazador

Un enlazador combina varios archivos objeto y bibliotecas para generar un archivo ejecutable o una biblioteca compartida.

El enlazador realiza normalmente las siguientes tareas.

  • Recopila y dispone las secciones de los archivos objeto de entrada.
  • Conecta definiciones y referencias de símbolos con el mismo nombre.
  • Busca símbolos no definidos en las bibliotecas.
  • Diagnostica definiciones duplicadas o conflictivas.
  • Determina la dirección final de cada fragmento de código y datos.
  • Aplica reubicaciones.
  • Puede eliminar secciones no utilizadas.
  • Organiza el orden de las funciones de inicialización y finalización.
  • Genera información para el enlazador dinámico.
  • Registra el punto de entrada y las cabeceras del programa ejecutable.

Enlace estático

El enlace estático incluye dentro del archivo ejecutable el código de las bibliotecas que necesita el programa. Esto puede simplificar la distribución porque no es necesario cargar por separado esas bibliotecas durante la ejecución, pero aumenta el tamaño del ejecutable y varios programas pueden contener cada uno su propia copia del mismo código de biblioteca.

Una biblioteca estática suele ser un archivo contenedor que reúne varios archivos objeto. El enlazador selecciona e incluye en el resultado aquellos objetos que proporcionan los símbolos realmente referenciados por el programa.

Enlace dinámico

El enlace dinámico conecta el programa con bibliotecas externas al cargarlo o al utilizar por primera vez un símbolo, en lugar de copiar dentro del ejecutable todo el código y los datos de las bibliotecas compartidas.

En sistemas basados en ELF, un ejecutable que participa en el enlace dinámico puede especificar el enlazador dinámico mediante la información del intérprete del programa. El sistema operativo carga el ejecutable y el enlazador dinámico dentro del espacio de direcciones del proceso, y este localiza los objetos compartidos necesarios, resuelve símbolos y procesa reubicaciones.

El enlace dinámico permite que varios procesos compartan código de biblioteca de solo lectura y que una biblioteca compatible pueda sustituirse sin volver a compilar el ejecutable. Sin embargo, debe existir en el entorno de ejecución una versión correcta de la biblioteca y deben administrarse las versiones de símbolos, las rutas de búsqueda y la compatibilidad ABI.

ABI y convenciones de llamada

Una interfaz binaria de aplicaciones es un conjunto de reglas que permite que código compilado por separado interactúe a nivel binario. Se abrevia como ABI.

Una ABI incluye normalmente los siguientes elementos.

  • Cómo se colocan los argumentos de una función en registros y en la pila.
  • Cómo se transmite el valor de retorno.
  • Qué registros deben conservarse antes y después de una llamada.
  • La alineación de la pila y la estructura del marco de pila.
  • El tamaño y la alineación de los tipos básicos.
  • La disposición en memoria de estructuras y clases.
  • El método para transformar nombres de símbolos en nombres binarios.
  • Las reglas de gestión de excepciones y desenrollado de pila.
  • La estructura de las tablas de funciones virtuales.
  • El formato de archivos objeto y ejecutables.
  • Las llamadas al sistema y las interfaces del sistema operativo.

Aunque dos funciones posean la misma declaración en el código fuente, si utilizan ABI diferentes pueden interpretar de manera distinta los argumentos y los valores de retorno y no podrán llamarse correctamente.

C++ utiliza decoración de nombres para representar en los símbolos información sobre sobrecarga de funciones, clases, espacios de nombres y plantillas. La Itanium C++ ABI define interfaces de llamada, gestión de excepciones, nombres globales y convenciones de código objeto para permitir que código C++ compilado de forma independiente funcione conjuntamente.

Una ABI no es lo mismo que la especificación del lenguaje. Mientras que la especificación define el significado de los programas, la ABI define cómo se representa ese significado mediante estructuras binarias en una plataforma e implementación determinadas. Un mismo lenguaje puede utilizar ABI diferentes según el sistema operativo, la arquitectura de CPU y la familia de compiladores.

Archivos ejecutables y carga

El cargador es el componente del sistema operativo que lee un archivo ejecutable, construye el espacio de direcciones de un proceso nuevo e inicia su ejecución.

Un proceso de carga habitual es el siguiente.

  1. Examina la cabecera del archivo ejecutable.
  2. Comprueba la arquitectura de destino y el formato de ejecución.
  3. Mapea en memoria el código, los datos de solo lectura y los datos escribibles.
  4. Prepara la región de datos no inicializados.
  5. Crea la pila y el estado inicial del proceso.
  6. Carga el enlazador dinámico cuando es necesario.
  7. Mapea bibliotecas compartidas y aplica reubicaciones.
  8. Inicializa el runtime del lenguaje y los objetos globales.
  9. Transfiere el control al punto de entrada del programa.

El punto de entrada invocado inicialmente por el sistema operativo no tiene que ser necesariamente la función main escrita por el usuario. En programas C y C++, el código de inicio recibe los argumentos y la información del entorno del sistema operativo, inicializa el runtime y posteriormente llama a main. Al finalizar, puede transmitir el valor de retorno al sistema operativo y ejecutar manejadores de terminación registrados y tareas de limpieza de objetos estáticos.

La Java Virtual Machine distingue la carga de la representación binaria de una clase, el enlace que la incorpora al estado de ejecución y la inicialización que prepara su estado estático. En la JVM también pueden cargarse nuevas clases durante la ejecución mediante cargadores de clases definidos por el usuario.

Sistema de runtime

Un sistema de runtime es una capa de software que admite las funciones de un lenguaje de programación mientras se ejecuta el programa compilado. El tamaño y las funciones del runtime varían considerablemente según el lenguaje.

Un entorno de ejecución habitual de C puede utilizar un runtime relativamente limitado, compuesto por código de inicio, la biblioteca estándar, funciones auxiliares aritméticas y de desenrollado de pila. Java y .NET utilizan runtimes administrados más amplios que incluyen máquinas virtuales, compiladores JIT, recolectores de basura, sistemas de tipos, cargadores de clases, gestión de excepciones y administración de hilos.

Un runtime puede proporcionar las siguientes funciones.

  • Inicialización del programa y de los hilos.
  • Administración de la pila de llamadas y los marcos de ejecución.
  • Asignación de memoria en el montón.
  • Recolección de basura.
  • Administración de metadatos de objetos y tipos.
  • Despacho dinámico de métodos.
  • Lanzamiento de excepciones y desenrollado de pila.
  • Reflexión.
  • Carga dinámica de módulos y clases.
  • Planificación de hilos y tareas.
  • Corrutinas y entrada y salida asíncrona.
  • Comprobación de límites de cadenas y arreglos.
  • Operaciones de tipado dinámico.
  • Interoperabilidad con código nativo.
  • Recopilación de información para perfiles y optimización JIT.

El entorno administrado de .NET proporciona servicios como compilación JIT, recolección de basura, interoperabilidad con código no administrado, depuración entre lenguajes, distribución y administración de versiones.

Pila de ejecución y marcos de llamada

Cuando se llama a una función suele crearse un marco de llamada que almacena el estado necesario para esa invocación. También se denomina marco de pila.

Un marco de llamada puede incluir la siguiente información.

  • Dirección de retorno.
  • Argumentos de la función.
  • Variables locales.
  • Resultados temporales.
  • Información que apunta al marco anterior.
  • Valores de registros que deben conservarse.
  • Información de gestión de excepciones y desenrollado de pila.

Cuando una función llama a otra se añade un marco nuevo, y cuando una función retorna se elimina dicho marco. Las llamadas recursivas crean sucesivamente marcos distintos para una misma función.

La JVM proporciona a cada hilo su propia pila JVM y crea al llamar a un método un marco que incluye un arreglo de variables locales y una pila de operandos. El montón de la JVM es compartido por todos los hilos y en él se asignan instancias de clases y arreglos.

No todos los valores tienen que almacenarse necesariamente en la memoria física de la pila. En código nativo optimizado, una variable local puede existir únicamente en un registro o eliminarse por completo. En una implementación de máquina virtual, los marcos de llamada también pueden almacenarse en una pila nativa continua, en el montón o en una estructura interna independiente.

Montón y asignación de memoria

El montón es una región de memoria utilizada para almacenar objetos y datos cuyo tamaño y tiempo de vida se determinan dinámicamente durante la ejecución. Los objetos que deben sobrevivir independientemente de la estructura de llamadas de la pila suelen asignarse en el montón.

Un asignador de montón localiza y devuelve un bloque de memoria del tamaño solicitado y reutiliza posteriormente las regiones liberadas. Un asignador eficiente puede utilizar listas libres por tamaños, asignación basada en regiones, cachés por hilo y administración por páginas.

El uso del montón puede producir los siguientes problemas.

  • Fugas provocadas por no liberar memoria asignada.
  • Acceso a memoria ya liberada.
  • Liberación doble de la misma memoria.
  • Fragmentación que reduce las regiones contiguas disponibles.
  • Costes de sincronización cuando varios hilos acceden al asignador.
  • Reducción de la eficiencia de caché cuando los objetos quedan alejados entre sí.

Los lenguajes y runtimes administran el tiempo de vida de los objetos del montón mediante métodos como liberación manual, conteo de referencias, recolección de basura por trazado, administración basada en regiones y análisis de propiedad.

Recolección de basura

La recolección de basura es un método mediante el cual el runtime recupera automáticamente la memoria de los objetos a los que el programa ya no puede acceder.

Un recolector por trazado suele localizar un conjunto raíz formado por variables globales, pilas de hilos y registros de CPU, entre otros elementos, y sigue las referencias desde esas raíces para marcar los objetos alcanzables. Los objetos no alcanzables no pueden volver a ser utilizados por el programa y se convierten en candidatos para su recuperación. El recolector de basura de .NET también examina el grafo de objetos alcanzables basándose en la información de raíces administrada por el compilador JIT y el runtime.

Existen varios métodos de implementación de la recolección de basura.

  • El método de marcado y barrido marca los objetos alcanzables y posteriormente recupera los no marcados.
  • El método de copia mueve los objetos vivos a otra región y vacía completamente la región anterior.
  • El método generacional administra los objetos nuevos y los de larga duración en regiones diferentes.
  • El método incremental divide la recolección en varias etapas pequeñas.
  • El método concurrente ejecuta parte de la recolección simultáneamente con los hilos de la aplicación.
  • El método de compactación reúne los objetos vivos para reducir la fragmentación.

La recolección de basura automatiza la liberación de memoria, pero no significa que recursos externos que deben devolverse inmediatamente, como archivos, sockets, bloqueos y objetos de GPU, se limpien automáticamente en el momento adecuado. Estos recursos pueden requerir construcciones de limpieza explícita, administración basada en ámbito o métodos de cierre.

Gestión de excepciones

La gestión de excepciones es una estructura de control que transmite a un llamador superior errores o situaciones especiales difíciles de tratar mediante el flujo normal de retorno.

Cuando se produce una excepción, el runtime busca un controlador apropiado en la función actual. Si no existe, avanza hacia el marco de la función llamadora y continúa recorriendo la pila de llamadas hasta encontrar un controlador. Durante este proceso puede ejecutar destructores de objetos locales o construcciones de limpieza. Esto se denomina desenrollado de pila.

La gestión de excepciones puede implementarse mediante los siguientes métodos.

  • Comprobar continuamente en cada llamada y operación si se ha producido una excepción.
  • No añadir bifurcaciones adicionales durante la ejecución normal y utilizar tablas para localizar los puntos de tratamiento cuando se produce una excepción.
  • Administrar mediante el runtime los objetos de excepción y su información de tipo.
  • Integrarse con sistemas de excepciones estructuradas o señales del sistema operativo.

Una ABI de C++ puede definir reglas binarias para la interfaz de excepciones, el desenrollado de pila, la limpieza de objetos y la localización de controladores. Para transmitir excepciones entre compiladores o runtimes de lenguajes diferentes se requieren ABI y modelos de excepción compatibles.

Las excepciones son un método de representación de errores a nivel de lenguaje y no siempre equivalen a las excepciones de hardware del sistema operativo. Algunas implementaciones transforman errores de hardware o del sistema operativo, como la división por cero y el acceso inválido a memoria, en excepciones del lenguaje, mientras que otras terminan inmediatamente el programa.

Bytecode y máquinas virtuales

El bytecode es un formato de instrucciones destinado a una máquina virtual en lugar de a una CPU física concreta. Al transformar el código fuente en bytecode puede situarse una capa de ejecución común entre el frontend del lenguaje y el hardware real.

Una máquina virtual define las instrucciones del bytecode, el modelo de memoria, las reglas de llamada, la verificación de tipos y el formato de módulos. Un intérprete puede ejecutar directamente el bytecode o un compilador JIT puede transformarlo en código nativo.

JVM

El código fuente Java se compila normalmente en un formato class que contiene instrucciones de la JVM. La JVM no conoce directamente el lenguaje Java y también puede ejecutar otros lenguajes capaces de generar un formato class válido. Un archivo class de la JVM contiene bytecode, un depósito de constantes que actúa como tabla de símbolos, campos, métodos y distintos atributos.

Antes de ejecutar un archivo de clase, la JVM puede verificar la validez de su estructura y bytecode. La verificación comprueba que las relaciones de tipos, el uso de la pila y los destinos de bifurcación satisfacen las reglas de la JVM, limitando la posibilidad de que un binario incorrecto rompa las reglas internas del runtime.

CPython

CPython transforma el código fuente Python en objetos de código internos y bytecode. Un objeto de código es un objeto inmutable que contiene código compilado y metadatos relacionados, mientras que un objeto de función combina dicho objeto de código con un espacio global de nombres, argumentos predeterminados y otros contextos de ejecución.

Al importar un módulo, CPython puede guardar el resultado de la compilación en un archivo de caché .pyc. Si en una ejecución posterior la caché coincide con el código fuente actual, puede reducirse la necesidad de transformar de nuevo el código fuente en bytecode. El sistema de importación de Python puede comprobar la validez de la caché mediante marcas de tiempo, tamaños de archivo o hashes del código fuente.

.NET

Los compiladores de lenguajes .NET generan normalmente ensamblados que contienen lenguaje intermedio común y metadatos. El CLR carga el ensamblado, interpreta la información de tipos y métodos y compila mediante JIT los métodos que deben ejecutarse o utiliza código compilado previamente.

El sistema común de tipos y los metadatos permiten que lenguajes diferentes, como C# y F#, utilicen el mismo runtime y las mismas bibliotecas. Sin embargo, no todas las funciones específicas de un lenguaje quedan expuestas de forma idéntica a otros lenguajes, y para la interoperabilidad deben utilizarse interfaces públicas que puedan representarse mediante reglas comunes.

WebAssembly

WebAssembly es un formato portable de instrucciones virtuales de bajo nivel. Su especificación principal define el conjunto de instrucciones, los tipos, los módulos, la codificación binaria, la validación y la semántica de ejecución. Los métodos de interacción con API de sistemas operativos o navegadores concretos se separan de la semántica principal de ejecución y son proporcionados por el anfitrión o por capas de interfaz independientes.

Un módulo WebAssembly se valida según reglas estructurales y de tipos antes de ejecutarse. Al instanciarlo se crean instancias de runtime para funciones, memoria, tablas y variables globales, se inicializan los segmentos de datos y puede ejecutarse una función de inicio designada.

Intérpretes

Un intérprete lee la representación interna de un programa y ejecuta directamente las operaciones correspondientes a su significado.

El intérprete más sencillo de recorrido de árbol visita recursivamente los nodos del AST.

Ejecución de un nodo de suma:
    Evaluar el nodo hijo izquierdo.
    Evaluar el nodo hijo derecho.
    Sumar ambos resultados.

El recorrido de árbol es fácil de implementar y mantiene una correspondencia clara entre la estructura del código fuente y la ejecución, pero puede tener un coste elevado porque debe determinar la clase de cada nodo y seguir punteros en cada operación.

Un intérprete de bytecode transforma el AST en una secuencia de instrucciones más compacta y ejecuta dichas instrucciones desplazando un contador de programa virtual.

LOAD_CONST 10
LOAD_LOCAL value
ADD
RETURN

Un intérprete de bytecode utiliza despacho de instrucciones y una pila de operandos o registros virtuales. Una máquina virtual puede usar una arquitectura basada en pila o en registros, y ambas poseen características diferentes en cuanto al tamaño de las instrucciones, el coste de decodificación y las posibilidades de optimización.

Los intérpretes poseen tiempos de compilación cortos y permiten modificar con flexibilidad la estructura del programa durante la ejecución. Sin embargo, como el coste de interpretar cada operación se repite, pueden ser más lentos que el código nativo en cálculos de larga duración. Los runtimes modernos pueden iniciar rápidamente la ejecución mediante un intérprete y compilar mediante JIT únicamente las partes ejecutadas con frecuencia.

Compilación JIT

La compilación JIT transforma durante la ejecución bytecode o una representación intermedia en lenguaje máquina para el sistema actual.

Un compilador JIT puede recopilar durante la ejecución información como la siguiente.

  • Funciones llamadas realmente con frecuencia.
  • Número de ejecuciones de los bucles.
  • Proporción real de las bifurcaciones.
  • Tipos de objetos observados en puntos concretos de llamada.
  • Destinos reales de llamadas a métodos virtuales.
  • Tamaños de arreglos y rangos de valores.
  • Rutas en las que aparecen excepciones con frecuencia.
  • Tipos de valores que reciben repetidamente los argumentos de una función.

Esta información permite especializar el código basándose en condiciones que un compilador anticipado tendría dificultades para asumir de forma segura. Por ejemplo, si en un punto de llamada solo se observa un tipo de objeto, puede omitirse la búsqueda del método virtual y llamarse directamente a la función correspondiente.

Sin embargo, durante la ejecución puede aparecer un tipo nuevo que invalide las suposiciones anteriores. En ese caso, el runtime debe descartar el código optimizado y volver a código más general o al estado del intérprete. Esto se denomina desoptimización.

La compilación JIT también implica los siguientes costes.

  • Utiliza tiempo de CPU para compilar durante la ejecución.
  • Requiere memoria para almacenar el lenguaje máquina generado.
  • Al inicio del programa todavía no existe suficiente información de perfiles.
  • El rendimiento puede variar antes y después de completarse la optimización.
  • La generación dinámica de memoria ejecutable puede verse afectada por políticas de seguridad y firma de código.

La API ORC JIT de LLVM está diseñada para construir estructuras basadas en JIT capaces de transformar LLVM IR en código nativo y administrar símbolos durante la ejecución.

Compilación por niveles

La compilación por niveles utiliza conjuntamente varios niveles de ejecución con diferentes velocidades y costes de compilación.

Intérprete rápido
    ↓ Se ejecuta con frecuencia
Compilación JIT de bajo coste
    ↓ Se ejecuta con mucha frecuencia
Compilación JIT optimizada de alto coste

El programa se inicia inmediatamente mediante un intérprete o un compilador rápido. El runtime observa la frecuencia de ejecución y vuelve a compilar el código utilizado con frecuencia con un nivel de optimización superior. El código ejecutado raramente no recibe optimizaciones costosas, lo que reduce el tiempo de inicio y el uso de memoria.

Compilación AOT

La compilación AOT transforma un programa en código de destino antes de su ejecución. Además de la compilación nativa habitual, también puede incluir la transformación previa a la distribución de un formato intermedio de máquina virtual en una imagen nativa.

Las principales características de la compilación AOT son las siguientes.

  • El coste de compilación durante la ejecución es bajo o inexistente.
  • El tiempo de inicio y el rendimiento inicial son más predecibles.
  • No es necesario permitir la generación dinámica de código en el entorno de ejecución.
  • Debe generarse previamente código adaptado a la arquitectura de destino.
  • La información de tipos y llamadas que solo se conoce durante la ejecución puede aprovecharse de manera limitada.
  • Pueden necesitarse metadatos adicionales para conservar funciones como reflexión, carga dinámica y generación de código durante la ejecución.

Las plataformas modernas no siempre eligen de manera fija entre AOT y JIT, sino que también pueden combinar código base precompilado con optimizaciones basadas en perfiles durante la ejecución.

Interoperabilidad nativa

Una interfaz de funciones externas es un sistema que permite llamar funciones e intercambiar datos entre distintos lenguajes de programación y runtimes. Se abrevia como FFI.

Una FFI debe resolver los siguientes problemas.

  • Cómo corresponder los tipos de ambos lenguajes.
  • Convenciones de llamada de funciones.
  • Codificación y representación de cadenas en memoria.
  • Disposición de estructuras y arreglos.
  • Tiempo de vida de objetos y referencias.
  • Propietario de la memoria y responsabilidad de liberación.
  • Transmisión de funciones callback.
  • Conversión de excepciones y errores.
  • Conexión entre hilos y estado del runtime.
  • Movimiento y fijación de objetos administrados por el recolector de basura.

La ABI de C es relativamente sencilla y compatible con numerosos lenguajes, por lo que se utiliza ampliamente como frontera común de interoperabilidad. Las clases, plantillas, excepciones y objetos de la biblioteca estándar de C++ dependen en mayor medida de la ABI y de la versión del compilador. Por ello, las interfaces externas suelen utilizar convenciones de llamada de C, handles opacos y funciones explícitas de creación y liberación.

Al llamar a una función nativa desde un runtime administrado puede ser necesario realizar un proceso de marshaling para transformar un objeto administrado en una representación nativa de memoria. A la inversa, cuando el código nativo conserva una referencia a un objeto administrado, se requieren handles especiales para que el recolector de basura reconozca la referencia y mantenga vivo el objeto.

Compilación cruzada

La compilación cruzada es un método en el que el sistema anfitrión donde se ejecuta el compilador es diferente del sistema de destino donde se ejecutará el programa generado.

Por ejemplo, puede generarse en una computadora x86-64 con Linux un programa para AArch64 Linux o para un sistema operativo embebido.

Un entorno de compilación cruzada necesita normalmente los siguientes elementos.

  • Backend de generación de código compatible con la arquitectura de destino.
  • ABI y convención de llamada del destino.
  • Cabeceras del sistema operativo de destino.
  • Biblioteca estándar y bibliotecas del sistema del destino.
  • Ensamblador y enlazador de destino o herramientas que los incluyan.
  • Sysroot que represente el sistema de archivos del destino.
  • Dispositivo real o emulador para ejecutar y probar el programa.

No basta con que el compilador pueda generar lenguaje máquina para la CPU de destino. Las bibliotecas utilizadas por el programa, el código de inicio y la configuración del enlazador también deben adaptarse al entorno de destino. Clang puede generar código para otras arquitecturas especificando el target, la CPU, la ABI, el sysroot y las rutas de cabeceras y bibliotecas.

Compilación heterogénea

Los programas modernos pueden estar destinados a más de una CPU. Cuando se utilizan conjuntamente dispositivos de ejecución diferentes, como GPU, DSP y aceleradores neuronales, es necesario compilar por separado el código del anfitrión y el código del dispositivo a partir de una misma fuente o módulo.

Una sola fuente
├── Código anfitrión de CPU → x86-64 o AArch64
└── Código de dispositivo GPU → ISA de GPU o SPIR-V

La cadena de herramientas puede compilar la misma unidad de traducción para varios destinos e incluir el código de dispositivo generado dentro del ejecutable anfitrión o empaquetarlo en un binario independiente. La cadena de herramientas de offloading de Clang también puede generar objetos de código separados para el anfitrión y uno o más dispositivos y combinarlos en un mismo paquete.

Información de depuración

El lenguaje máquina optimizado no conserva directamente las variables, sentencias y estructuras de tipos del código fuente original. La información de depuración es el conjunto de metadatos que permite a un depurador volver a conectar el estado de ejecución del lenguaje máquina con conceptos del nivel del código fuente.

La información de depuración puede incluir los siguientes elementos.

  • Correspondencia entre direcciones de lenguaje máquina y archivos, líneas y columnas del código fuente.
  • Funciones e información de llamadas inline.
  • Tipos de variables y parámetros.
  • Registro o posición de memoria donde se encuentra un valor en un punto concreto de ejecución.
  • Disposición de campos de estructuras y clases.
  • Espacios de nombres y módulos.
  • Información de reconstrucción de marcos de llamada.

En entornos ELF de sistemas Unix se utiliza ampliamente el formato DWARF, mientras que en entornos PE/COFF de Windows se emplean formatos de la familia CodeView y PDB. DWARF está estandarizado como formato de información destinado a admitir depuración a nivel de código fuente para varios lenguajes compilados.

En un programa optimizado, un valor correspondiente a una variable del código fuente puede haber sido eliminado o desplazado entre varias posiciones, una función puede haberse insertado mediante inlining y el orden de las sentencias puede cambiar. Por ello, la posición de ejecución y los valores mostrados por el depurador pueden no coincidir completamente con el orden intuitivo del código fuente.

Compilación incremental y caché

Compilar desde cero un programa grande en cada ocasión prolonga el tiempo de desarrollo. La compilación incremental vuelve a procesar únicamente las partes modificadas y los elementos dependientes afectados por dichos cambios.

Un compilador incremental puede almacenar la siguiente información.

  • Hash del código fuente de cada módulo.
  • Interfaces públicas e información de tipos.
  • Relaciones de dependencia entre módulos.
  • Resultados anteriores del análisis sintáctico y semántico.
  • Representaciones intermedias generadas.
  • Resultados de optimización y archivos objeto.
  • Opciones de compilación y entorno de destino.

Si solo cambia la implementación interna de una función y se conserva su interfaz pública, puede regenerarse únicamente el módulo que la contiene. En cambio, si cambia un tipo público, una plantilla o una función inline, puede ser necesario volver a compilar también otros módulos que la utilicen.

Una caché de compilación utiliza el contenido del código fuente, la versión del compilador, las opciones, las cabeceras y los archivos dependientes como claves para reutilizar resultados anteriores. Si no se construye una clave de caché precisa, pueden reutilizarse resultados antiguos de manera incorrecta o invalidarse más elementos de los necesarios.

Bootstrapping y autohospedaje

Cuando el compilador de un lenguaje de programación está escrito en ese mismo lenguaje se denomina compilador autohospedado. Como un lenguaje nuevo todavía no dispone de su propio compilador, inicialmente puede implementarse en otro lenguaje o comenzar con un compilador pequeño que procese un subconjunto del nuevo lenguaje.

Un proceso habitual de bootstrapping es el siguiente.

Escribir el compilador inicial en un lenguaje existente
    ↓
Compilar el nuevo lenguaje con el compilador inicial
    ↓
Reescribir el compilador en el nuevo lenguaje
    ↓
Construir el nuevo compilador con el compilador anterior
    ↓
El nuevo compilador compila su propio código fuente

El autohospedaje demuestra que el lenguaje puede utilizarse para escribir software real de gran tamaño y permite a los desarrolladores del compilador utilizar directamente su propio lenguaje y cadena de herramientas. Sin embargo, existe un problema de confianza del bootstrap, relacionado con la verificación de si el binario y el código fuente del compilador son confiables y con la influencia que las etapas anteriores de compilación tienen sobre el resultado.

Correspondencia entre la especificación y la implementación

Una implementación debe ejecutar correctamente el significado de los programas definido por la especificación del lenguaje. Aunque el compilador genere código más rápido, no puede modificar arbitrariamente el comportamiento observable del programa.

La corrección de una implementación puede comprobarse mediante los siguientes métodos.

  • Pruebas de conformidad que verifican cada función del estándar del lenguaje.
  • Pruebas unitarias e integradas del propio compilador.
  • Pruebas diferenciales que ejecutan el mismo programa en varias implementaciones.
  • Fuzzing que genera programas aleatorios.
  • Verificación de representaciones intermedias y pases de optimización.
  • Comparación del comportamiento del programa antes y después de la compilación.
  • Uso de analizadores estáticos y comprobadores durante la ejecución.
  • Compiladores formalmente verificados o transformaciones verificadas.

En lenguajes que permiten comportamiento indefinido o comportamiento definido por la implementación, no es necesario que todas las implementaciones produzcan el mismo resultado. En esos casos, la especificación distingue entre las reglas que la implementación debe respetar y los aspectos que puede elegir.

Los binarios destinados a máquinas virtuales pueden pasar por una etapa de validación independiente antes de la ejecución. WebAssembly verifica que las instrucciones y los módulos satisfagan reglas estáticas de tipos y estructura, mientras que la JVM también aplica restricciones estructurales a los archivos de clase y al bytecode.

Flujo completo de ejecución

Aunque el proceso de ejecución de un lenguaje de programación varía según el lenguaje y la implementación, la función de cada etapa puede resumirse de la siguiente manera.

EtapaFunción principalResultado representativo
PreprocesamientoProcesamiento de archivos incluidos, macros y código condicionalTokens preprocesados
Análisis léxicoDivisión de caracteres en unidades con significadoTokens
Análisis sintácticoOrganización de tokens en estructuras gramaticalesÁrbol sintáctico o AST
Análisis semánticoComprobación de nombres, tipos, ámbitos y reglas del lenguajeAST con información semántica
Bajada de nivelTransformación de funciones de alto nivel en operaciones básicasAST o IR de bajo nivel
Generación de representación intermediaConstrucción de un formato apto para optimización y transformación de destinoIR
OptimizaciónMejora del código conservando su significadoIR optimizado
Generación de códigoDeterminación de instrucciones, registros y convenciones de llamada del destinoEnsamblador o lenguaje máquina
EnsambladoConstrucción de lenguaje máquina y metadatosArchivo objeto
EnlaceResolución de símbolos, reubicación y combinación de binariosArchivo ejecutable o biblioteca
CargaDisposición del programa y las bibliotecas en memoriaEstado de ejecución del proceso
RuntimeSoporte de funciones del lenguaje, como memoria, tipos, excepciones e hilosPrograma en ejecución

En implementaciones que utilizan bytecode y máquinas virtuales, algunas etapas cambian de la siguiente manera.

Código fuente
    ↓
Análisis del frontend
    ↓
Bytecode o lenguaje intermedio común
    ↓
Validación y carga de módulos
    ↓
Intérprete o compilador JIT
    ↓
Ejecución de instrucciones nativas

La implementación de un lenguaje de programación no es simplemente un trabajo de traducción que convierte la gramática en lenguaje máquina. Los compiladores y los intérpretes deben implementar sobre el hardware y el sistema operativo reales el significado, el sistema de tipos, el modelo de memoria y las reglas de excepciones y concurrencia del lenguaje. El enlazador, el cargador, la ABI, el runtime y la biblioteca estándar también forman parte del ecosistema de ejecución que determina el resultado del programa.

Las implementaciones modernas combinan según las necesidades compilación AOT, interpretación, bytecode y compilación JIT, y optimizan los programas utilizando representaciones intermedias de varios niveles e información de perfiles. Por ello, el rendimiento real, el uso de memoria, el tiempo de inicio, la portabilidad y las características de depuración de un lenguaje de programación no quedan determinados únicamente por su sintaxis, sino por el sistema completo de ejecución formado por el compilador, el runtime, el sistema operativo, la ABI, las bibliotecas y el hardware de destino.

Sistema de tipos

Un sistema de tipos es un conjunto de reglas que clasifica los valores y las expresiones utilizados en un programa dentro de categorías determinadas y define las operaciones permitidas para cada categoría y las relaciones de compatibilidad entre valores. Un tipo no representa únicamente la forma en que un valor se almacena en memoria, sino también las operaciones que pueden realizarse con él, las condiciones bajo las cuales puede transmitirse a otros valores, las entradas y salidas de las funciones y los límites de abstracción del programa.

Por ejemplo, los enteros y las cadenas son valores almacenados en memoria, pero admiten operaciones y significados diferentes. A los enteros pueden aplicarse operaciones aritméticas, mientras que a las cadenas pueden aplicarse operaciones como la concatenación y el cálculo de longitud. Un tipo de función representa qué valores recibe una función y qué valores devuelve, mientras que un tipo de objeto puede representar los campos y métodos disponibles o las interfaces que satisface dicho objeto.

Un sistema de tipos no es un mecanismo capaz de eliminar todos los errores de un programa. Situaciones como la inexistencia de un archivo, un fallo de conexión de red, una entrada incorrecta del usuario o un error lógico del algoritmo pueden seguir produciéndose durante la ejecución porque resultan difíciles de determinar únicamente mediante tipos. La función del sistema de tipos consiste en detectar incompatibilidades entre valores y operaciones dentro del alcance definido por el lenguaje, expresar la estructura y la intención del programa y proporcionar información que permita al compilador y a las herramientas de desarrollo analizarlo. Cardelli y Wegner trataron los tipos como un concepto central para sistematizar la clasificación, la abstracción y el polimorfismo de las expresiones y los valores de los programas.

Las principales clasificaciones de los sistemas de tipos pueden resumirse de la siguiente manera.

CriterioClasificaciones principalesObjeto de la distinción
Momento de comprobaciónTipado estático, tipado dinámicoSi las reglas de tipos se comprueban antes o durante la ejecución
Notación de tiposTipos explícitos, inferencia de tiposCómo determinan el programador y el compilador la información de tipos
Relación entre tiposTipado nominal, tipado estructuralSi la compatibilidad se determina mediante nombres o estructuras
PolimorfismoParamétrico, de subtipos, ad hocCómo puede un mismo código tratar varios tipos
Combinación de valoresTipos producto, tipos suma, tipos de funciónCómo se representan valores compuestos
Uso de recursosTipos lineales, tipos afines, tipos de propiedadCuántas veces puede utilizarse un valor y quién lo administra
PrecisiónTipos ordinarios, tipos refinados, tipos dependientesCuántas condiciones sobre los valores se incluyen en el tipo
EfectosTipos ordinarios, tipos de efectoSi las acciones externas de una función se rastrean mediante tipos
Método de introducciónTipado completamente estático, tipado gradualEn qué grado se aplica información estática de tipos al programa

Estas clasificaciones no son mutuamente excluyentes. Un mismo lenguaje puede proporcionar tipado estático, inferencia de tipos, tipado nominal, genéricos, análisis de nulabilidad y comprobaciones de propiedad. A la inversa, un lenguaje dinámico puede añadir anotaciones opcionales de tipos y analizadores estáticos para aplicar comprobaciones a determinadas partes del código.

Tipos y juicios de tipado

La comprobación de tipos es el proceso de asignar un tipo determinado a una expresión del programa y verificar que dicho tipo sea compatible con el contexto en el que se utiliza. En un sistema formal de tipos suelen emplearse juicios semejantes al siguiente.

Γ ⊢ expression : Type

Esto significa que, dentro del entorno Γ, expression posee el tipo Type. El entorno contiene información sobre las variables, funciones, parámetros de tipo y sus respectivos tipos disponibles dentro del ámbito actual.

Puede suponerse el siguiente código.

x: Integer
y: Integer

x + y

El comprobador de tipos aplica la regla según la cual x e y son enteros y el resultado de sumar dos enteros también es un entero, por lo que asigna el tipo entero a x + y. En cambio, si se intentan sumar valores como una cadena y un objeto de archivo para los que no se ha definido una regla de suma, se produce un error de tipo.

Las llamadas a funciones se comprueban de la misma forma.

parse: String → Integer

Si parse es una función que recibe una cadena y devuelve un entero, una expresión de cadena puede transmitirse como argumento, pero puede no estar permitido pasar un objeto arbitrario que no pueda convertirse en cadena.

Un juicio de tipado no consiste simplemente en comparar si los nombres de los tipos son iguales. También pueden tenerse en cuenta las relaciones de subtipado, las conversiones implícitas, las restricciones genéricas, la resolución de sobrecargas, los tiempos de vida, la nulabilidad y los resultados de inferencia de parámetros de tipo.

Seguridad de tipos y solidez

La seguridad de tipos es la propiedad que restringe el uso de valores de formas prohibidas por las reglas de tipos. Por ejemplo, un sistema de tipos puede impedir que un valor entero se invoque como un puntero a función, que una cadena se interprete como una disposición arbitraria de objeto o que se llame a un método inexistente.

En la teoría formal de lenguajes suelen utilizarse dos propiedades, preservación y progreso, para explicar la solidez de tipos.

  • La preservación establece que una expresión correctamente tipada continúa satisfaciendo las reglas de tipos después de un paso de ejecución.
  • El progreso establece que una expresión correctamente tipada ya es un valor final o puede avanzar hacia el siguiente paso de ejecución definido por el lenguaje.

Si se demuestran conjuntamente estas dos propiedades, se obtiene una base para afirmar que un programa que supera la comprobación de tipos no entra durante la ejecución en un estado no definido por el sistema de tipos. Wright y Felleisen sistematizaron un enfoque sintáctico para demostrar la solidez de tipos mediante estas propiedades.

La solidez de tipos no es equivalente a la seguridad de memoria. Si un lenguaje permite operaciones inseguras con punteros, conversiones arbitrarias de tipos o llamadas a código nativo externo, pueden producirse errores de memoria incluso en código que supera la comprobación estática. Algunos lenguajes sitúan estas operaciones dentro de límites separados, como regiones unsafe, para distinguir claramente las garantías conservadas en el código seguro. Rust también permite utilizar código unsafe dentro de abstracciones seguras, pero el autor de dicho código debe garantizar directamente las reglas de punteros, alias y tiempos de vida.

Las garantías proporcionadas por un sistema de tipos varían según el lenguaje. Algunos lenguajes comprueban los límites de arreglos mediante tipos, mientras que otros los comprueban durante la ejecución. Algunos tratan el desbordamiento de enteros como un error, mientras que otros hacen que el valor se ajuste de forma cíclica. Por ello, la expresión «lenguaje seguro en cuanto a tipos» no debe interpretarse como una garantía de toda clase de seguridad durante la ejecución.

Tipado estático y tipado dinámico

Un sistema de tipado estático analiza antes de la ejecución las relaciones de tipo entre variables, expresiones y llamadas a funciones. En Java, los tipos de todas las variables y expresiones se determinan durante la compilación, y esos tipos limitan los posibles valores y las operaciones permitidas.

Un comprobador estático puede detectar antes de la ejecución problemas como los siguientes.

  • Uso de campos o métodos inexistentes.
  • Incompatibilidad entre argumentos y tipos de parámetros.
  • Tipo de retorno incorrecto.
  • Conversión de tipo no permitida.
  • Incumplimiento de restricciones genéricas.
  • Patrones o ramas de tipos no cubiertos.
  • Incumplimiento de reglas de nulabilidad.
  • Uso incorrecto de tiempos de vida y propiedad.
  • Incumplimiento de determinadas reglas de efectos y excepciones.

El tipado estático no consiste en conocer antes de la ejecución todos los valores reales del programa. Puede saberse que el tipo de una variable es entero, pero normalmente no puede determinarse si su valor real será 1 o 100. Un tipo representa de manera abstracta el conjunto de valores posibles y el alcance de las operaciones.

En un sistema de tipado dinámico, el tipo de un valor y la validez de una operación se comprueban principalmente durante la ejecución. Resulta más preciso explicar que el valor al que apunta actualmente un nombre posee información de tipo, en lugar de asignar un tipo fijo al propio nombre de la variable.

value = 10
value = "text"

En un lenguaje dinámico, un mismo nombre puede referirse en momentos diferentes a un entero y a una cadena. Sin embargo, esto no significa que desaparezca la distinción entre enteros y cadenas. El entorno de ejecución examina el tipo del valor actual y determina si la operación correspondiente está permitida.

El tipado dinámico resulta adecuado para escribir programas rápidamente y manipular con flexibilidad estructuras creadas durante la ejecución y datos de distintas formas. Sin embargo, determinados errores de tipo pueden no descubrirse hasta que se recorra realmente la ruta de ejecución correspondiente. Para compensarlo, suelen utilizarse conjuntamente pruebas, comprobaciones durante la ejecución, anotaciones opcionales de tipos y analizadores estáticos externos.

Los lenguajes estáticos también pueden contener comprobaciones de tipos durante la ejecución. Los downcasts, la carga dinámica de módulos, la reflexión, los objetos de interfaz y la deserialización de datos externos requieren examinar valores reales durante la ejecución. A la inversa, un compilador de un lenguaje dinámico puede diagnosticar previamente errores evidentes o inferir tipos para realizar optimizaciones. Por ello, la distinción entre tipado estático y dinámico no significa que todas las comprobaciones se produzcan exclusivamente en un único momento, sino que indica en qué etapa sitúa el lenguaje la responsabilidad principal de la corrección de tipos.

Tipos explícitos e inferencia de tipos

La anotación de tipos consiste en que el programador escriba directamente los tipos de variables, funciones, parámetros y valores de retorno.

fn add(a: i32, b: i32) -> i32 {
    a + b
}

La inferencia de tipos es el proceso mediante el cual el compilador determina los tipos de las partes del código fuente donde no se han escrito explícitamente, analizando las expresiones circundantes y sus relaciones de uso.

let value = 10;

En este código, el compilador puede inferir el tipo de value a partir del literal entero y de su contexto de uso. El uso de inferencia no convierte el lenguaje en dinámico. El tipo inferido se utiliza para la comprobación estática, y una asignación posterior de un valor incompatible puede producir un error.

La inferencia de tipos utiliza información como la siguiente.

  • Tipo predeterminado de los literales.
  • Tipos de operandos exigidos por los operadores.
  • Tipos de parámetros y retorno de las funciones.
  • Relaciones de asignación entre variables.
  • Restricciones de los parámetros genéricos.
  • Relaciones de subtipado y conversión.
  • Tipo objetivo exigido por el contexto.
  • Refinamiento de tipos según el flujo de control.

Los sistemas de la familia Hindley–Milner permiten inferir el tipo principal más general incluso en programas funcionales con pocas anotaciones explícitas. Las investigaciones de Damas y Milner establecieron un sistema de tipos principales en el que otros tipos aplicables a un programa pueden expresarse como instancias de su tipo principal.

La inferencia de tipos de los lenguajes modernos no coincide completamente con Hindley–Milner. Cuando se combinan subtipado, sobrecarga, objetos, genéricos, tiempos de vida y mutabilidad, se requieren sistemas de restricciones más complejos. La inferencia de Java también transforma la compatibilidad de expresiones y las relaciones de subtipado en restricciones y calcula los posibles límites de las variables de tipo.

La inferencia de tipos puede tener distintos alcances.

  • La inferencia local de tipos infiere únicamente algunos tipos dentro de una función o expresión.
  • La inferencia global de tipos analiza un ámbito amplio del programa más allá de los límites de las funciones.
  • La inferencia unidireccional calcula el tipo a partir de la propia expresión.
  • La inferencia contextual transmite hacia la expresión el tipo exigido por el entorno.
  • La comprobación bidireccional de tipos sintetiza el tipo de algunas expresiones y comprueba que otras coincidan con un tipo objetivo dado.

La comprobación bidireccional distingue entre síntesis y comprobación de tipos y se utiliza para controlar la cantidad de anotaciones necesarias y la complejidad de la comprobación incluso en sistemas de tipos con gran expresividad.

Inferir automáticamente todos los tipos no siempre resulta conveniente. Especificar los tipos de las funciones públicas aclara el contrato de la API y los mensajes de error y reduce la posibilidad de que un cambio de implementación modifique involuntariamente el resultado de la inferencia.

Tipos básicos y tipos compuestos

Los lenguajes de programación proporcionan diversos tipos para representar clases de valores. La clasificación varía según el lenguaje, pero suelen aparecer los siguientes tipos.

  • Enteros y números reales.
  • Valores lógicos.
  • Caracteres y cadenas.
  • Punteros y referencias.
  • Arreglos y slices.
  • Tuplas y registros.
  • Estructuras y clases.
  • Tipos enumerados.
  • Tipos de función.
  • Interfaces y traits.
  • Tipos genéricos.
  • Tipos suma y tipos intersección.
  • Tipos recursivos.
  • Tipos anulables.
  • Parámetros de tipo.

Java divide los tipos en tipos primitivos y tipos de referencia. Los tipos primitivos incluyen valores lógicos y numéricos, mientras que los tipos de referencia incluyen clases, interfaces y arreglos.

Estas clasificaciones no se aplican de la misma forma a todos los lenguajes. En algunos lenguajes los enteros también son objetos, mientras que en otros los enteros y los objetos poseen representaciones de memoria y reglas de llamada distintas. La diferencia entre tipos por valor y tipos por referencia tampoco se define simplemente como una diferencia entre pila y montón. Lo fundamental es cómo se copian, comparan y comparten los valores y cómo define el lenguaje su identidad.

Tipos producto

Un tipo producto es un tipo compuesto que contiene varios valores simultáneamente. Las tuplas, los registros y las estructuras son ejemplos representativos.

Point = {
    x: Float,
    y: Float
}

Un valor Point debe contener tanto x como y. Se denomina tipo producto porque todas las combinaciones posibles de valores de x e y forman el conjunto total de valores Point.

Las tuplas suelen distinguir sus componentes mediante posiciones, mientras que los registros los distinguen mediante nombres.

(Integer, String)
{
    id: Integer,
    name: String
}

Las tuplas de Haskell también se definen como tipos de datos algebraicos con un único constructor.

Tipos suma

Un tipo suma es un tipo que adopta una de varias formas posibles. También recibe los nombres de variant, tagged union o suma discriminada.

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Un valor Result<T, E> no contiene simultáneamente Ok y Err, sino que adopta una de las dos formas. Cada forma puede contener datos diferentes, y la forma actual se distingue mediante una etiqueta o constructor.

Los tipos suma resultan útiles para representar explícitamente estados como los siguientes.

  • Éxito o fracaso.
  • Presencia o ausencia de un valor.
  • Distintos tipos de comandos.
  • Diferentes clases de nodos de un árbol sintáctico.
  • Tipos de mensajes de red.
  • Estado actual de una máquina de estados.

El enum de Rust define un tipo enumerado nominal y permite construir y utilizar mediante coincidencia de patrones cada una de sus variantes.

Tipos de datos algebraicos

Un tipo de datos algebraico se define combinando tipos producto y tipos suma. Haskell proporciona tipos algebraicos definidos por el usuario y coincidencia de patrones como funciones centrales del lenguaje.

El siguiente ejemplo representa un árbol binario mediante un tipo algebraico recursivo.

data Tree a
    = Empty
    | Node (Tree a) a (Tree a)

Tree a es Empty o un Node que contiene un árbol izquierdo, un valor y un árbol derecho. Como la definición hace referencia a sí misma, también constituye un tipo recursivo.

La coincidencia de patrones examina el constructor actual y extrae los valores contenidos en él.

size Empty = 0
size (Node left _ right) =
    1 + size left + size right

El compilador puede comprobar si se han tratado todas las formas posibles de un tipo suma cerrado. Esto permite localizar el código afectado cuando se añade una variante nueva o diagnosticar estados no tratados.

Tipos de función

Un tipo de función representa las entradas que recibe una función y la salida que devuelve.

String → Integer

Esto representa el tipo de una función que recibe una cadena y devuelve un entero.

Varios parámetros pueden considerarse una tupla.

(Integer, Integer) → Integer

En un lenguaje que utiliza currificación puede representarse como una función que recibe un argumento y devuelve otra función.

Integer → Integer → Integer

En los lenguajes que admiten funciones de primera clase, los tipos de función pueden utilizarse para variables, campos, parámetros y valores de retorno.

En el subtipado de funciones, la dirección de los tipos de entrada y salida actúa de manera diferente. Puede ser seguro proporcionar un tipo de retorno más específico, pero el tipo de los parámetros debe aceptar un conjunto suficientemente amplio de valores que el llamador pueda transmitir. Esta relación conduce a la covarianza y la contravarianza.

Tipos unión y tipos intersección

Un tipo unión indica que un valor puede pertenecer a uno de varios tipos.

string | number

Un valor de este tipo es una cadena o un número. El tipo concreto debe refinarse mediante la ruta de ejecución o una comprobación condicional, y antes de determinarlo solo pueden utilizarse operaciones seguras para todos los tipos componentes. TypeScript utiliza el flujo de control y las comprobaciones de tipos para reducir los tipos unión a tipos más concretos.

Un tipo intersección debe satisfacer simultáneamente las condiciones de varios tipos.

Serializable & Loggable

Este tipo debe proporcionar todos los componentes exigidos tanto por Serializable como por Loggable. Los tipos intersección de TypeScript combinan los miembros de varios tipos en uno solo.

Los tipos unión e intersección pueden entenderse desde la perspectiva de conjuntos de valores.

  • A | B es el conjunto de valores que pertenecen a A o a B.
  • A & B es el conjunto de valores que satisfacen simultáneamente las condiciones de A y B.

Tipos especiales

Además de los tipos de datos ordinarios, un sistema de tipos puede incluir tipos especiales destinados a representar estructuras de cálculo.

Tipo superior

El tipo superior es el tipo más amplio del sistema e incluye todos los valores. Según el lenguaje puede denominarse Object, Any, unknown u otros nombres.

No pueden aplicarse inmediatamente a un valor del tipo superior operaciones que solo existen en un tipo concreto. Para utilizarlo debe reducirse a un tipo más específico mediante comprobación de tipos, coincidencia de patrones o downcast.

En algunos lenguajes, any proporciona una capacidad de evasión mayor que un simple tipo superior. Puede desactivar la comprobación de tipos o permitir casi todas las operaciones, por lo que no debe considerarse equivalente a un tipo superior en sentido teórico.

Tipo inferior

El tipo inferior es un tipo sin valores posibles. Puede representar una función que nunca retorna normalmente o que siempre lanza una excepción.

El tipo ! de Rust no contiene valores y representa un cálculo que no finaliza, y puede convertirse de forma coercitiva en otros tipos.

fn terminate() -> ! {
    panic!("terminated");
}

Como el tipo inferior no produce ningún valor real, puede aparecer lógicamente en posiciones que exigen distintos tipos.

let value = if condition {
    10
} else {
    terminate()
};

terminate() no devuelve un valor, por lo que no entra en conflicto con el tipo entero de la otra rama.

Tipo unidad

El tipo unidad posee un único valor sin información significativa. Puede expresar que una función no devuelve un resultado de cálculo y únicamente realiza efectos secundarios.

Unit

El tipo unidad se diferencia del tipo inferior, que no posee valores.

  • El tipo unidad contiene un valor.
  • El tipo inferior no contiene ningún valor.

Tipo nulo

En algunos lenguajes, null o un valor equivalente forma parte de los tipos de referencia. En otros, la posibilidad de ser nulo se distingue dentro del propio tipo.

Si todos los tipos de referencia incluyen implícitamente el valor nulo, cada uso de una referencia debe considerar la posibilidad de nulidad. Para reducirlo, los lenguajes modernos separan la presencia y la ausencia de valores mediante formas como T, T?, Option<T> y Maybe T.

Tipado nominal y tipado estructural

Un sistema de tipado nominal determina la compatibilidad a partir del nombre de las declaraciones de tipos y de relaciones explícitas.

class UserId
class OrderId

Aunque ambos tipos posean la misma representación interna, consistente en un único entero, pueden tratarse como tipos diferentes si fueron declarados por separado. Para que un tipo sea subtipo de otro suele requerirse una relación explícita, como herencia o implementación de una interfaz.

El tipado nominal resulta útil para distinguir conceptos de dominio y evitar que se mezclen tipos de forma involuntaria. Aunque la longitud y la masa utilicen la misma representación de número real, definirlas como tipos diferentes puede impedir asignaciones incorrectas.

Un sistema de tipado estructural determina la compatibilidad a partir de la estructura real de campos y métodos proporcionados, en lugar del nombre del tipo.

type Named = {
    name: string;
};

Un valor que posea un campo name: string puede ser compatible con Named aunque no declare explícitamente que implementa dicho tipo. TypeScript utiliza tipado estructural para reflejar el uso extendido de objetos anónimos y literales de objeto en JavaScript.

El tipado nominal y el estructural poseen ventajas y desventajas diferentes.

DistinciónTipado nominalTipado estructural
Criterio de compatibilidadNombre de la declaración y relaciones explícitasEstructura de campos y métodos
Declaración de relaciónGeneralmente necesariaPuede omitirse si la estructura coincide
Distinción de dominiosFuerteLas estructuras iguales pueden mezclarse
Reutilización de objetos existentesPuede requerir una implementación explícitaPuede ser compatible sin modificaciones
Evolución de APIRelaciones declaradas claramenteLos cambios estructurales pueden tener efectos amplios

Algunos lenguajes combinan tipos nominales y elementos estructurales. Pueden proporcionar clases nominales junto con tipos de función estructurales o registros anónimos.

Subtipado

El subtipado es una relación que permite utilizar con seguridad un valor de un tipo en una posición que exige otro tipo.

Dog <: Animal

Esto significa que Dog es un subtipo de Animal. Si Dog satisface todas las condiciones esperadas por el código que requiere un Animal, puede transmitirse un valor Dog a dicha posición.

El subtipado no significa simplemente que un tipo posea más campos. También deben considerarse las entradas y salidas de los métodos, los campos mutables, las excepciones y las reglas de modificación de estado. Cardelli y Wegner clasificaron el polimorfismo de inclusión basado en subtipos como una forma de polimorfismo universal.

La herencia y el subtipado están estrechamente relacionados, pero no son conceptos idénticos.

  • La herencia es una estructura del lenguaje destinada a reutilizar o extender implementaciones y declaraciones.
  • El subtipado es una relación de compatibilidad que permite que un valor ocupe una posición que exige otro tipo.

Según el lenguaje, puede proporcionarse subtipado mediante interfaces sin herencia, o puede existir reutilización de implementación que no produzca una relación segura de asignación.

Rust no utiliza jerarquías ordinarias de subtipado entre clases y limita el subtipado principalmente a las relaciones entre tiempos de vida y tiempos de vida de orden superior. Si se eliminan los tiempos de vida, la mayoría de las relaciones de tipos se tratan como igualdad de tipos.

Covarianza, contravarianza e invariancia

La varianza describe cómo un parámetro de tipo dentro de un constructor de tipos conserva las relaciones de subtipado.

Si se supone que Dog <: Animal, la cuestión principal es qué relación existe entre Container<Dog> y Container<Animal>.

Covarianza

La relación es covariante cuando conserva la misma dirección.

Dog <: Animal
Producer<Dog> <: Producer<Animal>

Un productor que genera únicamente Dog puede utilizarse como productor de Animal. El llamador espera recibir un Animal, y no existe problema si realmente recibe un Dog.

Contravarianza

La relación es contravariante cuando invierte la dirección.

Dog <: Animal
Consumer<Animal> <: Consumer<Dog>

Un consumidor capaz de tratar cualquier Animal también puede tratar un Dog, por lo que puede utilizarse donde se necesita un consumidor de Dog.

Invariancia

La relación es invariante cuando el subtipado no se transmite entre tipos genéricos.

No existe una relación automática de subtipado
entre Box<Dog> y Box<Animal>

Un contenedor mutable que permite tanto lectura como escritura debe ser generalmente invariante para resultar seguro. Si Box<Dog> pudiera tratarse como Box<Animal>, alguien podría introducir un Cat y romper las suposiciones del código que utiliza el contenedor original como Box<Dog>.

Las interfaces y delegados genéricos de .NET permiten declarar covarianza y contravarianza, mientras que los argumentos de tipos invariantes requieren exactamente el mismo tipo. Los tipos de retorno que actúan como productores pueden ser covariantes, y los tipos de parámetros que actúan como consumidores pueden ser contravariantes.

En los tipos de función, los parámetros suelen ser contravariantes y los valores de retorno covariantes.

Animal → Dog

Esta función puede recibir un Animal y devuelve un Dog. Si se sustituye por una función que acepta únicamente una entrada más específica, puede dejar de ser capaz de procesar un Animal general transmitido por el llamador. En cambio, devolver un Dog más específico resulta seguro para un llamador que espera un Animal.

Conversiones de tipos y casts

Una conversión de tipos es el proceso de tratar un valor de un tipo como valor de otro tipo o de modificar realmente su representación.

Las conversiones pueden dividirse en las siguientes clases.

  • Conversión implícita.
  • Conversión explícita.
  • Conversión numérica que modifica la representación.
  • Upcast de subtipos.
  • Downcast que requiere comprobación durante la ejecución.
  • Boxing y unboxing.
  • Reinterpretación de punteros.
  • Conversión definida por el usuario.

Una coerción es una conversión que el lenguaje realiza automáticamente en determinados contextos. Las coerciones de Rust también se producen únicamente en posiciones determinadas y entre relaciones limitadas de tipos.

Un upcast suele tratar un subtipo como un tipo superior sin pérdida de información.

Dog → Animal

Un downcast puede requerir comprobar durante la ejecución si el valor real del tipo superior pertenece a un subtipo determinado.

Animal → Dog

La especificación de Java también explica que una conversión de Thread a Object puede realizarse sin comprobaciones adicionales, mientras que una conversión de Object a Thread debe comprobar durante la ejecución que el objeto real pertenezca a dicho tipo.

En las conversiones numéricas pueden variar el rango y la precisión. La ampliación de un entero pequeño a un tipo entero mayor puede conservar el valor, pero convertir un número real en entero o un entero grande en uno pequeño puede perder información.

Debe distinguirse entre conversiones que modifican el significado del valor y reinterpretaciones que mantienen la misma representación de bits pero cambian su interpretación. Una reinterpretación arbitraria puede infringir la alineación, los patrones de bits válidos y las reglas de alias y provocar comportamiento indefinido.

Polimorfismo

El polimorfismo es la propiedad que permite que una función, tipo o interfaz se aplique a valores de varias clases. Cardelli y Wegner dividieron ampliamente el polimorfismo en universal y ad hoc, y subdividieron el polimorfismo universal en paramétrico y de inclusión.

Polimorfismo paramétrico

El polimorfismo paramétrico abstrae un tipo concreto mediante un parámetro de tipo y aplica la misma implementación a varios tipos.

identity<T>(value: T) -> T

identity puede aplicarse con la misma estructura a enteros, cadenas y objetos definidos por el usuario. La implementación utiliza únicamente operaciones permitidas para todos los tipos, en lugar de depender de la identidad concreta de T.

El polimorfismo paramétrico aumenta la generalidad y la reutilización porque la implementación no cambia arbitrariamente según el tipo.

Polimorfismo de inclusión

El polimorfismo de inclusión utiliza valores de subtipos mediante una interfaz de tipo superior.

draw(shape: Shape)

Si Circle y Rectangle son subtipos de Shape, pueden transmitirse a la misma función. La implementación real del método puede seleccionarse según el tipo de runtime mediante despacho dinámico.

Polimorfismo ad hoc

El polimorfismo ad hoc permite que un mismo nombre u operación posea implementaciones distintas según el tipo. La sobrecarga de funciones y operadores son ejemplos representativos.

add(Integer, Integer)
add(Float, Float)
add(Vector, Vector)

Se utiliza el mismo nombre add, pero la implementación real puede variar según los tipos.

Clases de tipos

Una clase de tipos declara las operaciones que debe proporcionar un tipo y permite definir por separado la implementación correspondiente a cada tipo, sistematizando así el polimorfismo ad hoc.

class Eq a where
    (==) :: a -> a -> Bool

Los tipos que proporcionan una instancia de Eq pueden utilizar la operación de igualdad. Wadler y Blott propusieron expresar mediante clases de tipos formas de polimorfismo ad hoc como la sobrecarga de operadores.

Los traits de Rust también definen interfaces abstractas que un tipo puede implementar e incluyen funciones, tipos asociados y constantes asociadas. Una función genérica puede utilizar un trait bound para limitar los comportamientos admitidos por los tipos aceptados.

Genéricos

Los genéricos son una función del lenguaje que permite definir parte de un tipo o función mediante parámetros de tipo para reutilizarla con varios tipos.

struct Pair<T> {
    first: T,
    second: T,
}

Los parámetros genéricos pueden tener restricciones.

fn maximum<T: Ord>(values: &[T]) -> &T

Esta función no admite sin condiciones todos los tipos, sino únicamente aquellos que proporcionan una operación de comparación de orden.

Las restricciones genéricas pueden adoptar las siguientes formas según el lenguaje.

  • Límite superior de clase o interfaz.
  • Trait bound.
  • Restricción de clase de tipos.
  • Conjunto de métodos.
  • Restricción de tiempo de vida.
  • Condición de existencia de constructor.
  • Condición de tipo por valor o por referencia.
  • Conjunto de tipos y condiciones sobre operadores.

Aunque los genéricos puedan parecer semejantes a nivel de código fuente, sus métodos de implementación en código ejecutable pueden diferir.

Borrado de tipos

El borrado de tipos transforma durante la compilación algunos o todos los argumentos genéricos a una representación común. Los genéricos de Java utilizan borrado para conservar la compatibilidad hacia atrás, y no todos los argumentos de tipo pueden consultarse directamente durante la ejecución. La especificación de Java define reglas de borrado para tipos genéricos y variables de tipo.

El borrado permite que varias instancias de tipos compartan el mismo código ejecutable, pero puede limitar operaciones que requieren información sobre parámetros de tipo durante la ejecución.

Monomorfización

La monomorfización genera código especializado para cada tipo concreto con el que se utiliza un fragmento genérico. Rust examina durante la compilación los lugares de uso de los genéricos y genera código adaptado a los tipos concretos.

La monomorfización favorece la optimización específica por tipos y el despacho estático, pero si se utilizan muchas combinaciones de tipos pueden aumentar el tamaño del código generado y el tiempo de compilación.

Conservación de tipos durante la ejecución

Algunos runtimes conservan los argumentos genéricos durante la ejecución. Los genéricos de .NET mantienen información de tipos en runtime y no utilizan el mismo método de borrado que Java.

Este enfoque puede proporcionar más información para reflexión y comprobaciones de runtime, pero requiere que el runtime y los metadatos lo admitan.

Una implementación real puede no pertenecer completamente a uno de estos tres métodos. Puede compartir código para tipos de referencia y especializarlo para tipos por valor, o generar código común y transmitir determinadas operaciones mediante funciones auxiliares específicas de cada tipo.

Nulabilidad y tipos opcionales

null se utiliza para indicar que un valor no existe o que una referencia no apunta a ningún objeto. Si null se incluye implícitamente en todos los tipos de referencia, resulta difícil determinar únicamente mediante tipos la seguridad del siguiente código.

user.name

Debe comprobarse por separado si user es realmente un objeto o null.

Los sistemas con seguridad frente a valores nulos suelen distinguir los tipos de la siguiente forma.

User
User?

User solo admite valores no nulos, mientras que User? admite User o null. Kotlin distingue la nulabilidad en el sistema de tipos y restringe el acceso directo a valores anulables.

Los nullable reference types de C# se implementan mediante anotaciones de compilación y análisis de flujo sobre los tipos de referencia existentes. string y string? no son clases diferentes durante la ejecución, sino información utilizada por el compilador para advertir de posibles referencias nulas.

Los lenguajes funcionales y Rust utilizan principalmente tipos suma para representar la presencia o ausencia de valores.

enum Option<T> {
    None,
    Some(T),
}

En esta estructura, la ausencia de valor constituye una variante explícita, por lo que deben tratarse ambos casos mediante coincidencia de patrones o funciones de combinación.

Incluso cuando la nulabilidad se representa mediante tipos, los errores nulos no desaparecen por completo debido a la interoperabilidad con código externo, las aserciones forzadas, la reflexión y los problemas de orden de inicialización. Deben distinguirse los límites a los que se aplican las garantías del sistema de tipos y los mecanismos que pueden eludirlas.

Tipos sensibles al flujo y refinamiento de tipos

El análisis de tipos sensible al flujo ajusta de manera más concreta la información de tipos de una misma variable según el flujo de control del programa.

function length(value: string | string[]) {
    if (typeof value === "string") {
        return value.length;
    }

    return value.length;
}

Dentro de la condición, value se refina a cadena según la comprobación typeof value === "string". En la otra rama puede refinarse a arreglo de cadenas.

TypeScript utiliza typeof, instanceof, comprobaciones de propiedades, predicados definidos por el usuario y el flujo de control para refinar tipos unión.

Un sistema semejante también recibe el nombre de occurrence typing. Typed Scheme introdujo un sistema de este tipo que refina el tipo de las variables según las condiciones y comprobaciones de tipos.

El análisis sensible al flujo también debe considerar si el valor de una variable puede cambiar después de la comprobación. Las variables mutables compartidas, los callbacks asíncronos y el estado modificable desde otros hilos pueden invalidar una comprobación anterior. Por ello, el compilador puede mantener el tipo refinado únicamente para variables locales inmutables o dentro de ámbitos que pueda analizar.

Tipado gradual

El tipado gradual es un enfoque que permite utilizar conjuntamente dentro de un mismo programa partes tipadas estáticamente y partes comprobadas dinámicamente.

En lugar de transformar de una sola vez toda una base de código dinámica en código estático, pueden añadirse tipos por módulos, funciones o expresiones. Las partes con información de tipos concreta se comprueban estáticamente, y al atravesar límites de tipos desconocidos pueden insertarse comprobaciones durante la ejecución.

El tipado gradual posee un significado más estricto que proporcionar comprobaciones estáticas y dinámicas en paralelo. Deben definirse sistemáticamente la relación entre tipos precisos y tipos desconocidos, las comprobaciones de runtime en los límites y la ubicación responsable de los errores de tipo. La investigación sobre tipado gradual se ha desarrollado con el objetivo de permitir que el programador ajuste el grado de comprobación estática aplicado a cada parte del programa.

TypeScript es un ejemplo práctico representativo de análisis estático añadido a código JavaScript, aunque no todas sus funciones pueden considerarse idénticas a un modelo formal específico de tipado gradual. Esto se debe a la coexistencia de mecanismos como any, que evita ampliamente las comprobaciones, el tipado estructural y las anotaciones borradas.

La ventaja del tipado gradual es que permite ampliar progresivamente la cobertura de tipos conservando el código existente y las bibliotecas externas. Sin embargo, pueden aparecer costes de comprobación y complejidad en el seguimiento de errores en los límites entre regiones estáticas y dinámicas, y se requieren reglas que impidan que valores sin tipos rompan las suposiciones de las regiones estáticas.

Tipos refinados

Un tipo refinado añade una condición lógica a un tipo existente para representar con mayor precisión el rango de valores.

PositiveInteger = {
    value: Integer | value > 0
}

El tipo entero ordinario admite todos los enteros, mientras que PositiveInteger solo admite enteros mayores que cero.

También pueden incluirse condiciones en los tipos de funciones.

divide:
    x: Integer
    y: Integer where y != 0
    → Integer

Este tipo expresa la precondición de que el divisor no debe ser cero.

Un comprobador de tipos refinados puede transmitir las condiciones lógicas generadas por el programa a herramientas automáticas de demostración, como un SMT solver. Los sistemas de Liquid Types utilizan un conjunto limitado de refinamientos que añade predicados lógicos a los tipos y permite automatizar la comprobación. Los tipos refinados verifican propiedades del programa con mayor precisión que los tipos ordinarios.

Entre las propiedades que pueden expresarse mediante tipos refinados se encuentran las siguientes.

  • Que un índice se encuentre dentro de los límites de un arreglo.
  • Que un entero pertenezca a un intervalo determinado.
  • Que la longitud del arreglo resultante sea igual a la longitud de entrada.
  • Que el denominador no sea cero.
  • Que una colección esté ordenada.
  • Permisos de acceso y niveles de seguridad.
  • Ausencia de desbordamiento aritmético.

Cuanto más complejas son las condiciones lógicas, mayores pueden ser el coste de la comprobación y la dificultad de explicar los errores. Pueden limitarse las clases de lógica que el demostrador automático debe resolver o permitirse que el usuario proporcione pruebas auxiliares cuando sea necesario.

Tipos dependientes

Un tipo dependiente es un sistema en el que un tipo puede depender de un valor. Mientras que un tipo genérico ordinario recibe otros tipos como parámetros, un tipo dependiente también puede utilizar valores como números naturales, valores lógicos y cadenas para determinar un tipo.

Un ejemplo representativo es un vector cuya longitud forma parte del tipo.

Vector<Element, 3>
Vector<Element, 4>

Un vector de longitud 3 y otro de longitud 4 son tipos diferentes. Una función que concatena vectores puede expresar mediante tipos la relación entre sus longitudes.

append:
    Vector<T, n>
    → Vector<T, m>
    → Vector<T, n + m>

La documentación de Agda describe los tipos dependientes como familias de tipos indexadas por objetos de otros tipos y utiliza Vec n como ejemplo de tipo vectorial indexado por un número natural n.

En un tipo de función dependiente, el tipo de retorno puede variar según el valor de entrada.

isSingleton:
    Bool → Type

A nivel de tipos puede calcularse que una entrada true produzca un tipo entero y una entrada false un tipo de lista de enteros. Idris trata los tipos como valores de primera clase que pueden transmitirse y devolverse desde funciones o utilizarse en cálculos.

Los tipos dependientes pueden incorporar propiedades como las siguientes.

  • Tamaño de arreglos y matrices.
  • Estado actual de un protocolo.
  • Prueba de que un árbol sintáctico satisface determinadas reglas de tipos.
  • Longitud y estructura del resultado de una operación.
  • Prueba de que un programa satisface un teorema determinado.
  • Evidencia de que un acceso mediante índice es válido.

Según la correspondencia de Curry–Howard, un tipo puede interpretarse como una proposición y un valor de dicho tipo como una prueba de la proposición. La implementación de una función se convierte en un término que demuestra la proposición expresada por su tipo. Esta estructura permite escribir programas y demostraciones dentro del mismo lenguaje.

Los tipos dependientes proporcionan contratos muy precisos, pero requieren cálculos y normalización de valores durante la comprobación de tipos. Cuando la inferencia automática resulta difícil, deben escribirse explícitamente tipos y términos de prueba, y un cambio de implementación puede requerir modificar las demostraciones.

Tipos lineales y tipos afines

En un sistema de tipos ordinario, un valor puede utilizarse varias veces o no utilizarse en absoluto. Un tipo lineal obliga a utilizar un valor exactamente una vez. Un tipo afín permite utilizarlo como máximo una vez y admite descartarlo sin usarlo.

LinearResource

Si este recurso no puede copiarse ni consumirse dos veces, pueden administrarse mediante tipos recursos que deben tener un único propietario, como handles de archivos, búferes de memoria, canales de comunicación y búferes de comandos de GPU.

Los tipos lineales controlan el uso duplicado y la omisión de recursos limitando el número de usos de un valor. Los tipos afines obligan a que determinados valores no se utilicen más de una vez.

Entre sus aplicaciones se encuentran las siguientes.

  • Liberar memoria exactamente una vez.
  • Cerrar un archivo después de utilizarlo.
  • No volver a utilizar un bloqueo después de liberarlo.
  • Enviar un mensaje una sola vez.
  • Administrar recursos de hardware que no pueden copiarse.
  • Limitar la copia de estados cuánticos.
  • Restringir alias en programas concurrentes.

Rust no es idéntico a un lenguaje lineal tradicional, pero combina propiedad, movimiento, préstamo y tiempos de vida con la comprobación de tipos. Las reglas de propiedad permiten que el compilador examine las relaciones de administración de memoria y constituyen la base para proporcionar seguridad de memoria sin recolector de basura.

Las reglas de referencias de Rust permiten en un momento dado una referencia mutable o varias referencias inmutables e impiden que una referencia sobreviva al valor al que apunta.

La propiedad y la linealidad muestran que un sistema de tipos puede rastrear no solo la forma de los valores, sino también sus permisos de uso y tiempos de vida.

Tipos de efecto

Un tipo de función ordinario solo representa las entradas y las salidas.

String → Integer

Sin embargo, dos funciones con los mismos tipos de entrada y salida pueden realizar operaciones muy diferentes.

  • Analizar una cadena de forma pura.
  • Leer un archivo.
  • Enviar una solicitud de red.
  • Modificar un estado global.
  • Lanzar una excepción.
  • Iniciar una tarea asíncrona.

Un sistema de efectos rastrea junto con los tipos los efectos secundarios que puede realizar una función.

readConfig:
    Path → Config
    effects { FileRead, IOError }

Un sistema de efectos puede representar los siguientes elementos.

  • Entrada y salida.
  • Lectura y modificación de estado.
  • Excepciones.
  • Ejecución asíncrona.
  • Continuaciones y efectos de control.
  • Acceso a regiones de memoria.
  • Creación de tareas concurrentes.
  • Permisos de seguridad.
  • Pureza de una función.

Lucassen y Gifford propusieron un sistema de efectos que rastreaba polimórficamente los efectos que podía realizar una expresión para analizar restricciones de ejecución paralela.

Un tipo de efecto incluye dentro de la interfaz no solo el tipo del resultado, sino también las propiedades del proceso de ejecución. Esto permite impedir que una función que modifica estado se transmita a una posición que exige una función pura o exigir que el llamador trate determinadas excepciones.

Las excepciones comprobadas de Java pueden considerarse una forma limitada de seguimiento de efectos, aunque un sistema general de efectos puede combinar e inferir no solo excepciones, sino varias clases de efectos secundarios.

Tipos de sesión

Un tipo de sesión representa mediante tipos el orden, la dirección, las bifurcaciones y las reglas de finalización de los mensajes intercambiados a través de un canal de comunicación.

Client:
    Send<Request>
    Receive<Response>
    Close

Este tipo representa un protocolo en el que el cliente debe enviar una solicitud, recibir una respuesta y cerrar después el canal.

El tipo del servidor representa la comunicación en la dirección opuesta.

Server:
    Receive<Request>
    Send<Response>
    Close

Los tipos de sesión pueden utilizarse para impedir errores como los siguientes.

  • Envío de mensajes en un orden incorrecto.
  • Recepción de un tipo inesperado de mensaje.
  • Incompatibilidad entre bifurcaciones de comunicación.
  • Canales cerrados únicamente por una de las partes.
  • Omisión de determinadas etapas del protocolo.
  • Incompatibilidad de roles entre participantes.

Los tipos de sesión multipartitos definen protocolos completos entre más de dos roles y pueden proyectarlos en tipos locales para cada participante. La investigación sobre tipos de sesión se ha desarrollado como una base tipada para estructurar programas centrados en la comunicación.

Los tipos de sesión suelen combinarse con tipos lineales. Si el estado de un canal pudiera utilizarse arbitrariamente desde varias ubicaciones, el progreso del protocolo podría dividirse. Por ello, se restringen la propiedad y el número de usos del canal para que la siguiente etapa sea única.

Representación de tipos durante la ejecución

La conservación de los tipos únicamente en el código fuente y durante la compilación o también durante la ejecución depende del lenguaje y de la implementación.

Los tipos reificados conservan información de tipos durante la ejecución y pueden utilizarla para reflexión, comprobaciones de tipos y serialización. Los objetos de clase, los descriptores de tipos en runtime y las tablas de métodos virtuales pueden contener dicha información.

Los tipos borrados pierden parte de su información después de la comprobación. Pueden borrarse variables de tipo genéricas, alias de tipos e información de nulabilidad utilizada únicamente de forma estática.

El borrado de tipos no significa que los tipos no tengan ningún efecto sobre el resultado de la ejecución. La información de tipos ya puede haber determinado los siguientes elementos.

  • Función y sobrecarga que deben invocarse.
  • Disposición de memoria.
  • Código máquina que debe generarse.
  • Comprobaciones de runtime que deben insertarse.
  • Especialización de genéricos.
  • Métodos de destrucción y copia.
  • ABI y convención de llamada.
  • Posibilidades de optimización.

También existe información de tipos cuya representación de runtime es la misma pero que se utiliza únicamente para el análisis de flujo del compilador, como los nullable reference types de C#.

Tipado fuerte y tipado débil

«Tipado fuerte» y «tipado débil» son expresiones ampliamente utilizadas, pero carecen de un único criterio formal consensuado. Según el contexto pueden hacer referencia a propiedades diferentes como las siguientes.

  • Alcance de las conversiones implícitas.
  • Posibilidad de mezclar tipos diferentes.
  • Posibilidad de reinterpretar memoria.
  • Existencia de comprobaciones de tipos durante la ejecución.
  • Funciones que permiten eludir las reglas de tipos.
  • Conversiones automáticas entre cadenas y números.
  • Operaciones con punteros y comportamiento indefinido.
  • Momento en el que se produce un error de tipo.

Por ejemplo, resulta difícil comparar mediante una única escala de fuerza un lenguaje estático que proporciona muchas conversiones numéricas implícitas y un lenguaje dinámico con pocas conversiones automáticas pero comprobaciones de tipos durante la ejecución.

Cuando se requiere una explicación precisa, resulta preferible describir directamente propiedades concretas como las siguientes.

  • Utiliza comprobación estática de tipos.
  • Permite conversiones implícitas entre cadenas y números.
  • Un downcast requiere una comprobación durante la ejecución.
  • Permite reinterpretar un puntero como un tipo arbitrario.
  • Los argumentos genéricos se conservan durante la ejecución.
  • Un error de tipo provoca una excepción.
  • El código seguro no permite reinterpretar la memoria.

La especificación de Java describe Java como un lenguaje estático y fuertemente tipado, pero lo hace en el contexto de las restricciones de tipos y operaciones definidas por Java. No define una escala universal de fuerza que pueda aplicarse a todos los lenguajes.

Evasión del sistema de tipos y límites de confianza

Los lenguajes prácticos proporcionan mecanismos para eludir el sistema de tipos cuando deben interactuar con sistemas externos o expresar operaciones difíciles de representar mediante tipos.

Entre los ejemplos representativos se encuentran los siguientes.

  • Cast explícito.
  • Tipo dinámico.
  • Tipos que excluyen la comprobación, como any.
  • Punteros sin procesar.
  • Bloques unsafe.
  • Reflexión.
  • Interfaces de funciones externas.
  • Serialización y deserialización.
  • Plugins nativos.
  • Aserciones de tipos.
  • Funciones intrínsecas del compilador.

Estas funciones pueden resultar necesarias para programación de sistemas, optimización del rendimiento y conexión con API de bajo nivel. Sin embargo, las condiciones que el comprobador de tipos podía demostrar pasan a ser responsabilidad directa del programador o del código externo.

Para construir límites seguros, los mecanismos de evasión deben limitarse a pequeñas implementaciones internas y proporcionar al exterior interfaces que satisfagan las reglas de tipos. La encapsulación de una implementación unsafe dentro de una función segura en Rust constituye un ejemplo de esta estructura.

Los datos externos también se encuentran fuera del límite de confianza del sistema de tipos. Los documentos JSON, los paquetes de red, los archivos y las filas de una base de datos no satisfacen automáticamente los tipos esperados por el programa. Durante la deserialización deben comprobarse su estructura y los rangos de valores antes de transformarlos en tipos internos.

Decisiones de diseño de los sistemas de tipos

Un sistema de tipos no se diseña únicamente para maximizar sin condiciones la expresividad y la seguridad. Los diseñadores de lenguajes deben equilibrar los siguientes elementos.

  • Precisión de la comprobación de tipos.
  • Cantidad de anotaciones de tipos.
  • Posibilidad de inferencia.
  • Tiempo de compilación.
  • Comprensibilidad de los mensajes de error.
  • Compatibilidad con código existente.
  • Funciones dinámicas y reflexión.
  • Rendimiento de ejecución y tamaño del código.
  • Compilación separada y límites entre módulos.
  • Interoperabilidad con otros lenguajes.
  • Capacidad de análisis por herramientas e IDE.
  • Complejidad de la especificación y de la implementación.

Un sistema más preciso puede detectar más errores antes de la ejecución y documentar propiedades del programa. Sin embargo, el problema de comprobación puede volverse más complejo y aumentar la cantidad de anotaciones y código de demostración. A la inversa, un sistema sencillo facilita la comprensión del lenguaje y de su implementación, pero puede delegar más invariantes del programa en comprobaciones durante la ejecución y pruebas.

Los lenguajes modernos combinan varios niveles de comprobación en lugar de elegir un único método. Pueden proporcionar tipado estático básico e inferencia de tipos y añadir, cuando sea necesario, tipos suma y coincidencia de patrones, restricciones genéricas, nulabilidad, propiedad, tipos refinados o analizadores estáticos externos.

El objetivo de un sistema de tipos no es aceptar todos los programas posibles. También forma parte de su función rechazar aquellos que el lenguaje no puede demostrar como seguros. Incluso un programa realmente seguro puede ser rechazado si el comprobador carece de información suficiente para demostrarlo. En ese caso puede modificarse la estructura del programa, añadirse información de tipos y demostraciones, posponerse la comprobación hasta la ejecución o utilizarse un límite inseguro restringido.

Por lo tanto, un sistema de tipos no es simplemente una función que coloca etiquetas sobre los valores, sino un sistema formal que representa la estructura del programa, los límites de cálculo, la abstracción y la propiedad de recursos, los errores y los efectos secundarios y los protocolos de comunicación. Partiendo de funciones básicas como la comprobación estática y dinámica, la inferencia, el subtipado, el polimorfismo, los genéricos y los tipos de datos algebraicos, puede ampliarse hasta tipos dependientes, lineales, de efecto y de sesión, y determina qué programas puede expresar un lenguaje y qué errores puede impedir anticipadamente.

Diseño y estandarización de lenguajes

El diseño de un lenguaje de programación no se limita a crear palabras clave y reglas sintácticas nuevas. Consiste en determinar los problemas que pretende resolver el lenguaje y sus principales entornos de uso, y en organizar dentro de un sistema computacional coherente los valores y tipos, los cambios de estado, las llamadas a funciones, la administración de memoria, el tratamiento de errores, la concurrencia, los módulos, los métodos de ejecución y las reglas de interoperabilidad con sistemas externos. Para formar un ecosistema de lenguaje de programación que pueda mantenerse a largo plazo, también deben combinarse una especificación del lenguaje, implementaciones, una biblioteca estándar, herramientas de desarrollo, procedimientos de modificación y políticas de compatibilidad.

La estandarización de un lenguaje de programación es el proceso de definir su sintaxis y semántica en un documento común y establecer criterios para que distintas implementaciones procesen el mismo programa de la forma más coherente posible. Su objetivo es aumentar la portabilidad de programas y bibliotecas y formar un ecosistema que no dependa de una única implementación. SC 22, perteneciente a ISO/IEC JTC 1, es el subcomité internacional de normalización responsable de los lenguajes de programación, sus entornos de ejecución y las interfaces de software de sistemas, y considera la portabilidad uno de sus principales objetivos.

No todos los lenguajes de programación poseen un estándar internacional. C (lenguaje de programación) y C++ se estandarizan mediante grupos de trabajo de ISO/IEC, mientras que ECMAScript, la especificación fundamental de JavaScript, es desarrollado por TC39 de Ecma International. Python discute los cambios mediante PEP, Rust mediante RFC, Go (lenguaje de programación) mediante un proceso de propuestas, Kotlin mediante KEEP y Swift mediante Swift Evolution. De esta forma, la evolución de un lenguaje puede ser dirigida mediante métodos distintos por organismos internacionales de estandarización, consorcios industriales, empresas, proyectos de software abierto y comunidades independientes.

Objetivos de diseño y ámbito de aplicación

Al diseñar un lenguaje de programación nuevo, primero deben definirse el problema que pretende resolver y sus usuarios principales. Resulta difícil conseguir simultáneamente el mayor rendimiento de ejecución en todos los ámbitos, la sintaxis más sencilla, la máxima seguridad, la compilación más rápida, la mayor portabilidad y una compatibilidad hacia atrás completa. El diseño de lenguajes es un proceso de selección de prioridades entre objetivos que pueden entrar en conflicto.

Entre los principales objetivos de diseño pueden incluirse los siguientes.

  • Permitir un control detallado del hardware y los recursos del sistema.
  • Impedir hasta cierto punto errores de memoria y de tipos.
  • Permitir predecir el comportamiento y el coste de ejecución de los programas.
  • Expresar el código de forma concisa y clara.
  • Proporcionar una compilación y un inicio del programa rápidos.
  • Ofrecer alto rendimiento en programas de larga ejecución.
  • Facilitar la portabilidad a distintos sistemas operativos y procesadores.
  • Admitir bases de código grandes y la colaboración entre varios desarrolladores.
  • Reutilizar lenguajes, bibliotecas y API de sistemas operativos existentes.
  • Expresar la concurrencia y el paralelismo de forma segura.
  • Facilitar la implementación del compilador, el runtime y las herramientas de desarrollo.
  • Mantener durante mucho tiempo la compatibilidad del código fuente y las bibliotecas.
  • Proporcionar mensajes de error y una experiencia de depuración comprensibles.

Los lenguajes de programación de sistemas suelen conceder gran importancia a la disposición y el tiempo de vida de la memoria, las convenciones de llamada, las interfaces de funciones externas, la latencia y la previsibilidad de los costes de ejecución. Los lenguajes utilizados principalmente para automatización o análisis de datos pueden dar prioridad a la ejecución interactiva, la expresión concisa, las estructuras de datos dinámicas y el acceso a bibliotecas. Los lenguajes educativos valoran la claridad conceptual, una sintaxis coherente y mensajes de error comprensibles, mientras que los lenguajes específicos de dominio se centran en expresar de forma breve y precisa los conceptos de un campo concreto.

El entorno de ejecución previsto también influye directamente en el diseño. Si el lenguaje está destinado a sistemas operativos y sistemas embebidos, adquieren importancia los ejecutables independientes, un runtime reducido, una ABI clara y el funcionamiento con recursos limitados. Si se ejecuta en un navegador web, son importantes el sandbox, la carga gradual de código y la conexión con el entorno anfitrión. En un lenguaje destinado a servicios distribuidos, las tareas ligeras, el aislamiento de fallos, la comunicación de red, la observabilidad y las herramientas de despliegue pueden convertirse en elementos fundamentales.

Principios de diseño

Los diseñadores deben establecer principios fundamentales aplicables a todo el lenguaje, en lugar de añadir cada función de manera independiente. Si cada función utiliza reglas distintas, el lenguaje se vuelve más difícil de aprender e implementar, y la combinación de varias funciones puede producir comportamientos difíciles de predecir.

Entre los principios representativos se encuentran los siguientes.

  • La coherencia hace que conceptos semejantes posean una sintaxis y una semántica semejantes.
  • La ortogonalidad permite combinar un número reducido de funciones independientes de diversas maneras.
  • La explicitud hace visibles en el código fuente los cambios de estado importantes, las posibilidades de error y los costes.
  • La abstracción oculta detalles de implementación mientras conserva el significado y las características de rendimiento necesarias.
  • La capacidad de razonamiento local reduce la necesidad de examinar todo el programa para comprender una parte del código.
  • La extensión conservadora evita que las funciones nuevas modifiquen innecesariamente el significado de programas existentes.
  • La implementabilidad permite realizar las funciones definidas por el lenguaje mediante compiladores y runtimes realistas.
  • La compatibilidad con herramientas permite que parsers, formateadores, depuradores, analizadores estáticos e IDE examinen el código de manera estable.
  • La interoperabilidad permite conectarse con el sistema operativo, bibliotecas externas, otros lenguajes y formatos de datos existentes.
  • La simplicidad mantiene al mínimo posible los conceptos del lenguaje y sus reglas excepcionales.

Una alta ortogonalidad no significa que todas las funciones deban combinarse sin ninguna restricción. Aunque dos funciones sean sencillas por separado, su combinación puede crear una semántica ambigua o una implementación excesivamente compleja. Los genéricos, la sobrecarga, la inferencia de tipos, las conversiones implícitas y el subtipado son útiles individualmente, pero al ofrecerse conjuntamente aumentan considerablemente la complejidad de la comprobación de tipos y del diagnóstico de errores. Por ello, el diseño debe examinar no solo la cantidad de funciones, sino también sus interacciones.

La simplicidad tampoco significa simplemente utilizar pocas palabras clave. Aunque la gramática sea breve, un sistema semántico puede ser complejo si contiene muchas operaciones implícitas ocultas y numerosas reglas excepcionales. A la inversa, aunque existan más elementos sintácticos, el comportamiento de los programas puede ser más fácil de predecir si las funciones y las reglas de combinación de cada elemento están claramente definidas.

Modelo computacional fundamental

Un lenguaje debe determinar mediante qué modelo computacional representará los programas. Los lenguajes imperativos se centran en los cambios de estado del almacenamiento y el orden de ejecución de las instrucciones, mientras que los lenguajes funcionales se centran en la evaluación de funciones y la transformación de valores. Los lenguajes orientados a objetos se organizan alrededor de objetos, mensajes o llamadas a métodos, y los lenguajes lógicos alrededor de hechos, reglas y consultas.

Los lenguajes modernos suelen admitir varios paradigmas, pero no todos ellos poseen el mismo peso dentro de cada lenguaje. Aunque las funciones sean valores de primera clase, si la estructura básica de los programas y la biblioteca estándar se diseñan principalmente alrededor de objetos mutables y flujos de control imperativos, el lenguaje puede utilizarse sobre todo de forma imperativa y orientada a objetos.

Al definir el modelo computacional deben diseñarse conjuntamente los siguientes elementos.

  • Qué constituyen los valores y los objetos.
  • Si los valores poseen identidad y mutabilidad.
  • Cómo se llaman las funciones y los métodos.
  • En qué orden se evalúan las expresiones.
  • Dónde se almacena el estado del programa y cómo se modifica.
  • Cómo se expresan los efectos secundarios y la entrada y salida.
  • Cómo se transmiten los errores y los fallos.
  • Cómo comparten o intercambian estado las tareas concurrentes.
  • En qué unidades se divide y combina el programa.

Es importante organizar semánticamente el modelo computacional antes de diseñar la sintaxis. Un mismo modelo puede expresarse mediante gramáticas distintas, pero si se decide primero la sintaxis mientras el significado permanece indefinido, pueden acumularse reglas excepcionales y operaciones implícitas al añadir nuevas funciones.

Valores y modelo de datos

El diseño de un lenguaje debe definir qué constituye un valor dentro de un programa. Además de valores básicos como enteros, números reales, booleanos, caracteres y cadenas, el modelo de datos puede incluir arreglos, tuplas, registros, funciones, objetos, punteros, referencias, tipos suma y módulos.

El modelo de datos debe responder preguntas como las siguientes.

  • Si los valores se copian directamente o se comparten mediante referencias.
  • Si los valores poseen una identidad única.
  • Si pueden modificarse después de su creación.
  • Cómo se distinguen identidad e igualdad.
  • Qué representación poseen en memoria.
  • Quién administra su tiempo de vida.
  • Si se permiten referencias cíclicas.
  • Si las funciones y los tipos también pueden tratarse como valores.
  • Si la información de tipos se conserva durante la ejecución.

La diferencia entre tipos por valor y tipos por referencia no se define simplemente como una diferencia entre pila y montón. Lo esencial es si la asignación y el paso de argumentos copian un valor o comparten una referencia, y si una modificación puede observarse a través de otras referencias. Una implementación optimizada puede colocar tipos por valor en el montón u objetos de tipos por referencia en la pila, por lo que debe distinguirse entre la semántica del lenguaje y la disposición concreta de memoria.

Estado y mutabilidad

La posibilidad de modificar el estado del programa y el ámbito en el que se permite hacerlo son también problemas fundamentales de diseño. Las variables y los objetos mutables facilitan la representación directa del estado de sistemas reales, pero si varias partes comparten el mismo estado puede resultar más difícil razonar sobre el comportamiento del programa.

Un lenguaje puede controlar la mutabilidad de las siguientes maneras.

  • Hacer que todas las variables sean mutables de forma predeterminada.
  • Distinguir mediante declaraciones diferentes las variables inmutables y mutables.
  • Separar la mutabilidad del propio objeto de la mutabilidad de la referencia.
  • Representar mediante tipos si una función modifica estado externo.
  • Permitir que un único propietario modifique un valor.
  • Permitir únicamente acceso inmutable a valores compartidos.
  • Limitar los cambios de estado al interior de módulos u objetos concretos.

La inmutabilidad no significa eliminar completamente el estado del programa. Consiste en crear valores nuevos o transmitir el estado como argumentos y resultados explícitos para controlar el lugar y el alcance de los cambios. A la inversa, incluso en un lenguaje mutable pueden utilizarse la encapsulación, la propiedad y las restricciones de acceso para localizar los cambios de estado.

Diseño del sistema de tipos

El sistema de tipos define las relaciones entre los valores y las operaciones permitidas por el lenguaje. Los diseñadores deben decidir cómo combinar la comprobación estática y dinámica, la inferencia de tipos, el subtipado, los genéricos, la sobrecarga, las conversiones y la información de tipos durante la ejecución.

Entre las decisiones del diseño del sistema de tipos se encuentran las siguientes.

  • Comprobar los tipos antes o durante la ejecución.
  • Determinar en qué medida debe escribir tipos el programador.
  • Utilizar los nombres o las estructuras como criterio de compatibilidad.
  • Determinar el alcance permitido de las conversiones implícitas.
  • Expresar e implementar el código genérico.
  • Incluir en los tipos la nulabilidad y los estados de error.
  • Permitir que el comprobador rastree propiedad y tiempos de vida.
  • Representar mediante tipos los efectos secundarios y las excepciones.
  • Determinar los límites en los que se permiten operaciones inseguras.

Aumentar la expresividad permite comprobar más errores e invariantes durante la compilación, pero puede volver más complejos tanto el análisis como los mensajes de error. Un sistema sencillo facilita comprender el lenguaje y su implementación, pero debe delegar más condiciones en comprobaciones durante la ejecución y en pruebas.

La inferencia de tipos tampoco debe diseñarse únicamente para omitir todas las anotaciones. Puede inferirse el tipo evidente de variables locales y exigir tipos explícitos en funciones públicas y límites de módulos, reduciendo la verbosidad sin perder claridad en los contratos de las API.

Flujo de control y reglas de evaluación

El flujo de control determina qué partes del programa se ejecutan y en qué orden. El lenguaje debe definir reglas para la ejecución secuencial, las bifurcaciones, los bucles, las llamadas a funciones, la recursión, las excepciones, la coincidencia de patrones, las corrutinas y la ejecución asíncrona.

Si el orden de evaluación no está claro, expresiones con efectos secundarios pueden producir resultados distintos entre implementaciones. Por ello, la especificación debe determinar cuestiones como las siguientes.

  • En qué orden se evalúan los argumentos de una función.
  • Si los operadores lógicos realizan evaluación de cortocircuito.
  • Cuáles son la precedencia y asociatividad de la asignación y los operadores.
  • Cuándo se crean y destruyen valores temporales y variables locales.
  • Qué tareas de limpieza se realizan al producirse una excepción.
  • En qué momento se suspende y reanuda una función asíncrona.
  • En qué orden se observan las operaciones de memoria entre varios hilos.

Si un lenguaje deja sin especificar parte del orden de evaluación, una implementación puede seleccionar distintos órdenes para optimizar. Sin embargo, si el programador depende de un orden no especificado, los resultados pueden variar según la implementación y el nivel de optimización. Cuando se permite esta libertad, la especificación debe distinguir claramente entre lo garantizado y lo no garantizado.

Funciones y modelo de llamada

Las funciones son unidades básicas de separación y reutilización del cálculo en la mayoría de los lenguajes. Su diseño incluye no solo la sintaxis de declaración, sino también el paso de parámetros, los retornos, las capturas, la sobrecarga, el polimorfismo y las reglas de llamada.

El lenguaje debe decidir los siguientes aspectos.

  • Si los argumentos se transmiten por valor o por referencia.
  • Si se admite el paso por nombre o la evaluación perezosa.
  • Si se permiten argumentos predeterminados y variables.
  • Si las funciones pueden tratarse como valores de primera clase.
  • Cómo capturan las clausuras las variables externas.
  • Si se permite la sobrecarga de funciones y operadores.
  • Si las llamadas a métodos utilizan despacho dinámico.
  • Si se garantiza la optimización de llamadas de cola.
  • Cómo se representan los errores y efectos que una función puede devolver.
  • Qué ABI se utiliza al llamar a código externo.

La copia por valor o captura por referencia de variables externas por parte de una clausura y la forma de extender sus tiempos de vida están estrechamente relacionadas con el modelo de memoria. El modelo de llamada también debe diseñarse junto con el sistema de tipos, el modelo de objetos y la implementación del runtime.

Administración de memoria y recursos

El diseñador debe determinar cómo se administran los tiempos de vida de objetos, memoria, archivos, conexiones de red, bloqueos y recursos de GPU.

Entre los métodos representativos se encuentran los siguientes.

  • El programador asigna y libera directamente.
  • Se cuentan las referencias de un objeto y se libera automáticamente.
  • Se utiliza un recolector de basura que rastrea objetos alcanzables.
  • Los objetos se administran por regiones o arenas.
  • La propiedad y los tiempos de vida se comprueban estáticamente.
  • Se ejecutan automáticamente destructores o funciones de limpieza al finalizar un ámbito.
  • Se combinan varios métodos.

La administración automática de memoria no implica que los recursos externos también se liberen automáticamente en el momento adecuado. Incluso en lenguajes con recolección de basura, archivos, sockets y bloqueos que deben devolverse inmediatamente requieren estructuras de limpieza separadas.

El método de administración afecta al rendimiento, la latencia, la seguridad, la ABI, la interoperabilidad con otros lenguajes y la estructura de los programas. La administración manual permite controlar detalladamente los costes, pero obliga al programador a evitar usos posteriores a la liberación, dobles liberaciones y fugas. La recolección automatiza los tiempos de vida, pero puede introducir costes de recogida, pausas y mayor complejidad del runtime. Los sistemas basados en propiedad pueden impedir estáticamente muchos errores, pero requieren restricciones adicionales sobre referencias y tiempos de vida.

Tratamiento de errores

El lenguaje debe definir cómo se representan y transmiten los fallos. Puede devolver códigos de error, lanzar excepciones o representar éxito y fracaso mediante tipos suma o tipos de resultado.

Entre los aspectos que deben considerarse se encuentran los siguientes.

  • Distinguir errores recuperables de defectos del programa.
  • Expresar los errores en el tipo de una función.
  • Obligar al llamador a tratarlos.
  • Determinar cómo atraviesan las excepciones los límites de funciones.
  • Limpiar recursos durante el desenrollado de pila.
  • Conservar varios errores simultáneamente.
  • Determinar dónde se transmiten errores de tareas asíncronas y concurrentes.
  • Transformar errores en límites con otros lenguajes.

Las excepciones pueden simplificar la propagación, pero dificultar saber únicamente mediante la declaración de la función qué excepciones pueden producirse. Los tipos de resultado hacen explícita la posibilidad de error, pero pueden aumentar el código repetitivo de transmisión. El lenguaje puede combinar sintaxis, tipos, coincidencia de patrones y operadores de propagación para reducir estos costes.

Concurrencia y modelo de memoria

Los lenguajes modernos deben considerar entornos en los que varias tareas se ejecutan simultáneamente. No basta con proporcionar una API para crear hilos; también se necesita un modelo de memoria que defina en qué orden observan las operaciones de memoria varias unidades de ejecución.

El diseño de la concurrencia incluye decisiones como las siguientes.

  • Utilizar memoria compartida e hilos.
  • Centrarse en el paso de mensajes y los canales.
  • Proporcionar actores y estados aislados.
  • Ofrecer corrutinas y funciones asíncronas.
  • Definir operaciones atómicas y órdenes de memoria.
  • Tratar las condiciones de carrera como errores o comportamiento indefinido.
  • Organizar la cancelación y la propagación de fallos entre tareas.
  • Incluir concurrencia estructurada.
  • Expresar bucles paralelos y paralelismo de datos.

Los compiladores y las CPU pueden reordenar operaciones de memoria para mejorar el rendimiento. El modelo debe definir qué reorganizaciones están permitidas y qué relaciones de orden forman las operaciones de sincronización. Si no es claro, un mismo programa puede comportarse de forma distinta según el procesador y el compilador.

Módulos y espacios de nombres

Un sistema de módulos divide el programa en varias unidades y separa la interfaz pública de cada unidad de su implementación interna.

El diseño debe determinar los siguientes elementos.

  • Si un archivo y un módulo son la misma unidad.
  • Si varios archivos pueden formar un solo módulo.
  • Si los espacios de nombres pueden anidarse.
  • Cómo se distinguen declaraciones públicas y privadas.
  • Cómo se resuelven conflictos de nombres al importar módulos.
  • Si se permiten dependencias cíclicas.
  • Si los módulos pueden compilarse por separado.
  • En qué formato se almacena la información de tipos y funciones públicas.
  • Cómo se distinguen paquetes, módulos y bibliotecas.
  • Si pueden utilizarse simultáneamente versiones distintas de un paquete.

Un buen sistema oculta implementaciones, hace explícitas las dependencias y admite compilación incremental. Si una interfaz pública expone demasiados detalles internos, pequeños cambios pueden provocar recompilaciones o problemas de compatibilidad en módulos dependientes.

Interoperabilidad e interfaces externas

Para utilizarse en entornos reales, un lenguaje nuevo debe conectarse con sistemas operativos, bibliotecas, formatos de datos y otros lenguajes existentes. La interoperabilidad es especialmente importante durante la formación inicial del ecosistema.

Su diseño incluye los siguientes elementos.

  • Posibilidad de llamar a la ABI de C y funciones nativas.
  • Formato de memoria para transmitir estructuras, arreglos y cadenas.
  • Responsabilidad sobre la propiedad y liberación de objetos.
  • Posibilidad de transmitir callbacks a otros lenguajes.
  • Posibilidad de propagar excepciones fuera del límite del lenguaje.
  • Posibilidad de fijar objetos administrados para impedir que el runtime los mueva.
  • Representación de genéricos, clases e interfaces de otros lenguajes.
  • Uso conjunto de binarios y runtimes de versiones diferentes.

Una interfaz externa puede ser un límite en el que se interrumpen las garantías de seguridad. Si una función externa devuelve un puntero o longitud incorrectos, pueden producirse errores incluso dentro de un lenguaje seguro. Por ello, se necesita una capa que valide los datos externos y los transforme en tipos internos seguros.

Diseño sintáctico

La sintaxis es el método mediante el que se representan los conceptos del lenguaje con caracteres y tokens. Su diseño debe considerar no solo la longitud del código, sino también la legibilidad, la ambigüedad, la recuperación de errores y la capacidad de procesamiento mediante herramientas.

Entre las decisiones sintácticas se encuentran las siguientes.

  • Forma de los identificadores y palabras clave.
  • Separación de sentencias y bloques.
  • Significado de espacios y sangría.
  • Precedencia y asociatividad de operadores.
  • Forma de declaraciones de funciones, tipos y módulos.
  • Sintaxis de genéricos y anotaciones de tipos.
  • Formato de comentarios y documentación.
  • Notación de literales de cadenas y números.
  • Sintaxis de macros y metaprogramación.
  • Posibilidad de recuperación tras errores gramaticales.

La gramática debe evitar estructuras ambiguas que admitan varias interpretaciones. Aunque un parser pueda resolverlas, una expresión que una persona pueda malinterpretar reduce la mantenibilidad. Además, añadir palabras clave nuevas puede romper programas que las utilizaban como identificadores, por lo que la política de palabras reservadas está vinculada con la evolución a largo plazo.

El diseño sintáctico también afecta a formateadores automáticos, IDE y generadores de código. Si el significado de un token varía excesivamente según el contexto o la gramática depende de información de otros archivos, se dificulta el análisis independiente y el soporte de editores.

Separación entre gramática y semántica

La especificación distingue entre programas sintácticamente válidos y semánticamente válidos. La gramática define cómo pueden combinarse los tokens, mientras que la declaración de nombres, la compatibilidad de tipos, los permisos de acceso y los resultados de evaluación se determinan mediante reglas semánticas separadas.

Por ejemplo, la siguiente expresión puede ser sintácticamente válida.

left + right

Sin embargo, el análisis semántico y el sistema de tipos determinan si puede aplicarse la suma a left y right, qué implementación debe utilizarse y cuál es el tipo resultante.

Una especificación suele distinguir las siguientes capas.

  • Gramática léxica.
  • Gramática sintáctica.
  • Reglas semánticas estáticas.
  • Semántica dinámica de ejecución.
  • Interfaces de biblioteca.
  • Ámbitos de elección de la implementación.
  • Comportamientos de los que el programa no debe depender.

Si la gramática es detallada pero la semántica ambigua, distintas implementaciones pueden procesar de manera diferente el mismo código. A la inversa, si la semántica se define de forma excesivamente vinculada a un procedimiento concreto de implementación, se dificulta utilizar nuevas optimizaciones o estrategias. La especificación debe definir claramente los resultados y comportamientos observables sin imponer restricciones innecesarias sobre la estructura interna.

Especificación del lenguaje

Una especificación del lenguaje es un documento normativo que define qué programas fuente son válidos y qué significado poseen los programas válidos. Se diferencia de un documento que simplemente describe el comportamiento actual de un compilador concreto. Las implementaciones deben seguir la especificación, y una diferencia se trata como un defecto de la implementación o una ambigüedad de la especificación.

Una especificación habitual incluye los siguientes contenidos.

  • Conjunto de caracteres y codificación del código fuente.
  • Tokens y gramática léxica.
  • Gramática sintáctica.
  • Nombres y ámbitos.
  • Tipos y conversiones.
  • Expresiones y sentencias.
  • Declaraciones y módulos.
  • Orden de ejecución y reglas de evaluación.
  • Modelo de objetos y memoria.
  • Concurrencia y operaciones atómicas.
  • Errores y excepciones.
  • Biblioteca estándar.
  • Condiciones de conformidad de la implementación.
  • Comportamiento definido por la implementación, no especificado e indefinido.
  • Relación con otros estándares.

El estándar de C (lenguaje de programación) establece la forma y la interpretación de los programas expresados en C y proporciona una base común para programas portables e implementaciones conformes. WG14 es responsable de la estandarización internacional de C, mientras que WG21 lo es de C++.

Contenido normativo e informativo

Los estándares pueden distinguir entre contenido normativo, que una implementación debe cumplir, y contenido informativo destinado a facilitar la comprensión.

El contenido normativo puede incluir expresiones como las siguientes.

  • La implementación debe realizar una operación determinada.
  • El programa debe satisfacer una condición concreta.
  • Si se incumple una condición, debe proporcionarse un diagnóstico.
  • El resultado debe pertenecer a un intervalo definido.

El contenido informativo ofrece ejemplos, fundamentos de diseño, recomendaciones y referencias, pero puede no determinar directamente la conformidad.

Si esta distinción es ambigua, puede discutirse si ejemplos y explicaciones deben interpretarse como reglas. Por ello, es deseable que una especificación oficial separe claramente los requisitos de las explicaciones de referencia.

Programas e implementaciones conformes

Un estándar puede definir la conformidad no solo de los programas, sino también de las implementaciones. Una implementación conforme debe admitir la sintaxis, semántica y biblioteca requeridas y proporcionar los diagnósticos necesarios.

Puede permitirse que una implementación proporcione extensiones no incluidas en el estándar. Sin embargo, no debe modificar el significado de programas conformes que no utilicen dichas extensiones ni rechazar programas estándar. Algunas implementaciones ofrecen por separado un modo de extensiones y un modo estricto.

La conformidad no significa que todas las implementaciones deban generar el mismo lenguaje máquina. Pueden elegir libremente optimizaciones, disposiciones de memoria y estructuras internas, pero los resultados observables deben permanecer dentro del intervalo permitido por la especificación.

Comportamiento definido por la implementación, no especificado e indefinido

Una especificación no tiene que fijar todos los detalles de comportamiento. Para admitir diferencias de hardware y sistemas operativos o permitir libertad de optimización, puede dividir ciertos comportamientos en varias categorías.

Comportamiento definido por la implementación

El comportamiento definido por la implementación permite que una implementación elija entre varias posibilidades, pero exige documentar cuál ha seleccionado.

Pueden incluirse aspectos como los siguientes.

  • Tamaño de los tipos enteros básicos.
  • Signo predeterminado de los caracteres.
  • Tratamiento de archivos y codificaciones concretas.
  • Tipos de extensión ofrecidos por la implementación.
  • Convenciones de llamada específicas de la plataforma.

Un programa puede depender de dicho comportamiento después de consultar la documentación, pero el resultado puede cambiar al portarlo a otra implementación.

Comportamiento no especificado

El comportamiento no especificado permite varios resultados sin exigir que la implementación documente cuál seleccionará. Incluso en la misma implementación, la elección puede variar según las optimizaciones o las condiciones de ejecución.

El programa solo debe asumir que se producirá uno de los resultados permitidos y no debe depender de uno concreto.

Comportamiento indefinido

El comportamiento indefinido es aquel para el que la especificación no establece ningún resultado. La implementación no garantiza ningún comportamiento cuando se produce.

En lenguajes cercanos al hardware se utiliza en ocasiones para permitir alto rendimiento y diversas implementaciones. Sin embargo, si el programador lo provoca en una ruta normal de ejecución, puede producir vulnerabilidades y errores impredecibles.

Debe distinguirse el comportamiento indefinido de un defecto de implementación. Si un programa cumple la especificación y la implementación produce un resultado incorrecto, existe un defecto. Si el programa realiza una operación prohibida por la especificación, la ausencia de un resultado concreto no constituye necesariamente un defecto.

Relación entre especificación e implementaciones

Un lenguaje puede disponer de varias implementaciones, cada una con estructuras de compilador, métodos de optimización, máquinas virtuales, recolectores de basura, bibliotecas estándar y plataformas de destino diferentes.

La existencia de varias implementaciones ofrece ventajas como las siguientes.

  • Reduce la dependencia de una empresa o proyecto concreto.
  • Permite utilizar estrategias adaptadas a distintos entornos.
  • Ayuda a descubrir ambigüedades mediante comparaciones.
  • Permite confirmar defectos de una implementación con otra.
  • Facilita experimentar con nuevas optimizaciones y runtimes.

Sin embargo, si distintas implementaciones procesan realmente de forma diferente el mismo programa, el ecosistema puede fragmentarse. Para impedirlo se necesitan una especificación clara, pruebas de conformidad y pruebas diferenciales.

En un lenguaje donde solo se utiliza en la práctica una implementación, su comportamiento puede actuar como una especificación informal. No obstante, si no se distinguen los comportamientos accidentales internos de las reglas intencionadas, posteriores optimizaciones o correcciones pueden romper programas existentes.

Implementación de referencia

Una implementación de referencia es un compilador o intérprete representativo que implementa la especificación. Permite probar las funciones del lenguaje y proporciona un punto de comparación para otras implementaciones.

Sin embargo, la implementación de referencia y la especificación no son lo mismo. La implementación también puede contener defectos y utiliza solo uno de los métodos permitidos. Otra implementación puede poseer estructura interna y características de rendimiento diferentes y seguir siendo conforme.

Si la implementación de referencia se convierte en un criterio excesivamente fuerte, detalles no especificados pueden consolidarse como estándares de facto. Por ello, es deseable separar claramente las reglas intencionadas mediante especificaciones y pruebas.

Pruebas de conformidad

Las pruebas de conformidad son conjuntos de pruebas que verifican si una implementación cumple los requisitos de la especificación y de la biblioteca estándar.

Pueden incluir los siguientes aspectos.

  • Confirmar que los programas válidos se procesan correctamente.
  • Confirmar que los programas inválidos reciben los diagnósticos necesarios.
  • Comprobar resultados de expresiones y flujos de control.
  • Comprobar conversiones y selección de sobrecargas.
  • Comprobar resultados y condiciones de error de la biblioteca.
  • Comprobar modelos de memoria y concurrencia.
  • Comprobar estructuras de formatos de archivos y binarios.
  • Comparar resultados en plataformas diferentes.

Superar estas pruebas no significa que la implementación no posea defectos. El número de programas posibles es prácticamente infinito, por lo que las pruebas examinan reglas representativas y condiciones límite. La generación aleatoria de programas, el fuzzing, las pruebas diferenciales y la verificación formal pueden descubrir más defectos.

La especificación de Java trata explícitamente las condiciones que debe satisfacer una implementación independiente y la necesidad de superar pruebas de compatibilidad. La especificación del lenguaje Java también define en un capítulo separado las reglas de compatibilidad binaria de clases e interfaces.

Biblioteca estándar

La utilidad práctica de un lenguaje depende considerablemente de su biblioteca estándar, además de la sintaxis central. Debe decidirse qué funciones de cadenas, colecciones, archivos, tiempo, matemáticas, concurrencia, redes y serialización se ofrecen como estándar.

Una biblioteca pequeña facilita la implementación y la portabilidad y permite que diversas bibliotecas externas compitan. Sin embargo, si incluso las funciones básicas dependen de paquetes externos, cada proyecto puede utilizar interfaces distintas y fragmentar el ecosistema.

Al diseñarla deben considerarse los siguientes aspectos.

  • Límite entre funciones centrales y funciones de biblioteca.
  • Funciones disponibles en todas las plataformas.
  • Método de abstracción de diferencias entre sistemas operativos.
  • Tratamiento de errores.
  • API concurrentes y asíncronas.
  • Compatibilidad binaria.
  • Nombres y coherencia de las API.
  • Procedimiento de retirada de funciones antiguas.
  • Reparto de responsabilidades con el ecosistema externo.

Las API de biblioteca pueden afectar directamente a más código de usuario que la propia gramática. Como los nombres y comportamientos publicados resultan difíciles de modificar, deben considerarse desde el principio la compatibilidad y la capacidad de extensión.

Propuestas de modificación del lenguaje

Los lenguajes maduros no añaden funciones inmediatamente, sino que utilizan procesos formales de propuesta. Los documentos deben explicar no solo el problema y la solución, sino también la relación con funciones existentes, las alternativas, la compatibilidad, la implementabilidad y las desventajas.

Un documento habitual contiene los siguientes elementos.

  • Problema que pretende resolver.
  • Casos reales de uso.
  • Sintaxis y semántica propuestas.
  • Reglas de comprobación de tipos y ejecución.
  • Compatibilidad con código existente.
  • Efectos sobre biblioteca estándar y herramientas.
  • Método de implementación y costes de rendimiento.
  • Consecuencias para seguridad.
  • Alternativas consideradas y no adoptadas.
  • Problemas aún no resueltos.
  • Posibilidades de extensión futura.
  • Plan de pruebas y documentación.

Los PEP de Python son documentos de diseño que proporcionan información a la comunidad o describen nuevas funciones del lenguaje, sus procedimientos y su entorno, y deben ofrecer una especificación técnica y la justificación de la propuesta.

El proceso RFC de Rust permite que los cambios importantes del lenguaje y la biblioteca recorran un camino coherente y controlado. Las propuestas pasan por discusión pública y revisión de equipos antes de aceptarse o rechazarse, y la aceptación de un RFC no convierte inmediatamente una función en estable. La implementación, las pruebas y la estabilización son etapas separadas.

En el proceso de Go se presenta primero un issue que explica brevemente el problema. Durante la revisión puede aceptarse, rechazarse o solicitarse un diseño detallado. El objetivo es formar un consenso general, y pueden rechazarse propuestas que entren en conflicto con el diseño fundamental o resulten difíciles de implementar eficientemente.

El repositorio KEEP de Kotlin administra tanto propuestas en curso como documentos de diseño de cambios ya implementados.

Swift Evolution examina públicamente cambios visibles para los usuarios del lenguaje, la biblioteca estándar y las herramientas principales. Su objetivo es reflejar una amplia variedad de opiniones manteniendo la dirección global y la coherencia conceptual.

Desarrollo gradual de las propuestas

Una función no suele entrar directamente en el estándar mediante una sola propuesta. Puede atravesar etapas como las siguientes.

Idea y planteamiento del problema
    ↓
Diseño inicial y casos de uso
    ↓
Documento público de propuesta
    ↓
Discusión y revisión
    ↓
Implementación de prototipo
    ↓
Pruebas y recopilación de experiencia real
    ↓
Redacción del texto de la especificación
    ↓
Revisión de estabilización
    ↓
Inclusión oficial en el lenguaje o estándar

En las primeras etapas se evalúa si el problema es realmente suficientemente importante. Una función aparentemente útil puede no añadirse si puede resolverse adecuadamente con la gramática y la biblioteca existentes o si sus casos de uso son limitados.

El prototipo comprueba si la propuesta puede implementarse y descubre problemas de sintaxis, tipos, mensajes de error, rendimiento y herramientas. Si se decide únicamente mediante documentación sin experiencia de implementación, la complejidad oculta puede aparecer tarde.

El proceso de TC39 para ECMAScript utiliza varias etapas desde la idea inicial hasta la investigación, el diseño, la retroalimentación de implementaciones y la finalización de la especificación. Las propuestas en la etapa final deben satisfacer condiciones como implementaciones reales, pruebas y texto editable de especificación.

Funciones experimentales y estabilización

Si una función nueva se ofrece inmediatamente como estable, resulta difícil corregir defectos posteriores. Para reducir este problema, los lenguajes utilizan funciones experimentales, previews e API de incubación.

Durante la etapa experimental pueden permitirse las siguientes condiciones.

  • La sintaxis y la semántica pueden cambiar en versiones posteriores.
  • Puede requerirse una opción explícita del compilador.
  • Puede proporcionarse en un espacio de nombres separado.
  • Puede desaconsejarse su uso en producción.
  • Las garantías de compatibilidad pueden estar limitadas.
  • Se recopila experiencia de uso y retroalimentación de implementación.

Antes de estabilizar deben revisarse los siguientes aspectos.

  • Existencia de suficientes casos reales.
  • Coherencia con las funciones existentes.
  • Posibilidad de implementación eficiente.
  • Calidad de mensajes de error y soporte de herramientas.
  • Posibilidad de expansión futura.
  • Ausencia de problemas graves de seguridad.
  • Ausencia de cambios en el significado de programas existentes.
  • Posibilidad de implementación independiente.

OpenJDK utiliza el proceso JEP para rastrear funciones y cambios importantes del JDK y de la plataforma Java. Los módulos de incubación y las funciones preview también se administran mediante JEP.

Si una función permanece experimental demasiado tiempo, la frontera entre estable y experimental puede volverse confusa. Si se estabiliza sin suficiente experiencia, una API o sintaxis incorrecta puede tener que mantenerse durante años. Por ello, es importante definir previamente el propósito y los criterios de finalización del periodo experimental.

Consenso y toma de decisiones

Los cambios del lenguaje suelen ser difíciles de decidir mediante mayoría simple, porque una misma función afecta de forma diferente a usuarios, implementadores, desarrolladores de herramientas y autores de bibliotecas.

Los procesos de consenso suelen considerar los siguientes elementos.

  • Viabilidad técnica.
  • Dirección a largo plazo.
  • Necesidades reales de los usuarios.
  • Carga para implementaciones y herramientas.
  • Compatibilidad con código existente.
  • Existencia de alternativas.
  • Opinión de la comunidad.
  • Coste de mantenimiento.
  • Capacidad de los equipos responsables para mantener la función.

El consenso no significa que todos los participantes prefieran la propuesta. Puede significar que las objeciones importantes han sido revisadas suficientemente y que un organismo responsable dispone de la información necesaria para juzgar ventajas y costes.

La discusión pública ayuda a encontrar casos y problemas diversos, pero los participantes no representan necesariamente a todos los usuarios. Por ello, deben utilizarse conjuntamente opiniones, estudios de código, mediciones de rendimiento, encuestas, ejemplos de errores existentes y experiencia con prototipos.

Gobernanza

La gobernanza define quién decide la dirección técnica y los cambios de un lenguaje y mediante qué procedimientos.

Puede adoptar formas como las siguientes.

  • Una persona o un pequeño grupo toma la decisión final.
  • Un equipo interno de una empresa administra el lenguaje.
  • Equipos centrales y subequipos de un proyecto abierto dividen responsabilidades.
  • Un comité formado por empresas decide mediante consenso.
  • Se utiliza un proceso internacional con representantes nacionales.
  • Una fundación u organización sin ánimo de lucro proporciona administración y recursos.

La estructura afecta a la velocidad, la transparencia, la coherencia y la participación. El liderazgo de un único diseñador puede mantener una dirección coherente y decisiones rápidas, pero aumentar la dependencia personal. Un comité refleja más intereses, pero puede tardar más.

Un buen sistema debe publicar claramente los siguientes aspectos.

  • Método de presentación de propuestas.
  • Personas y organizaciones responsables de revisarlas.
  • Criterios de aceptación y rechazo.
  • Métodos para presentar opiniones.
  • Tratamiento de conflictos de interés.
  • Alcance de publicación de decisiones y actas.
  • Procedimiento para revisar decisiones anteriores.
  • Relación de autoridad entre equipos y comités.

Aunque la especificación sea pública, si el proceso real de decisión es opaco, implementadores y usuarios externos tienen dificultades para predecir la dirección futura.

Estandarización internacional

La estandarización internacional es el proceso mediante el que organismos nacionales y expertos producen normas comunes. En lenguajes, SC 22 de JTC 1, el comité técnico conjunto de ISO e IEC, desempeña un papel principal. Su ámbito incluye lenguajes, entornos de ejecución e interfaces de software de sistemas.

WG14 se ocupa de la estandarización internacional de C (lenguaje de programación) y administra su estándar y especificaciones técnicas.

WG21 se ocupa de C++ y desarrolla el estándar y los documentos técnicos que incluyen las reglas centrales y la biblioteca. Las propuestas se presentan como documentos de comité y pueden incorporarse al borrador tras revisiones de investigación, evolución y redacción.

Un estándar internacional no designa el código fuente de una implementación concreta como norma, sino que define documentalmente los requisitos de programas e implementaciones. Los documentos pueden revisarse periódicamente, y pueden existir procedimientos separados para informes de defectos e interpretaciones.

La ventaja es proporcionar un criterio común independiente de una empresa o implementación. Su desventaja es que los procesos nacionales, el consenso y la revisión documental pueden hacer que los cambios sean más lentos que en proyectos abiertos.

Estándares industriales y consorcios

Algunos lenguajes se desarrollan principalmente mediante asociaciones industriales o consorcios.

ECMAScript es una especificación administrada por TC39 de Ecma International. TC39 mantiene propuestas y texto de especificación en repositorios públicos y discute su avance en reuniones periódicas. Debido a la importancia de la compatibilidad entre implementaciones de JavaScript y el ecosistema web, la experiencia de navegadores y runtimes se refleja en el proceso.

Este método puede desarrollar funciones más rápidamente que la estandarización internacional y facilitar la incorporación directa de retroalimentación de implementaciones. Sin embargo, la influencia de empresas e implementadores principales puede ser mayor, por lo que son importantes los procedimientos públicos, las actas y los criterios de consenso.

Procesos de evolución de proyectos abiertos

Los lenguajes administrados como proyectos abiertos pueden evolucionar mediante repositorios, sistemas de issues y documentos públicos.

El sistema PEP de Python abarca no solo funciones del lenguaje, sino también procedimientos de desarrollo, gobernanza y estándares de empaquetado. Como se conservan números, estados e historiales, también pueden consultarse los antecedentes de diseños rechazados o retirados.

El sistema RFC de Rust exige documentos públicos para cambios importantes del lenguaje y la biblioteca. Los equipos responsables revisan las propuestas, y la implementación y estabilización continúan mediante procesos separados después de la aceptación.

El proceso de Go no exige un documento extenso para todos los cambios pequeños. La revisión inicial determina importancia y complejidad, y solo solicita un diseño detallado cuando es necesario. Esto ajusta el coste del proceso al tamaño del cambio.

KEEP de Kotlin y Swift Evolution también desarrollan el lenguaje y la biblioteca mediante documentos, discusión pública y decisiones de equipos responsables.

Compatibilidad

Cuando un lenguaje empieza a utilizarse ampliamente, evitar romper programas existentes puede volverse más importante que añadir funciones nuevas. La compatibilidad se divide en varios niveles.

TipoSignificado
Compatibilidad de código fuenteSi el código existente puede recompilarse con un compilador nuevo
Compatibilidad binariaSi bibliotecas y programas no recompilados pueden funcionar juntos
Compatibilidad de comportamientoSi el programa conserva significado y resultados anteriores
Compatibilidad de APISi se mantienen los métodos de uso de funciones, tipos y módulos públicos
Compatibilidad ABISi se conservan convenciones de llamada, símbolos y disposición de objetos
Compatibilidad de datosSi siguen leyéndose archivos, protocolos y formatos serializados existentes
Compatibilidad de herramientasSi continúan funcionando sistemas de compilación, depuradores, formateadores y analizadores
Compatibilidad de ecosistemaSi paquetes y frameworks pueden utilizarse conjuntamente con una versión nueva

Puede mantenerse la compatibilidad de código fuente y romperse la binaria. El código puede funcionar tras recompilarse, pero una biblioteca antigua puede no poder enlazarse directamente con un programa nuevo.

También puede mantenerse la compatibilidad binaria y variar el comportamiento. Si cambia el tratamiento de errores, el orden de clasificación o los resultados de coma flotante de una función estándar, el programa puede ejecutarse pero producir resultados distintos.

La especificación de Java distingue concretamente los cambios que conservan compatibilidad binaria de clases e interfaces de aquellos que no lo hacen.

Compatibilidad hacia atrás

La compatibilidad hacia atrás es la capacidad de una versión nueva del lenguaje o de una implementación para seguir procesando programas escritos para versiones anteriores.

Para conservarla deben considerarse los siguientes aspectos.

  • Conflicto entre nuevas palabras clave e identificadores existentes.
  • Cambios en resultados de sobrecarga o inferencia.
  • Ambigüedad entre gramática antigua y nueva.
  • Conflictos de nombres por nuevas funciones de biblioteca.
  • Interpretación como código válido con otro significado de código antes erróneo.
  • Cambios en el orden de ejecución o en cálculos numéricos.
  • Conservación de ABI y formatos de serialización.

No es posible garantizarla absolutamente. Puede ser necesario cambiar comportamientos para corregir vulnerabilidades o defectos claros. Además, los programas que dependían de operaciones no garantizadas pueden romperse.

El documento de compatibilidad de Go 1 expresa la expectativa de que programas Go 1 sigan compilándose y ejecutándose en implementaciones posteriores, pero también describe excepciones como problemas de seguridad y comportamientos no especificados.

Retirada y eliminación

Aunque aparezca una función mejor o deje de recomendarse una función insegura, eliminarla inmediatamente puede romper muchos programas. Por ello se utiliza un proceso de deprecación.

Un proceso habitual es el siguiente.

Proporcionar una función alternativa
    ↓
Marcar la función anterior como obsoleta
    ↓
Mostrar advertencias en compilador y documentación
    ↓
Proporcionar herramientas de conversión automática
    ↓
Mantener un periodo suficiente de migración
    ↓
Eliminarla en una versión principal o modo nuevo

Una función obsoleta no deja de estar disponible inmediatamente, sino que deja de recomendarse. Es conveniente que la advertencia incluya la alternativa y una fecha prevista de eliminación.

Mantener permanentemente todas las funciones aumenta continuamente la complejidad. Eliminarlas demasiado rápido impide que el ecosistema migre. Deben considerarse uso real, riesgo de seguridad, coste de mantenimiento y posibilidad de migración automática.

Versiones y ediciones del lenguaje

También debe definirse en qué unidades se agrupan y distribuyen los cambios.

Entre los métodos habituales se encuentran los siguientes.

  • Publicar versiones nuevas a intervalos regulares.
  • Incorporar continuamente funciones completadas en una especificación viva.
  • Ofrecer versiones principales con soporte prolongado.
  • Permitir seleccionar una versión en archivos o proyectos.
  • Admitir varias ediciones mediante un mismo compilador.

Rust coloca ciertos cambios incompatibles dentro de límites optativos denominados ediciones. Si un proyecto no selecciona una nueva edición, mantiene las reglas anteriores, y bibliotecas de ediciones distintas pueden coexistir en un mismo grafo de dependencias. Rust proporciona herramientas automáticas de migración.

Este enfoque permite introducir cambios sintácticamente incompatibles sin dividir completamente el lenguaje, aunque obliga al compilador y a las herramientas a administrar simultáneamente diferencias de varias ediciones.

ECMAScript incorpora propuestas completadas en una especificación viva y las publica periódicamente como ediciones anuales.

Ciclos de distribución del estándar y la implementación

El ciclo de revisión del estándar y el de distribución de compiladores pueden ser diferentes. Una implementación puede ofrecer experimentalmente una función aún no incluida en el estándar o implementar gradualmente una norma nueva después de su publicación.

Deben distinguirse estados como los siguientes.

  • Función propuesta pero no implementada.
  • Función con implementación experimental.
  • Función incluida en la especificación pero no admitida por todas las implementaciones.
  • Extensión admitida por implementaciones principales pero no estandarizada.
  • Función estable tanto en la norma como en las implementaciones principales.

El usuario debe comprobar no solo la versión del lenguaje, sino también la versión de la implementación, la plataforma y las opciones activadas. Incluso indicando la misma versión pueden existir diferencias de soporte y defectos.

Extensiones del estándar

Las implementaciones pueden proporcionar funciones no incluidas en el estándar, como tipos vectoriales específicos de hardware, atributos, ensamblador en línea, API exclusivas del sistema operativo y funciones intrínsecas.

Estas extensiones permiten utilizar funciones de hardware y plataforma difíciles de expresar mediante el estándar, pero reducen la portabilidad.

Las implementaciones pueden administrarlas de las siguientes formas.

  • Utilizar un espacio de nombres o prefijo separado.
  • Exigir activación explícita.
  • Diagnosticarlas en modo estándar.
  • Proporcionar macros o atributos de detección.
  • Proponer extensiones ampliamente adoptadas al comité.

Si varias implementaciones proporcionan extensiones semejantes de forma independiente, pueden convertirse en candidatas a estandarización. Las funciones excesivamente dependientes de una implementación concreta resultan más difíciles de convertir en normas comunes.

Detección de funciones

Para admitir varias versiones e implementaciones, un programa necesita métodos para comprobar si una función está disponible.

Comprobar únicamente el número de versión del compilador puede no manejar correctamente forks o backports. Por ello pueden ofrecerse los siguientes mecanismos.

  • Comprobar soporte de una característica sintáctica.
  • Comprobar la existencia de una API estándar.
  • Comprobar soporte de atributos e intrínsecos.
  • Comprobar sistema operativo y funciones de CPU.
  • Seleccionar edición o conjunto de funciones.
  • Utilizar compilación condicional.

La detección puede aumentar la complejidad mediante numerosas bifurcaciones. Resulta preferible definir una versión mínima común y tratar únicamente las diferencias necesarias.

Defectos e interpretación de la especificación

Incluso una especificación detallada puede contener expresiones ambiguas, contradicciones y omisiones. Los problemas se descubren durante la implementación o al aparecer casos nuevos.

Los defectos pueden adoptar formas como las siguientes.

  • Una gramática admite dos interpretaciones.
  • Dos secciones poseen reglas contradictorias.
  • El resultado de una condición límite no está definido.
  • Los ejemplos no coinciden con el texto normativo.
  • Existe un requisito imposible o excesivamente ineficiente.
  • El comportamiento común de implementaciones difiere de la norma.

Las organizaciones reciben informes y revisan la intención, las implementaciones existentes y la compatibilidad. Las correcciones editoriales pueden aplicarse rápidamente, mientras que los cambios de significado pueden requerir un proceso similar a una propuesta.

También debe considerarse de qué interpretación dependen los programas existentes. Incluso una regla teóricamente más correcta puede requerir una migración gradual si rompe código ampliamente utilizado.

Experiencia de implementación y diseño empírico

Las funciones deben evaluarse no solo por su coherencia teórica, sino por su utilidad en código real. Los compiladores prototipo, ramas experimentales y bibliotecas externas permiten acumular experiencia antes de incorporar una función oficialmente.

El diseño empírico puede utilizar los siguientes datos.

  • Patrones repetidos en bases de código reales.
  • Errores frecuentes de compilación.
  • Longitud y complejidad del código que utiliza la función.
  • Tiempo de compilación y rendimiento.
  • Tamaño del binario generado.
  • Dificultad de implementación en IDE y analizadores.
  • Resultados de aprendizaje de usuarios nuevos.
  • Comparación con alternativas escritas mediante funciones existentes.
  • Resultados de conversiones automáticas en proyectos abiertos.

Una función puede parecer concisa en ejemplos pequeños y producir mensajes de error e inferencias complejas en proyectos grandes. A la inversa, una función inicialmente limitada puede resolver problemas comunes en muchos campos.

En revisiones históricas de la especificación de Java también se han utilizado colecciones reales de código abierto para medir efectos de cambios en la inferencia de tipos.

Seguridad y protección

El diseño debe considerar no solo la expresividad y el rendimiento, sino también si facilita la aparición de vulnerabilidades.

Entre los principales aspectos se encuentran los siguientes.

  • Acceso fuera de límites.
  • Uso de memoria liberada y punteros inválidos.
  • Desbordamientos y truncamientos de enteros.
  • Combinación incorrecta de cadenas y comandos.
  • Uso de valores no inicializados.
  • Condiciones de carrera.
  • Deserialización insegura.
  • Límites de permisos y confianza.
  • Excepciones y limpieza de recursos.
  • Higiene de macros y generación de código.
  • Ejecución de código en la cadena de suministro y paquetes.

No toda la seguridad puede garantizarse en el lenguaje. Sin embargo, los valores predeterminados seguros y los límites explícitos para operaciones peligrosas reducen el ámbito donde pueden producirse defectos.

Las funciones de seguridad también afectan al rendimiento y la complejidad. Las comprobaciones de límites, desbordamientos y tipos durante la ejecución introducen costes, por lo que las reglas semánticas y el IR deben permitir eliminar comprobaciones innecesarias mediante optimización.

Portabilidad

La portabilidad es la propiedad de utilizar un programa con pocas modificaciones en distintas implementaciones, sistemas operativos y procesadores. También constituye un objetivo central de la estandarización de C y de las actividades de SC 22.

Para aumentarla deben considerarse los siguientes aspectos.

  • Tamaño y rango de tipos básicos.
  • Operaciones enteras y de coma flotante.
  • Endianness y alineación.
  • Rutas de archivo y codificación de caracteres.
  • Hilos y modelo de memoria.
  • Diferencias entre API de sistemas operativos.
  • Tiempo y configuración regional.
  • Redes y sistemas de archivos.
  • Bibliotecas externas y ABI.
  • Compilación condicional.

Resulta difícil ocultar completamente todas las diferencias, y una abstracción excesiva puede dificultar utilizar funciones específicas de la plataforma. El lenguaje y la biblioteca deben ofrecer funciones comunes y permitir acceder a extensiones cuando sea necesario.

Estabilidad y evolución del lenguaje

Un lenguaje que no cambia tiene dificultades para responder a hardware, métodos de desarrollo y requisitos de seguridad nuevos. Si cambia demasiado rápido, los usuarios deben modificar continuamente su código y las bibliotecas y herramientas tienen dificultades para estabilizarse.

Una evolución estable requiere los siguientes elementos.

  • Objetivos de diseño claros.
  • Procedimientos documentados de modificación.
  • Propuestas y revisiones públicas.
  • Experiencia real de implementación y uso.
  • Pruebas de conformidad.
  • Políticas de compatibilidad hacia atrás.
  • Procedimientos de retirada y migración.
  • Herramientas automáticas de transformación.
  • Sincronización continua entre especificación e implementaciones.
  • Gobernanza responsable.

No solo es importante añadir funciones, sino también decidir no añadirlas. Una vez estandarizadas, la sintaxis, la semántica y las API deben mantenerse durante mucho tiempo, por lo que deben considerarse la coherencia global y el coste de mantenimiento por encima de la conveniencia a corto plazo.

La estandarización tampoco consiste en congelar el lenguaje e impedir los cambios. Consiste en mantener criterios comunes consensuados, revisar nuevas necesidades y equilibrar la estabilidad de los programas existentes con la posibilidad de evolución futura.

Por lo tanto, el diseño y la estandarización de lenguajes de programación son tanto tareas técnicas para determinar sintaxis y funciones como procesos para crear reglas y estructuras de decisión que permitan que implementaciones, usuarios, bibliotecas y herramientas evolucionen conjuntamente durante largos periodos. El éxito de un lenguaje no depende únicamente de la cantidad de funciones, sino de la coherencia de sus principios, la precisión de su especificación, la calidad de sus implementaciones y la estabilidad con la que se conectan las políticas de compatibilidad y el ecosistema.

Ámbitos de aplicación

Los lenguajes de programación se utilizan en prácticamente todos los campos que emplean computadoras, desde los sistemas operativos y el control de dispositivos hasta los servicios web, las aplicaciones móviles, los videojuegos, el cálculo científico, la inteligencia artificial y la automatización de tareas administrativas. Sin embargo, no todos los lenguajes son igualmente adecuados para todos los ámbitos. Su campo real de uso depende de hasta qué punto su modelo de ejecución, sistema de tipos, método de administración de memoria, plataformas compatibles, biblioteca estándar, herramientas de desarrollo y ecosistema existente se ajustan a las necesidades de cada área.

Los ámbitos de aplicación de un lenguaje no se determinan únicamente por sus funciones intrínsecas. Un mismo lenguaje puede utilizarse ampliamente en un sector concreto al integrarse con un determinado sistema operativo, framework, runtime o ecosistema industrial. Kotlin es un lenguaje de propósito general, pero está estrechamente integrado con el entorno de desarrollo de Android, mientras que Swift ocupa una posición central en el desarrollo de aplicaciones para plataformas de Apple. JavaScript desempeña un papel fundamental en el frontend web gracias a su posición como lenguaje de ejecución estándar de los navegadores. Google orienta el desarrollo de Android principalmente alrededor de Kotlin, y Apple proporciona Swift como lenguaje para sus plataformas, incluidas iOS, iPadOS, macOS, tvOS y watchOS.

Un mismo sistema de software también puede estar compuesto por varios lenguajes. Un videojuego puede implementar el motor y los sistemas centrados en el rendimiento mediante C++, mientras escribe las reglas del juego o las herramientas con C#, Lua o scripting visual. Un servicio web puede utilizar JavaScript o TypeScript en el navegador, Java, C#, Go, Python o Rust en el servidor y SQL para las consultas de base de datos. Un sistema de aprendizaje automático puede organizar los modelos y el proceso de entrenamiento mediante Python, mientras implementa las operaciones de cálculo intensivo en C++, CUDA u otros lenguajes destinados a aceleradores.

Por ello, para comprender los ámbitos de aplicación no debe fijarse un lenguaje a una única área, sino analizar conjuntamente las condiciones exigidas por cada campo y el papel que desempeña el lenguaje dentro de él.

Software de sistemas

El software de sistemas es el software que proporciona la base de ejecución para otros programas, como sistemas operativos, controladores de dispositivos, compiladores, runtimes, sistemas de archivos, motores de bases de datos, pilas de red y máquinas virtuales.

En el software de sistemas son importantes las siguientes características.

  • Debe permitir controlar detalladamente la disposición y el tiempo de vida de la memoria.
  • Debe proporcionar acceso directo a interfaces del sistema operativo y del hardware.
  • Debe permitir predecir el tiempo de ejecución y el uso de memoria.
  • Debe poder ejecutarse sin depender de un runtime independiente de gran tamaño.
  • Debe admitir varias arquitecturas de CPU y ABI.
  • Debe poder llamar de forma estable a bibliotecas externas y API del sistema operativo.
  • Debe conservar el rendimiento y la compatibilidad en código mantenido durante largos periodos.

C (lenguaje de programación) se ha utilizado durante mucho tiempo en sistemas operativos, bibliotecas de sistemas y software embebido gracias a sus punteros, estructuras, administración explícita de memoria, modelo de ejecución relativamente sencillo y ABI ampliamente compatibles. El estándar de C define la representación, la sintaxis, las restricciones y las reglas semánticas de los programas C, además de las condiciones que deben cumplir las implementaciones conformes.

C++ conserva un control del hardware y un rendimiento de ejecución nativo próximos a C, al tiempo que proporciona clases, plantillas, programación genérica, administración de recursos basada en ámbitos y una biblioteca estándar. Se utiliza en sistemas que requieren simultáneamente rendimiento y abstracciones complejas, como navegadores, motores de videojuegos, bases de datos, compiladores y grandes aplicaciones de escritorio.

Rust es un lenguaje de programación de sistemas que busca proporcionar seguridad de memoria sin recolector de basura mediante propiedad, préstamos y comprobación de tiempos de vida. Los programas de línea de comandos, los servicios de red, WebAssembly y los dispositivos embebidos han sido tratados como importantes áreas de aplicación dentro del proyecto Rust.

La programación de sistemas no exige que todo el código se escriba en lenguajes de bajo nivel. El núcleo de un sistema operativo y los límites con el hardware pueden implementarse en lenguajes nativos, mientras que las herramientas de administración, instaladores, sistemas de compilación y automatización de pruebas pueden escribirse en Python o lenguajes de shell. Es habitual combinar lenguajes de distintos niveles para mejorar conjuntamente la fiabilidad del sistema y la eficiencia del desarrollo.

Sistemas embebidos

Un sistema embebido es un sistema informático incluido dentro de un dispositivo o producto para realizar una función limitada y específica. Incluye microcontroladores, sensores, controladores de automóviles, equipos industriales, electrodomésticos, dispositivos médicos, drones, equipos de comunicaciones y dispositivos de Internet de las cosas.

Los entornos embebidos varían considerablemente según el dispositivo. Algunos utilizan microcontroladores de 8 bits con solo unos pocos kilobytes de memoria, mientras que otros poseen procesadores multinúcleo, sistemas operativos y varios gigabytes de memoria. La documentación de Embedded Rust también explica que la programación embebida abarca desde pequeños MCU de 8 bits hasta sistemas multinúcleo y que se aplican restricciones diferentes según el destino.

En el software embebido son importantes los siguientes elementos.

  • RAM y almacenamiento limitados.
  • Bajo consumo energético.
  • Control de registros de hardware y periféricos.
  • Gestión de interrupciones.
  • Respuesta en tiempo real.
  • Ejecución bare-metal sin sistema operativo.
  • Estabilidad a largo plazo.
  • Actualizaciones remotas y recuperación ante fallos.
  • Seguridad funcional y protección.
  • Compilación cruzada para el dispositivo de destino.

C y C++ se utilizan gracias a compiladores ampliamente compatibles, bibliotecas para dispositivos y SDK proporcionados por fabricantes de hardware. Rust también admite entornos no_std sin sistema operativo ni biblioteca estándar y permite aprovechar en microcontroladores sus funciones de propiedad y seguridad de tipos. Embedded Rust Book explica oficialmente cómo utilizar Rust en microcontroladores bare-metal.

En los entornos embebidos, la concurrencia no se manifiesta únicamente mediante hilos de un sistema operativo. Los manejadores de interrupciones pueden ejecutarse en momentos impredecibles, las tareas de tiempo real pueden alternarse y varios núcleos pueden ejecutar código independientemente. En estas estructuras deben considerarse conjuntamente el estado compartido, el acceso atómico, las prioridades de interrupción y la planificación en tiempo real.

Sistemas de tiempo real

Un sistema de tiempo real no es simplemente un sistema que se ejecuta rápidamente, sino uno cuyo resultado solo se considera correcto si se proporciona dentro de un plazo determinado. Entre sus principales ámbitos se encuentran el control de fábricas, la robótica, el control de aeronaves y vehículos, los equipos de comunicaciones, los dispositivos médicos y el procesamiento de audio.

En estos sistemas son más importantes que la velocidad media de procesamiento el peor tiempo de ejecución, los límites máximos de latencia, las prioridades de tareas, la respuesta a interrupciones, la competencia por recursos y la previsibilidad de la planificación. La recolección de basura capaz de detener la ejecución durante periodos arbitrariamente largos o la compilación durante la ejecución pueden resultar inadecuadas para determinados entornos de tiempo real, por lo que se utilizan runtimes restringidos o métodos previsibles de administración de memoria.

Ada ha evolucionado para admitir tareas, ejecución en tiempo real, un sistema de tipos fuerte y sistemas que requieren alta integridad. El anexo de sistemas de alta integridad del estándar de Ada aborda restricciones y entornos de ejecución destinados a sistemas que necesitan gran fiabilidad y seguridad.

Tampoco es necesario escribir todos los componentes de un programa de tiempo real en un único lenguaje. Los bucles de control con restricciones estrictas pueden implementarse en C, C++, Ada o Rust, mientras que las herramientas de configuración, el análisis de datos, la simulación y la interfaz de usuario pueden implementarse en otros lenguajes de alto nivel.

Aplicaciones de escritorio

Las aplicaciones de escritorio son programas que se ejecutan directamente en sistemas operativos de computadoras personales como Windows, macOS y Linux. Incluyen editores de documentos, herramientas gráficas, entornos de desarrollo, mensajeros, programas multimedia, herramientas de diseño, administradores de archivos y clientes empresariales.

En las aplicaciones de escritorio son importantes los siguientes requisitos.

  • Integración con ventanas, entrada, archivos y notificaciones del sistema operativo.
  • Interfaces gráficas de usuario complejas.
  • Acceso a archivos y dispositivos locales.
  • Ejecución sin conexión.
  • Instalación y actualizaciones automáticas.
  • Compatibilidad con varios sistemas operativos.
  • Accesibilidad e internacionalización.
  • Estabilidad durante ejecuciones prolongadas.
  • Rendimiento nativo y uso de memoria.

Los programas nativos de Windows pueden utilizar C++, C# y .NET, mientras que los de macOS pueden utilizar Swift, Objective-C y C++. Los programas multiplataforma pueden emplear C++ con Qt, frameworks multiplataforma basados en C#, Java y la JVM, runtimes de escritorio basados en JavaScript o herramientas de GUI desarrolladas con Rust.

Python también puede utilizarse en aplicaciones de escritorio, scripts, servicios web y cálculo científico, y permite combinar bibliotecas gráficas con módulos de extensión nativos.

Aunque una aplicación de escritorio escriba toda su interfaz en un único lenguaje, puede utilizar como motor interno una biblioteca implementada en otro. Los editores de vídeo y las herramientas de producción 3D suelen separar la interfaz y la capa de scripts de las capas de alto rendimiento para procesamiento de medios y gráficos.

Aplicaciones móviles

Las aplicaciones móviles se ejecutan en teléfonos inteligentes, tabletas y dispositivos vestibles, y deben considerar la entrada táctil, sensores, cámaras, notificaciones, ubicación, consumo energético limitado y políticas de ejecución en segundo plano.

En Android, Kotlin y Java son los principales lenguajes. Google recomienda Kotlin como opción prioritaria para el desarrollo de Android y permite integrarlo con código Java existente.

Las corrutinas de Kotlin se utilizan para expresar tareas asíncronas que no deben bloquear el hilo de la interfaz, como solicitudes de red y accesos a bases de datos. La documentación oficial de Android presenta las corrutinas como método recomendado para la programación asíncrona y destaca la concurrencia estructurada y la propagación de cancelaciones.

En las plataformas de Apple se utilizan Swift y Objective-C. Apple proporciona Swift como lenguaje para todas sus plataformas, mientras que SwiftUI es un framework para construir interfaces en distintos dispositivos de Apple mediante Swift.

En el desarrollo móvil multiplataforma pueden utilizarse frameworks que admiten Android e iOS desde una sola base de código. Kotlin Multiplatform permite compartir lógica de negocio entre Android e iOS y combinarla con interfaces específicas de cada plataforma o con interfaces compartidas.

Los videojuegos móviles y las aplicaciones multimedia de alto rendimiento también pueden conectar un motor común escrito en C++ con capas específicas de plataforma desarrolladas en Kotlin, Java, Swift u Objective-C.

Frontend web

El frontend web es el ámbito del software que se ejecuta en un navegador y procesa la representación visual del documento, las entradas del usuario, la comunicación de red, las animaciones y los contenidos multimedia.

Las tecnologías básicas de la web se dividen por funciones.

  • HTML representa la estructura y el significado del documento.
  • CSS define su apariencia y disposición.
  • JavaScript gestiona la entrada del usuario, los cambios dinámicos de estado y el uso de API del navegador.

JavaScript es un lenguaje que añade actualización dinámica de contenidos, gráficos, control multimedia e interacción a las páginas web. Los navegadores proporcionan a JavaScript diversas Web API, como el modelo de objetos del documento, redes, almacenamiento, gráficos y audio.

TypeScript añade anotaciones y análisis estático de tipos a JavaScript y se transforma en JavaScript ordinario. En grandes aplicaciones frontend puede especificar tipos para propiedades de componentes, datos de red y estructuras de estado, facilitando la navegación de código, la refactorización y la detección de errores.

El frontend web está sujeto a las restricciones del navegador. El acceso a archivos, dispositivos y redes debe respetar el modelo de seguridad y los permisos del usuario, y deben considerarse el soporte de funciones, el rendimiento y la accesibilidad entre navegadores diferentes.

WebAssembly puede utilizarse como destino de ejecución para código de bajo nivel generado por lenguajes como C, C++ y Rust. Es posible implementar mediante WebAssembly módulos de cálculo intensivo para imágenes, audio, videojuegos, simulación y compresión, mientras JavaScript se encarga de la interfaz del navegador y la conexión con las API.

Backend web y aplicaciones de servidor

El backend web es el software del lado del servidor que recibe solicitudes de clientes y ejecuta autenticación, lógica de negocio, acceso a bases de datos, procesamiento de archivos y comunicación con servicios externos.

En los servidores web y las API son importantes los siguientes elementos.

  • Gestión de muchas conexiones simultáneas.
  • Entrada y salida de red y ejecución asíncrona.
  • Conexión con bases de datos.
  • Autenticación y autorización.
  • Validación de datos de entrada.
  • Escalabilidad horizontal.
  • Observabilidad y registros.
  • Actualizaciones de seguridad.
  • Despliegue estable.
  • Integración con servicios y bibliotecas existentes.

Java y Kotlin pueden utilizar los frameworks de servidor y el ecosistema empresarial de la JVM, mientras que C# se combina con frameworks web y de nube de .NET. Python puede emplearse para API web, servicios administrativos y backends centrados en datos gracias a su rápido desarrollo y amplia biblioteca. JavaScript y TypeScript permiten compartir lenguajes y estructuras entre navegador y servidor mediante runtimes como Node.js.

Go se utiliza en servicios de red y herramientas de nube gracias a sus goroutines, canales, bibliotecas estándar de red y HTTP y despliegue nativo. Rust puede aplicarse en servicios que requieren runtimes asíncronos, seguridad de memoria y rendimiento nativo. Los servicios de red también han sido tratados como una de las principales áreas de aplicación del proyecto Rust.

La selección del lenguaje del servidor no depende únicamente del rendimiento por solicitud. También influyen la velocidad de desarrollo, la respuesta ante fallos, la madurez de las bibliotecas, el entorno de despliegue, el uso de memoria, el tiempo de inicio, la experiencia del personal de operaciones y la compatibilidad con sistemas existentes.

Computación en la nube

En los entornos de computación en la nube, los programas se ejecutan sobre máquinas virtuales, contenedores, entornos serverless, servicios administrados de datos e infraestructuras distribuidas entre varias regiones.

El software de nube puede dividirse en los siguientes ámbitos.

  • Control de infraestructura y aprovisionamiento de recursos.
  • Administración de contenedores y clústeres.
  • Microservicios y API.
  • Funciones serverless.
  • Recopilación de registros y métricas.
  • Pipelines de procesamiento de datos.
  • Automatización de despliegues y operaciones.
  • Políticas de seguridad y control de acceso.

Go se utiliza en herramientas de infraestructura de nube gracias a sus ejecutables individuales, tiempos de inicio relativamente rápidos, concurrencia y programación de red. Java y C# se usan ampliamente en sistemas empresariales y servicios administrados, mientras que Python puede emplearse para automatización mediante API de nube, procesamiento de datos y funciones serverless. Rust y C++ pueden aplicarse en software de infraestructura que necesita un alto rendimiento y baja latencia, como proxies, motores de almacenamiento y componentes de red.

Los programas de nube se ven afectados por las características del runtime y los costes operativos. El uso de memoria y el tiempo de inicio pueden influir en los costes de entornos serverless y despliegues masivos de contenedores, mientras que la recolección de basura y la compilación JIT presentan características diferentes en servicios de larga duración y funciones de ejecución breve.

Sistemas distribuidos

Un sistema distribuido está formado por varias computadoras con memorias independientes y posibilidades de fallo que colaboran a través de una red para proporcionar un servicio.

En estos sistemas son importantes los siguientes problemas.

  • Latencia e interrupciones de red.
  • Duplicación y reordenación de mensajes.
  • Fallos parciales en los que solo dejan de funcionar algunos nodos.
  • Replicación y consistencia de datos.
  • Reintentos de tareas.
  • Incertidumbre temporal y de orden.
  • Detección de fallos.
  • Descubrimiento de servicios.
  • Equilibrio de carga.
  • Comunicación entre servicios de versiones diferentes.

Los lenguajes que proporcionan paso de mensajes, actores, funciones asíncronas, tareas ligeras y concurrencia estructurada pueden representar de manera más directa algunas estructuras de estos sistemas. Sin embargo, las funciones del lenguaje no resuelven automáticamente el consenso distribuido, la consistencia de datos ni las particiones de red. También deben diseñarse el almacenamiento de datos, los protocolos de comunicación, las políticas de reintento y el modelo de fallos.

Java, C#, Go, Rust, Erlang, Elixir, Scala, C++ y Python, entre otros, se utilizan en servicios distribuidos. Los lenguajes de la familia Erlang se centran en procesos aislados, paso de mensajes y estructuras de supervisión, mientras que los lenguajes basados en JVM y .NET aprovechan ecosistemas maduros de servidores y observabilidad.

Programas de línea de comandos

Los programas de línea de comandos se ejecutan desde terminales o shells, reciben argumentos y entrada estándar y proporcionan resultados mediante la salida estándar o archivos. Incluyen compiladores, herramientas de control de versiones, convertidores de archivos, gestores de paquetes, herramientas de compilación y programas de administración de sistemas.

En ellos son importantes las siguientes características.

  • Inicio rápido.
  • Argumentos y ayuda claros.
  • Composición mediante tuberías y archivos.
  • Códigos de salida fáciles de automatizar.
  • Distribución en varios sistemas operativos.
  • Ejecutables pequeños y pocas dependencias.
  • Mensajes de error y registros.
  • Autocompletado del shell.

Los lenguajes de shell resultan adecuados para conectar rápidamente comandos y pipelines del sistema operativo, pero cuando el programa aumenta de tamaño pueden aparecer limitaciones relacionadas con tipos, estructuras de datos y tratamiento de errores. Python puede procesar argumentos mediante elementos de la biblioteca estándar como argparse, y Rust considera oficialmente el desarrollo de CLI mediante ejecutables nativos y su ecosistema de paquetes como una de sus áreas de aplicación.

Go, C++ y C# también se utilizan para herramientas de administración y desarrollo independientes. La opción adecuada depende del proceso de instalación, la plataforma de destino, el tiempo de inicio y la necesidad de bibliotecas nativas.

Scripts y automatización de tareas

Un script es un programa relativamente pequeño escrito para automatizar procesamiento de archivos, ejecución de programas, transformación de datos, configuración del sistema y tareas repetitivas.

En la automatización son importantes las siguientes funciones.

  • Procesamiento de cadenas y archivos.
  • Ejecución de comandos del sistema operativo.
  • Procesamiento de JSON, XML y CSV.
  • Solicitudes HTTP y llamadas a API.
  • Modificación y ejecución rápidas.
  • Conexión con programas externos.
  • Tratamiento de errores y registros.
  • Compatibilidad con varios sistemas operativos.

Los lenguajes de shell son apropiados para controlar directamente archivos, procesos y pipelines. Python permite construir procesamiento de archivos, redes, serialización, pruebas y herramientas de línea de comandos mediante su biblioteca estándar y paquetes externos. PowerShell proporciona pipelines basados en objetos y un entorno de administración de Windows, mientras que JavaScript se utiliza en herramientas basadas en Node.js y automatización del desarrollo web.

Una tarea breve y desechable puede resolverse con un script sencillo, pero cuando el programa de automatización se encarga de despliegues, seguridad o datos importantes necesita el mismo nivel de comprobación de tipos, pruebas, tratamiento de errores, administración de permisos y mantenimiento que una aplicación ordinaria.

DevOps y automatización de compilación

En los entornos de DevOps y entrega de software se automatizan los procesos de compilación, prueba, empaquetado y despliegue del código fuente.

Los programas utilizados incluyen los siguientes.

  • Scripts de compilación.
  • Pipelines de integración continua.
  • Configuración de imágenes de contenedor.
  • Definiciones de infraestructura.
  • Herramientas de despliegue y rollback.
  • Automatización de pruebas.
  • Recopilación de registros y estados.
  • Configuración de entornos de desarrollo.

Shell, Python, Go, JavaScript, Ruby y PowerShell se utilizan para desarrollar herramientas de automatización. Algunos sistemas de compilación e infraestructura proporcionan formatos declarativos como YAML o lenguajes específicos de dominio propios.

En estos entornos son importantes la reproducibilidad, la idempotencia, la administración de secretos, la detención y recuperación ante fallos y los registros de ejecución. En lugar de seleccionar un lenguaje únicamente porque su sintaxis sea breve, debe considerarse si puede detectar correctamente los fallos y controlar las diferencias del entorno.

Bases de datos y administración de datos

En el ámbito de las bases de datos se distinguen los lenguajes utilizados para implementar el motor de almacenamiento y los utilizados para consultar los datos almacenados.

El motor interno de un sistema de administración de bases de datos realiza las siguientes funciones.

  • Administración de páginas de disco y memoria.
  • Índices.
  • Optimización de consultas.
  • Transacciones.
  • Control de concurrencia.
  • Registros y recuperación.
  • Replicación.
  • Protocolos de red.

Como estos componentes necesitan un control detallado del rendimiento, la memoria y la concurrencia, pueden utilizar C, C++, Rust y Java.

Los usuarios describen mediante lenguajes declarativos como SQL las condiciones y la forma de los datos deseados. La elección real de índices, el orden de los joins y los métodos de procesamiento paralelo son determinados por el optimizador de consultas.

Las aplicaciones situadas alrededor de una base de datos utilizan lenguajes como Java, C#, Python, JavaScript, Go y PHP para ejecutar SQL y transformar los resultados en objetos del dominio. También pueden utilizar herramientas de mapeo objeto-relacional, aunque no pueden abstraer por completo las transacciones, los índices y los planes de ejecución de la base de datos.

Ingeniería de datos

La ingeniería de datos es el campo que recopila, depura y transforma datos procedentes de varias fuentes para convertirlos en formas utilizables por análisis y servicios.

Entre sus principales tareas se encuentran las siguientes.

  • Recopilación de datos.
  • Procesamiento por lotes y de flujos.
  • Transformación de esquemas.
  • Comprobación de calidad de los datos.
  • Movimiento entre almacenes de datos.
  • Planificación de tareas.
  • Procesamiento distribuido.
  • Administración de metadatos.
  • Creación de modelos de datos para análisis.

Python y SQL se utilizan para configurar procesos de datos y sistemas analíticos, mientras que Java y Scala se emplean en sistemas distribuidos de procesamiento basados en JVM. Azure Databricks también explica que admite varios lenguajes para tareas de ingeniería y ciencia de datos, y que las ubicaciones y limitaciones disponibles varían según el lenguaje.

En la ingeniería de datos, el motor distribuido, el formato de almacenamiento, los costes de red, la distribución y el particionamiento pueden influir más en el rendimiento total que la velocidad del lenguaje utilizado para una operación individual. Un trabajo escrito en un lenguaje de alto nivel puede ejecutarse internamente mediante JVM, C++ u operaciones vectoriales nativas.

Análisis de datos y estadística

El análisis de datos es el campo que explora y resume datos, busca relaciones estadísticas y los visualiza.

Python y R (lenguaje de programación) proporcionan ecosistemas para análisis interactivo, estadística y visualización. SQL se utiliza para seleccionar y agregar datos, mientras que lenguajes y entornos como Julia y MATLAB también se aplican en cálculo numérico e investigación.

En los lenguajes de análisis son importantes las siguientes características.

  • Procesamiento de datos en forma de arreglos y tablas.
  • Funciones estadísticas.
  • Visualización.
  • Entornos interactivos de ejecución.
  • Notebooks.
  • Conexión con bibliotecas científicas.
  • Llamadas a bibliotecas de alto rendimiento escritas en C, C++ y Fortran.
  • Reproducibilidad de los resultados.

El código de análisis interactivo resulta adecuado para experimentar rápidamente, pero al desplegarlo en sistemas de producción deben reforzarse la validación de entradas, las pruebas, la fijación de versiones, el uso de recursos y el tratamiento de fallos.

Inteligencia artificial y aprendizaje automático

En los campos de la inteligencia artificial y el aprendizaje automático se realizan preparación de datos, definición de modelos, entrenamiento, evaluación, inferencia y despliegue de servicios.

Python se utiliza en interfaces de alto nivel para investigación, experimentación y pipelines de entrenamiento gracias a su expresión concisa y su ecosistema de datos y cálculo numérico. Sin embargo, las operaciones masivas de matrices y tensores y los kernels de GPU suelen ejecutarse mediante C++, CUDA u otras implementaciones nativas. El código Python puede actuar como capa de control de estas bibliotecas y runtimes de alto rendimiento.

CUDA es una plataforma y un modelo de programación paralela que utiliza GPU para acelerar programas de cálculo intensivo. La documentación oficial de NVIDIA explica que el cálculo mediante GPU se utiliza en aprendizaje profundo, cálculo científico y computación de alto rendimiento, y trata la programación de GPU mediante C++ y Python.

Un sistema de aprendizaje automático puede organizarse en varias capas.

Experimentación y definición de modelos
    Python

Framework y motor de ejecución
    C++ / Rust / Java

Operaciones de GPU
    CUDA / lenguajes de shaders y kernels

Procesamiento de datos
    Python / SQL / Scala / Java

API de servicio
    Python / Go / Java / C# / Rust

Inferencia móvil y en el edge
    C++ / Java / Kotlin / Swift

La selección del lenguaje no determina directamente la precisión del modelo. Influyen más directamente los frameworks compatibles, los aceleradores de hardware, el entorno de procesamiento de datos, el destino de despliegue y las herramientas operativas.

Cálculo científico

El cálculo científico es el ámbito que calcula mediante computadoras modelos matemáticos de física, química, meteorología, fluidos, astronomía, ciencias biológicas, análisis numérico e ingeniería.

En él son importantes los siguientes elementos.

  • Precisión y reproducibilidad de los cálculos de coma flotante.
  • Operaciones con arreglos y matrices.
  • Algoritmos numéricos.
  • Procesamiento paralelo.
  • Memoria y almacenamiento de gran tamaño.
  • Compatibilidad con código de investigación existente.
  • Mantenimiento del código durante varias generaciones.
  • Interoperabilidad con otros lenguajes y bibliotecas.

Fortran se ha utilizado principalmente en áreas científicas y de ingeniería que adoptaron tempranamente las computadoras. Fortran-lang describe como principales campos de aplicación la predicción meteorológica y oceánica, la dinámica de fluidos computacional, las matemáticas aplicadas, la estadística y las finanzas.

C, C++, Python, Julia y MATLAB también se utilizan en cálculo científico. Python puede emplearse como capa superior para organizar procedimientos de investigación y análisis de datos, mientras delega el cálculo real en bibliotecas escritas en Fortran, C y C++.

En el software científico, los algoritmos y formatos de datos ya verificados y el valor del código acumulado durante años pueden ser más importantes que una sintaxis moderna. Al introducir un lenguaje nuevo deben considerarse la conexión con código existente, la verificación de los resultados y las diferencias en comportamiento de coma flotante.

Computación de alto rendimiento

La computación de alto rendimiento utiliza supercomputadoras, clústeres, CPU multinúcleo y GPU para realizar cálculos a gran escala. Se aplica a predicción meteorológica, simulación física, análisis genómico, dinámica de fluidos, investigación de materiales, cálculos financieros y entrenamiento de aprendizaje automático.

En este ámbito pueden coexistir varios niveles de paralelización.

  • Vectorización a nivel de instrucciones de CPU.
  • Varios hilos dentro de un nodo.
  • Paso de mensajes entre nodos.
  • Offloading hacia GPU y aceleradores.
  • Ejecución heterogénea que combina varios dispositivos.

Fortran, C y C++ se han utilizado tradicionalmente, mientras que Python y Julia pueden emplearse para la configuración y el análisis de alto nivel. En cálculo mediante GPU se utilizan CUDA C++, lenguajes de kernels y bibliotecas para aceleradores. El modelo de programación de CUDA proporciona funciones para escribir código destinado a GPU y controlar la memoria y la estructura de ejecución paralela.

El rendimiento no depende únicamente de la velocidad teórica del lenguaje. Son importantes los patrones de acceso a memoria, la disposición de datos, el volumen de comunicación, la vectorización, la estructura paralela del algoritmo y la optimización para el dispositivo de destino.

Gráficos por computadora

En el ámbito de los gráficos por computadora se implementan renderizado 2D y 3D, procesamiento de imágenes, modelado, animación, simulación e interfaces de usuario.

El software gráfico suele estructurarse en las siguientes capas.

  • API gráfica y abstracción de dispositivos.
  • Motor de renderizado.
  • Shaders.
  • Procesamiento de escenas y recursos.
  • Editor y herramientas.
  • Integración con animación y física.
  • Scripts de contenido.

Los motores de renderizado y controladores gráficos suelen utilizar C y C++ para acceder al hardware y controlar el rendimiento. Rust también puede emplearse en wrappers de API gráficas y motores de renderizado.

Los shaders se escriben en lenguajes específicos de dominio destinados a las etapas de vértices, píxeles y cálculo de la GPU. GLSL, HLSL y Metal Shading Language son ejemplos representativos. El código shader debe considerar modelos de ejecución masivamente paralelos y estructuras de memoria diferentes a los programas ordinarios de CPU.

En herramientas de creación y pipelines gráficos, Python puede utilizarse para transformar recursos, automatizar tareas repetitivas y desarrollar plugins y herramientas de producción. Por ello, dentro de un mismo programa gráfico pueden coexistir un renderer nativo, lenguajes de shaders y scripts de herramientas.

Desarrollo de videojuegos

El desarrollo de videojuegos es un campo complejo que combina renderizado en tiempo real, física, audio, redes, inteligencia artificial, entrada, interfaz de usuario, herramientas y producción de contenidos.

Según la función pueden utilizarse lenguajes diferentes.

  • Motor y renderer.
  • Sistemas de física y audio.
  • Código de gameplay.
  • Servidores y redes.
  • Editor y herramientas de producción.
  • Pipeline de compilación y recursos.
  • Shaders.
  • Mods y scripts de usuario.

C++ se utiliza en grandes motores gracias a su alto rendimiento, control de memoria y compatibilidad con consolas y API gráficas. Unreal Engine proporciona frameworks y API para desarrolladores C++ y permite combinar C++ con el scripting visual de Blueprint para construir sistemas de juego y contenido.

C# se utiliza en algunos motores y herramientas gracias a los runtimes administrados y la productividad. Lua, Python y lenguajes de scripting propios pueden integrarse en motores nativos para reglas de juego, contenido, mods y automatización del editor.

La selección del lenguaje no depende únicamente de obtener la máxima tasa de fotogramas. Los requisitos del código en tiempo real y de las herramientas son diferentes. En las rutas centrales de renderizado y física son importantes la baja latencia y el uso predecible de memoria, mientras que en editores y pipelines de recursos pueden ser más importantes el desarrollo rápido, la interfaz y el procesamiento de archivos.

Procesamiento de audio y medios

Los programas de audio y vídeo procesan grandes flujos de datos en tiempo real o de forma diferida. Incluyen códecs, mezcladores, procesamiento de señales, conversión de vídeo, edición, streaming y reproducción.

En audio en tiempo real, incumplir el plazo de procesamiento puede provocar interrupciones audibles, por lo que son importantes la ejecución predecible y la limitación de asignaciones de memoria. C y C++ se utilizan en DSP, códecs y motores de audio, mientras que Rust puede aplicarse en procesamiento nativo de medios que requiere seguridad de memoria.

Los programas de producción multimedia pueden combinar motores de procesamiento nativos con capas de automatización y plugins basadas en Python o JavaScript. CUDA y los compute shaders pueden utilizarse para procesamiento de vídeo mediante GPU.

Programación de redes

La programación de redes implementa sockets, protocolos, servidores y clientes, proxies, equipos de red y bibliotecas de comunicación.

En los programas de red son importantes los siguientes elementos.

  • Gran cantidad de conexiones simultáneas.
  • Entrada y salida asíncrona.
  • Tiempos de espera y cancelación.
  • Límites de mensajes y serialización.
  • Protocolos de seguridad.
  • Envíos y recepciones parciales.
  • Cierre y reconexión.
  • Administración de búferes y memoria.
  • API de red específicas de cada sistema operativo.

C y C++ se utilizan en pilas de red del sistema operativo, proxies de alto rendimiento e implementaciones de protocolos. Go resulta adecuado para servicios mediante goroutines y su biblioteca estándar de red, mientras que Rust puede aprovechar seguridad de memoria y bibliotecas asíncronas. Java, C#, Python y JavaScript también se utilizan en aplicaciones servidor y cliente.

La sintaxis asíncrona proporcionada por un lenguaje puede reducir la complejidad, pero el estado de los protocolos, los límites temporales, la validación de entradas y las defensas frente a ataques deben diseñarse por separado.

Ciberseguridad

En el campo de la ciberseguridad se desarrollan productos de seguridad, análisis de vulnerabilidades, vigilancia de redes, implementaciones criptográficas, análisis de malware, informática forense y automatización.

Los lenguajes utilizados varían según la tarea.

  • C y C++ se utilizan en sistemas operativos, binarios, redes y herramientas de bajo nivel.
  • Rust puede emplearse en herramientas y componentes de sistemas donde la seguridad de memoria es importante.
  • Python se utiliza en análisis, automatización, conexión con API de seguridad y herramientas de prueba.
  • El lenguaje ensamblador puede ser necesario para analizar ejecutables e instrucciones del procesador.
  • JavaScript se utiliza en seguridad web y análisis de entornos de navegador.
  • SQL está relacionado con seguridad de bases de datos y análisis de consultas.

Utilizar un lenguaje seguro no convierte automáticamente todo el programa en seguro. La autenticación, la autorización, los protocolos criptográficos, la validación de entradas, la administración de claves y la seguridad de la cadena de suministro son problemas distintos de la seguridad de memoria.

Criptografía

El software de criptografía implementa algoritmos de cifrado, firma, hash, intercambio de claves y generación de números aleatorios.

En las implementaciones criptográficas son importantes los siguientes elementos.

  • Operaciones exactas con enteros y bits.
  • Ejecución en tiempo constante.
  • Administración de memoria que contiene secretos.
  • Cambios de código provocados por optimizaciones.
  • Instrucciones de aceleración por hardware.
  • Uso de algoritmos verificados.
  • Comprobación estricta de entradas externas.
  • Revisión independiente de la implementación.

Las bibliotecas de alto rendimiento pueden combinar C, C++, Rust y lenguaje ensamblador. Las aplicaciones escritas en Python, Java y C# suelen llamar a bibliotecas estándar y API de plataforma verificadas, en lugar de implementar directamente algoritmos criptográficos.

Sistemas financieros y administrativos

Los sistemas financieros, de seguros, gubernamentales, minoristas, contables y empresariales procesan transacciones, clientes, contratos, pagos, libros contables e informes.

En este ámbito son importantes los siguientes elementos.

  • Exactitud de los datos.
  • Transacciones y registros de auditoría.
  • Compatibilidad a largo plazo.
  • Permisos de acceso y cumplimiento normativo.
  • Procesamiento de grandes cantidades de datos.
  • Integración con sistemas existentes.
  • Recuperación ante fallos.
  • Cálculos monetarios precisos.

COBOL se ha utilizado en sistemas administrativos existentes de bancos, aseguradoras, gobiernos y grandes empresas. Java, C# y SQL se utilizan en servidores empresariales y aplicaciones modernas, mientras que JavaScript y TypeScript se emplean en interfaces de usuario.

En áreas como negociación de alta frecuencia y cálculo de riesgos, donde la latencia y el rendimiento son importantes, pueden utilizarse C++, Java, Rust y Fortran. Fortran incluye la estadística y las finanzas entre sus campos de aplicación científica y numérica.

Cambiar el lenguaje de un sistema financiero no es una simple conversión de código. Deben conservarse con precisión las reglas de transacción, los formatos de datos, los requisitos regulatorios y los comportamientos acumulados durante décadas, por lo que resultan importantes la modernización gradual y la interoperabilidad.

Sistemas críticos para la seguridad

Los sistemas críticos para la seguridad son aquellos cuyos fallos pueden provocar daños a la vida y la salud humanas, al medio ambiente o a bienes de gran valor. Incluyen aeronáutica, ferrocarriles, automoción, dispositivos médicos, control industrial y sistemas nucleares.

Estos ámbitos requieren procesos de desarrollo y verificación más estrictos que las aplicaciones ordinarias.

  • Trazabilidad de requisitos.
  • Normas de codificación.
  • Análisis estático.
  • Medición de cobertura de pruebas.
  • Verificación formal.
  • Análisis del peor tiempo de ejecución.
  • Uso restringido de memoria dinámica.
  • Herramientas certificables.
  • Administración de cambios y registros de auditoría.

Ada puede utilizarse en aplicaciones de alta integridad y sistemas que requieren certificación según normas de seguridad. Su sistema de tipos fuerte y sus perfiles restringidos están diseñados para reducir errores y facilitar la verificación.

C y C++ también se utilizan en automoción, aeronáutica e industria, pero pueden combinarse con subconjuntos seguros, normas de codificación, análisis estático y restricciones de runtime. El proceso de desarrollo, las herramientas de verificación, el hardware y el diseño de seguridad del sistema completo pueden ser más importantes que la selección del lenguaje por sí sola.

Robótica y sistemas autónomos

Los sistemas robóticos y autónomos combinan entrada de sensores, localización, planificación, control, procesamiento de imágenes y comunicación con hardware.

Las funciones de los lenguajes pueden dividirse de la siguiente manera.

  • C y C++ se utilizan en control en tiempo real, procesamiento de sensores y middleware robótico.
  • Python se utiliza en algoritmos de investigación, experimentación, herramientas e integración de modelos de aprendizaje automático.
  • CUDA y los lenguajes de GPU aceleran procesamiento de imágenes y redes neuronales.
  • Rust puede aplicarse en comunicación segura con dispositivos y componentes de sistemas.
  • Entornos como MATLAB y Simulink pueden utilizarse para modelos de control y simulación.

Como estos programas interactúan directamente con el mundo físico, un error puede provocar no solo la terminación del programa, sino un comportamiento peligroso del dispositivo. Las restricciones de tiempo real, la incertidumbre de los sensores, la parada de emergencia y los estados seguros deben abordarse conjuntamente con el diseño general del sistema.

Internet de las cosas

El Internet de las cosas conecta sensores y dispositivos a una red para recopilar datos y ejecutar comandos remotos.

Un sistema IoT puede estar compuesto por varias capas.

Firmware del dispositivo
    C / C++ / Rust

Gateway
    C++ / Rust / Go / Python

Procesamiento de mensajes y servidor
    Go / Java / C# / Python / JavaScript

Almacenamiento y análisis de datos
    SQL / Python / Scala

Pantalla de administración
    JavaScript / TypeScript

En los dispositivos son importantes la memoria y energía limitadas, las interrupciones de red y la fiabilidad de las actualizaciones remotas. En los servidores son importantes la conexión simultánea de numerosos dispositivos, el procesamiento de mensajes, la autenticación de dispositivos y el análisis de datos.

Como los dispositivos IoT pueden permanecer instalados durante mucho tiempo y resultar difíciles de mantener físicamente, las actualizaciones seguras, la administración de claves, el cifrado de comunicaciones y la recuperación ante fallos son tan importantes como la selección del lenguaje.

Blockchain y registros distribuidos

Los sistemas de blockchain y registros distribuidos están compuestos por programas de nodos, protocolos de consenso, comunicación de red, almacenamiento y contratos inteligentes.

Las implementaciones de nodos pueden utilizar C++, Go, Rust y Java, mientras que los contratos inteligentes utilizan lenguajes específicos de cada plataforma o lenguajes de propósito general restringidos.

Como los contratos pueden resultar difíciles de modificar después del despliegue y los costes de ejecución y cambios de estado pueden estar relacionados directamente con valores económicos, son importantes los siguientes elementos.

  • Ejecución determinista.
  • Procesamiento de enteros y valores monetarios.
  • Comprobaciones de permisos.
  • Reentrancia y relaciones de llamadas.
  • Límites de uso de recursos.
  • Atomicidad de los cambios de estado.
  • Verificación formal y análisis estático.
  • Políticas de actualización.

Un defecto recuperable en un servidor ordinario puede provocar una pérdida permanente de activos en un contrato inteligente, por lo que pueden priorizarse las restricciones y la verificabilidad sobre la expresividad.

Educación

Los lenguajes de programación se utilizan para enseñar informática, desarrollo de software, pensamiento lógico y resolución de problemas.

Al seleccionar un lenguaje educativo se consideran los siguientes aspectos.

  • Simplicidad y coherencia de la sintaxis.
  • Mensajes de error.
  • Facilidad de instalación y ejecución.
  • Prácticas interactivas.
  • Visualización y depuración.
  • Adecuación a los conceptos que se desea enseñar.
  • Conexión con lenguajes utilizados posteriormente.
  • Libros y materiales educativos.
  • Accesibilidad en sistemas operativos y dispositivos.

Python puede utilizarse en enseñanza introductoria y educación científica y de datos gracias a su sintaxis relativamente concisa y sus entornos interactivos. Swift Playgrounds permite aprender programación y desarrollo de aplicaciones mediante código Swift real y lecciones interactivas.

Scratch y los lenguajes basados en bloques reducen la carga de los errores sintácticos y permiten aprender visualmente conceptos de eventos, repeticiones, condiciones y variables. En la educación universitaria pueden utilizarse Python, Java, C, C++, Haskell y Scheme, entre otros, según los objetivos educativos.

Un lenguaje fácil para principiantes no siempre es el más apropiado para enseñar todos los conceptos avanzados. C y el lenguaje ensamblador pueden ser útiles para estudiar memoria y sistemas operativos, mientras que Haskell o los lenguajes de la familia ML pueden ser adecuados para enseñar funciones y teoría de tipos.

Investigación de lenguajes de programación

Los propios lenguajes de programación también son objetos de investigación. Se estudian sintaxis nuevas, sistemas de tipos, semántica, optimización de compiladores, verificación de programas y herramientas de desarrollo.

Entre los principales campos se encuentran los siguientes.

  • Teoría de tipos.
  • Semántica formal.
  • Compiladores.
  • Análisis estático.
  • Verificación de programas.
  • Seguridad de memoria.
  • Modelos de concurrencia.
  • Lenguajes específicos de dominio.
  • Síntesis y generación de código.
  • Lenguajes y experiencia de usuario.
  • Mensajes de error y herramientas.
  • Lenguajes de programación cuántica.
  • Lenguajes de programación probabilística.

Los lenguajes de investigación permiten experimentar con funciones nuevas difíciles de proporcionar en lenguajes industriales. Las funciones verificadas pueden incorporarse posteriormente a lenguajes existentes, bibliotecas y compiladores.

Una función exitosa en investigación no resulta necesariamente adecuada de inmediato para un lenguaje industrial. También deben examinarse el coste de implementación, el tiempo de compilación, el diagnóstico de errores, la compatibilidad con código existente y el coste de aprendizaje.

Lenguajes específicos de dominio

Un lenguaje específico de dominio está diseñado para expresar directamente los conceptos y reglas de un área concreta.

Entre los ejemplos representativos se encuentran los siguientes.

  • SQL expresa consultas de datos relacionales.
  • Las expresiones regulares expresan patrones de cadenas.
  • Los lenguajes de shaders expresan operaciones gráficas de GPU.
  • Los lenguajes de descripción de hardware expresan circuitos digitales.
  • Los lenguajes de compilación expresan dependencias entre archivos y tareas.
  • Los lenguajes de configuración expresan la configuración de programas y sistemas.
  • Los lenguajes de modelado estadístico expresan distribuciones de probabilidad y relaciones de observación.
  • Los lenguajes de políticas expresan reglas de permisos y acceso.

Un lenguaje específico de dominio proporciona expresiones más breves y claras que un lenguaje de propósito general dentro de su área y permite que el entorno de ejecución utilice conocimiento del dominio para optimización y verificación. Sin embargo, su ámbito es más estrecho y puede requerir herramientas y aprendizaje separados.

Puede existir como formato de archivo independiente o integrarse dentro de bibliotecas, operadores y macros de un lenguaje general.

Diseño de hardware

El diseño de circuitos digitales y procesadores requiere un modelo computacional diferente al de la ejecución secuencial de software. Varias partes del circuito funcionan simultáneamente y deben expresarse señales, relojes, retardos y recursos físicos.

Los lenguajes de descripción de hardware como Verilog y VHDL describen registros, circuitos combinacionales, máquinas de estados y conexiones entre módulos. Los diseños pueden simularse o sintetizarse para circuitos FPGA y ASIC.

También se utilizan lenguajes de construcción de hardware desarrollados sobre lenguajes generales como Scala, Python y Haskell. En estos casos se aprovechan abstracciones y metaprogramación del lenguaje anfitrión para generar finalmente una representación intermedia de descripción de hardware.

Shaders y programación de GPU

Las GPU están diseñadas para ejecutar operaciones semejantes en paralelo mediante numerosas unidades de cálculo. Los shaders y kernels de GPU se orientan a este modelo.

Los shaders gráficos realizan tareas como las siguientes.

  • Transformación de coordenadas de vértices.
  • Cálculo de materiales e iluminación.
  • Muestreo de texturas.
  • Cálculo del color de píxeles.
  • Posprocesamiento.
  • Trazado de rayos.
  • Cálculo paralelo de propósito general.

En estos lenguajes deben considerarse hilos, grupos de trabajo, jerarquías de memoria, sincronización y divergencia de bifurcaciones. Aunque utilicen una sintaxis semejante a los lenguajes de CPU, su modelo de ejecución es diferente.

CUDA proporciona un modelo para utilizar cálculo mediante GPU desde C++ y Python, mientras que las API gráficas pueden emplear representaciones de shaders como HLSL, GLSL y SPIR-V.

Computación cuántica

En la computación cuántica se utilizan lenguajes y SDK para expresar qubits, puertas cuánticas, mediciones y control clásico.

Los programas cuánticos suelen tener una estructura híbrida en la que un programa clásico construye un circuito cuántico, lo ejecuta y procesa posteriormente sus resultados.

Programa anfitrión clásico
    ↓
Generación del circuito cuántico
    ↓
Simulador o dispositivo cuántico
    ↓
Resultados de medición
    ↓
Análisis clásico

Los estados cuánticos no pueden copiarse libremente como valores ordinarios, y la medición afecta al estado. Por ello, se investigan tipos lineales, sistemas de efectos y lenguajes basados en circuitos.

Actualmente, la programación cuántica se utiliza tanto con dispositivos reales como con simuladores, educación e investigación de algoritmos, y son importantes el soporte del SDK de hardware, el compilador y la representación de circuitos más que un lenguaje concreto.

WebAssembly fuera del navegador

WebAssembly puede utilizarse no solo en navegadores, sino también en servidores, plugins, sandboxes y entornos portables de ejecución de módulos.

Entre sus aplicaciones se encuentran las siguientes.

  • Ejecución en el navegador de módulos escritos en lenguajes nativos.
  • Plugins aislados de forma segura.
  • Funciones serverless.
  • Formatos de distribución ejecutables en varios sistemas operativos.
  • Sistemas de mods para videojuegos y aplicaciones.
  • Computación en el edge.
  • Backend común para runtimes de lenguajes.

WebAssembly separa su modelo computacional central de las funciones de archivos y redes proporcionadas por el anfitrión. Para acceder a funciones del sistema operativo se necesitan API del navegador o interfaces de anfitrión como WASI.

Integración y extensión de lenguajes

Una aplicación puede incluir internamente un intérprete o máquina virtual de otro lenguaje de programación. Esto permite a los usuarios ampliar el comportamiento del programa o automatizar tareas repetitivas.

Entre los principales ámbitos se encuentran los siguientes.

  • Scripts de videojuegos.
  • Automatización de editores.
  • Mods.
  • Plugins.
  • Configuraciones y reglas.
  • Escenarios de simulación.
  • Pipelines de procesamiento de datos.

Lua, Python, JavaScript y lenguajes específicos de dominio propios pueden utilizarse como lenguajes embebidos. El programa anfitrión puede exponer al script únicamente API seguras y limitar el tiempo de ejecución, la memoria y el acceso a archivos y redes.

La integración aumenta la flexibilidad, pero debe resolver el tiempo de vida de objetos anfitriones, la transmisión de errores, la depuración, la compatibilidad entre versiones y el aislamiento de código no confiable.

Criterios para seleccionar un lenguaje

La selección de un lenguaje para un ámbito concreto no puede determinarse únicamente por su popularidad o su velocidad de ejecución.

Entre los principales criterios se encuentran los siguientes.

  • Compatibilidad con el sistema operativo y el hardware de destino.
  • Existencia de las bibliotecas y frameworks necesarios.
  • Cumplimiento de los requisitos de rendimiento y latencia.
  • Adecuación a los límites de memoria y energía.
  • Cumplimiento de los requisitos de seguridad y protección.
  • Capacidad de conectarse con código y datos existentes.
  • Disponibilidad de desarrolladores y personal de mantenimiento.
  • Existencia de suficientes herramientas de compilación, pruebas y depuración.
  • Políticas de soporte y compatibilidad a largo plazo.
  • Facilidad de distribución y actualización.
  • Adecuación a licencias, estándares y requisitos de certificación.
  • Ajuste al tamaño y al periodo de desarrollo del proyecto.

Seleccionar un lenguaje no tiene que significar imponer uno solo a todas las partes del proyecto. Si se separan componentes con necesidades diferentes y se conectan mediante interfaces estables, puede utilizarse el lenguaje más adecuado para cada ámbito.

Sin embargo, aumentar la cantidad de lenguajes también incrementa los siguientes costes.

  • Mayor complejidad del sistema de compilación y las herramientas.
  • Necesidad de transformar datos en los límites entre lenguajes.
  • Coordinación de runtimes y métodos de administración de memoria distintos.
  • Mayor dificultad de depuración y perfilado.
  • Necesidad de que los desarrolladores conozcan varios ecosistemas.
  • Administración de compatibilidad ABI y versiones.

Por ello, una estructura multilingüe resulta apropiada cuando los límites entre componentes están claros y los beneficios obtenidos al añadir un lenguaje son mayores que los costes de interoperabilidad.

Relación entre los ámbitos y los lenguajes

La relación entre un lenguaje de programación y sus ámbitos de aplicación no es fija. La aparición de nuevos entornos de ejecución, bibliotecas y compiladores puede extender un lenguaje existente hacia campos nuevos. Kotlin amplió su ámbito desde la JVM hacia Android y el desarrollo multiplataforma, mientras que Swift se está extendiendo más allá de las aplicaciones de Apple hacia servidores y sistemas embebidos. Los materiales oficiales de Apple sobre Swift también presentan como ámbitos de aplicación no solo las aplicaciones móviles, sino también los servidores y el firmware embebido.

A la inversa, un lenguaje ampliamente utilizado en un campo no siempre fue elegido porque fuera técnicamente superior a todos los demás. El código y las bibliotecas existentes, los materiales educativos, las herramientas de desarrollo, el soporte de fabricantes de hardware y el mercado laboral ejercen una gran influencia. El código verificado durante largo tiempo, los estándares y la interoperabilidad pueden tener más valor que las funciones sintácticas nuevas.

El uso de los lenguajes ha evolucionado más hacia la cooperación entre lenguajes de diferentes niveles de abstracción que hacia la sustitución de todos los ámbitos por uno solo. Dentro de un mismo sistema pueden coexistir una capa de infraestructura centrada en sistemas y rendimiento, una capa de aplicaciones administradas, scripts de automatización y lenguajes específicos para consultas de datos y GPU.

Por ello, comprender los ámbitos de aplicación de los lenguajes no consiste únicamente en enumerar qué puede construirse con cada uno. Significa identificar el modelo computacional, la seguridad, el rendimiento, el entorno de ejecución y el ecosistema exigidos por cada campo, y seleccionar el lenguaje, el runtime, las bibliotecas y las herramientas de acuerdo con los objetivos de todo el sistema.

Comparación de lenguajes de programación

Comparar lenguajes de programación no consiste en determinar si un lenguaje es, en términos generales, superior a otro. Cada lenguaje está diseñado para problemas, entornos de ejecución, requisitos de seguridad, métodos de desarrollo y ecosistemas existentes diferentes, por lo que no es posible establecer la superioridad de todos los lenguajes mediante un único criterio. Un lenguaje adecuado para el núcleo de un sistema operativo no tiene por qué ser también el más apropiado para el análisis de datos, y un lenguaje que permite escribir rápidamente un programa corto de automatización no ofrece necesariamente las mismas ventajas en el código central de un renderer en tiempo real.

Las características reales de un lenguaje no están determinadas únicamente por su sintaxis. La especificación del lenguaje, el sistema de tipos, el compilador o intérprete, el runtime, el recolector de basura, la biblioteca estándar, el ecosistema de paquetes, las herramientas de desarrollo y las plataformas de destino actúan conjuntamente. Incluso dentro de un mismo lenguaje, el rendimiento, el uso de memoria, el tiempo de inicio y el método de distribución pueden variar según la implementación, las opciones de compilación, las bibliotecas y el entorno de ejecución.

Por ello, los lenguajes de programación deben compararse separando varios criterios como los siguientes.

Criterio de comparaciónPreguntas principales
Abstracción y controlEn qué medida puede controlarse directamente el hardware y la memoria
Modelo de ejecuciónSi utiliza código nativo, una máquina virtual o un intérprete
Sistema de tiposCuándo se comprueban los errores y cómo se expresan los tipos
Administración de memoriaQuién administra el tiempo de vida de objetos y recursos
SeguridadQué clases de errores impiden el lenguaje y la implementación
RendimientoEn qué medida pueden controlarse el throughput, la latencia y el uso de memoria
ConcurrenciaCómo se expresan varias tareas y el estado compartido
PortabilidadSi puede ejecutarse en varios sistemas operativos y arquitecturas
InteroperabilidadCon qué facilidad puede utilizar bibliotecas existentes y otros lenguajes
Productividad de desarrolloQué tan cómodo es escribir, modificar, probar y detectar errores
Herramientas y ecosistemaSi existen suficientes compiladores, depuradores, paquetes y frameworks
Estabilidad y compatibilidadSi el lenguaje y la plataforma mantienen el código existente a largo plazo

Límites de la comparación

Al comparar lenguajes de programación debe distinguirse entre el lenguaje y sus implementaciones. Python es un lenguaje y CPython es su implementación representativa. Java es un lenguaje, mientras que la JVM es un entorno de ejecución que pueden utilizar varios lenguajes, incluido Java. C# es un lenguaje que generalmente se utiliza junto con .NET, pero la especificación de C# no obliga a que toda implementación conforme esté construida sobre CLI o .NET. La referencia oficial de Python también describe la sintaxis y la semántica fundamentales del lenguaje sin fijar todos los detalles de implementación a CPython.

La distinción entre «lenguaje compilado» y «lenguaje interpretado» tampoco debe tratarse como una propiedad absoluta del propio lenguaje. Java suele traducir el código fuente a bytecode independiente de la máquina para ejecutarlo posteriormente en la JVM, y esta puede interpretarlo o compilarlo mediante JIT. Las implementaciones de Python también pueden utilizar bytecode interno, máquinas virtuales, compilación JIT y otros métodos. La especificación de Java describe como resultado habitual de la compilación el bytecode independiente de la máquina, pero esto no significa que el lenguaje Java quede limitado a una única estrategia concreta de ejecución.

Tampoco puede concluirse el rendimiento únicamente a partir del nombre del lenguaje. Incluso dentro de un mismo lenguaje, los resultados cambian según el algoritmo, la estructura de datos, el compilador, las opciones de optimización, el estado de preparación del runtime, la configuración del recolector de basura y la arquitectura de CPU y memoria. El tiempo de inicio de un programa corto de línea de comandos, el throughput máximo de un servidor de larga duración y la peor latencia de un sistema de audio en tiempo real son criterios de rendimiento diferentes.

Por ello, la comparación debe centrarse menos en «qué lenguaje es el más rápido» y más en «qué costes se asumen en cada capa y hasta qué punto pueden predecirse y controlarse».

Nivel de abstracción y control del hardware

Los lenguajes de programación difieren en la medida en que exponen la estructura concreta del hardware.

C (lenguaje de programación) permite tratar directamente punteros, direcciones de memoria, representaciones de enteros, disposición de estructuras y administración manual de memoria. El estándar de C define la representación y el significado de los programas y busca portabilidad y ejecución eficiente en diversos sistemas. Sin embargo, el tamaño y la alineación exactos de los tipos y los resultados de algunas operaciones pueden variar según la implementación y el entorno de destino.

C++ proporciona un control de bajo nivel semejante al de C y añade abstracciones de alto nivel como clases, plantillas, programación genérica, semántica de valores y administración de recursos basada en ámbitos. Uno de los principios importantes del diseño de C++ es el de abstracción sin coste: no debe pagarse por funciones que no se utilizan y una abstracción utilizada, cuando se implementa correctamente, no debe exigir costes innecesarios frente a código de bajo nivel escrito directamente.

Rust también permite escribir programas de sistemas que necesitan punteros, control de disposición de memoria, ABI nativa y administración directa de recursos, pero en el código seguro restringe el uso de memoria mediante propiedad, préstamos y reglas de tiempos de vida. La propiedad de Rust es un método en el que el compilador comprueba las reglas de administración sin utilizar un recolector de basura durante la ejecución.

Go (lenguaje de programación) es un lenguaje de propósito general diseñado considerando la programación de sistemas, pero delega al runtime más detalles sobre memoria y disposición de objetos que C y Rust. La especificación de Go lo describe como un lenguaje con tipado estático, recolección de basura, soporte explícito para concurrencia y organización de programas basada en paquetes.

Lenguajes como Java, C#, Python y JavaScript normalmente no permiten manipular directamente la disposición de memoria de los objetos ni realizar aritmética de punteros, sino que utilizan el modelo de objetos de una máquina virtual o runtime. Esta abstracción puede aumentar la seguridad de memoria y la portabilidad, pero dificulta controlar directamente los costes de disposición de objetos, recolección y llamadas dinámicas.

El nivel de abstracción no puede evaluarse simplemente como alto o bajo. Un mayor control del hardware permite optimizaciones especiales y acceso a dispositivos, pero obliga a administrar diferencias entre plataformas y errores de memoria. Cuando el runtime administra más detalles, resulta más sencillo concentrarse en la lógica de la aplicación, aunque esto puede constituir una limitación en ámbitos donde son importantes la respuesta en tiempo real y la disposición de memoria.

Modelo de ejecución

Los modelos habituales de ejecución de lenguajes representativos pueden compararse de la siguiente manera.

LenguajeModelo de ejecución habitualCaracterísticas principales
C (lenguaje de programación)Compilación nativa AOTRuntime pequeño, ABI directa y acceso al sistema
C++Compilación nativa AOTAbstracciones de alto nivel y optimización específica del destino
RustCompilación nativa AOTGeneración de código nativo después de comprobar propiedad
Go (lenguaje de programación)Compilación nativa AOTIncluye recolector de basura y runtime del lenguaje
JavaBytecode JVM con JIT o AOTMáquina virtual y entorno administrado
C#Lenguaje intermedio común con .NET JIT o AOTCLR y runtime administrado
PythonInterpretación de bytecode, JIT o extensiones nativas según la implementaciónEjecución dinámica y desarrollo interactivo
JavaScriptIntérprete y JIT en motores de navegador o servidorIntegración estrecha con el entorno anfitrión
TypeScriptTransformación a JavaScript y ejecución en un entorno JavaScriptLa información de tipos suele eliminarse antes de la ejecución
KotlinJVM, JavaScript, código nativo, WebAssembly y otrosBackends específicos e interoperabilidad
SwiftCompilación nativaTipos por valor y administración automática de memoria
FortranCompilación nativa AOTOptimización centrada en cálculo numérico y arreglos, y funciones paralelas

Esta tabla representa implementaciones habituales, pero no limita todos los métodos posibles. El código C y C++ puede compilarse a WebAssembly u otras representaciones intermedias, y los programas Java y .NET también pueden distribuirse mediante AOT. Las partes de cálculo intensivo de Python pueden delegarse en bibliotecas de C, C++, Fortran o GPU.

JavaScript, cuyo estándar es ECMAScript, define reglas para el cálculo y la manipulación de objetos, pero delega la entrada y salida externas en el entorno anfitrión, como un navegador o un runtime de servidor. Por ello, el conjunto real de funciones de JavaScript es el resultado de combinar ECMAScript con las Web API del navegador o las API de Node.js.

TypeScript no sustituye el entorno de ejecución de JavaScript. Analiza estáticamente las formas y los comportamientos de los valores JavaScript y genera JavaScript eliminando o transformando anotaciones de tipos y determinadas construcciones. Su sistema de tipos se centra en el tipado estructural para modelar los patrones de uso de objetos de JavaScript.

Tipado estático y dinámico

Los lenguajes de tipado estático comprueban principalmente antes de la ejecución las relaciones de tipos entre variables, expresiones y llamadas a funciones. C, C++, Rust, Go, Java, C#, Kotlin, Swift y Fortran pertenecen a esta categoría. La especificación de Java lo define como un lenguaje estático en el que los tipos de todas las variables y expresiones se conocen durante la compilación.

En los lenguajes de tipado dinámico, los valores poseen tipos durante la ejecución y se comprueba el tipo real al realizar una operación. Python y JavaScript son ejemplos representativos. En el modelo de datos de Python, todos los datos se representan mediante objetos o relaciones entre objetos, y cada objeto posee identidad, tipo y valor.

La diferencia entre tipado estático y dinámico no consiste simplemente en escribir o no el nombre del tipo en una declaración. Rust, Kotlin, Swift y C#, entre otros, pueden inferir tipos locales y seguir realizando comprobaciones estáticas. A la inversa, Python y JavaScript también disponen de comprobaciones de tipos y objetos durante la ejecución.

Las principales ventajas del tipado estático son las siguientes.

  • Permite detectar algunos errores antes de la ejecución.
  • Permite expresar mediante tipos los contratos de funciones y módulos.
  • Facilita la navegación, el autocompletado y la refactorización en los IDE.
  • Permite utilizar información sobre valores y llamadas para optimizar.
  • Facilita comprobar el impacto de cambios en grandes bases de código.

Las principales ventajas del tipado dinámico son las siguientes.

  • Permite tratar de manera natural datos y objetos cuya forma cambia durante la ejecución.
  • Facilita escribir rápidamente programas pequeños y código experimental.
  • Permite construir abstracciones sin declaraciones genéricas o de tipos complejas.
  • Facilita utilizar directamente reflexión, metaprogramación y carga dinámica.

Incluso en lenguajes estáticos permanecen errores lógicos que el sistema de tipos no puede expresar, y los lenguajes dinámicos pueden alcanzar una elevada fiabilidad mediante pruebas suficientes y análisis estático. El momento de comprobación de tipos es solo uno de los factores que influyen en la calidad del software.

Tipado nominal y estructural

Las clases e interfaces de Java, C#, Kotlin y Swift siguen principalmente sistemas nominales basados en nombres declarados y relaciones explícitas de herencia o implementación. Aunque dos clases posean los mismos campos y métodos, normalmente no se tratan como el mismo tipo si no existe una relación declarada.

TypeScript utiliza principalmente un sistema estructural que compara la forma de los miembros de un objeto. Aunque no se declare explícitamente la implementación de una interfaz, un objeto que posea todos los campos y métodos requeridos puede ser compatible con ella.

Las interfaces de Go también poseen un carácter estructural, porque un tipo satisface una interfaz si proporciona el conjunto de métodos necesario sin declarar implements. Sin embargo, el sistema completo de tipos de Go y los tipos estructurales de objetos de TypeScript no se basan en las mismas reglas, por lo que no pueden considerarse simplemente idénticos.

El tipado nominal resulta útil para distinguir claramente conceptos de dominio y límites de API. El tipado estructural permite utilizar objetos existentes con una interfaz necesaria sin modificarlos y puede reducir el acoplamiento. En cambio, también debe considerarse la posibilidad de que valores con estructuras accidentalmente iguales sean compatibles en posiciones no previstas.

Genéricos y programación genérica

Los genéricos de C++ y Rust pueden generar normalmente durante la compilación código adaptado a tipos concretos. Esto permite optimización específica por tipo y despacho estático, aunque muchas combinaciones de tipos pueden aumentar el tiempo de compilación y el tamaño del código.

Los genéricos de Java utilizan principalmente borrado de tipos, por lo que varias instancias comparten bytecode común. Los genéricos de .NET conservan información de tipos durante la ejecución y pueden utilizar estrategias distintas de compartición y especialización según se trate de tipos por valor o por referencia. El sistema de tipos de C# unifica tipos por valor y por referencia permitiendo tratar finalmente todos los valores como object.

Los parámetros de tipos de Go utilizan restricciones con forma de interfaces para determinar los tipos y operaciones permitidos. Rust utiliza trait bounds y C++ plantillas y concepts. Los lenguajes de la familia Haskell expresan la generalización principalmente mediante polimorfismo paramétrico y clases de tipos.

Aunque la sintaxis de los genéricos sea semejante, los métodos de implementación, las restricciones de tipos y la conservación o no de información durante la ejecución cambian las características de rendimiento y el diseño de bibliotecas.

Métodos de administración de memoria

La administración de memoria es uno de los criterios más importantes al comparar lenguajes.

Administración manual de memoria

En C, el programador determina directamente cuándo asignar y liberar memoria. Esto permite un control detallado de recursos, pero obliga a prevenir manualmente fugas, usos posteriores a la liberación, dobles liberaciones y accesos fuera de límites.

C++ puede utilizar asignación manual y también proporciona RAII, punteros inteligentes y contenedores que conectan el tiempo de vida de los objetos con el ámbito. En C++ bien diseñado puede reducirse el uso directo de new y delete y limpiar recursos automáticamente al salir de un ámbito. Sin embargo, como permanecen disponibles los punteros sin procesar y la administración manual, la seguridad global depende del diseño y las reglas del código.

Administración basada en propiedad

Rust asigna un propietario a cada valor y limpia el recurso cuando dicho propietario sale del ámbito. Las referencias toman prestado el valor sin poseerlo, y el compilador comprueba las relaciones entre referencias mutables e inmutables y sus tiempos de vida. Este modelo busca proporcionar seguridad de memoria sin recolector de basura.

Su ventaja es que puede limitar durante la compilación numerosos errores de memoria y posibilidades de carrera de datos. En cambio, al expresar estructuras compartidas complejas, estructuras autorreferenciales y tiempos de vida asíncronos deben considerarse explícitamente propiedad y tiempos de vida, y determinadas operaciones de bajo nivel pueden requerir código unsafe.

Recolección de basura

Las implementaciones principales de Go, Java, C#, Python y JavaScript proporcionan administración automática de memoria. El runtime localiza los objetos que ya no son alcanzables y recupera su memoria, por lo que los objetos ordinarios no deben liberarse directamente.

El recolector de .NET asigna objetos en el montón administrado y recupera aquellos que no son alcanzables desde raíces como la pila, campos estáticos y registros de CPU. También puede compactar objetos para reducir fragmentación.

La recolección reduce la carga de administrar tiempos de vida, pero puede introducir pausas, uso adicional de memoria y complejidad del runtime. Los recolectores modernos reducen estos costes mediante generaciones, recolección concurrente y procesamiento incremental, aunque en sistemas con límites estrictos de peor latencia se requiere una consideración especial.

Conteo de referencias

Swift administra el tiempo de vida de instancias de clases mediante conteo automático de referencias y utiliza ampliamente tipos por valor como estructuras y enumeraciones. La documentación de Swift explica que tipos habituales como arreglos y diccionarios son tipos por valor y que la inicialización, la comprobación de límites y la seguridad de memoria forman parte importante del diseño.

El conteo de referencias facilita comprender el momento de destrucción, ya que esta se produce cuando desaparece la última referencia fuerte, pero exige prevenir ciclos de referencias fuertes e introduce el coste de incrementar y reducir contadores.

Seguridad de memoria

La seguridad de memoria garantiza que un programa acceda únicamente a memoria válida, con el tipo adecuado y dentro del tiempo de vida correcto.

C y C++ permiten acceso y reinterpretación directos, proporcionando un control muy amplio, pero requieren subconjuntos seguros, normas de codificación, análisis estático, comprobaciones de runtime y buen diseño de bibliotecas. Los punteros incorrectos y los errores de límites pueden producir comportamiento indefinido.

Rust comprueba propiedad y préstamos en código seguro, pero proporciona unsafe para interfaces externas, punteros sin procesar y algunas operaciones de sistemas. unsafe no desactiva todas las comprobaciones, sino que permite determinadas operaciones adicionales y deja sus condiciones de seguridad bajo responsabilidad del programador.

Los lenguajes administrados como Java, C# y Go restringen la aritmética arbitraria de punteros en el código ordinario y comprueban límites de arreglos y tipos de objetos. C# permite escribir la mayor parte del código como código seguro verificable, pero admite punteros y acceso directo en regiones explícitas unsafe.

Swift también proporciona comportamientos seguros de manera predeterminada, pero ofrece la familia UnsafePointer para interoperabilidad con C y optimizaciones especiales. Al utilizarlos, el desarrollador asume directamente ciertas responsabilidades de memoria y tiempos de vida.

Por ello, en lugar de dividir absolutamente los lenguajes en seguros e inseguros, debe compararse qué errores se impiden en el código predeterminado, qué mecanismos de evasión existen y con qué claridad pueden construirse los límites de seguridad.

Semántica de valores y semántica de referencias

Los lenguajes también difieren en su forma predeterminada de copiar y compartir valores.

Las estructuras y objetos ordinarios de C y C++ pueden copiarse por valor de forma predeterminada. Mediante punteros y referencias puede compartirse el mismo objeto. En C++, los constructores y operadores de copia y movimiento permiten definir cómo se copia un valor o se transfiere su propiedad.

Rust distingue tipos no copiables, cuyo comportamiento predeterminado es el movimiento, y tipos copiables explícitamente. Un valor que posee recursos del montón puede transferir su propiedad al asignarse o transmitirse a una función, y el uso del nombre anterior queda restringido.

En Java, Python y JavaScript, las variables de objetos suelen contener referencias. La asignación copia la referencia, no el objeto completo. Algunos valores, como enteros y cadenas, son inmutables o siguen reglas propias, pero las modificaciones de objetos definidos por el usuario pueden observarse desde varias referencias.

C# distingue explícitamente tipos por valor y tipos por referencia. Las estructuras y enumeraciones son tipos por valor y se copian al asignarse o transmitirse como argumentos. Las clases son tipos por referencia.

Swift trata las estructuras y enumeraciones como tipos por valor y las instancias de clases como tipos por referencia. Las colecciones estándar proporcionan semántica de valores y pueden utilizar internamente optimizaciones de copia al escribir.

La semántica de valores puede reducir modificaciones inesperadas en otros lugares y favorecer el procesamiento paralelo. La semántica de referencias permite compartir el mismo estado entre varios componentes sin copiar objetos grandes, pero obliga a administrar alias y sincronización.

Modelo orientado a objetos

Java y C# admiten programación orientada a objetos centrada en clases, interfaces, herencia, métodos virtuales y tipado nominal. C# también proporciona estructuras por valor y registros dentro de un sistema unificado.

C++ ofrece clases, herencia múltiple y funciones virtuales junto con semántica de valores y polimorfismo estático basado en plantillas. La orientación a objetos es solo uno de sus paradigmas, y no todas las abstracciones deben utilizar herencia y llamadas virtuales.

Rust no proporciona herencia tradicional de clases. Define métodos sobre estructuras y enumeraciones y abstrae comportamientos comunes mediante traits. También puede utilizar objetos trait dinámicos, aunque suele combinar genéricos y despacho estático.

Go tampoco proporciona herencia de clases y utiliza estructuras, métodos, interfaces y composición. Un tipo puede satisfacer una interfaz si proporciona el conjunto de métodos necesario sin declararlo explícitamente.

JavaScript utiliza un modelo de objetos basado en prototipos. Aunque proporciona sintaxis class, las relaciones de herencia se basan en enlaces de prototipos. La especificación de ECMAScript define los objetos como conjuntos de propiedades y sitúa el comportamiento de objetos, funciones y prototipos en el centro de la semántica.

La superioridad de un modelo depende de la abstracción deseada. Las clases e interfaces pueden ser adecuadas para contratos nominales estables, mientras que los tipos por valor y los tipos suma pueden ser mejores para modelos centrados en datos. Los prototipos y las propiedades dinámicas pueden ser útiles cuando son importantes la extensión dinámica de objetos y la metaprogramación.

Tratamiento de errores

Los lenguajes también difieren en la forma de transmitir errores.

C utiliza habitualmente valores de retorno, códigos de error y estados globales. El tratamiento es explícito, pero los errores pueden ignorarse si el llamador no comprueba el resultado.

C++, Java, C#, Python, JavaScript, Kotlin y Swift proporcionan excepciones. Estas pueden propagarse automáticamente a través de varias llamadas, pero puede resultar difícil conocer únicamente por el tipo de retorno ordinario qué fallos pueden producirse.

Rust representa principalmente errores recuperables mediante tipos suma como Result<T, E> y utiliza pánico para errores no recuperables. Go se centra en devolver errores como valores ordinarios. Ambos métodos hacen visible la posibilidad de fallo en el código llamador, aunque requieren sintaxis y patrones idiomáticos para reducir la repetición.

Swift puede utilizar throws, do, try, catch, valores opcionales y tipos de resultado, mientras que Kotlin interoperará con Java pero no obliga a tratar las checked exceptions dentro de su sistema de tipos.

El tratamiento de errores no es una simple preferencia sintáctica. Afecta a las API de bibliotecas, la limpieza de recursos, la ejecución asíncrona, los límites transaccionales y la estrategia de recuperación.

Seguridad frente a valores nulos

En lenguajes como Java y las primeras versiones de C#, la mayoría de los tipos de referencia incluyen null y debe comprobarse por separado si un valor es un objeto real. Los nullable reference types modernos de C# distinguen string y string? a nivel de código fuente, pero no crean tipos distintos durante la ejecución, sino que se utilizan para análisis estático del compilador.

Kotlin distingue sintácticamente tipos no anulables y anulables. En principio, String no admite null, mientras que String? sí. Sin embargo, al interoperar con Java puede no existir información suficiente sobre nulabilidad y deben tratarse tipos de plataforma y anotaciones externas.

Rust no incluye null en las referencias ordinarias y representa la ausencia mediante Option<T>. Swift también utiliza tipos opcionales.

En Python y JavaScript se utilizan valores como None, null y undefined durante la ejecución dinámica, y algunas posibilidades pueden comprobarse mediante analizadores estáticos o TypeScript.

Las funciones de seguridad frente a nulos reducen considerablemente los errores, pero todavía pueden aparecer mediante código externo, inicialización, aserciones forzadas e interfaces inseguras.

Concurrencia y paralelismo

El soporte de concurrencia depende tanto de la sintaxis como del runtime y la biblioteca estándar.

C y C++ utilizan hilos del sistema operativo, operaciones atómicas, bloqueos y modelos de memoria. Proporcionan control de bajo nivel, pero exigen administrar directamente el estado compartido, los tiempos de vida y la sincronización.

Rust también admite hilos, bloqueos, operaciones atómicas y ejecución asíncrona. Relaciones como Send y Sync pueden restringir estáticamente si un valor puede moverse o compartirse entre hilos. La concurrencia con memoria compartida sigue requiriendo diseño de bloqueos y atomicidad, pero algunas carreras de datos pueden impedirse mediante el sistema de tipos.

Go proporciona goroutines y canales como parte central del lenguaje y el runtime. Las goroutines son unidades más ligeras que los hilos del sistema operativo y los canales transmiten valores y sincronizan tareas. Sin embargo, Go también permite memoria compartida y bloqueos, por lo que los canales no son su único modelo de concurrencia.

Java y C# proporcionan pools de hilos, futures, tasks, async·await, colecciones concurrentes y runtimes administrados. Los ecosistemas de servidores de JVM y .NET ofrecen herramientas y bibliotecas maduras para servicios concurrentes de gran tamaño.

Las corrutinas de Kotlin combinan funciones suspendibles del lenguaje con un modelo de ejecución basado en bibliotecas. Los schedulers, canales y flows son proporcionados principalmente por kotlinx.coroutines. El contexto de una corrutina incluye elementos como el job y el dispatcher.

JavaScript suele utilizar un event loop, Promise y async·await para la entrada y salida asíncrona. Los navegadores y runtimes de servidor pueden proporcionar trabajo paralelo o asíncrono real mediante colas, Web Workers y sistemas nativos de entrada y salida.

Una sintaxis de concurrencia sencilla no resuelve automáticamente carreras, interbloqueos, cancelación o fallos parciales. El diseño del estado compartido, los tiempos de vida, la propagación de errores y el control de presión es más importante.

Rendimiento y previsibilidad

El rendimiento se divide en throughput, latencia, tiempo de inicio, uso de memoria, tamaño binario y consumo energético.

C, C++ y Rust suelen utilizar lenguaje máquina nativo y límites de ejecución pequeños, lo que permite controlar detalladamente disposición de memoria, asignaciones y llamadas. Esto favorece ámbitos que requieren optimización de bajo nivel y latencias previsibles. Sin embargo, el alto rendimiento no está garantizado automáticamente, y una estructura de datos o acceso a memoria inadecuados pueden reducir considerablemente el rendimiento en cualquier lenguaje.

Go genera código nativo, pero incluye recolector de basura y scheduler de goroutines. Facilita construir servidores y programas de red, aunque deben considerarse los costes de recolección y planificación.

Los compiladores JIT de Java y C# pueden optimizar a partir de información real de ejecución. En programas de larga duración son posibles optimizaciones como inlining dinámico y especialización de tipos, pero el inicio, el calentamiento, la caché de código y la recolección afectan al rendimiento.

Lenguajes dinámicos como Python y JavaScript pueden incurrir en costes al procesar durante la ejecución los tipos y operaciones. Los motores JIT de JavaScript pueden especializar tipos y formas de objetos repetidos, pero si se invalidan las suposiciones requieren desoptimización. En Python es habitual realizar operaciones masivas dentro de bibliotecas de C, C++ o Fortran para reducir el coste de los bucles del intérprete.

Fortran ha evolucionado alrededor de arreglos, cálculo numérico y ciencia, y admite no solo programación imperativa y procedimental, sino también formas centradas en arreglos, orientación a objetos y funciones. Su estructura y compiladores pueden ser opciones importantes para cálculo numérico y paralelo.

El rendimiento debe medirse con implementaciones y cargas reales, no con la imagen promedio del lenguaje. El procesamiento de cadenas, la espera de bases de datos, los arreglos numéricos, la asignación de memoria y la entrada y salida de red exigen características distintas.

Tiempo de compilación y de inicio

El tiempo de compilación de C y C++ puede variar considerablemente según la inclusión de cabeceras, la instanciación de plantillas y el nivel de optimización. Las plantillas complejas y la optimización global de C++ pueden exigir costes elevados.

Rust también puede tener tiempos de compilación significativos en proyectos grandes debido a la monomorfización de genéricos, la comprobación de propiedad y tipos y la optimización mediante LLVM.

Go adopta como objetivos principales la compilación rápida y dependencias sencillas, y administra estas mediante su estructura de paquetes.

Java y C# pueden generar bytecode o lenguaje intermedio relativamente rápido, pero durante la ejecución puede añadirse compilación JIT. El uso de AOT e imágenes de runtime preparadas permite ajustar el inicio.

Python y JavaScript pueden ejecutarse inmediatamente sin una compilación nativa completa, lo que favorece el desarrollo y los scripts breves. Sin embargo, la carga de módulos, la inicialización del runtime y el calentamiento del JIT pueden formar parte del tiempo de inicio.

El inicio es importante para CLI breves y funciones serverless, mientras que en servidores de larga ejecución puede ser más importante el throughput estable que el coste inicial.

Métodos de distribución

Los programas de C, C++, Rust y Go suelen distribuirse como ejecutables nativos para el sistema operativo y la CPU de destino. El enlace estático puede reducir dependencias externas, pero deben considerarse el tamaño, las licencias y las políticas de bibliotecas del sistema. El enlace dinámico permite compartir bibliotecas, pero depende de ABI y versiones instaladas.

Los programas Java necesitan una JVM y archivos de clases o JAR. El runtime puede empaquetarse con la aplicación o utilizar una JVM instalada. C# y .NET admiten distribución dependiente del framework, autocontenida y AOT nativa.

Los programas Python necesitan un intérprete y paquetes. Pueden empaquetarse como aplicaciones independientes, pero las extensiones nativas y las dependencias específicas del sistema pueden aumentar el tamaño y la complejidad.

El frontend JavaScript utiliza generalmente el navegador como runtime y bundlers para preparar módulos. JavaScript de servidor necesita un runtime como Node.js y sus paquetes.

La distribución de Swift y Kotlin cambia según el destino. Kotlin/JVM utiliza JVM y Kotlin/Native puede crear binarios nativos. Swift puede generar ejecutables nativos para plataformas Apple, Linux y Windows.

Portabilidad entre plataformas

C y C++ poseen implementaciones en casi todos los sistemas y procesadores principales y facilitan el acceso a hardware y API del sistema. Sin embargo, el programa puede tener que tratar directamente diferencias de tamaños de tipos, ABI, sistemas de archivos y API.

Rust y Go también admiten las principales arquitecturas de escritorio y servidor y algunas embebidas. El grado de soporte depende no solo de la especificación, sino también del backend, la biblioteca estándar y la madurez de las herramientas para cada plataforma.

Java fue diseñado para utilizar un nivel común de ejecución mediante JVM y ejecutar el mismo formato de clases en varios sistemas. Sin embargo, al utilizar bibliotecas nativas, gráficos e integración con el sistema reaparecen diferencias.

C# y .NET admiten varios sistemas operativos, pero algunos frameworks de interfaz y API del sistema son específicos.

JavaScript dispone del navegador como entorno común muy amplio, aunque debe considerarse el soporte de funciones y API del anfitrión entre navegadores.

Aunque Kotlin y Swift admitan varias plataformas, los frameworks y bibliotecas disponibles pueden ser diferentes. Que un lenguaje sea multiplataforma no implica que toda la aplicación funcione sin cambios en todas ellas.

Interoperabilidad con otros lenguajes

La ABI de C es relativamente sencilla y ampliamente compatible, por lo que se utiliza como frontera común. C++, Rust, Swift, Python, Java y C#, entre otros, pueden proporcionar interfaces para llamar a funciones C o ser llamados desde C.

La ABI de C++ depende en mayor medida de clases, decoración de nombres, excepciones, tipos de la biblioteca estándar y versiones del compilador. Para interfaces externas estables suelen utilizarse una ABI de C y handles opacos.

Kotlin fue diseñado teniendo como objetivo la interoperabilidad con Java, por lo que puede utilizar la mayoría del código Java directamente y exponer código Kotlin a Java.

C# comparte el sistema común de tipos y el lenguaje intermedio con otros lenguajes de .NET y puede utilizar P/Invoke y funciones de interoperabilidad nativa.

Python conecta bibliotecas nativas mediante su API de C y módulos de extensión. Esto permite utilizar Python como capa de control en ciencia y aprendizaje automático y delegar el cálculo intensivo en bibliotecas nativas.

Swift proporciona interoperabilidad con Objective-C y C y está ampliando la conexión con C++. Sin embargo, al utilizar interfaces externas inseguras deben comprobarse directamente memoria y tiempos de vida.

Estandarización y estabilidad

C y C++ administran sus especificaciones mediante normas ISO/IEC. WG14 se ocupa actualmente de C y WG21 de C++. Los estándares internacionales proporcionan una base común para implementaciones independientes.

Java ofrece por separado la especificación del lenguaje y la de la JVM, distinguiendo claramente el lenguaje fuente de la máquina virtual.

ECMAScript evoluciona mediante la especificación viva de TC39, incorporando propuestas completadas.

Python, Rust, Go, Kotlin y Swift evolucionan mediante propuestas públicas y gobernanza de proyecto. Incluso sin una norma internacional, pueden proporcionar compatibilidad práctica mediante documentación clara, políticas de estabilidad y calidad de sus implementaciones principales.

Que un lenguaje esté estandarizado no significa que todas las implementaciones admitan siempre todas las funciones recientes al mismo tiempo. A la inversa, un lenguaje administrado por un solo proyecto puede distribuir funciones con mayor rapidez, pero aumenta la dependencia de su implementación y gobernanza.

Productividad de desarrollo

La productividad no se mide únicamente por el número de líneas. Influyen el momento de detección de errores, la velocidad de compilación y pruebas, la facilidad de uso de bibliotecas, los mensajes de error y las herramientas.

Python resulta adecuado para automatización y desarrollo experimental gracias a objetos dinámicos, sintaxis concisa y ejecución interactiva. En bases grandes, las indicaciones de tipos, el análisis estático, las pruebas y los límites de módulos adquieren mayor importancia.

Java y C# permiten administrar grandes aplicaciones mediante tipos y modelos de objetos explícitos, bibliotecas amplias e IDE maduros. El código puede ser más verboso, pero proporciona información útil para refactorización y análisis.

Kotlin conserva la compatibilidad con Java y añade seguridad frente a nulos, inferencia, funciones de extensión y expresiones concisas de datos. Puede aprovechar las bibliotecas existentes, aunque deben considerarse las diferencias de nulabilidad e idiomática de API.

TypeScript mantiene el ecosistema de JavaScript y añade tipado estático y análisis en el editor. Su sistema es flexible para modelar patrones dinámicos, pero muchos tipos se eliminan antes de la ejecución, por lo que los datos externos deben validarse por separado.

Rust detecta durante la compilación problemas de propiedad, tiempos de vida y restricciones de traits, pero puede exigir un mayor coste inicial de diseño y aprendizaje. En código de sistemas mantenido a largo plazo, este coste puede traducirse en menos defectos y refactorizaciones más seguras.

C y C++ ofrecen excelente acceso al hardware y a bibliotecas existentes, pero exigen administrar memoria, ABI, configuración de compilación y diferencias de plataforma. Debido a la amplitud de funciones de C++, pueden ser importantes los subconjuntos y normas de equipo.

Comparación de familias representativas

C, C++ y Rust

Los tres pueden utilizarse para sistemas nativos y control de bajo nivel, pero difieren en seguridad y abstracciones.

CriterioC (lenguaje de programación)C++Rust
Abstracciones principalesFunciones, estructuras, punterosClases, tipos por valor, plantillas, RAIIEstructuras, enumeraciones, traits, propiedad
Administración de memoriaPrincipalmente manualRAII y administración manualPropiedad y préstamos
Recolección de basuraNoNoNo
GenéricosPrincipalmente macros y void*Plantillas y conceptsGenéricos y traits
HerenciaNoHerencia de clasesSin herencia de clases
Acceso inseguroFunción ordinaria del lenguajeFunción ordinaria del lenguajeSeparado mediante límites unsafe
ABIC ABI ampliamente utilizadaAlta dependencia de implementación y plataformaLa frontera externa suele utilizar C ABI
Principal ventajaModelo de ejecución sencillo y soporte amplioAlta expresividad y ecosistema existenteSeguridad estática de memoria y concurrencia
Principal cargaAdministración manual de seguridad de memoriaComplejidad de funciones y estructuras existentes insegurasModelo de propiedad y costes de compilación

C es adecuado para código de frontera donde son importantes un runtime pequeño y una ABI sencilla. C++ destaca en aplicaciones de alto rendimiento, abstracciones grandes y bibliotecas existentes. Rust es favorable para componentes nuevos que buscan reforzar la seguridad de memoria, aunque sustituir ecosistemas complejos de C++ exige costes adicionales.

Go, Java y C#

Se comparan con frecuencia en servidores, aplicaciones empresariales y servicios de red, pero difieren en filosofía y ejecución.

CriterioGo (lenguaje de programación)JavaC#
EjecuciónCódigo nativo con runtime de GoJVMCLR de .NET
MemoriaRecolección de basuraRecolección de basuraRecolección de basura
Modelo de objetosEstructuras, métodos e interfaces estructuralesClases e interfaces nominalesClases, estructuras e interfaces nominales
ConcurrenciaGoroutines y canalesHilos, futures, virtual threads y otrosTasks y async·await
GenéricosParámetros de tipos y restriccionesGenéricos basados en borradoGenéricos con información de tipos en runtime
DistribuciónEjecutable nativoJVM y bytecodeDependiente del runtime, autocontenida o AOT
Tendencia de diseñoLenguaje pequeño y herramientas integradasEcosistema JVM estableAmplias funciones del lenguaje e integración con .NET

Go limita relativamente las funciones y enfatiza la simplicidad de los programas concurrentes de red y de la compilación y distribución. Java se apoya en la JVM y en un ecosistema empresarial acumulado durante largo tiempo. C# está estrechamente integrado con .NET y proporciona tipos por valor, LINQ, funciones asíncronas, coincidencia de patrones y numerosas funciones.

Java y Kotlin

Kotlin fue diseñado no para sustituir Java mediante un ecosistema completamente separado, sino para modernizar la expresión del lenguaje aprovechando JVM y bibliotecas Java.

CriterioJavaKotlin
Entorno principalJVMJVM, JavaScript, Native, WebAssembly
Tratamiento de nullLas referencias tradicionales pueden contener nullDistingue tipos anulables
ExpresiónExplícita y conservadoraInferencia y sintaxis concisa
AsincroníaHilos, futures y virtual threadsCorrutinas y ejecución basada en bibliotecas
InteroperabilidadBase del ecosistema JVMInteroperabilidad directa con Java
EvoluciónJLS y JEPEspecificación de Kotlin, KEEP y procesos del proyecto

Si son importantes una organización Java existente, sus bibliotecas y la compatibilidad prolongada, Java puede ser la opción más sencilla. Si se desean mejor análisis de null, API concisas y corrutinas dentro del mismo ecosistema JVM, Kotlin puede resultar favorable. Al combinar ambos deben considerarse las diferencias de null, checked exceptions, métodos estáticos y propiedades.

JavaScript y TypeScript

TypeScript no es principalmente un runtime separado que compita con JavaScript, sino una capa que analiza estáticamente programas JavaScript y los transforma.

CriterioJavaScriptTypeScript
Comprobación de tiposPrincipalmente durante la ejecuciónAñade análisis estático antes de compilar
EjecuciónMotor de navegador o servidorSe ejecuta el JavaScript generado
Sistema de tiposTipos dinámicos de objetosTipado estático estructural
Datos externosSe procesan directamente durante la ejecuciónRequieren validación aparte de las declaraciones
Código existenteEjecutable directamentePuede incorporar JavaScript de forma gradual
Ventaja principalLenguaje básico de todos los entornos webFacilita explorar y modificar grandes bases de código

JavaScript puede ser suficiente para scripts pequeños y código muy dinámico. En aplicaciones grandes mantenidas por varios desarrolladores, TypeScript puede ayudar con contratos de API y refactorizaciones. Sin embargo, sus tipos se eliminan generalmente durante la ejecución, por lo que no garantizan que un JSON recibido por red cumpla una interfaz declarada.

Python y lenguajes estáticos compilados

Python suele clasificarse simplemente como más lento que los lenguajes estáticos nativos, pero en sistemas reales es importante la división de responsabilidades.

Python puede utilizarse para composición, automatización, análisis interactivo y definición de modelos, mientras que las operaciones intensivas se delegan en C, C++, Fortran, Rust o bibliotecas de GPU. En esta estructura resulta más importante enviar trabajo en unidades grandes a bibliotecas nativas que la velocidad de cada bucle Python.

Los lenguajes estáticos nativos resultan favorables para operaciones pequeñas repetitivas, disposición de memoria y control de latencia. Python puede ofrecer ventajas en velocidad de desarrollo y experimentación mediante su concisión, objetos dinámicos y amplia biblioteca.

Por ello, en lugar de elegir únicamente Python o C++, una estructura híbrida en la que Python gestiona el flujo y C++ el cálculo central puede ser adecuada.

Swift y Kotlin

Swift y Kotlin se comparan frecuentemente en los ecosistemas de Apple y Android, aunque su ámbito no se limita a dispositivos móviles.

Swift se centra en compilación nativa, tipos por valor, conteo automático de referencias, seguridad de memoria e integración con frameworks de Apple. Kotlin proporciona interoperabilidad JVM, recolección de basura, seguridad frente a null, corrutinas y backends multiplataforma.

Si el centro son la UI nativa y las API del sistema Apple, Swift es una opción natural. En Android y JVM, Kotlin lo es. Para compartir lógica entre plataformas pueden considerarse Kotlin Multiplatform o núcleos comunes basados en C++ o Rust.

Errores habituales de comparación

Confundir la velocidad del lenguaje con la de una implementación

La especificación define el significado del programa, pero no determina completamente el lenguaje máquina ni las optimizaciones. Un mismo lenguaje puede mostrar rendimientos diferentes según la implementación.

Suponer que el código corto siempre aumenta la productividad

El código corto puede acelerar la escritura, pero aumentar comportamientos ocultos, errores dinámicos y costes de mantenimiento. Las anotaciones y el tratamiento explícito de errores pueden alargar el código, pero ayudar en cambios de gran escala.

Suponer que el tipado estático elimina los errores

El tipado estático solo impide errores que el sistema pueda expresar. Algoritmos incorrectos, políticas de seguridad, errores transaccionales y entradas externas deben tratarse por separado.

Suponer que la recolección de basura siempre es lenta

Un recolector puede realizar asignaciones rápidamente y compactar objetos para mejorar la localidad. En cambio, sus tiempos de recolección y necesidad de memoria adicional constituyen costes. El resultado depende de la carga y el diseño del recolector.

Suponer que los lenguajes nativos siempre son más rápidos que los administrados

Los compiladores JIT pueden utilizar información real para optimizaciones dinámicas. Los compiladores nativos pueden realizar análisis previo y optimización específica del destino. La ventaja depende del programa, el tiempo de ejecución y la implementación.

Suponer que los lenguajes nuevos sustituyen siempre a los antiguos

El código, las bibliotecas, las ABI, el personal y los historiales de verificación existentes son activos importantes. Fortran, COBOL y C continúan utilizándose no solo por su sintaxis, sino por los sistemas y ecosistemas acumulados.

Suponer que admitir muchos paradigmas siempre es mejor

Muchas funciones permiten expresar problemas variados, pero aumentan la complejidad del lenguaje y de la base de código. Un lenguaje pequeño y limitado puede facilitar que todo el equipo use un estilo coherente.

Método de selección de un lenguaje

Al seleccionar un lenguaje deben separarse primero las condiciones obligatorias de las modificables.

Entorno de ejecución

Deben comprobarse primero el sistema operativo, la CPU, el navegador, la plataforma móvil, la consola de videojuegos y el dispositivo embebido. Los lenguajes y SDK compatibles oficialmente pueden determinar en la práctica el conjunto de opciones.

Requisitos de rendimiento

Deben definirse concretamente no solo el throughput promedio, sino también la peor latencia, el inicio, el límite de memoria, el tamaño binario y el consumo energético. Deben medirse prototipos con cargas semejantes a las reales.

Seguridad y fiabilidad

Deben evaluarse seguridad de memoria, errores de tipos, carreras, nulabilidad, tratamiento de errores y requisitos de certificación. También deben compararse normas de codificación, análisis estático y herramientas de prueba.

Ecosistema existente

Debe comprobarse qué lenguajes admiten los drivers de bases de datos, API gráficas, frameworks de aprendizaje automático, SDK de dispositivos y sistemas empresariales necesarios. La ausencia de una biblioteca central puede costar más que las ventajas sintácticas del lenguaje.

Organización de desarrollo

Deben considerarse la experiencia del equipo, la disponibilidad de contratación, el coste de formación y los procesos de revisión y operación. Utilizar en todo el sistema un lenguaje comprendido por pocos especialistas puede generar riesgos de mantenimiento.

Distribución y operación

Debe revisarse cómo se instalará, actualizará y observará el programa. La instalación de runtimes, el tamaño de contenedores, el cold start y las herramientas de depuración y profiling afectan a los costes.

Mantenimiento a largo plazo

Deben examinarse la gobernanza, las políticas de compatibilidad, la estandarización y los periodos de soporte. La posibilidad de actualizar el código y las dependencias durante años puede ser más importante que la velocidad inicial de desarrollo.

Tabla práctica de decisión

RequisitoCaracterísticas prioritariasOpciones representativas
Sistemas operativos y driversEjecución nativa, ABI, control de memoriaC, C++, Rust
Videojuegos y renderizado de alto rendimientoLatencia predecible y ecosistema gráficoC++, Rust, C#
Servicios de redConcurrencia, distribución y ecosistema de servidoresGo, Java, C#, Rust, Kotlin
Sistemas empresarialesCompatibilidad, runtime administrado y frameworksJava, C#, Kotlin
Frontend webEjecución en navegador y Web APIJavaScript, TypeScript
Automatización y herramientasEscritura rápida, archivos y procesosPython, shell, PowerShell
Análisis de datosEjecución interactiva y ecosistema estadístico y numéricoPython, R, Julia
Cálculo científico y numéricoArreglos, paralelismo y código existenteFortran, C++, Python, Julia
Aplicaciones móvilesSDK y framework de UI de la plataformaKotlin, Swift
Dispositivos embebidosRuntime pequeño y acceso al hardwareC, C++, Rust, Ada
Sistemas críticosVerificabilidad y modelo de ejecución limitadoAda, C, C++, Rust
Plugins y modsIntegración, sandbox y API sencillaLua, JavaScript, WebAssembly
Prototipado rápidoEjecución y modificación inmediatasPython, JavaScript
Grandes bases web estáticasTipado estructural y ecosistema JSTypeScript

Esta tabla no constituye una recomendación absoluta. Incluso dentro del mismo campo, la experiencia del equipo, la plataforma, las licencias y la tecnología existente pueden hacer más adecuada otra opción.

Síntesis

Las diferencias entre lenguajes de programación no se limitan a la forma de escribir la sintaxis. El modo de modelar valores y objetos, el momento de comprobar tipos, la administración de memoria y recursos y el entorno de ejecución determinan los métodos de desarrollo, los errores posibles y las características de rendimiento.

C y C++ proporcionan un control amplio del hardware y de los ecosistemas de sistemas existentes. Rust refuerza la seguridad basada en propiedad en áreas semejantes. Go ofrece un lenguaje relativamente pequeño y concurrencia basada en runtime. Java y C# se centran en plataformas administradas y ecosistemas de aplicaciones de gran escala. Python y JavaScript proporcionan ejecución dinámica, desarrollo rápido y amplios ámbitos de uso, mientras que TypeScript añade análisis estático al ecosistema JavaScript. Kotlin y Swift combinan tipos modernos y funciones de seguridad con ecosistemas de plataformas principales. Fortran posee un ecosistema científico y de ingeniería acumulado alrededor de arreglos y cálculo numérico.

Ningún lenguaje es superior en todos los criterios al mismo tiempo. Aumentar el control de bajo nivel amplía las responsabilidades del programador, mientras que aumentar la administración automática amplía las responsabilidades del runtime y de la implementación. Reforzar las comprobaciones estáticas puede impedir errores anticipadamente, pero aumentar la complejidad del sistema de tipos y la compilación. Reforzar la ejecución dinámica aumenta la flexibilidad, pero incrementa la responsabilidad de comprobación durante la ejecución y de las pruebas.

Por lo tanto, el objetivo de comparar lenguajes no es elegir un ganador definitivo. Consiste en comprender qué costes y restricciones ha elegido cada lenguaje para resolver determinados problemas y seleccionar la opción más adecuada para el entorno de ejecución, la seguridad, el rendimiento, la organización de desarrollo y el ecosistema existente del proyecto.

Documentos relacionados