El lenguaje Ada: historia, instalación de GNAT y primeros programas

Por: Artiko
sistemas-operativosadaelixirconcurrenciagnataliretipado-fuertecompilacionhistoria-de-la-computacion

El lenguaje Ada: historia, instalación de GNAT y primeros programas

En el capítulo 11 terminamos de recorrer la parte del sistema operativo que se ocupa de la persistencia: cómo un conjunto de bloques sin significado se convierte en archivos con nombre, cómo el inodo separa los metadatos del contenido, cómo el journal protege la consistencia frente a un corte de energía y qué ocurre exactamente entre una llamada a write y el momento en que los datos tocan el disco. Con eso cerramos el bloque de mecanismos: procesos, memoria, dispositivos y almacenamiento.

Desde este capítulo cambiamos el punto de observación. Hasta ahora describimos qué hace el sistema operativo; a partir de aquí vamos a escribir programas que ejerciten esos mecanismos desde arriba, y para eso el curso usa dos lenguajes con filosofías muy distintas: Ada y Elixir. Elixir nos servirá para razonar sobre concurrencia por paso de mensajes y tolerancia a fallos, que es la vista que un runtime construye sobre los procesos del sistema. Ada nos servirá para lo contrario: mirar el software de sistemas desde el lado del compilador, donde el tipo de un dato y el rango de un valor son verificados antes de que exista un ejecutable.

Este capítulo es la puerta de entrada a Ada. No asume que hayas escrito una línea de Ada antes, pero tampoco se queda en el “hola mundo”: vamos a ver por qué el lenguaje existe, cómo se instala el compilador, qué pasa físicamente cuando compilas, cómo se organizan los archivos de un proyecto, y tres programas completos explicados instrucción por instrucción. Al final vas a poder leer código Ada ajeno y compilar el tuyo sin depender de un entorno gráfico.

Por qué un curso de sistemas operativos usa Ada

Hay una razón práctica y una razón conceptual.

La práctica es que Ada sigue vivo exactamente en el terreno que estudia este curso: software que controla hardware, que corre durante años sin reiniciarse y donde un error de rango no produce un ticket sino un accidente. Sistemas de control de vuelo, señalización ferroviaria, control de tráfico aéreo, dispositivos médicos y sistemas espaciales siguen escribiéndose en Ada porque el proceso de certificación de esos productos exige demostrar propiedades del código, y Ada fue diseñado para que esas propiedades se puedan demostrar.

La conceptual es más interesante para nosotros. Un sistema operativo es, en el fondo, una máquina de imponer restricciones: este proceso no puede tocar la memoria de aquel, este usuario no puede abrir ese archivo, este descriptor no puede recibir más de tantos bytes. El kernel impone esas restricciones en tiempo de ejecución, pagando el costo de una verificación en cada operación. Ada plantea la misma idea desplazada en el tiempo: gran parte de esas restricciones se declaran en el código y las verifica el compilador antes de ejecutar. Ver los dos enfoques en paralelo hace evidente algo que en C queda escondido: toda garantía se paga en algún momento, y la pregunta de diseño es dónde.

Los cuatro rasgos que definen al lenguaje y que vamos a ir viendo a lo largo de los próximos capítulos son estos:

  • Legibilidad por encima de brevedad. Ada fue diseñado partiendo del supuesto de que un programa se escribe una vez y se lee muchas, incluso impreso en papel durante una auditoría. Por eso las palabras clave son palabras (begin, end, procedure, loop), los bloques se cierran nombrando lo que cierran (end Hola;) y no existe la sintaxis ambigua.
  • Tipado fuerte y explícito. Dos tipos numéricos distintos no se mezclan aunque ambos sean números reales; hay que convertir a mano. Un tipo puede llevar su rango válido incorporado, y salirse de él es un error del programa, no un valor extraño que se propaga.
  • Concurrencia dentro del lenguaje. Las tareas y los objetos protegidos son construcciones del lenguaje, no una biblioteca. El perfil Ravenscar restringe ese modelo a un subconjunto analizable para sistemas de tiempo real.
  • Continuidad. Las revisiones del estándar —Ada 83, 95, 2005, 2012 y 2022— mantuvieron un grado alto de compatibilidad hacia atrás, hasta el punto de que código de los años ochenta suele compilar con herramientas actuales.

Historia: de dónde sale el lenguaje

La historia de Ada es útil por sí misma porque muestra cómo se diseña un lenguaje cuando el cliente no es un programador individual sino una organización con un problema de costos.

El problema de 1974

A mediados de los setenta, el Departamento de Defensa estadounidense hizo un inventario del software que estaba pagando y encontró dos cifras incómodas. La primera: la mayor parte del gasto se iba en sistemas embebidos, es decir, computadores dentro de aviones, barcos, radares y misiles, no en los grandes computadores administrativos. La segunda: ese software estaba escrito en cientos de lenguajes y dialectos distintos, muchos de ellos creados para un solo proyecto y mantenidos por un solo proveedor.

La consecuencia directa era duplicación. Cada lenguaje necesitaba su compilador, su depurador, su documentación y su gente capacitada. Un ingeniero que cambiaba de proyecto empezaba de cero. Un compilador escrito para un proyecto no servía para el siguiente. La idea de fondo fue entonces buscar un lenguaje común para sistemas embebidos, con la expectativa de amortizar herramientas y formación a lo largo de muchos programas.

Los documentos de requisitos

Lo que siguió es poco frecuente en la historia de los lenguajes: antes de diseñar nada, se escribió y se sometió a revisión pública una serie de documentos de requisitos, cada uno más preciso que el anterior. Los nombres siguen una broma interna que va endureciendo el material del “hombre” que representa el documento:

DocumentoAño aproximadoQué aportó
Strawman1975Primer borrador de necesidades, deliberadamente frágil para invitar críticas
Woodenman1975-1976Refinamiento tras la primera ronda de comentarios
Tinman1976Primera lista concreta de capacidades exigidas al lenguaje
Ironman1977-1978Requisitos reorganizados y ya utilizables como base de un concurso
Steelman1978Versión final que sirvió de criterio para evaluar las propuestas

En paralelo se evaluó si algún lenguaje existente podía adoptarse tal cual. Se revisaron candidatos como Pascal, PL/I y Algol 68. La conclusión fue que ninguno cumplía los requisitos completos, pero que Pascal era una base razonable sobre la cual construir. Ese detalle explica buena parte del aspecto de Ada: la estructura de declaraciones, el begin/end y la disciplina de tipos vienen de la familia Algol-Pascal.

La competencia de los colores

Con Ironman en la mano se convocó un concurso. Cuatro equipos presentaron propuestas y, para que la evaluación fuera ciega respecto del proveedor, cada una se identificó con un color:

ColorOrganizaciónResultado
VerdeCII Honeywell BullPasó a la segunda fase y ganó
RojoIntermetricsPasó a la segunda fase
AzulSofTechEliminado en la primera fase
AmarilloSRI InternationalEliminado en la primera fase

Verde y Rojo refinaron sus diseños durante una segunda ronda. El 2 de mayo de 1979 se anunció que la propuesta Verde, liderada por Jean Ichbiah, era la ganadora.

flowchart TD
    A["1974: el DoD detecta<br/>cientos de lenguajes<br/>en sistemas embebidos"] --> B["1975-1978: serie de<br/>documentos de requisitos<br/>Strawman a Steelman"]
    B --> C{"¿Sirve un lenguaje<br/>ya existente?"}
    C -->|"Se evalúan Pascal,<br/>PL/I y Algol 68"| D["No cumplen Steelman,<br/>pero Pascal queda<br/>como base de partida"]
    D --> E["Concurso de propuestas<br/>identificadas por color"]
    E --> F["Verde<br/>CII Honeywell Bull"]
    E --> G["Rojo<br/>Intermetrics"]
    E --> H["Azul<br/>SofTech"]
    E --> I["Amarillo<br/>SRI International"]
    H --> J["Descartadas en<br/>la primera fase"]
    I --> J
    F --> K["Segunda fase<br/>de refinamiento"]
    G --> K
    K --> L["2 de mayo de 1979:<br/>gana Verde,<br/>equipo de Jean Ichbiah"]
    L --> M["1983: estándar ANSI<br/>conocido como Ada 83"]

El nombre

El lenguaje se llamó Ada en honor a Augusta Ada Byron, condesa de Lovelace (1815-1852), colaboradora de Charles Babbage en el trabajo sobre la máquina analítica y autora de las notas donde aparece lo que se reconoce como el primer algoritmo pensado para ser ejecutado por una máquina. Conviene notar el detalle tipográfico: Ada no es un acrónimo, por eso se escribe como nombre propio y no en mayúsculas.

El primer estándar recibió además una identificación militar, MIL-STD-1815, cuyo número no es casual: 1815 es el año de nacimiento de Ada Lovelace.

Las revisiones del estándar

RevisiónAporte principal
Ada 83Primer estándar. Paquetes, tipos fuertes, excepciones, tareas, genéricos
Ada 95Orientación a objetos con tipos etiquetados, jerarquía de bibliotecas con unidades hijas, anexos especializados
Ada 2005Interfaces abstractas, tipos sincronizados, contenedores estándar, mejoras de tiempo real
Ada 2012Contratos ejecutables con precondiciones, poscondiciones e invariantes; expresiones condicionales y funciones de expresión; soporte multinúcleo
Ada 2022Aspectos ampliados, 'Image sobre objetos, literales definidos por el usuario, iteradores paralelos

La revisión de 2012 es la que cambia más el estilo de escritura moderno, porque permite escribir la especificación de una función junto a su firma:

function Dividir (A, B : Integer) return Integer
  with Pre  => B /= 0,
       Post => Dividir'Result * B <= A;

Ese with Pre no es un comentario: el compilador puede generar la verificación y, si está activada, el programa aborta con una excepción cuando alguien llama a Dividir con B = 0. Es la misma idea de “verificar antes de actuar” que aplica el kernel al validar los argumentos de una llamada al sistema, pero expresada en el lenguaje.

Ada frente a los lenguajes que ya conoces

Una tabla ayuda a situar el lenguaje respecto de C, que es el idioma histórico de los sistemas operativos, y de Elixir, que usaremos más adelante.

AspectoCAdaElixir
Verificación de tiposDébil, con conversiones implícitasFuerte, conversiones explícitas obligatoriasDinámica, en tiempo de ejecución
Rango de un valorNo forma parte del tipoPuede formar parte del tipo, se verificaNo forma parte del tipo
Acceso fuera de un arregloComportamiento indefinidoExcepción Constraint_ErrorExcepción o error del proceso
ConcurrenciaBiblioteca externa, hilos del sistemaTareas y objetos protegidos en el lenguajeProcesos ligeros del runtime BEAM
Manejo de erroresCódigos de retorno y errnoExcepciones con propagación definidaFallar rápido y supervisar
CompilaciónA código nativoA código nativoA bytecode sobre la máquina virtual BEAM
Separación interfaz/implementaciónConvención con archivos .hObligatoria en el lenguaje: .ads y .adbMódulos con funciones públicas y privadas
Uso típicoKernels, drivers, runtimesAviónica, ferrocarril, defensa, espacioSistemas distribuidos, servicios de red

La fila que más nos va a servir en este bloque del curso es la del rango. En C, un int que representa un porcentaje puede valer -7 y nadie se entera hasta que produce un resultado absurdo. En Ada puedes declarar subtype Porcentaje is Integer range 0 .. 100; y cualquier intento de asignar -7 detiene el programa en el punto exacto donde ocurrió el error, no tres módulos más adelante.

Instalación del compilador

Ada tiene varios compiladores, pero el que vamos a usar es GNAT, el front-end Ada de GCC. Al ser parte de GCC comparte generador de código, optimizador y enlazador con los compiladores de C y C++, lo que significa que el ejecutable que produce es un binario nativo idéntico en formato al que produce gcc sobre un .c.

Hay dos caminos de instalación y conviene entender la diferencia antes de elegir.

Camino 1: el paquete de la distribución

Instala GNAT desde el gestor de paquetes del sistema. Es el camino más corto y deja el compilador disponible para todo el sistema.

# Debian y Ubuntu
sudo apt update
sudo apt install gnat gprbuild

# Fedora
sudo dnf install gcc-gnat gprbuild

# Arch Linux
sudo pacman -S gcc-ada gprbuild

# openSUSE
sudo zypper install gcc-ada

Verifica que quedó instalado:

gnatmake --version
gnat --version
gcc --version

La salida debe mostrar el número de versión de GNAT. Si gnatmake responde pero gcc no reconoce archivos .adb, es porque la instalación quedó parcial: gnat provee el front-end, y sin él GCC no sabe qué hacer con código Ada.

Camino 2: Alire

Alire es el gestor de paquetes y proyectos del ecosistema Ada, comparable a cargo en Rust o mix en Elixir. Su ejecutable se llama alr. Además de manejar dependencias, puede instalar y administrar la propia cadena de herramientas, lo que evita depender de la versión que empaquete tu distribución.

Se instala descargando el binario desde el sitio del proyecto (alire.ada.dev) y dejándolo en el PATH:

# Ejemplo con una descarga manual del binario ya compilado
mkdir -p ~/.local/bin
# se descarga el archivo comprimido desde alire.ada.dev y se extrae
unzip alr-*-bin-x86_64-linux.zip -d ~/alire
cp ~/alire/bin/alr ~/.local/bin/
export PATH="$HOME/.local/bin:$PATH"
alr version

Con alr disponible, se selecciona la cadena de herramientas:

alr toolchain --select

Ese comando muestra un menú con las versiones disponibles de gnat_native y gprbuild, las descarga y las deja registradas para los proyectos que gestione Alire.

Cuál usar

CriterioPaquete de la distribuciónAlire
InstalaciónUn comando, requiere privilegiosDescarga de un binario, sin privilegios
Versión de GNATLa que empaquete la distribuciónSe elige explícitamente
Dependencias de tercerosA manoResueltas desde el índice de crates
Archivo de proyecto.gpr escrito por ti.gpr generado más alire.toml
Aislamiento entre proyectosNinguno, compilador globalCada proyecto fija su cadena y dependencias

Para los ejemplos de este capítulo cualquiera de los dos sirve. Vamos a mostrar los comandos en ambas formas.

Anatomía de un proyecto en Ada

Antes del primer programa hay que entender tres extensiones de archivo y una regla de nombres, porque GNAT no busca el código por su contenido sino por el nombre del archivo.

ExtensiónNombreContenido
.adsAda specificationLa interfaz: qué ofrece una unidad, sin decir cómo
.adbAda bodyLa implementación: el código que realiza lo declarado
.gprGNAT project fileDescripción del proyecto: dónde están las fuentes, qué opciones usar, cuál es el programa principal

La regla de nombres de GNAT es directa: el archivo se llama como la unidad, en minúsculas, y los puntos de una unidad anidada se reemplazan por guiones.

Unidad AdaArchivo de especificaciónArchivo de cuerpo
Hola (procedimiento principal)no aplicahola.adb
Corecore.adscore.adb
Core.Conversioncore-conversion.adscore-conversion.adb
Ada.Text_IOprovisto por el runtimeprovisto por el runtime

Un procedimiento principal no necesita .ads: su interfaz es trivial, no lo usa nadie más y el enlazador solo necesita el cuerpo.

Un archivo .gpr mínimo, escrito a mano, se ve así:

project Ejemplos is
   for Source_Dirs use ("src");
   for Object_Dir use "obj";
   for Exec_Dir use "bin";
   for Main use ("hola.adb");

   package Compiler is
      for Default_Switches ("Ada") use ("-gnatwa", "-gnata", "-g");
   end Compiler;
end Ejemplos;

Cada línea dice algo concreto:

  • Source_Dirs: carpetas donde buscar .ads y .adb.
  • Object_Dir: dónde dejar los .o y los .ali intermedios.
  • Exec_Dir: dónde dejar los ejecutables.
  • Main: qué archivos contienen programas principales; por cada uno se genera un ejecutable.
  • Default_Switches ("Ada"): opciones que se pasan al compilador. -gnatwa activa todas las advertencias, -gnata habilita la verificación de precondiciones y poscondiciones, -g incluye información de depuración.

Se construye con:

gprbuild -P ejemplos.gpr

Primer programa: hola mundo

Crea el archivo hola.adb:

with Ada.Text_IO;

procedure Hola is
begin
   Ada.Text_IO.Put_Line ("Hola, mundo");
end Hola;

Compílalo y ejecútalo:

gnatmake hola.adb
./hola

Salida:

Hola, mundo

Con Alire el flujo es distinto porque se parte de un proyecto:

alr init --bin hola
cd hola
# el código va en src/hola.adb
alr build
alr run

Explicación línea a línea

with Ada.Text_IO;

Esta es la cláusula de contexto. Le dice al compilador que esta unidad depende de la unidad de biblioteca Ada.Text_IO. Dos consecuencias prácticas: primero, los nombres declarados en ese paquete pasan a ser visibles usando su nombre completo; segundo, el sistema de compilación sabe que si Ada.Text_IO cambia, esta unidad debe recompilarse.

No es un #include de C. #include copia texto de un archivo dentro de otro y ocurre antes de que el compilador vea el código. with declara una dependencia entre unidades ya compiladas y el compilador la usa para verificar coherencia. Si un cuerpo cambia sin cambiar su especificación, quien depende de él no necesita recompilarse.

procedure Hola is

Declara un procedimiento llamado Hola. Un procedimiento es un subprograma que no devuelve valor; el que sí devuelve se declara con function. Como este procedimiento está en el nivel superior de un archivo y no dentro de un paquete, es una unidad de biblioteca, y cualquier unidad de biblioteca sin parámetros puede servir como punto de entrada del programa.

El is abre la parte declarativa: entre is y begin van las declaraciones locales (variables, constantes, tipos, subprogramas anidados). En este programa no hay ninguna, así que la sección está vacía.

Aquí aparece la primera diferencia visible con C. En C, main es un nombre reservado por convención de la biblioteca estándar. En Ada el punto de entrada se llama como quieras; lo que lo convierte en principal es que el enlazador lo elija, cosa que ocurre al pasarlo a gnatmake o al declararlo en for Main use del .gpr.

begin

Cierra la parte declarativa y abre la parte ejecutable. Todo lo que va después son sentencias que se ejecutan en orden.

Ada.Text_IO.Put_Line ("Hola, mundo");

Llama al procedimiento Put_Line del paquete Ada.Text_IO pasando una cadena literal. Put_Line escribe el texto en la salida estándar y agrega un salto de línea. Su pariente Put escribe sin salto, y New_Line emite solo el salto.

El nombre está completamente calificado: paquete, punto, subprograma. Esto es verboso a propósito; más abajo veremos cómo acortarlo con use y por qué a veces conviene no hacerlo.

end Hola;

Cierra el procedimiento. El nombre después de end es opcional en algunos contextos y obligatorio en otros, pero la práctica habitual es siempre repetirlo: en un archivo de trescientas líneas con bloques anidados, end Hola; deja claro qué se está cerrando sin necesidad de contar la indentación.

Qué ocurre cuando compilas

gnatmake no es un compilador: es un conductor que decide qué hay que recompilar y en qué orden, y luego invoca tres herramientas distintas.

sequenceDiagram
    participant U as Usuario
    participant M as gnatmake
    participant C as gcc con front-end Ada
    participant B as gnatbind
    participant L as gnatlink
    participant FS as Sistema de archivos

    U->>M: gnatmake hola.adb
    M->>FS: leer hola.adb y sus dependencias
    M->>M: comparar marcas de tiempo<br/>de .adb, .ali y .o
    M->>C: compilar unidades desactualizadas
    C->>FS: escribir hola.o
    C->>FS: escribir hola.ali con la<br/>información de dependencias
    M->>B: gnatbind hola.ali
    B->>FS: verificar consistencia de<br/>todas las unidades
    B->>FS: generar b~hola.adb con<br/>el orden de elaboración
    M->>L: gnatlink hola.ali
    L->>FS: enlazar objetos y runtime
    L->>FS: escribir el ejecutable hola
    M-->>U: binario listo

Las tres etapas hacen cosas diferentes y vale la pena separarlas:

  1. Compilar (gcc -c hola.adb). Traduce cada unidad a código objeto. Además del .o, produce un archivo .ali (Ada Library Information) que registra de qué unidades depende, con qué versión y con qué opciones se compiló.
  2. Ligar (gnatbind hola.ali). Lee los .ali de todas las unidades involucradas y comprueba que todas se compilaron contra las mismas versiones de sus dependencias. Si compilaste un paquete, cambiaste su especificación y olvidaste recompilar a quien lo usa, esta etapa lo detecta y falla. Luego genera un archivo fuente auxiliar, típicamente b~hola.adb, que contiene el orden en que deben elaborarse las unidades, es decir, el orden en que se inicializan sus variables y se ejecutan sus bloques de arranque antes de llamar al programa principal.
  3. Enlazar (gnatlink hola.ali). Invoca al enlazador del sistema con los objetos, el archivo de ligado y la biblioteca de runtime de Ada, y produce el ejecutable.

Puedes ejecutar las tres a mano para verlas:

gcc -c hola.adb
gnatbind hola.ali
gnatlink hola.ali
./hola

Y observar los archivos generados:

ls -1
# b~hola.adb
# b~hola.ads
# b~hola.ali
# b~hola.o
# hola
# hola.adb
# hola.ali
# hola.o

Esa etapa de ligado es la que no existe en C y explica una diferencia de comportamiento importante: en Ada, la verificación de que todo el programa es coherente ocurre antes de enlazar, no en tiempo de ejecución ni por accidente. Es el mismo principio que aplica el cargador del sistema operativo cuando resuelve símbolos de bibliotecas dinámicas, pero adelantado a la construcción.

with y use

Escribir Ada.Text_IO.Put_Line cada vez es tedioso. La cláusula use hace visibles los nombres de un paquete sin calificar:

with Ada.Text_IO;

procedure Hola is
   use Ada.Text_IO;
begin
   Put_Line ("Hola, mundo");
   Put ("Sin salto de línea");
   New_Line;
   Put_Line ("Otra línea");
end Hola;

La distinción entre las dos cláusulas es la siguiente:

CláusulaQué haceDónde puede ir
with P;Declara la dependencia y permite escribir P.AlgoSolo antes de una unidad de biblioteca
use P;Hace visibles los nombres de P sin prefijoEn cláusula de contexto o dentro de una parte declarativa

with es obligatorio; use es opcional y siempre requiere un with previo. Ponerlo dentro del procedimiento, como en el ejemplo, limita su efecto a ese ámbito, lo que reduce las posibilidades de que dos paquetes distintos exporten el mismo nombre y el compilador no pueda decidir cuál usaste. Cuando eso ocurre el error es de ambigüedad y se resuelve calificando el nombre en el punto conflictivo.

Existe además use type, que hace visibles solo los operadores de un tipo:

with Ada.Numerics.Elementary_Functions;

procedure Ejemplo is
   use type Standard.Float;
begin
   null;
end Ejemplo;

Es la forma más acotada: permite escribir A + B sin importar todo el paquete.

Segundo programa: raíz cuadrada

El segundo ejemplo introduce tres cosas: subprogramas con parámetros, la biblioteca matemática, y el atributo 'Image para convertir valores a texto.

Versión con un procedimiento anidado

Archivo raiz.adb:

with Ada.Text_IO;
with Ada.Numerics.Elementary_Functions;

procedure Raiz is

   procedure Mostrar_Raiz (Numero : Float) is
      use Ada.Text_IO;
      use Ada.Numerics.Elementary_Functions;
   begin
      Put_Line (Float'Image (Sqrt (Numero)));
   end Mostrar_Raiz;

begin
   Mostrar_Raiz (9.0);
   Mostrar_Raiz (2.0);
   Mostrar_Raiz (144.0);
end Raiz;

Compilación y salida:

gnatmake raiz.adb
./raiz
 3.00000E+00
 1.41421E+00
 1.20000E+02

Explicación línea a línea

with Ada.Numerics.Elementary_Functions;

Este paquete forma parte del anexo numérico del estándar y ofrece las funciones matemáticas elementales para el tipo Float: Sqrt, Sin, Cos, Tan, Log, Exp, Arctan y sus variantes. Es en realidad una instancia predefinida del paquete genérico Ada.Numerics.Generic_Elementary_Functions, que puede instanciarse para cualquier tipo de punto flotante que definas:

with Ada.Numerics.Generic_Elementary_Functions;

procedure Precision_Alta is
   type Real is digits 15;
   package Real_Funciones is
      new Ada.Numerics.Generic_Elementary_Functions (Real);
   use Real_Funciones;
   X : Real := Sqrt (2.0);
begin
   null;
end Precision_Alta;

Ese type Real is digits 15; declara un tipo de punto flotante con al menos quince dígitos decimales significativos. Ada no te obliga a saber si eso corresponde a un double de la máquina: tú declaras la precisión que el problema necesita y el compilador elige la representación, o rechaza el programa si el hardware no puede darla.

procedure Mostrar_Raiz (Numero : Float) is

Un procedimiento anidado dentro de otro. Se declara en la parte declarativa de Raiz, entre el is y el begin del procedimiento externo. Solo es visible ahí dentro.

El parámetro se escribe Nombre : Tipo. Cuando no se indica el modo, es in, que significa solo lectura: dentro del subprograma, Numero es una constante. Los tres modos posibles son:

ModoSignificadoUso típico
inSolo lectura, es el modo por defectoDatos de entrada
outSolo escritura, el valor inicial no está definidoResultados adicionales
in outLectura y escritura, se recibe y se modificaEstructuras que se actualizan

Esta declaración explícita del modo es una diferencia notable con C, donde para lograr lo mismo hay que pasar punteros y confiar en que el llamado respete la intención, o marcarlos const y confiar en que nadie haga un cast.

use Ada.Text_IO; y use Ada.Numerics.Elementary_Functions;

Ambas dentro de la parte declarativa del procedimiento anidado. Su efecto termina en end Mostrar_Raiz;. Gracias a ellas se puede escribir Put_Line y Sqrt sin prefijo.

Put_Line (Float'Image (Sqrt (Numero)));

Aquí hay tres operaciones anidadas que conviene leer de adentro hacia afuera:

  1. Sqrt (Numero) calcula la raíz cuadrada y devuelve un Float.
  2. Float'Image (...) aplica un atributo al tipo Float. Los atributos se escriben con comilla simple y son consultas o funciones asociadas a un tipo o a un objeto. 'Image convierte un valor a su representación textual estándar y devuelve un String.
  3. Put_Line (...) escribe ese String.

El resultado 3.00000E+00 merece dos comentarios. El primero es el espacio inicial: 'Image reserva la primera posición para el signo, y en los valores no negativos queda un espacio en blanco. El segundo es la notación exponencial: ese es el formato por defecto de 'Image para tipos de punto flotante. Si quieres una tabla alineada con decimales fijos, hay que usar Ada.Float_Text_IO, que veremos en el tercer programa.

En Ada 2022 se puede aplicar 'Image directamente sobre el objeto, lo que acorta la escritura:

Put_Line (Sqrt (Numero)'Image);

Para que el compilador acepte esa forma hay que pedirle esa versión del lenguaje:

gnatmake -gnat2022 raiz.adb

o declararlo en el archivo de proyecto:

package Compiler is
   for Default_Switches ("Ada") use ("-gnat2022");
end Compiler;

Si escribes X'Image sin habilitar Ada 2022 y tu compilador aplica estrictamente Ada 2012, el error indicará que el prefijo del atributo debe ser un tipo. La forma Float'Image (X) funciona en todas las versiones.

Qué pasa con un número negativo

Sqrt no está definida para argumentos negativos. Si escribes Mostrar_Raiz (-4.0); el programa compila sin problema, pero al ejecutarse aborta con una excepción:

raised ADA.NUMERICS.ARGUMENT_ERROR : ...

Este es el comportamiento base de Ada frente a un error: no devolver un valor especial que el llamador podría ignorar, sino interrumpir el flujo normal. Para manejarlo se agrega un manejador de excepciones:

with Ada.Text_IO;
with Ada.Numerics;
with Ada.Numerics.Elementary_Functions;

procedure Raiz_Segura is

   procedure Mostrar_Raiz (Numero : Float) is
      use Ada.Text_IO;
      use Ada.Numerics.Elementary_Functions;
   begin
      Put_Line (Float'Image (Sqrt (Numero)));
   exception
      when Ada.Numerics.Argument_Error =>
         Put_Line ("Sin raíz real para " & Float'Image (Numero));
   end Mostrar_Raiz;

begin
   Mostrar_Raiz (9.0);
   Mostrar_Raiz (-4.0);
   Mostrar_Raiz (2.0);
end Raiz_Segura;

Salida:

 3.00000E+00
Sin raíz real para -4.00000E+00
 1.41421E+00

La sección exception va antes del end del bloque y cada when captura un tipo de excepción. Si ninguna cláusula coincide, la excepción sigue propagándose hacia el llamador, y si llega hasta el nivel del programa principal sin ser capturada, el runtime aborta imprimiendo el nombre de la excepción.

flowchart TD
    A["Llamada a Mostrar_Raiz"] --> B["Sqrt evalúa el argumento"]
    B --> C{"¿El argumento<br/>es negativo?"}
    C -->|No| D["Devuelve el valor<br/>y se imprime"]
    C -->|Sí| E["Se eleva<br/>Argument_Error"]
    E --> F{"¿Hay un manejador<br/>en este bloque?"}
    F -->|Sí| G["Se ejecuta el when<br/>y el bloque termina normalmente"]
    F -->|No| H["Se propaga al<br/>llamador inmediato"]
    H --> I{"¿Queda algún<br/>llamador arriba?"}
    I -->|Sí| F
    I -->|No| J["El runtime imprime la<br/>excepción y aborta el proceso"]
    D --> K["Continúa el programa"]
    G --> K

El paralelo con el sistema operativo es directo. Cuando un proceso hace una división entera por cero, el hardware genera una excepción, el kernel la convierte en una señal SIGFPE y, si el proceso no instaló un manejador, lo termina. La estructura es la misma: se eleva una condición, se busca un manejador subiendo por una cadena, y si no hay ninguno el nivel superior aborta. Ada implementa ese patrón dentro del programa; el kernel lo implementa entre el hardware y el proceso.

Versión con paquete propio

El procedimiento anidado sirve para un ejemplo, pero no se puede reutilizar desde otro programa. La forma habitual de organizar código en Ada es el paquete, que se escribe en dos archivos: la especificación y el cuerpo.

Archivo core.ads (especificación):

package Core is

   --  Imprime la raíz cuadrada de Numero en la salida estándar.
   --  Si Numero es negativo imprime un mensaje en lugar de abortar.
   procedure Mostrar_Raiz (Numero : Float);

   --  Devuelve la raíz cuadrada, o 0.0 si el argumento es negativo.
   function Raiz_O_Cero (Numero : Float) return Float;

end Core;

Archivo core.adb (cuerpo):

with Ada.Text_IO;
with Ada.Numerics;
with Ada.Numerics.Elementary_Functions;

package body Core is

   procedure Mostrar_Raiz (Numero : Float) is
      use Ada.Text_IO;
   begin
      Put_Line (Float'Image (Raiz_O_Cero (Numero)));
   end Mostrar_Raiz;

   function Raiz_O_Cero (Numero : Float) return Float is
      use Ada.Numerics.Elementary_Functions;
   begin
      if Numero < 0.0 then
         return 0.0;
      else
         return Sqrt (Numero);
      end if;
   end Raiz_O_Cero;

end Core;

Archivo raiz.adb (programa principal):

with Core;

procedure Raiz is
begin
   Core.Mostrar_Raiz (9.0);
   Core.Mostrar_Raiz (-4.0);
   Core.Mostrar_Raiz (2.0);
end Raiz;

Compilación:

gnatmake raiz.adb
./raiz
 3.00000E+00
 0.00000E+00
 1.41421E+00

Nota que raiz.adb solo hace with Core;. No menciona Ada.Text_IO ni la biblioteca matemática, porque no las usa: las usa el cuerpo de Core, y las cláusulas de contexto del cuerpo no se propagan hacia quien usa el paquete. Ese es el punto central de la separación entre .ads y .adb.

flowchart LR
    subgraph Principal["raiz.adb"]
        R["procedure Raiz"]
    end
    subgraph Interfaz["core.ads"]
        S["Mostrar_Raiz<br/>Raiz_O_Cero"]
    end
    subgraph Implementacion["core.adb"]
        B["package body Core"]
    end
    subgraph Runtime["Biblioteca estándar"]
        T["Ada.Text_IO"]
        N["Ada.Numerics.<br/>Elementary_Functions"]
    end

    R -->|"with Core"| S
    B -->|"implementa"| S
    B -->|"with"| T
    B -->|"with"| N
    R -.->|"no depende<br/>de estas"| T

Consecuencias prácticas de esa separación:

Cambio realizadoQué hay que recompilar
Se modifica solo core.adbEl cuerpo de Core, y se vuelve a enlazar
Se modifica core.adsEl cuerpo de Core y todo lo que hace with Core
Se agrega un with en core.adbSolo el cuerpo de Core
Se agrega un subprograma a core.adsTodo lo que hace with Core, aunque no lo use

En un proyecto grande esta regla es la diferencia entre una compilación de segundos y una de minutos, y explica por qué en Ada se procura que las especificaciones sean estables y pequeñas.

Tercer programa: conversor de temperatura

El tercer ejemplo produce una tabla de conversión de Fahrenheit a Celsius. Es un programa clásico, y sirve para mostrar bucles, salida formateada y —sobre todo— dónde muerde la aritmética entera.

Versión con enteros

Archivo temperatura.adb:

with Ada.Text_IO;

procedure Temperatura is
   use Ada.Text_IO;

   Fahrenheit, Celsius   : Integer;
   Inferior, Superior, Paso : Integer;
begin
   Inferior := 0;
   Superior := 300;
   Paso     := 20;

   Put_Line ("  F" & ASCII.HT & "  C");

   Fahrenheit := Inferior;
   while Fahrenheit <= Superior loop
      Celsius := 5 * (Fahrenheit - 32) / 9;

      Put_Line (Integer'Image (Fahrenheit) & ASCII.HT &
                Integer'Image (Celsius));

      Fahrenheit := Fahrenheit + Paso;
   end loop;
end Temperatura;

Salida:

  F	  C
 0	-17
 20	-6
 40	 4
 60	 15
 80	 26
 100	 37
 120	 48
 140	 60
 160	 71
 180	 82
 200	 93
 220	 104
 240	 115
 260	 126
 280	 137
 300	 148

Explicación línea a línea

use Ada.Text_IO; en la parte declarativa

Aquí el use está fuera de cualquier subprograma anidado, en la parte declarativa del procedimiento principal, así que vale para todo el cuerpo.

Fahrenheit, Celsius : Integer;

Declaración de dos variables del mismo tipo en una línea. Integer es el tipo entero predefinido; su rango exacto depende de la implementación, pero puedes consultarlo con atributos:

Put_Line (Integer'Image (Integer'First));
Put_Line (Integer'Image (Integer'Last));

En una implementación de 32 bits eso imprime -2147483648 y 2147483647.

Estas variables no tienen valor inicial. En Ada leer una variable no inicializada no es un error del compilador pero sí un error del programa; una forma de evitarlo es inicializar en la propia declaración:

Fahrenheit : Integer := 0;

Inferior := 0;

El operador de asignación es :=, no =. En Ada, = es el operador de comparación de igualdad, y /= el de desigualdad. Esta separación elimina de raíz la confusión clásica de C entre = y ==: escribir if Fahrenheit = 32 then compara, y no hay forma de asignar por accidente dentro de una condición porque la asignación es una sentencia, no una expresión.

Put_Line (" F" & ASCII.HT & " C");

El operador & concatena. Puede unir dos String, un String con un Character o dos Character.

ASCII.HT es el carácter de tabulación horizontal. ASCII es un paquete anidado dentro de Standard, disponible sin ningún with, que declara nombres para los caracteres de control. Se mantiene por compatibilidad desde Ada 83; la forma moderna equivalente es:

with Ada.Characters.Latin_1;
--  ...
Put_Line ("  F" & Ada.Characters.Latin_1.HT & "  C");

while Fahrenheit <= Superior loop ... end loop;

El bucle while evalúa la condición antes de cada iteración. Nota que no hay paréntesis obligatorios alrededor de la condición y que el bloque se cierra con end loop;.

Ada tiene tres formas de bucle:

FormaSintaxisCuándo se usa
Bucle simpleloop ... end loop;Repetición infinita, se sale con exit when
Bucle whilewhile Condición loop ... end loop;La cantidad de vueltas depende de una condición
Bucle forfor I in 0 .. 15 loop ... end loop;Se recorre un rango discreto conocido

Un detalle del for de Ada que sorprende a quien viene de C: no existe un incremento configurable. for I in 1 .. 10 loop avanza de uno en uno, y for I in reverse 1 .. 10 loop retrocede de uno en uno. La variable del bucle se declara sola, es constante dentro del cuerpo y desaparece al salir. Para avanzar de veinte en veinte hay que calcularlo:

for I in 0 .. 15 loop
   Fahrenheit := I * 20;
   --  ...
end loop;

Celsius := 5 * (Fahrenheit - 32) / 9;

Esta línea concentra el punto didáctico del programa. Al ser todos los operandos de tipo Integer, la división es entera: descarta la parte fraccionaria truncando hacia cero. Por eso 0 °F aparece como -17 y no como -17,8.

El orden importa. * y / tienen la misma precedencia y se evalúan de izquierda a derecha, de modo que primero se multiplica por 5 y después se divide por 9. Si se escribiera (Fahrenheit - 32) / 9 * 5, la división truncaría antes de multiplicar y el error se amplificaría por cinco. Es la misma trampa que aparece al calcular porcentajes o promedios con enteros en cualquier lenguaje, y en un sistema de control acumulada sobre miles de iteraciones deja de ser un detalle.

Integer'Image (Fahrenheit)

Igual que antes, convierte el valor a texto. Para enteros no hay notación exponencial, pero sí queda el espacio reservado para el signo: los valores positivos salen precedidos de un espacio y los negativos del -. Eso explica la alineación irregular de la salida.

Versión con punto flotante y salida formateada

Archivo temperatura_float.adb:

with Ada.Text_IO;
with Ada.Float_Text_IO;

procedure Temperatura_Float is
   use Ada.Text_IO;
   use Ada.Float_Text_IO;

   Fahrenheit, Celsius      : Float;
   Inferior, Superior, Paso : Float;
begin
   Inferior := 0.0;
   Superior := 300.0;
   Paso     := 20.0;

   Put_Line ("    F        C");

   Fahrenheit := Inferior;
   while Fahrenheit <= Superior loop
      Celsius := 5.0 * (Fahrenheit - 32.0) / 9.0;

      Put (Item => Fahrenheit, Fore => 5, Aft => 1, Exp => 0);
      Put (Item => Celsius,    Fore => 5, Aft => 1, Exp => 0);
      New_Line;

      Fahrenheit := Fahrenheit + Paso;
   end loop;
end Temperatura_Float;

Salida:

    F        C
    0.0    -17.8
   20.0     -6.7
   40.0      4.4
   60.0     15.6
   80.0     26.7
  100.0     37.8
  120.0     48.9
  140.0     60.0
  160.0     71.1
  180.0     82.2
  200.0     93.3
  220.0    104.4
  240.0    115.6
  260.0    126.7
  280.0    137.8
  300.0    148.9

Los cambios respecto de la versión entera:

with Ada.Float_Text_IO;

Este paquete es una instancia predefinida del genérico Ada.Text_IO.Float_IO para el tipo Float. Aporta procedimientos Get y Put que trabajan con valores de punto flotante y permiten controlar el formato.

Inferior := 0.0; en lugar de Inferior := 0;

Aquí aparece la regla más característica del lenguaje. En Ada, 0 es un literal entero universal y 0.0 es un literal real universal; asignar 0 a una variable Float es un error de compilación, no una conversión silenciosa. El mensaje es del estilo expected type "Standard.Float", found type universal integer.

Lo mismo vale para mezclar variables. Esto no compila:

declare
   N : Integer := 9;
   X : Float := 2.0;
   Y : Float;
begin
   Y := X * N;     --  error: no se puede multiplicar Float por Integer
end;

La forma correcta es convertir explícitamente:

Y := X * Float (N);

La conversión se escribe como si el tipo fuera una función. Ese es el tipado fuerte en la práctica: cuesta un par de caracteres extra y, a cambio, la pregunta “¿en qué unidad está este número?” nunca se responde por accidente.

Put (Item => Fahrenheit, Fore => 5, Aft => 1, Exp => 0);

Esta llamada usa asociación con nombre: cada argumento se identifica por el nombre de su parámetro en vez de por su posición. Es legal en cualquier llamada de Ada y es especialmente útil cuando hay varios parámetros del mismo tipo, porque hace imposible confundir el orden.

Los parámetros de este Put significan:

ParámetroSignificadoEfecto de subirlo
ItemEl valor a imprimir
ForeAncho mínimo de la parte entera, incluido el signoMás espacios a la izquierda, columnas alineadas
AftCantidad de dígitos tras el punto decimalMás decimales visibles
ExpAncho del campo del exponente; 0 desactiva la notación científicaCon 0 se imprime en notación posicional

Con Exp => 0 desaparece la notación 3.00000E+00 que veíamos con 'Image, y con Fore fijo las columnas quedan alineadas sin necesidad de tabulaciones.

Versión con tipos propios

El programa anterior funciona, pero tiene un problema conceptual: Fahrenheit y Celsius son ambos Float, así que nada impide sumarlos entre sí o asignar uno al otro. Ada permite crear tipos distintos a partir del mismo tipo base para que el compilador impida esa mezcla.

Archivo temperatura_tipada.adb:

with Ada.Text_IO;

procedure Temperatura_Tipada is

   type Grados_F is new Float;
   type Grados_C is new Float;

   package F_IO is new Ada.Text_IO.Float_IO (Grados_F);
   package C_IO is new Ada.Text_IO.Float_IO (Grados_C);

   function A_Celsius (F : Grados_F) return Grados_C is
     (Grados_C ((Float (F) - 32.0) * 5.0 / 9.0));

   use Ada.Text_IO;

   Inferior : constant Grados_F := 0.0;
   Superior : constant Grados_F := 300.0;
   Paso     : constant Grados_F := 20.0;

   F : Grados_F := Inferior;
begin
   Put_Line ("    F        C");

   while F <= Superior loop
      F_IO.Put (Item => F,             Fore => 5, Aft => 1, Exp => 0);
      C_IO.Put (Item => A_Celsius (F), Fore => 5, Aft => 1, Exp => 0);
      New_Line;
      F := F + Paso;
   end loop;
end Temperatura_Tipada;

Las novedades, una por una:

type Grados_F is new Float;

Declara un tipo derivado. Grados_F tiene la misma representación interna que Float y hereda sus operadores, pero es un tipo diferente: Grados_F y Grados_C no son compatibles entre sí ni con Float. Si intentas F := A_Celsius (F); el compilador rechaza el programa, porque estarías guardando grados Celsius en una variable de grados Fahrenheit. Ese es exactamente el tipo de error que provoca fallos en sistemas reales cuando dos subsistemas usan unidades distintas.

Conviene distinguirlo de un subtipo:

subtype Temperatura_Valida is Float range -273.15 .. 10_000.0;

Un subtipo no crea un tipo nuevo: es el mismo tipo con una restricción de rango. Se mezcla libremente con su tipo base, y lo que aporta es una verificación en tiempo de ejecución: asignar un valor fuera del rango eleva Constraint_Error. Nota el guion bajo en 10_000.0; Ada permite separadores en literales numéricos para hacerlos legibles y el compilador los ignora.

Construcción¿Tipo nuevo?¿Se mezcla con el base?Verificación
type T is new Float;No, requiere conversión explícitaEn compilación
subtype S is Float range A .. B;NoEn ejecución, Constraint_Error
type T is digits 15;Sí, independienteNoEn compilación

package F_IO is new Ada.Text_IO.Float_IO (Grados_F);

Como Grados_F es un tipo nuevo, Ada.Float_Text_IO no sirve: ese paquete solo sabe imprimir Float. Hay que instanciar el genérico Ada.Text_IO.Float_IO para el tipo propio. Esta línea crea un paquete nuevo, con su propio Put y su propio Get, especializado en Grados_F.

Los genéricos son el mecanismo de Ada para el código parametrizado por tipos, equivalente en propósito a las plantillas de C++ o a los genéricos de Rust. A diferencia de esos, la instancia se declara explícitamente y recibe un nombre, lo que hace visible en el código dónde se generó cada versión.

function A_Celsius (F : Grados_F) return Grados_C is (...)

Una función de expresión, introducida en Ada 2012. Cuando el cuerpo es una sola expresión, se puede escribir entre paréntesis en lugar de un bloque is begin return ... end. La forma larga equivalente sería:

function A_Celsius (F : Grados_F) return Grados_C is
begin
   return Grados_C ((Float (F) - 32.0) * 5.0 / 9.0);
end A_Celsius;

Dentro se hacen dos conversiones: Float (F) baja de Grados_F al tipo base para poder operar con literales, y Grados_C (...) sube el resultado al tipo de salida. Cada conversión es visible en el código, que es precisamente el objetivo.

Inferior : constant Grados_F := 0.0;

La palabra constant declara un valor que no puede modificarse. Cualquier asignación posterior es un error de compilación. Es el equivalente a const en C, pero sin las excepciones que permiten evadirlo.

stateDiagram-v2
    [*] --> Inicializacion: F := Inferior
    Inicializacion --> Evaluacion
    Evaluacion --> Conversion: F <= Superior
    Evaluacion --> Fin: F > Superior
    Conversion --> Impresion: A_Celsius aplica<br/>Grados_F a Grados_C
    Impresion --> Incremento: F_IO.Put y C_IO.Put
    Incremento --> Evaluacion: F := F + Paso
    Fin --> [*]

    note right of Conversion
        El compilador verifica aquí
        que no se mezclen unidades
    end note

Leer datos desde el teclado

Los tres programas anteriores tienen sus valores fijos en el código. Para cerrar el capítulo agregamos entrada del usuario, que es donde aparecen los errores de conversión y donde el manejo de excepciones deja de ser teórico.

Archivo raiz_interactiva.adb:

with Ada.Text_IO;
with Ada.Numerics;
with Ada.Numerics.Elementary_Functions;

procedure Raiz_Interactiva is
   use Ada.Text_IO;
   use Ada.Numerics.Elementary_Functions;

   Linea  : String (1 .. 100);
   Largo  : Natural;
   Numero : Float;
begin
   loop
      Put ("Número (o una línea vacía para salir): ");
      Get_Line (Linea, Largo);

      exit when Largo = 0;

      begin
         Numero := Float'Value (Linea (1 .. Largo));
         Put_Line ("Raíz: " & Float'Image (Sqrt (Numero)));
      exception
         when Constraint_Error =>
            Put_Line ("Eso no es un número válido.");
         when Ada.Numerics.Argument_Error =>
            Put_Line ("No hay raíz real de un número negativo.");
      end;
   end loop;

   Put_Line ("Fin.");
end Raiz_Interactiva;

Las piezas nuevas:

  • Linea : String (1 .. 100); declara una cadena de longitud fija. En Ada un String es un arreglo de Character cuyo largo forma parte del objeto y no cambia; no existe el terminador nulo de C. Los detalles de arreglos y cadenas de longitud variable son el tema del capítulo siguiente.
  • Get_Line (Linea, Largo); lee una línea completa de la entrada estándar. Copia los caracteres en Linea y deja en Largo cuántos leyó realmente. Por eso después se usa Linea (1 .. Largo), que es un corte del arreglo, no la cadena completa con relleno.
  • Natural es un subtipo predefinido de Integer con rango 0 .. Integer'Last. Su pariente Positive empieza en 1. Usarlos comunica la intención y hace que un valor negativo sea imposible.
  • exit when Largo = 0; sale del bucle cuando la condición se cumple. Es la forma de terminar un loop simple.
  • Float'Value (...) es el inverso de 'Image: toma un String y devuelve el valor correspondiente. Si el texto no representa un número válido, eleva Constraint_Error.
  • El bloque begin ... exception ... end interno acota el manejo de errores a esas dos líneas. Sin él, una excepción saldría del bucle y terminaría el programa; con él, se informa el problema y se vuelve a pedir un número.

Paquetes estándar de referencia

Estos son los paquetes que aparecen con más frecuencia al empezar. Todos se activan con una cláusula with.

PaquetePara qué sirveElementos frecuentes
Ada.Text_IOEntrada y salida de textoPut, Put_Line, Get_Line, New_Line
Ada.Integer_Text_IOEntrada y salida de IntegerPut con Width y Base, Get
Ada.Float_Text_IOEntrada y salida de FloatPut con Fore, Aft, Exp
Ada.Text_IO.Float_IOGenérico para instanciar con tipos propiosSe instancia con new
Ada.NumericsConstantes y excepciones numéricasPi, e, Argument_Error
Ada.Numerics.Elementary_FunctionsMatemática para FloatSqrt, Sin, Cos, Log, Exp
Ada.Numerics.Generic_Elementary_FunctionsGenérico de lo anteriorSe instancia con tu tipo real
Ada.Characters.Latin_1Nombres de caracteresHT, LF, CR, NUL
Ada.Strings.FixedOperaciones sobre cadenas de largo fijoTrim, Index, Move
Ada.Strings.UnboundedCadenas de largo variableUnbounded_String, To_String
Ada.Command_LineArgumentos y código de salidaArgument_Count, Argument, Set_Exit_Status
Ada.ExceptionsInformación sobre excepcionesException_Name, Exception_Message
Ada.CalendarFecha y horaClock, Year, Month, Seconds

Errores comunes y cómo resolverlos

Error o síntomaCausaSolución
file "hola.ali" not found o el compilador no encuentra la unidadEl nombre del archivo no coincide con el de la unidadRenombrar el archivo a la unidad en minúsculas, con guiones en lugar de puntos: Core.Utils va en core-utils.adb
expected type "Standard.Float", found type universal integerSe asignó un literal entero a una variable realEscribir el literal con parte decimal: 0.0 en lugar de 0
invalid operand types for operator "*"Se mezclaron Integer y Float en una expresiónConvertir explícitamente: X * Float (N)
El resultado de una división sale truncadoAmbos operandos son enteros, la división es enteraUsar tipos reales o convertir antes de dividir
"Put_Line" is undefinedFalta with Ada.Text_IO; o falta el prefijo del paqueteAgregar la cláusula with y usar Ada.Text_IO.Put_Line o un use
ambiguous expression (cannot resolve "Put")Dos paquetes con use exportan el mismo nombreCalificar la llamada: Ada.Float_Text_IO.Put (...)
prefix of "Image" attribute must be a typeSe usó X'Image sin habilitar Ada 2022Escribir Float'Image (X) o compilar con -gnat2022
raised CONSTRAINT_ERROR : ... range check failedUn valor salió del rango del subtipo o del índiceRevisar el rango declarado; validar la entrada antes de asignar
raised CONSTRAINT_ERROR : ... access check failedSe usó un puntero sin inicializarInicializar el acceso o verificar contra null antes de desreferenciar
raised ADA.NUMERICS.ARGUMENT_ERRORSe llamó a Sqrt o Log con un argumento fuera de dominioValidar el argumento o capturar la excepción
raised STORAGE_ERROR : stack overflowRecursión sin caso base o un objeto local demasiado grandeCorregir la recursión o mover el objeto grande al montón
elaboration circularity detected en la etapa de bindDos unidades dependen mutuamente en su inicializaciónRomper el ciclo moviendo la dependencia al cuerpo o usando pragma Elaborate_Body
Los acentos salen como caracteres extrañosDiscordancia entre la codificación del archivo fuente y la que asume el compiladorCompilar con -gnatW8 si el archivo está en UTF-8, o limitar los literales a caracteres ASCII
gnatmake: "hola.adb" compilation error sin más detalleEl error real está más arriba en la salidaLeer la salida completa desde el principio; el primer error suele explicar los siguientes
El binario no se regenera tras editar un .adsSe compiló solo el cuerpo a mano con gcc -cUsar gnatmake o gprbuild, que recalculan las dependencias

Ejercicios propuestos

1. Verificar la instalación en detalle. Instala GNAT por el camino de la distribución y también Alire. Compila el mismo hola.adb con ambos y compara los binarios con ls -l y file. Ejecuta ldd sobre cada uno y anota qué bibliotecas dinámicas requiere cada versión.

2. Desarmar la compilación. Ejecuta gcc -c, gnatbind y gnatlink por separado sobre hola.adb. Abre hola.ali con un editor de texto y describe qué información contiene. Después abre b~hola.adb e identifica dónde se llama al procedimiento principal.

3. Provocar un fallo de ligado. Crea el paquete Core de la sección de raíz cuadrada y el programa principal. Compila todo y ejecuta. Luego modifica solo core.ads agregando un parámetro a Mostrar_Raiz, recompila únicamente core.adb con gcc -c core.adb, e intenta enlazar a mano. Documenta el mensaje de error y explica en qué etapa apareció.

4. Precisión y truncamiento. Escribe un programa que calcule el promedio de los enteros 1, 2 y 4 de dos formas: con aritmética entera y con conversión a Float antes de dividir. Imprime ambos resultados. Después extiende el programa a una lista de veinte valores y calcula la diferencia acumulada entre los dos métodos.

5. Formato de salida. Modifica la versión con Float del conversor de temperatura para que produzca una tabla con tres columnas: Fahrenheit, Celsius y Kelvin. Ajusta Fore y Aft hasta que las tres columnas queden perfectamente alineadas y ningún valor se desborde.

6. Tipos que no se mezclan. Extiende Temperatura_Tipada con un tercer tipo Grados_K. Escribe las funciones de conversión entre los tres. Después intenta deliberadamente sumar un Grados_C con un Grados_F y transcribe el mensaje de error del compilador.

7. Subtipos con rango. Declara subtype Kelvin_Valido is Float range 0.0 .. Float'Last; y escribe un procedimiento que reciba un valor y lo asigne a una variable de ese subtipo. Prueba con un valor negativo y captura la excepción resultante, imprimiendo el nombre con Ada.Exceptions.Exception_Name.

8. Bucle sin while. Reescribe el conversor de temperatura usando un bucle for sobre un rango entero, calculando el valor de Fahrenheit dentro del cuerpo. Compara la legibilidad con la versión while y explica en qué casos cada forma resulta más directa.

9. Genéricos. Declara type Real is digits 18;, instancia Ada.Numerics.Generic_Elementary_Functions para ese tipo y calcula la raíz cuadrada de 2 con la máxima precisión que soporte tu máquina. Compara el resultado con el de Float e indica a partir de qué decimal difieren. Si el compilador rechaza los 18 dígitos, baja el número hasta que acepte y anota el máximo alcanzado.

10. Excepciones anidadas. Escribe un programa con tres niveles de subprogramas donde el más interno eleve una excepción propia declarada con Mi_Error : exception;. Captúrala en el nivel intermedio, imprime un mensaje y vuelve a elevarla con raise;. Captúrala finalmente en el nivel superior. Describe el orden en que aparecen los mensajes.

11. Código de salida. Modifica raiz_interactiva.adb para que, usando Ada.Command_Line.Set_Exit_Status, termine con estado distinto de cero si el usuario ingresó al menos un valor inválido. Verifica el resultado con echo $? en la terminal y relaciónalo con lo que vimos sobre procesos y su valor de retorno.

12. Comparar con C. Escribe el mismo conversor de temperatura en C. Compila ambos con optimización y compara el tamaño del ejecutable, el tiempo de compilación y la cantidad de llamadas al sistema que reporta strace -c en cada uno. Explica las diferencias en función de lo que sabes del runtime de cada lenguaje.

Qué sigue

En este capítulo cubrimos el punto de partida completo de Ada: de dónde viene el lenguaje y qué problema organizacional buscaba resolver, cómo se instala GNAT con el gestor de paquetes o con Alire, qué significa cada uno de los archivos de un proyecto, y qué ocurre realmente en las tres etapas de compilar, ligar y enlazar. Sobre esa base escribimos tres programas y los leímos línea por línea: el hola mundo nos dio la estructura mínima de una unidad y la diferencia entre with y use; la raíz cuadrada nos dio subprogramas con parámetros, atributos como 'Image, excepciones y la separación entre especificación y cuerpo; el conversor de temperatura nos dio bucles, salida formateada, la trampa de la división entera y el tipado fuerte aplicado a unidades físicas.

Lo que todavía no podemos hacer es manejar colecciones de datos. Todos nuestros programas trabajan con valores sueltos, y en cuanto se necesita procesar una serie de mediciones, el texto de una línea de comandos o el contenido de un archivo, hacen falta arreglos y cadenas de verdad. Ada tiene un tratamiento de arreglos poco habitual: los índices pueden empezar en cualquier valor y ser de cualquier tipo discreto, el largo forma parte del tipo del objeto, y los cortes son una construcción del lenguaje. Las cadenas heredan todo eso, lo que explica por qué existen tres familias distintas de String y cuándo conviene cada una.

En el capítulo 13 entramos en ese terreno: arreglos de una y varias dimensiones, arreglos con y sin restricción de tamaño, atributos 'First, 'Last, 'Length y 'Range, las tres formas de cadena, y el uso de Ada.Command_Line para recibir argumentos desde la terminal, que es el punto donde nuestros programas empiezan a conversar con el sistema operativo. Puedes revisar el recorrido completo del curso en el índice.