Mostrando entradas con la etiqueta SuperBASIC. Mostrar todas las entradas
Mostrando entradas con la etiqueta SuperBASIC. Mostrar todas las entradas

sábado, 16 de noviembre de 2024

ZB2SB. Conversor de Basic de los ZX al Super Basic del QL (III): Analizadór léxico segunda parte: Extractor de Tokens

Índice de entradas del conversor


Expresiones Regulares, Gramáticas regulares, Autómatas finitos, Autómatas de Pila, Máquinas de Turing, estos son algunas de las cosas que se emplean en la descomposición en tokens de los programas. Como he dicho que no voy a ser muy académico (y también porque no existen el manejo de expresiones regulares en SuperBASIC), voy a desarrollar la descomposición de Tokens usando un pequeño autómata.

No os voy a aburriros explicando lo que son los autómatas y como se programan, aunque es un tema importante en Informática, por ejemplo si leéis el manual del Kenbak-1 veréis que John Blankenbaker lo planteó como un autómata. De todas formas hay información de sobra en Internet sobre estos temas, y centrándonos en lo que estoy desarrollando podéis consultar libros sobre Compiladores, que hay muchos, de los cuales os recomiendo los dos que más se usan en las universidades sobre la materia: "Compiladores, principios, técnicas y herramientas" de Aho, Sethi y Ullman, también llamado "El libro del dragón" por su portada, de los varios que he leído creo que es el más claro y que más me gustó, el otro es "Construcción de compiladores. Principios y práctica" de Louden, aunque personalmente me gusta menos por no explicar tan bien como el otro, pero la parte de generación de código creo que es un poco mejor.

En lugar de eso voy a hacer un autómata muy sencillo que será fácilmente comprensible sin necesidad de muchas explicaciones. Así gano dos cosas, que no os canséis y no sobrecargar al QL de procesos, ya que aunque dispone (para mi) del mejor Basic con diferencia de las máquinas de la época, mi muy querido QL nunca se ha lucido por su velocidad. Por ser honestos, es superado por el BBC Basic, pero este tuvo una evolución importante con varias versiones, lo que no ocurrió con SuperBasic. Ambos fueron mucho mejor que los basados en Microsoft BASIC, de los que la mejor versión fue el MSX Basic, y desde luego mucho mejores que el GWBasic del MS.DOS en los PC. 

El BBC fue desarrollado por uno/una de los grandes de la informática, Roger Wilson, que luego cambió de sexo y hoy es Sophie Wilson (lo que personalmente me parece estupendo el que cada uno haga lo que desee, menciono ambos nombres por si los buscáis en Internet), que no solo desarrolló el BBC Micro y su Basic, sino que desarrolló para IBM los primeros procesadores RISC, luego usados en estaciones de trabajo de Acorn, unas máquinas estupendas pero poco conocidas.

Volviendo al tema, este es mi autómata:

tipo = Get_Tipo(c$)
SELect ON tipo
  = TIPONUM : Extrae_Numero(c$)
  = TIPOALFA : Extrae_Identificador(c$)
  = TIPOCADENA : Extrae_Cadena(c$)
  = TIPOSEPARADOR: Extrae_Separador(c$)
  = TIPOOPERADOR : Extrae_Operador(c$)
  = TIPOOTRO : Extrae_Desconocido(c$)
END SELect

 

Como vemos toma un carácter de la línea que estamos analizando, mira de que tipo es, y según este tipo va a llamar al proceso que extrae el token de ese tipo de la cadena, con la salvedad de que cuando extrae un identificador este puede ser: 

  • Un comando del BASIC. En este caso si además el comando es el comentario extrae también el resto de la cadena como texto del comentario.
  • En otro caso será el nombre de una variable.

He desarrollado un módulo para probar la extracción de Tokens del analizador léxico de forma independiente al resto del desarrollo, podéis descargarlo desde aquí

En la siguiente entrada ya entramos en la conversión, ampliaré este módulo para incluir las pruebas de conversión, ya más adelante uniré el módulo de lectura de líneas con este para comenzar a analizar programas.

Para poder convertir haré una mejora de la parte donde identifico los comandos del ZX Basic, que ahora mismo es una cadena de caracteres con estos separador por un espacio, la tengo que convertir en una tabla que identifique el comando y el proceso que se usará para convertirlo.

martes, 12 de noviembre de 2024

ZB2SB. Conversor de Basic de los ZX al Super Basic del QL. Indice

01. Introducción
02. Analizador Léxico primera parte: Lectura de líneas 
03. Analizador Léxico segunda parte: Extractor de Tokens

ZB2SB. Conversor de Basic de los ZX al Super Basic del QL (II): Analizadór léxico primera parte, lectura de líneas

Índice de entradas del conversor


Comenzamos con el analizador léxico, esta lo divido en dos partes para simplificar, por un lado la lectura de líneas y por otro su descomposición en Tokens.

Partimos de que el programa de origen será un fichero de texto guardado en el Microdriver (o equivalentes como las unidades con una SD, un disquete, un disco duro, o desde un emulador un directorio en el equipo). El resultado serán líneas completas que pasaremos a la rutina que la descompone en Tokens. En esta entrada presentaré las rutinas de lectura del fichero fuente y las de escritura del fichero de destino, que son bastante sencillas realmente.

Para las pruebas el programa simplemente copiar la línea leída en el fichero de origen y la guardará tal cual en el fichero de destino. En otras entradas iré desarrollando esta parte.

En esta primera versión del programa incluyo la base del sistema, compuesta de una serie de procedimientos y funciones que iré llamando, usando este procedimiento:

1170 DEFine PROCedure Main
1180   Init        : REMark Inicializar variables globales
1190   Pantalla    : REMark Pantalla de petición de datos
1200   :
1210   REMark Preparar una ventana para ver los resultados
1220   :
1230   OPEN#cCon,con
1240   WINDOW #cCon,400,140,50,70 : BORDER #cCon; 2,2
1250   :
1260   Proceso     : REMark procesar el fichero de entrada
1270   :
1280   INK #cCon;6 : PRINT #cCon;LF$;"Finalizado"
1290   CLOSE #cCon
1300 END DEFine
 

  • Init: Este procedimiento inicializa las variables globales que usaremos. Al no existir contantes en SuperBASIC, usaremos variables para simularlas, por haber programado en C tengo la costumbre de que las contantes se escriban con nombres en Mayúsculas siempre.
  • Pantalla: Este procedimiento presenta la pantalla inicial, y solicita los nombres de los ficheros de origen y destino.
  • El OPEN y WINDOW crea una ventana para ir presentando las líneas leidas y las generadas
  • Proceso: Este es el procedimeinto que realizará todo el proceso de conversión, en este momento lo único que hace es copìar la entrada sobre la salida para las pruebas de lectura y escritura de ficheros.
  • Luego se presenta el mensaje de fin.

Veamos los procesos principales, comenzando por el denominado Proceso que es el que hace todo el trabajo de dirección:

1850 DEFine PROCedure Proceso
1860   LOCal fin,salida$
1870   :
1880   OPEN_IN  #cIn, fIn$
1890   OPEN_NEW #cOut, fOut$
1900   CLS #0 : REMark Por si hay un mensaje de sobreescribir
1910   :
1920   REMark --- Lectura del fichero de entrada
1930   :
1940   REPeat BucleLectura
1950     :
1960     fin = LeerFichero
1970     linea$=Trim$(linea$)
1980     IF (linea$ <> "") THEN
1990       nrl = nrl + 1
2000       Mostrar_Entrada linea$
2010       salida$=Montar$(linea$)
2020       Mostrar_Salida salida$
2030       Guardar(salida$)
2040     END IF
2050     IF fin THEN EXIT BucleLectura
2060     :
2070   END REPeat BucleLectura
2080   :
2090   REMark Finalizar el proceso
2100   :
2110   CLOSE #cIn
2120   CLOSE #cOut
2130 END DEFine Proceso

Este proceso comienza abriendo los canales para lectura y escritura asociandolos a los ficheros que usaremos, al llegar a la línea 1890, si el sistema encuentra que el fichero de salida ya existe nos pregunta si deseamos sobre-escribirlo. si usamos Minerva o algún Toolkit existe la posibilidad de evitar esto indicando que deseamos sobre-escribir siempre, pero yo prefiero usar la ROM estándar únicamente en estas entradas.

Luego llamará al procedimiento que lee el fichero línea a línea, presentando la línea en la pantalla, llama al proceso que Montará la línea de salida (repito en este momento solo copia origen a destino), y nos presenta la línea generada.

Al final cierra los canales de entrada y salida.

2530 DEFine FuNction LeerFichero
2540     LOCal car$
2550     :
2560     linea$=""
2570     REPeat Bucle_Lectura
2580         IF (EOF(#cIn)) THEN RETurn TRUE
2590         :
2600         car$=INKEY$(#cIn)
2610         :
2620         IF (car$ <> CR$) AND (car$ <> LF$) THEN
2630           linea$ = linea$ & car$
2640         ELSE
2650           IF (linea$<>"") THEN
2660             EXIT Bucle_Lectura
2670           END IF
2680         END IF
2690     END REPeat Bucle_Lectura
2700     RETurn FALSE
2710     :
2720 END DEFine LeerFichero

Esta es la función de lectura del fichero de origen, como el final de las líneas puede ser LF, CR+LF o solo CR dependiendo del origen del fichero, no puedo leer por líneas sino que leo carácter a carácter, y si no es un CR o un LF lo incluyo en la línea que estoy leyendo, pero si es uno de estos entiendo que es el fin de línea y retorno lo que he leído hasta el momento. Si el siguiente carácter es CR o LF, es de la línea anterior o es una línea en blanco, lo que no está realmente permitido, y en este caso no retorno nada y sigo leyendo.

Ahora el procedimiento para guardar las línea convertidas en el fichero de salida, como vemos es muy  sencillo ya que guarda la línea completa sin complicaciones, en formato del QL que es el que lo ejecutará:

2760 DEFine PROCedure Guardar(s$)
2770   PRINT #cOut, s$
2780 END DEFine 
 

Como de momento el proceso solo copia tal cual, si se ejecuta con un fichero de prueba la pantalla que aparece será como esta:

 

Vemos que se solicita los nombres de los ficheros de origen y destino, y el proceso va presentando las líneas, el primer número en color blanco es el contador de líneas leídas. Luego en verde está la línea que ha leído, marcada como "L:", debajo y en color rojo la línea que ha convertido (repito que nuevamente en esta fase solo es copia de la leída) marcada como "C:".

El módulo completo lo podéis descargar desde este enlace

lunes, 11 de noviembre de 2024

ZB2SB. Conversor de Basic de los ZX al Super Basic del QL (I): Introducción


Índice de entradas del conversor

Cambios en rojo el 23/11/2024


Como sabemos, los programas se escriben en un lenguaje de alto nivel, y usando otro programa se convierten en programas escritos en otro lenguaje. Cuando ese lenguaje es directamente código máquina (a veces es ensamblador que se ensambla y linka de forma transparente) tenemos los que habitualmente se llama un COMPILADOR.

Por definición, un COMPILADOR es un programa que convierte un programa escrito en un lenguaje, en un programa escrito en otro lenguaje. Cuando ese otro lenguaje no es código máquina (o ensamblador, que se considera lo mismo en este tema), sino que lo convierten en otro lenguaje diferente, se le denomina TRANSPILADOR.

Los viejos quizá recuerden el compilador Turbo Pascal y luego el compilador Turbo C (posteriormente Borland C), que eran ampliamente usados por generar no solo de forma mas rápida que otros compiladores del momento (recuerdo en un 386 esperar 20 minutos para que terminara de compilar un programa en ABAL), sino porque generaba un código bastante rápido al estar bien optimizado. Luego se pasó al Turbo C++, pero las primeras versiones usaban dos pasos, primero un pre-procesador convertía el código C++ en C estándar, y luego el compilador compilaba el programa en C. Esto hacía que depurar fuera un infierno, depurabas el programa en C que no era el que habías escrito tu en C++, lo que era un desastre. Por fortuna, fueron rápidos en sacar un compilador completo, que generaba ya directamente desde C++ código máquina y podías debugear sin problemas.

Partes de un compilador

Un compilador se divide en varias partes, cada una centrada en un aspecto de la conversión del fuente en el objeto:

  • El analizador Léxico toma el programa y lo descompone en sus unidades básicas, sentencias, variables, operadores, separadores, etc. Para hacerlo necesita unas tablas auxiliares con los elementos que debe reconocer, separadores, sentencias, formato de variables, formato de comentarios, formato de cadenas, etc. Cada uno de estos componentes se denomina un TOKEN, y tiene dos atributos, el token y su tipo.
    Por ejemplo si tenemos la instrucción 100 IF a>=b THEN GOTO 500 lo descomponemos en estos tokens: 
    • 100, número de línea
    • IF, sentencia de comparación
    • a, variable
    • >=, operador de comparación
    • b, variable
    • THEN, sentencia de ejecución
    • GOTO, sentencia de salto
    • 500, operador del salto
    • vacío, fin de instrucción (en lenguajes como C sería ";" en lugar de vacío)
  • El analizador sintáctico toma los Tokens que va recibiendo del léxico y monta lo que se denomina el árbol de sintaxis, que representa la estructura lógica de las sentencias, y unas tablas auxiliares con las variables, funciones, procedimientos y en los lenguajes que los usan los saltos que se han ido encontrando.
  • El Analizador semántico es el resultado de validar que el árbol de sintaxis es correcto.
  • El generador de código crea a partir de lo anterior el código de destino. En general se divide en tres partes, aunque puede usar una, dos o las tres partes dependiendo del lenguaje de destino principalmente si es o no lenguaje máquina, o de la complejidad del lenguaje destino, no es lo mismo un I7 que un pequeño microcontrolador PIC:
    • El generador de código intermedio crea a partir de la estructura lógica el código en el lenguaje de destino, o a veces en un lenguaje similar simplificado, en general genera lo que se denomina "código de tres direcciones", no mira mas que las sentencias que está recibiendo.
    • El optimizador mejora el código intermedio generando uno lo más rápido posible, usará las tablas auxiliares y puede tener en cuenta código precedente y posterior para eso.
    • El generador final convierte el código intermedio optimizado en el definitivo en lenguaje de destino.
El compilador actúa simultáneamente en las tres fases, no las hace una tras otra (salvo en algunos compiladores antiguos o cuando se desea ver lo que se genera de forma didáctica), en general el Sintáctico es el director, ya pidiendo tokens al léxico y va montando las estructuras, cuando tiene una sentencia completa llama al generador de código.

El diseño de compiladores actual utiliza programas que se han desarrollado, probado y optimizado para ayudarnos a generar el código necesario, estos programas los encontrareis escritos en C y en Java principalmente, como son Flex y JFlex para el léxico y para el sintáctico yacc, GNU bison y javaCC. El generador de código es lo que se programa realmente.

Notación BNF

No quiero ser muy académico (aunque para eso estamos los ingenieros informáticos), prefiero ser lo mas didáctico posible, pero solo por completar la definición, para poder compilar un lenguaje es primero necesario definirlo correctamente, para eso se utiliza la notación BNF.

Para definir el lenguaje a compilar, el señor John Backus, uno de los grandes pioneros, de los que antes de que existieran lenguajes de programación ya trabajaba con ordenadores en código máquina, para desarrollar el FORTRAN ideó una notación para describir la sintaxis del lenguaje, de forma que no hubiera problemas ni existieran ambigüedades. Este sistema fue ampliada por Peter Naur fijando lo que se conoce como Notación BNF. Para poder hacer algo con lenguajes de programación, lo primero que se debe hacer es definir el lenguaje de la forma más precisa y exhaustiva posible, para eso lo mejor es la BNF, o su equivalente en formato gráfico.


Objetivo

Mi idea en esta serie de entradas es intentar desarrollar algo mas sencillo de que un transpilador, ya que los lenguajes de origen y destino son muy similares y bastante compatibles, en su lugar mi idea es desarrollar en Super Basic un conversor que transforme programas escritos en ZX BASIC o Sinclair BASIC (se conoce por ambos nombres), que así se denomina el BASIC que fue inicialmente para el ZX80/ZX81, luego ampliado con nuevas sentencias en el Spectrum (el mismo en todos sus modelos, salvo comandos específicos para el +2 para cambiar el modo 48/ampliado o los de manejo del disco en los +3), en programas en Super Basic del QL. Digo intento porque espero acabarlo, ahora mismo lo tengo iniciado y genera código sin problemas para programas sencillos, pero le faltan todavía algunas cosas para considerarlo operativo.


Del compilador solo voy a usar el analizador léxico, que me retornará las sentencias separadas en Tokens, que convertirá a la sintaxis del SuperBasic.

En esta serie de entradas mi idea es reescribirlo mejorado, siempre que se reescribe un programa no partes de cero, sino de lo aprendido anteriormente, por lo que siempre se genera mejor código, y el que tengo no me convence actualmente, por lo que tengo excusa para empezar esta serie. Pero hay que empezar desde el principio.


Características del Basic de Sinclair

Me basaré es estas características que tienen todos los programas en Sinclair Basic, que en principio son comunes al SuperBasic:

  1. El programa lo leeremos desde un fichero de texto.
  2. Un programa se compone de líneas individuales, separadas por un terminador de línea * ver nota al final. Este terminador depende del origen del fichero que las contiene (si el origen es Windows será CR+LF, si el origen es Linux o el propio QL será LF, si estamos en Apple será CR). Por tanto lo primero que haremos es un proceso que lea un fichero de texto con el programa y lo descomponga en líneas, pasando el proceso para cada una de forma independiente.
  3. Cada línea del programa se compone de un número de línea, un espacio en blanco, y una o varias sentencias separadas por el carácter ":"
  4. Cada sentencia comienza por un identificador de sentencia, seguido por un espacio y una serie de modificadores. Por ejemplo la sentencia LET a=r-3 se compone del identificador "LET", seguido por el modificadores"a=r-3". 
  5. Existen sentencias simples como REM, LET o GOTO, y sentencias compuestas como pueden ser las decisiones IF/THEN o los bucles FOR/TO.
  6. Un comentario se define por el identificador de sentencia REM y su modificador abarca siempre hasta el final de la línea en que se encuentra. No hay comentarios multi-línea y por tanto tampoco anidados.
  7. Se aceptan nombres de variables con cualquier longitud.

Hay algunas sentencias que no se pueden usar en SuperBasic, por ejemplo PLOT, hay que analizar todas las sentencias y buscar su equivalencia, pero en general las principales diferencias entre ambos BASIC las podemos resumir en:

  1. El ZXBasic del Spectrum amplia el de los ZX80/81, por lo que incluye algunos comandos adicionales, como PAPER o INK. Por tanto todo lo aplicable a los programas para el Spectrum se debe poder aplicar a los programas para los ZX80/81.
  2. El ZXBasic utiliza variables numéricas sin atributo adicional, o de cadena con el atributo $. Super Basic se comporta igual, pero añade la posibilidad de usar variables enteras con el atributo %
  3. ZXBasic utiliza obligatoriamente la sentencia LET en las asignaciones, en SuperBasic es opcional.
  4. ZXBasic usa NEXT como final del bucle FOR, mientras que SuperBasic utilizar END FOR en su lugar, reservando NEXT dentro del bucle para otro comportamiento.
  5. No existe un cierre de los condicionales en el ZXBasic, por tanto estas instrucciones solo ocupan una línea. En este caso la sentencia "IF operador1 THEN sentencias", en la parte de las sentencias puede abarcar varias. Se admite lo mismo en SuperBasic, pero existe un fin de comparación por lo que es posible separar en varias líneas un IF.
  6. Las sentencias GO TO y GO SUB se escriben como una o como dos palabras separadas por un espacio, aunque sean una sola sentencia. En ZXBasic irán juntas y también en SuperBasic van en dos palabras separadas. En ZXBasic por un solo espacio, en SuperBASIC pueden usarse uno o varios espacios indiferentemente.
  7. La sentencia "PRINT expresion" admite varios operadores separados por coma o por punto y coma, algunos operadores NO los admite super Basic. Dentro de una sentencia PRINT la expresión puede ser uno de estos términos (algunos no se pueden usar en ZX80/81):
    • Quedar vacío
    • Una expresión numérica
    • Una expresión de cadena
    • AT m,n  >> Desplaza el curso a la línea-columnas. Esto no lo admite SuperBasic,
    • TAB n >> Desplaza el cursor al tabulador n. No admitido por SuperBasic
    • PAPER, INK, FLASH, BRIGHT,  INVERSE, OVER >> Atributos de color, que no admite SuperBasic
     
    Por esto, si tenemos por ejemplo:
    PRINT AT 4,12;INK 4;PAPER 5;"Hola ";INK 2;"que tal"
    Lo debemos convertir en:
    AT 4,12 : INK 4 : PAPER 5 : PRINT "Hola" : INK 2 : PRINT "que tal"

Limitaciones

El principal problema proviene de las diferencias entre los sistemas, no por los procesadores que sean diferentes, sino por las características propias de cada máquina, esencialmente en la pantalla y las llamadas a la ROM o a las variables del sistema desde el Basic.

Los ZX usan una pantalla con un tamaño y unos colores, mientras que el QL tiene varias resoluciones con varios modos de color. Aunque no es directo pero esto se puede ajustar creando una ventana en el QL.

Los ZX usan un juego de caracteres de 8x8, mientras que el QL usa varios juegos de resoluciones variables, y los comandos para redefinir el juego de caracteres del Spectrum no funcionan en el QL, que usa otras resoluciones y puede disponer de varios juegos, esto es complejo pero se puede llegar a solucionar.

Casi todos (por no decir todos) los comandos que se refieren al hardware del aparato no se pueden convertir, si en el ZX se hace un POKE para obtener una variable del sistema, lo más seguro es que esa variable no exista en el QL. Hay veces que se llama a una rutina en la ROM para acelerar el programa, esto no es posible replicarlo en el QL (no es el objetivo de estas entradas, pero siempre existe la posibilidad de escribir rutinas en código máquina que hagan algo equivalente), por tanto no se van a convertir comandos dependientes del Hard como PEEK o POKE.

(*) Nota sobre CR y LF. En las máquinas de escribir, el papel se insertaba en un rodillo que estaba ubicado sobre lo que se denominada Carro. Las teclas eran fijas, pero el carro se movía a izquierda o derecha, y el rodillo se movía hacia arriba o hacia abajo, de esta forma al pulsar una tecla esta presionaba una cinta entintada sobre el papel marcando un carácter, y luego movía el carro una posición a la izquierda para escribir el siguiente carácter. Si pulsábamos el espacio el carro se movía una posición a la izquierda sin imprimir nada. Al llegar al final de la hoja, había que hacer dos cosas, mover el carro completamente hacia la derecha y mover el rodillo una línea hacia arriba, esto se hacía con una palanca ubicada a la derecha, al apretarla primero movía el rodillo hacia arriba una línea (esto se podía usar siempre de manera independiente), si seguías apretando soltaba un freno y movías todo el carro hacia la derecha. Cuando se crearon los teletipos, esto se hacía de forma eléctrica, pero necesitaban dos comandos para hacerlo:

  • CR: significa Carriage Return (Retorno de carro). Al recibir este comando el sistema movía el carro completamente a la derecha.
  • LF: significa Line Feed (Avance de línea). Al recibir este comando el sistema movía el rodillo una línea hacia arriba.

Realmente se solía enviar primero el LF y luego el CR, aunque el orden es indiferente. En los sistemas operativos se adoptó una codificación u otra, según lo decidieron sus creadores, así:

  • En Unix (y todos sus derivados como Linux o BSD) se decidió que los ficheros de texto usaran solo LF para marcar el fin de línea. A la hora de imprimir se hacía con un programa que la controlaba y cambiaba en los teletipos LF por CR+LF. 
  • En CP/M se decidió usar CR+LF, que es lo que se remitía realmente a los teletipos al usar un controlador de impresión muy básico, del CP/M pasó al MS.DOS (a través del QDOS que era un clon del CP/M), y de este al Windows actual. 
  • Apple decidió lo de siempre, llevar la contraria a todos y usar CR únicamente. 
  • Los Mainframes de IBM usaban otro carácter diferente, denominado NEL, de NExt Line (línea siguiente). Otros sistemas como VMS no requerían usar fin de línea ya que guardaban los textos como registros.

jueves, 5 de septiembre de 2024

Programación del Sinclair QL (XXV): Arboles. Pruebas de los nodos


Índice de entradas sobre programación del Sinclair QL



Vamos con el programa para poder probar lo escrito en la entrada anterior. Es muy sencillo, genero en un bucle una cantidad aleatoria de nodos, cada uno con un valor que será una cadena de caracteres aleatorios entre "A" y "Z". Luego pruebo las rutinas de modificar los punteros a los hijos de los nodos, y la de cambiar el contenido del nodo.

Como las rutinas para montar los nodos están en otro fichero, tengo que cargarlo con un MERGE. En las entradas anteriores usaba un módulo base con los MERGE, y si se ejecutaba el módulo más de una vez volvía a ejecutarlos, con la pérdida de tiempo que eso genera.

La solución ha sido añadir un controlador de errores, si detecta que no se ha cargado el módulo hace el MERGE, si ve que se ha cargado no hace nada. De esta forma podemos ejecutarlo varias veces sin pérdidas de tiempo.

  • WHEN ERRor: Es la rutina que se encarga de errores, para activarla se mira en la línea 260 el valor de una variable NODOS, si no existe dará un error -17
  • Prepara el sistema: Prepara la pantalla y llama a la rutina del módulo de nodos para inicializarlo
  • Crea módulos: Crea los nodos con información aleatoria
  • Modifica módulo: Modifica un nodo para añadir puntero a los hijos y cambia el contenido.
  • RndChr: Retorna un caracter aleatorio entre "A" y "Z"


100 REMark *************************************************************
110 REMark * Pruebas para el Manejo de nodos del arbol binario
120 REMark *************************************************************
130 :
140 REMark --- Control de si se ha incluido o no el módulo de nodos
150 :
160 WHEN ERRor
170   IF ERNUM=-17 THEN
180     PRINT "Cargando rutinas de nodos..."
190     MERGE mdv1_nodos
200     NODOS=1
201     RETRY
210   ELSE
220     PRINT "Se ha producido un error ";ERNUM;" en l“nea ";ERLIN
230     STOP
240   END IF
250 END WHEN
260 IF NODOS=0 THEN REMark --- Si da un error es que no ha cargado ---
270 :
280 REMark --- Preparar el sistema
290 :
300 MODE 0 : PAPER 0 : CLS
310 inicializa
320 :
330 REMark --- Generar nodos aleatorios para probar las rutinas
340 :
350 generar = RND(1 TO 10) : PRINT "Generando "& generar & " nodos"
360 FOR n=1 TO generar
370   c$=""
380   IF RND(1 TO 9) > 2 THEN
390     FOR i=0 TO RND(20)
400       c$ = c$ & RndChr$
410     NEXT i
420   END IF
430   n$ = NuevoNodo$(n,c$)
440   PRINT n$
450 NEXT n
460 :
470 PRINT "Finalizados los ";n;" nodos"
480 PRINT
490 PRINT "Cambiando datos de un nodo"
500 PRINT NuevoHijo$(n$,pIzq,56)
510 PRINT NuevoHijo$(n$,pDer,67)
520 PRINT NuevoValor$(n$,"Pruebas de valores")
530 :
540 REMark --- Retorna un caracter aleatório entre A y Z
550 :
560 DEFine FuNction RndChr$
570   RETurn CHR$(RND(CODE("A") TO CODE("Z")))
580 END DEFine aleatorio
590 :

 

El programa fuente lo podéis bajar de aquí.


En la siguiente entrada se añadirá el módulo memoria, añadiendo y eliminando nodos en la misma, por lo que luego mejoraré este programa para poder hacer pruebas de ambas partes, añadiendo un menú para definir cual se desea probar.

Para completar habría que hacer un proceso para compactar la memoria, pero eso requiere cambiar los punteros de los nodos, para lo que es necesario conocer su estructura interna, cosa que el sistema operativo no puede hacer, es responsabilidad de nuestro programa. En los lenguajes modernos con recolectores de basura, como Java o C# intentan hacer esto, con la ventaja de que no usan punteros a memoria, sino que las referencias son a la tabla de variables, con lo cual al cambiar la tabla ya ha cambiado todos los punteros, lo que es mucho mas sencillo que ir buscando en cada objeto a donde apunta y cambiarlo, pero a cambio usan mas memoria para la tabla de variables.


miércoles, 4 de septiembre de 2024

Programación del Sinclair QL (XXIV): Arboles. Estructura de los nodos en Super Basic

 

Índice de entradas sobre programación del Sinclair QL



Hemos visto el tema de las variables y el de los punteros, ahora vamos a hacer algo en Super Basic, pero ya que no es un lenguaje que soporte punteros ni se pueden usar variables compuestas de tipo registro, es un poco más complicado su manejo. Para manejar memoria hay que usar llamadas al Sistema Operativo para reservar memoria y código máquina para manejar los registros y punteros, o en el Toolkit II hay algunas funciones que nos lo facilitan. Como esto se sale de estas entradas que son de programación en SuperBasic, con la idea de que se pueda portar a otros Basic, voy a hacer otra cosas diferente para manejar árboles binarios, aunque no descarto luego hacer algo usando código máquina y llamadas al sistema.

Simularé la memoria usando una cadena de caracteres, usando el hecho de que una cadena tiene n caracteres, y podemos acceder a cualquiera de ellos individualmente, y cada carácter tiene un valor de 8 bits, lo que es muy similar a como se maneja una memoria. Desarrollaré funciones para reservar "memoria" dentro de la cadena, guardar datos en ella, leer cualquier dato usando un puntero a su dirección base, etc.

Nodos

Para crear los nodos del árbol, en entradas anteriores usé varios vectores de enteros, ya que los valores que manejamos lo eran. Ahora usaremos valores de tipo cadena de longitud variable, por lo que ya no nos sirve usar un vector, desperdiciamos demasiada memoria definiendo cadenas de longitud fija que pueden estar casi vacías. En su lugar en C se usaría un registro, que es una variable que se subdivide en varias, comportándose como un todo o como cada variable individual. Veamos como lo definiríamos en C:

struct nombre_registro    //El nombre que deseamos darle
{
     tipodato campo_1;    //Se define como cualquier variable, tipo y nombre
     tipodato campo_2;
     ....
     tipodato campo_N;    //Podemos usar todas las variables necesarias       
};

En el programa usaremos el nombre como el tipo de variable:

nombre_registro Variable_Registro;     //El nombre que deseamos darle a la variable

Y luego llamamos a los valores añadiendo un punto al nombre:

Variable_Registro.campo_n = ....
if (Variable_Registro.campo_n == true) {....

En los árboles binarios, necesitamos estos datos dentro para montar el registro:

  • Nodo_Izquierdo: Puntero al sub-nodo izquierdo de este nodo.
  • Nodo_Derecho: Puntero al sub-nodo derecho de este nodo.
  • Nodo_Padre: Puntero al nodo padre de este nodo.Este dato no es estrictamente necesario, pero simplifica el montaje del árbol.
  • Contenido del nodo: En nuestro caso es una cadena de caracteres, pero puede ser cualquier tipo, como un entero o un vector, o incluso otra estructura de tipo registro.
  • Longitud del contenido: Al ser una cadena de longitud variable, necesitamos sabes su longitud para reservar solo la memoria necesaria.

Funciones para montar registros

Para montar un registro usaré una cadena de caracteres dividida en partes, los punteros serán valores numéricos entre 0 y 99, mientras que el contenido lo limitaremos a entre 0 y 99 caracteres. 

Para almacenar los números usaré cadenas de longitud fija, ajustadas a la izquierda con ceros por la derecha, así será sencillo ver su valor, realmente lo que se debe usar son bytes, con 1 byte podemos almacenar un número entero sin signo entre 0 y 255, con 2 bytes alcanzamos entre 0 y 65.535, esto pueden ser fácilmente dos caracteres, pero muchos no sería visibles ni nos informarían de los valores del puntero, por eso prefiero usar números almacenados en la cadena, aunque solo puedo almacenar entre 0 y 99 tengo la ventaja de que los puedo ver de forma sencilla, y este desarrollo intenta ser instructivo más que efectivo.

Nuestro nodos serán una cadena de caracteres con este formato <NN:NN:NN:NN:cadena> en donde:

  • Inicio del nodo: Un carácter "<"
  • Puntero al nodo padre: Dos caracteres numéricos. No es necesario pero simplifica la navegación por el árbol.
  • Separador: Un carácter ":"
  • Puntero al nodo hijo izquierdo: Dos caracteres numéricos.
  • Separador: Un carácter ":"
  • Puntero al nodo hijo derecho: Dos caracteres numéricos.
  • Separador: Un carácter ":"
  • Longitud del contenido: Dos caracteres numéricos.
  • Separador: Un carácter ":"
  • Contenido: Una cadena de entre 0 y 99 caracteres
  • Fin del registro: Un carácter ">"

Los caracteres de inicio y fin no son necesarios, así como los separadores tampoco lo son, pero los usaré para poder ver los nodos de manera sencilla, y rellenaré la "memoria" con asteriscos, de forma que se vean los huecos libres, y cuando queramos ver la "memoria" lo podemos hacer simplemente imprimiendo la cadena, y veremos un texto de la forma:

<00:01:23:04:HOLA>******************<19:40:00:05;ADIOS><04:25_09:00:>********....

Así creo que es mas sencillo presentar los valores de forma que se vean correctamente, sin necesidad de grandes procesos.

Empecemos por las funciones que montan los nodos, para lo que defino funciones y procedimientos para manejarlas:

  • inicializa: Este procedimiento inicializa las variables que usaremos, en donde:
    • mLon: Longitud de las cadenas para guardar los punteros
    • mNodos: Máximo número de nodos que manejamos
    • nIni, nSep, nFin: inicio, separador y final para montar el nodo
    • pPadre, pIzq, pDer, pLon, pVal: Orden de los campos para el nodo
  • n2c$(n,l): Esta función retorna una cadena cuyo contenido es un valor numérico (n), ajustada a una longitud máxima (l) y rellena con ceros por la izquierda. Si le pasamos por ejemplo (17,3) nos retorna "017"
  • MontarNodo$(p,i,d,c$): Función que retorna un nodo montado con los valores para el padre (p), hijo izquierdao (i), hijo derecho (d) y una cadena de caracteres (c$), si le pasamos por ejemplo (2,12,3,"Hola") nos retorna "<02:12:03:04:Hola>"
  • NuevoNodo$(p,c$): Función que retorna un nuevo nodo sin hijos, con los valores para el padre (p) y una cadena de caracteres (c$), si le pasamos por ejemplo (2,"Hola") nos retorna "<02:00:00:04:Hola>"
  • Posicion(pos): Retorna un número que indica donde comienza en la cadena que representa el nodo uno de los valores del nodo (pos con valores para 0=padre, 1=izquierdo, 2=derecho, 3=longitud del contenido, 4=valor del contenido), si le pasamos por ejemplo (pIzq) nos retorna 5
  • NuevoHijo$(nodo$, pos, valor): Retorna una cadena que representa un nodo (nodo$), en la que cambia el puntero para uno de los hijos (pos donde 1=izquierdo o 2=derecho), con su nuevo valor de puntero (valor), si le pasamos por ejemplo ("<02:00:00:04:Hola>", pIzq,2) nos retorna "<02:02:00:04:Hola>"
  • NuevoValor$(nodo$, NuevoValor$): Retorna una cadena que representa un nodo (nodo$), en la que cambia el valor del contenido (NuevoValor$), si le pasamos por ejemplo ("<02:02:00:04:Hola>", "Adios") nos retorna "<02:02:00:05:Adios>"

5000 REMark *************************************************************
5010 REMark * Librería.: Arbol Binario                                  *
5020 REMark * Programa.: Nodos                                          *
5030 REMark * Contenido: Rutinas de manejo de los nodos                 *
5040 REMark * Autor....: javu61, octubre 2024                           *
5050 REMark *************************************************************
5060 :
5070 REMark --- Inicializar el sistema
5080 :
5090 DEFine PROCedure inicializa
5100   mLon=2
5110   mNodos = 10^mLon - 1
5120   nIni$="<" : nSep$=":" : nFin$=">"
5130   pPadre=0 : pIzq=1 : pDer = 2 : pLon=3 : pVal=4
5140 END DEFine inicializa
5150 :
5160 REMark --- Numero en cadena ajustado a longitud
5170 :
5180 DEFine FuNction n2c$(n,l)
5190   LOCal c$
5200   c$=n
5210   REPeat bucle_n2c
5220     IF LEN(c$)>=l THEN EXIT bucle_n2c
5230     c$ = "0" & c$
5240   END REPeat bucle_n2c
5250   c$= c$(1 TO l)
5260   RETurn c$
5270 END DEFine n2c$
5280 :
5290 REMark --- Montar un nodo
5300 :
5310 DEFine FuNction MontarNodo$(p,i,d,c$)
5320   LOCal n$
5330   n$ = nIni$
5340   n$ = n$ & n2c$(p,mLon) & nSep$
5350   n$ = n$ & n2c$(i,mLon) & nSep$
5360   n$ = n$ & n2c$(d,mLon) & nSep$
5370   n$ = n$ & n2c$(LEN(c$),mLon) & nSep$ & c$
5380   n$ = n$ & nFin$
5390   RETurn n$
5400 END DEFine MontarNodo
5410 :
5420 REMark --- Montar un nodo nuevo sin hijos
5430 :
5440 DEFine FuNction NuevoNodo$(p,c$)
5450   RETurn MontarNodo$(p, 0, 0, c$)
5460 END DEFine NuevoNodo
5470 :
5480 REMark --- Retorna la posición de un elemento en el nodo
5490 :
5500 DEFine FuNction Posicion(pos)
5510   RETurn LEN(nIni$) + (pos * mLon) + (pos * LEN(nSep$)) + 1
5520 END DEFine Posicion
5530 :
5540 REMark --- Añadir un hijo a un nodo
5550 :
5560 DEFine FuNction NuevoHijo$(nodo$, pos, valor)
5570   LOCal p
5580   p = Posicion(pos)
5590   nodo$(p TO p+mLon-1)= n2c$(valor, mLon)
5600   RETurn nodo$
5610 END DEFine NuevoHijo$
5620 :
5630 REMark --- Cambiar el valor de un nodo
5640 :
5650 DEFine FuNction NuevoValor$(nodo$, NuevoValor$)
5660   LOCal p1,p2
5670   p1 = Posicion(pLon)
5680   p2 = Posicion(pVal)
5690   nodo$ = nodo$(1 TO p1-1)
5700   nodo$ = nodo$& n2c$(LEN(NuevoValor$), mLon) & nSep$
5710   nodo$ = nodo$ & NuevoValor$ & nFin$
5720   RETurn nodo$
5730 END DEFine NuevoValor$

 

En la siguiente entrada añadiré un programa para probar que estas funciones trabajen correctamente, y si queréis descargar este fichero lo tenéis aquí.

miércoles, 7 de agosto de 2024

Programación del Sinclair QL (XXIII): Punteros

Índice de entradas sobre programación del Sinclair QL



Tras la entrada sobre las variables, entraremos a explicar lo que son los punteros, ya que la definición habitual es real pero muy poco explicativa: Un puntero es una variable que apunta a otra variable.

En la siguiente entrada usaremos ya los punteros en SuperBasic, que aunque no los soporta de forma nativa, hay maneras de simularlos.

Punteros

Como vimos en la entrada anterior, los compiladores e intérpretes usan una tabla en donde almacenar los datos de las variables que se utilizan en el programa. Las entradas de esta tabla disponen de una serie de datos importantes: nombre, tipo, longitud y posición en memoria donde se almacena (lo que se denomina dirección base de la misma, y su ocupación es la longitud de la misma (normalmente viene dados por el tipo).

Un puntero es una variable de tipo entero, que almacena una dirección de memoria, usualmente la dirección base de otra variable o de una zona de memoria libre. Veamos los dos tipos con ejemplos en  lenguaje C (lo tengo bastantes oxidado, quizá no sea del todo correcta la sintaxis): 


[1] float a=78.65;
[2] float *b=&a;
[3] float *c=malloc(4);
[4] printf('%d %f', b, *b);
[5] printf('%d %f', c, *c);
[6] *c = a;
[7] printf('%f', c);


  • En la línea 1 creamos una variable de tipo decimal que ocupa 4 octetos, y le damos como valor inicial 78'65. 
  • En la línea 2 creamos una variable de tipo puntero, que apuntará a una zona de memoria que contiene una variable de tipo decimal de 4 octetos, y la inicializamos con la dirección de base de la variable a. 
  • En la línea 3 creamos una variable de tipo puntero que apuntará a una variable de tipo decimal, y lo inicializamos a una nueva zona de memoria en donde se reservan 4 octetos.
  • En la línea 4 el sistema imprimirá la dirección de memoria a la que apunta la variable b, seguida por el contenido de la memoria a la que apunta b, en este caso 78'65.
  • En la línea 5 el sistema imprimirá la dirección de memoria a la que apunta la variable c, seguida del contenido de esa dirección de memoria, en este caso indefinido pues no se ha inicializado a ningún valor, puede ser cero o dar un error por no servir el contenido como representación de una variable decimal.
  • En la línea 6 asociamos a la dirección base apuntada por la variable c el contenido de la variable a, sería lo mismo que hacer un c=a si ambas variables fueran float.
  • En la línea 7 se imprime 78'65

Como vemos, si una variable de tipo puntero la usamos por su nombre, obtenemos la dirección de memoria a la que apunta, pero si el nombre lo precedemos por asterisco, obtenemos el contenido de dicha dirección de memoria. Igualmente una variable precedida por & nos retorna al dirección de memoria de dicha variable y no su valor.

Uso de los punteros a variables

Al definirse las funciones, los parámetros que les pasamos son variables locales cuyo valor inicial es que que les pasemos en la llamada, y al finalizar  solo retornan un valor, pero en C definieron un sistema para poder alterar los parámetros  usando los punteros. Vemos un ejemplo:


[1] void incrementar(int i) { i = i + 1; }
[2] void duplicar(int *j) { j = j + 1; }
[3] int a=5; incrementar(a); printf('%c', a);
[4] int b=5; duplicar(&b); printf('%c', b);

  • En la línea 1 creamos una función que recibe un parámetro, que es una variable local a la misma, esto incrementa la variable en una unidad. En la línea 2 definimos una semejante, pero recibe un puntero como parámetro.
  • En la línea 3 definimos una variable entera con valor 5, llamamos a incrementar, al que le pasamos el valor de a que es 5, como en la línea 1 estamos incrementando la variable local no se afecta, y luego se imprime otra vez 5. 
  • En la línea 4 definimos una variable entera con valor 5, llamamos a duplicar, al que le pasamos la dirección de b, como en la línea 2 estamos recibiendo el puntero, al incrementar será la zona de memoria a la que apunte, por tanto al final de la línea imprimirá 6.

De esta manera es sencillo para el programador decidir que variables serán alterada en las funciones y cuales no, protegiendo mejor el programa. En lenguajes modernos sin punteros, se añade una palabra, normalmente var, antes de la variable para indicar que su valor podrá ser alterado, lo que internamente es usar un puntero de forma transparente.

Otro uso habitual es emplear punteros a funciones, de esta manera en una variable podemos guardar una u otra función en relación con los datos que estamos procesando, pudiendo alterar el manejo del programa sin necesidad de complicar mucho la lógica. 

En lenguajes modernos esto es transparente, por ejemplo un objeto es solo un puntero a una posición de memoria que contiene funciones y variables, lo manejamos sin necesidad de saber siquiera lo que es un puntero.

Uso de punteros a memoria

La principal ventaja de los punteros es el manejo de memoria libre de forma no estructurada y de longitud variable, pudiendo crear elementos libremente cuando los necesitemos, a diferencia de los vectores que debemos definir su número de elementos y el tamaño de estos antes de empezar a manejarlos. Esto es muy útil para manejar estructuras de datos, como una lista de cadenas, cuando necesitamos un elemento llamamos a una función que nos retorne una dirección de memoria libre, del tamaño que necesitemos para esa cadena más el puntero al siguiente elemento, guardamos los datos, ajustamos los punteros, y ya tenemos una lista que podemos recorrer, sin saber cuanto ocuparán las cadenas de caracteres ni cuantas usaremos. 

Esto lo hacen los lenguajes modernos de forma transparente, al crear una estructura de tipo lista, que luego podemos recorrer con un FOREACH.

Problemas de los punteros

Tras muchos años manejando punteros, por los problemas que pueden ocasionar su uso, en los lenguajes actuales se ha optado por eliminarlos, aunque el sistema los maneja de forma interna de forma transparente, en nuestro programa no podemos usarlos.

El principal problema de los punteros es que son propensos a usarse de forma errónea provocando errores en el programa o, lo que es peor, en el sistema. Si apunto mal en una lista, puedo crear una circular y no salir nunca del bucle que la recorra, o apuntar mal un elemento y salirnos de la lista, por lo que el programa trabajará con datos erróneos. En un vector no podemos usar mas elementos de los definidos, pero con punteros podemos pasarnos y seguir leyendo elementos incorrectos. Esto se soluciona siendo cuidadosos al programar y probar bien nuestros programas con muchos datos diferentes.

Otro problema es la fragmentación, si creamos y destruimos muchos trozos pequeños de memoria, el sistema no puede reutilizarlos y podemos ocupar toda la memoria posible sin darnos cuenta. Esto tiene difícil solución, habría que compactar la memoria reubicando los registros y los punteros, lo que puede ser largo. Esto es realmente lo que hacen en lenguajes modernos los recolectores de basura de forma automática y transparente, ocultando los punteros a los programadores.

Otro problema es la seguridad, mediante un puntero podemos acceder a cualquier posición de memoria, por tanto podemos leer en otras zonas de memoria que no son específicas de nuestro programa, en sistemas como UNIX que son multi-usuario, podemos leer la memoria asignada a otro usuario y obtener por ejemplo claves de acceso. Hay mecanismos en el sistema operativo para que esto no sea posible, dando errores si accedemos a ubicaciones de memoria que no son del rango que tenemos asignado a nuestro programa.