Lenguajes de Programación y Procesadores Francisco Gortázar Bellas Raquel Martínez Unanue Víctor Fresno Fernández A Carol, Nico y Bruno, por tanto. Víctor A Sebas y Ana. Raquel A Susana, Paula y Alejandro, sin vosotros todos estos esfuerzos no serían lo mismo. Francisco Índice Índice de figuras xiv Índice de tablas xvii Prefacio 1 2 1 Introducción a los lenguajes de programación 1.1 Introducción . . . . . . . . . . . . . . . . . . . 1.2 Sintaxis . . . . . . . . . . . . . . . . . . . . . 1.2.1 Gramáticas independientes del contexto 1.2.2 Notación BNF . . . . . . . . . . . . . 1.3 Semántica básica . . . . . . . . . . . . . . . . 1.4 Tipos de datos . . . . . . . . . . . . . . . . . . 1.4.1 Tipos de datos simples . . . . . . . . . 1.4.2 Tipo de datos estructurados . . . . . . . 1.5 Expresiones y enunciados . . . . . . . . . . . . 1.5.1 Expresiones . . . . . . . . . . . . . . . 1.5.2 Efectos colaterales . . . . . . . . . . . 1.5.3 Evaluación de expresiones . . . . . . . 1.5.4 Enunciados . . . . . . . . . . . . . . . 1.6 Procedimientos y ambientes . . . . . . . . . . 1.6.1 Paso de parámetros . . . . . . . . . . . 1.6.2 Ámbito de variables . . . . . . . . . . 1.7 Tipos abstractos de datos y módulos . . . . . . 1.8 Ejercicios resueltos . . . . . . . . . . . . . . . 1.9 Ejercicios propuestos . . . . . . . . . . . . . . 1.10 Notas bibliográficas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7 7 10 11 12 18 18 19 22 25 25 26 27 28 32 34 35 38 43 47 51 Procesadores de lenguajes 2.1 Introducción . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.2 Tipos de procesadores de lenguajes . . . . . . . . . . . . . . . . . . . . 2.2.1 Traductores . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53 53 54 54 ix . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . x Í NDICE 2.3 2.4 2.5 2.6 2.7 3 2.2.2 Ensambladores . . . . . . . . . . . . . . . . . . . 2.2.3 Compiladores . . . . . . . . . . . . . . . . . . . . 2.2.4 Intérpretes . . . . . . . . . . . . . . . . . . . . . 2.2.5 Máquinas virtuales . . . . . . . . . . . . . . . . . 2.2.6 Otros tipos . . . . . . . . . . . . . . . . . . . . . Estructura de un compilador . . . . . . . . . . . . . . . . 2.3.1 Análisis léxico . . . . . . . . . . . . . . . . . . . 2.3.2 Análisis sintáctico . . . . . . . . . . . . . . . . . 2.3.3 Análisis semántico . . . . . . . . . . . . . . . . . 2.3.4 Generación de código intermedio . . . . . . . . . 2.3.5 Optimización de código intermedio . . . . . . . . 2.3.6 Generación y optimización de código objeto . . . . Traducción dirigida por la sintaxis . . . . . . . . . . . . . 2.4.1 Definiciones dirigidas por la sintaxis . . . . . . . . 2.4.2 Esquemas de traducción . . . . . . . . . . . . . . 2.4.3 Métodos de análisis . . . . . . . . . . . . . . . . . 2.4.4 Herramientas para la construcción de compiladores Ejercicios resueltos . . . . . . . . . . . . . . . . . . . . . Ejercicios propuestos . . . . . . . . . . . . . . . . . . . . Notas bibliográficas . . . . . . . . . . . . . . . . . . . . . Paradigmas y modelos de programación 3.1 Introducción . . . . . . . . . . . . . . . . . . . . . . . . . 3.2 Programación funcional . . . . . . . . . . . . . . . . . . . 3.2.1 Funciones . . . . . . . . . . . . . . . . . . . . . . 3.2.2 Manejo de listas y ajuste de patrones . . . . . . . . 3.2.3 Tipos definidos por el usuario . . . . . . . . . . . 3.2.4 Funciones de orden superior . . . . . . . . . . . . 3.3 Programación lógica . . . . . . . . . . . . . . . . . . . . 3.3.1 Hechos . . . . . . . . . . . . . . . . . . . . . . . 3.3.2 Consultas . . . . . . . . . . . . . . . . . . . . . . 3.3.3 Reglas . . . . . . . . . . . . . . . . . . . . . . . . 3.3.4 Bases de reglas . . . . . . . . . . . . . . . . . . . 3.4 Programación orientada a objetos . . . . . . . . . . . . . . 3.4.1 Elementos de la programación orientada a objetos . 3.4.2 Vista pública y vista privada de clases . . . . . . . 3.4.3 Vista pública y vista privada de objetos . . . . . . 3.4.4 Herencia . . . . . . . . . . . . . . . . . . . . . . 3.4.5 Polimorfismo . . . . . . . . . . . . . . . . . . . . 3.5 Programación concurrente . . . . . . . . . . . . . . . . . 3.5.1 Concurrencia . . . . . . . . . . . . . . . . . . . . 3.5.2 Relaciones e interacciones entre procesos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55 56 60 63 65 69 70 78 81 86 86 87 87 89 92 93 109 114 119 120 . . . . . . . . . . . . . . . . . . . . 121 121 122 122 130 132 135 136 137 137 138 141 143 144 145 149 150 151 156 157 157 Í NDICE 3.6 3.7 3.8 3.9 4 3.5.3 Instrucciones atómicas e intercalación 3.5.4 Modelos de programación concurrente 3.5.5 Sincronización de procesos . . . . . . 3.5.6 Sincronización avanzada . . . . . . . Programación con lenguajes dinámicos . . . . 3.6.1 Tipado dinámico . . . . . . . . . . . 3.6.2 Portabilidad . . . . . . . . . . . . . . 3.6.3 Tipos de datos . . . . . . . . . . . . 3.6.4 Clausuras . . . . . . . . . . . . . . . 3.6.5 Iteradores . . . . . . . . . . . . . . . Ejercicios resueltos . . . . . . . . . . . . . . Ejercicios propuestos . . . . . . . . . . . . . Notas bibliográficas . . . . . . . . . . . . . . xi . . . . . . . . . . . . . Lenguajes de marcado. XML 4.1 Introducción . . . . . . . . . . . . . . . . . . . 4.2 Componentes de un documento XML . . . . . 4.2.1 Elementos . . . . . . . . . . . . . . . . 4.2.2 Etiquetas . . . . . . . . . . . . . . . . 4.2.3 Comentarios . . . . . . . . . . . . . . 4.2.4 Sección CDATA . . . . . . . . . . . . 4.2.5 Entidades . . . . . . . . . . . . . . . . 4.2.6 Instrucciones de procesamiento . . . . 4.2.7 Prólogo . . . . . . . . . . . . . . . . . 4.3 Modelado de datos en XML . . . . . . . . . . 4.4 Fundamentos de la DTD . . . . . . . . . . . . 4.4.1 Declaración de tipo de elemento . . . . 4.4.2 Declaración de tipo de atributo . . . . . 4.4.3 Declaración de entidades . . . . . . . . 4.4.4 Declaración de notaciones . . . . . . . 4.4.5 DTD internas y externas . . . . . . . . 4.4.6 Corrección de un documento XML . . 4.5 Espacios de nombres . . . . . . . . . . . . . . 4.5.1 Definir de un espacio de nombres . . . 4.5.2 Espacios de nombres en la DTD . . . . 4.6 Fundamentos del XML-Schema o XSD . . . . 4.6.1 Definición de elementos . . . . . . . . 4.6.2 Definición de atributos . . . . . . . . . 4.6.3 Tipos de datos predefinidos . . . . . . . 4.6.4 Creación de nuevos tipos de datos . . . 4.6.5 Espacios de nombres en XML-Schema 4.7 Procesadores de documentos XML . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 157 161 162 166 168 168 169 169 174 175 177 187 188 . . . . . . . . . . . . . . . . . . . . . . . . . . . 191 191 194 194 194 195 195 196 196 197 198 198 199 203 207 210 211 213 215 217 220 220 221 226 226 230 234 238 xii Í NDICE 4.7.1 Procesador de eventos: SAX . . 4.7.2 Procesador del árbol: DOM . . 4.7.3 Elección del tipo de procesador 4.8 Vinculación entre documentos . . . . . 4.8.1 XPath . . . . . . . . . . . . . . 4.8.2 XPointer . . . . . . . . . . . . 4.8.3 XLink . . . . . . . . . . . . . . 4.9 Ejercicios resueltos . . . . . . . . . . . 4.10 Ejercicios propuestos . . . . . . . . . . 4.11 Notas bibliográficas . . . . . . . . . . . 5 6 . . . . . . . . . . . . . . . . . . . . Lenguajes de script 5.1 Introducción . . . . . . . . . . . . . . . . . 5.2 Dominios de aplicación . . . . . . . . . . . 5.2.1 Intérpretes de comandos . . . . . . 5.2.2 Procesamiento de textos . . . . . . 5.2.3 Lenguajes de extensión y embebidos 5.2.4 Lenguajes glue . . . . . . . . . . . 5.2.5 Lenguajes de script en www . . . . 5.2.6 Aplicaciones científicas . . . . . . 5.3 Algunos lenguajes de script destacados . . 5.3.1 Perl . . . . . . . . . . . . . . . . . 5.3.2 PHP . . . . . . . . . . . . . . . . . 5.4 Ejercicios resueltos . . . . . . . . . . . . . 5.5 Ejercicios propuestos . . . . . . . . . . . . 5.6 Notas bibliográficas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Aspectos pragmáticos de los lenguajes de programación 6.1 Introducción . . . . . . . . . . . . . . . . . . . . . . . 6.2 Principios de diseño de los lenguajes . . . . . . . . . . 6.2.1 Claridad y sencillez . . . . . . . . . . . . . . . 6.2.2 Fiabilidad . . . . . . . . . . . . . . . . . . . . 6.2.3 Ortogonalidad . . . . . . . . . . . . . . . . . 6.2.4 Generalidad . . . . . . . . . . . . . . . . . . . 6.2.5 Notación . . . . . . . . . . . . . . . . . . . . 6.2.6 Uniformidad . . . . . . . . . . . . . . . . . . 6.2.7 Subconjuntos . . . . . . . . . . . . . . . . . . 6.2.8 Portabilidad . . . . . . . . . . . . . . . . . . . 6.2.9 Simplicidad . . . . . . . . . . . . . . . . . . . 6.2.10 Abstracción . . . . . . . . . . . . . . . . . . . 6.2.11 Modularidad . . . . . . . . . . . . . . . . . . 6.2.12 Ocultación de información . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 238 244 252 252 252 257 260 262 270 271 . . . . . . . . . . . . . . 273 273 275 276 280 285 286 287 290 290 290 295 305 315 316 . . . . . . . . . . . . . . 319 319 319 320 321 321 321 322 322 322 323 323 323 324 324 Í NDICE 6.3 6.4 6.5 6.6 6.7 6.8 Interacción e interoperabilidad . . . . . . . . 6.3.1 Interoperabilidad a nivel de aplicación 6.3.2 Interoperabilidad a nivel de lenguaje . Lenguajes embebidos . . . . . . . . . . . . . Criterios de selección de lenguajes . . . . . . Ejercicios resueltos . . . . . . . . . . . . . . Ejercicios propuestos . . . . . . . . . . . . . Notas bibliográficas . . . . . . . . . . . . . . Bibliografía xiii . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 324 325 327 328 331 332 333 333 334 Índice de figuras 1.1 1.2 1.3 1.4 1.5 1.6 Dos árboles sintácticos para la cadena ababa . . . . . . . . . . . . . . . . Dos árboles sintácticos para la expresión 2 − 1 − 1 . . . . . . . . . . . . . Árbol sintáctico para la expresión 2 − 1 − 1 según la gramática 1.9 . . . . . . Árbol de sintaxis abstracta para la expresión 2 − 1 − 1 según la gramática 1.9 Diagrama sintáctico correspondiente a la gramática 1.11 . . . . . . . . . . Diagrama sintáctico correspondiente a la gramática 1.12 . . . . . . . . . . 14 15 16 17 17 17 2.1 2.2 2.3 2.4 2.5 2.6 2.7 2.8 2.9 2.10 2.11 2.12 55 59 59 61 62 63 70 71 80 83 84 2.17 2.18 2.19 2.20 Notación en T, esquema básico de un traductor. . . . . . . . . . . . . . . . Tiempo de compilación. . . . . . . . . . . . . . . . . . . . . . . . . . . . Tiempo de ejecución. . . . . . . . . . . . . . . . . . . . . . . . . . . . . Proceso de compilación, montaje y ejecución . . . . . . . . . . . . . . . . Esquema funcional de un intérprete . . . . . . . . . . . . . . . . . . . . . Esquema de funcionamiento de una máquina virtual. . . . . . . . . . . . . . Etapas de traducción de un compilador: análisis y síntesis . . . . . . . . . . Fases de un compilador . . . . . . . . . . . . . . . . . . . . . . . . . . . Ejemplo de árbol de derivación . . . . . . . . . . . . . . . . . . . . . . . Ejemplo de árbol de análisis semántico . . . . . . . . . . . . . . . . . . . Código a partir del cual se genera la tabla de símbolos de la Tabla 2.1 . . . . Ejemplo de construcción de un subgrafo de dependencias para una regla del atributo sintetizado A.a = f (X.x,Y.y) asociada a una producción A → XY . . Ejemplo de construcción de un subgrafo de dependencias para una regla del atributo heredado X.x = g(A.a,Y.y) asociada a una producción A → XY . . . Ejemplo de Definición Dirigida por la Sintaxis . . . . . . . . . . . . . . . . Ejemplo de árbol sintáctico anotado a partir de un esquema de traducción . . Árbol de análisis sintáctico y árbol abstracto de análisis sintáctico de las expresiones id ∗ id e (id + id) ∗ id . . . . . . . . . . . . . . . . . . . . . . . Modelo de análisis descendente predictivo dirigido por tabla . . . . . . . . . Modelo de análisis ascendente . . . . . . . . . . . . . . . . . . . . . . . Autómata determinista correspondiente a la gramática S → A + n|n . . . . . Árbol sintáctico anotado solución al ejercicio 7 . . . . . . . . . . . . . . . 3.1 Evaluación de la definición recursiva no final de factorial para el valor 3 . . . 129 2.13 2.14 2.15 2.16 xv 91 91 92 93 94 100 105 108 118 xvi Í NDICE DE FIGURAS 4.1 4.2 4.3 4.4 4.5 4.6 4.7 Un documento XML . . . . . . . . . . . . . . . . . . . . . Un documento XML con un espacio de nombres predeterminado Un documento XML con un espacio de nombres con prefijo . . Un documento XML con dos espacios de nombres con prefijo . Jerarquía de nodos . . . . . . . . . . . . . . . . . . . . . . Ejemplo de árbol generado por un analizador XPath . . . . . Ejemplo de documento XML . . . . . . . . . . . . . . . . . 6.1 Esquema de funcionamiento de un Web Service . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 222 234 235 236 245 253 254 . . . . . . . . . . . . . . . 333 Índice de tablas 2.1 92 103 104 2.6 Ejemplo de entradas en una tabla de símbolos empleada en el análisis del código que se muestra en la Figura 2.11 . . . . . . . . . . . . . . . . . . . Ejemplo de esquema de traducción que transforma expresiones infijas con suma y resta en las expresiones posfijas correspondientes . . . . . . . . . . . . . Ejemplo de tabla de análisis sintáctico . . . . . . . . . . . . . . . . . . . . Ejemplo de pila en análisis ascendente con retroceso . . . . . . . . . . . . . Ejemplo de tabla de análisis sintáctico correspondiente al autómata finito determinista de la Figura 2.19 . . . . . . . . . . . . . . . . . . . . . . . . . Listado de alumnos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.1 3.2 3.3 3.4 Correspondencia entre listas . . . . . . . . . . . . . . . . . . . . . . Patrón ajustado en cada caso para la evaluación de longitud [1,2] . Posibles intercalaciones de instrucciones . . . . . . . . . . . . . . . . Instrucciones atómicas correspondientes a la instrucción a:=a+1 . . . . . . . . . . . . 130 132 160 161 4.1 4.2 4.3 4.4 Métodos de ContentHandler . . . . . . . . . . . . . . . . . . . . . Tipos de datos DOM . . . . . . . . . . . . . . . . . . . . . . . . . . Tipos de nodos DOM . . . . . . . . . . . . . . . . . . . . . . . . . . Valores de los atributos nodeName y nodeValue según el tipo de nodo . . . . 240 246 247 247 2.2 2.3 2.4 2.5 xvii 84 108 117 Prefacio En este libro se combinan contenidos que habitualmente se estudian por separado en asignaturas específicas dentro de algunas titulaciones de informática. En particular es el caso de la teoría de los Lenguajes de Programación, que en algunas titulaciones corresponde a una asignatura de la que hay numerosa bibliografía específica, y también es el caso del estudio de los Procesadores de Lenguajes, que también suele corresponder a una asignatura con su correspondiente bibliografía específica. Normalmente entre estas dos materias hay cierto solapamiento de temas aunque no se abordan con igual profundidad. Por ejemplo, en la bibliografía de teoría de los lenguajes de programación se tratan aspectos de la sintaxis, pero no se detallan los algoritmos de análisis sintáctico que se utilizan, que es una materia propia de los procesadores. De la misma forma, en la bibliografía de procesadores de lenguajes se tratan aspectos de la teoría de lenguajes, como tipos de datos, ámbito de las variables, etc. aunque no se entre en detalle en cuanto a los diferentes paradigmas. Por otra parte, hay libros que también tratan estas dos materias aunque no profundizan en los lenguajes de script ni tratan otros tipos de lenguajes como los de marcado. Sin embargo, en algunos nuevos grados en informática propuestos dentro del marco de la EEES1 se ha adoptado otro enfoque a la hora de estructurar estos contenidos. Es el caso de algunos grados relacionados con las Tecnologías de la Información que, siguiendo las recomendaciones del Computing Curricula de la ACM y el IEEE (Curriculum Guidelines for Undergraduate Degree Programs in Information Technology 2 ), han dedicado menos créditos a estas materias de manera que ahora se abordan unificadas junto con otras relacionadas en una única asignatura. Este libro ofrece una respuesta a este nuevo enfoque combinando contenidos de teoría de los lenguajes de programación, procesadores de lenguajes, paradigmas de los lenguajes de programación, aspectos pragmáticos de los lenguajes de programación y lenguajes de marcado. En particular este libro está pensado como texto básico para la asignatura de "Lenguajes de Programación y Procesadores" del Grado en Ingeniería en Tecnologías de la Información de la UNED. 1 Espacio Europeo Educación Superior (http://www.eees.es/) Technology 2008. Curriculum Guidelines for Undergraduate Degree Programs in Information Technology. Association for Computing Machinery (ACM) and IEEE Computer Society 2 Information 1 2 P REFACIO Planificación del libro El contenido del libro se puede cubrir en un semestre académico. Este libro está pensado como texto básico para la enseñanza de la teoría de los lenguajes de programación y sus paradigmas, así como de la teoría de los procesadores de lenguajes. Su contenido se centra en los aspectos introductorios fundamentales de las materias anteriores y los complementa con el estudio de los lenguajes de script y de los lenguajes de marcado. La inclusión de éstos se justifica porque su uso resulta muy relevante en aplicaciones relacionadas con las Tecnologías de la Información. Cada capítulo se estructura siguiendo el siguiente esquema: • Presentación del tema al comienzo de cada capítulo para que el lector tenga una visión panorámica de los contenidos que va a encontrar y pueda conocer las dificultades que implica su estudio. • Desarrollo del tema en cuestión proporcionando ejemplos para facilitar la comprensión de los aspectos teóricos presentados. • Ejercicios resueltos representativos de los aspectos teóricos y prácticos más destacados del capítulo. • Ejercicios propuestos que no están solucionados en el libro y que se presentan al lector como retos abiertos. • Notas bibliográficas que comentan la bibliografía más destacada de cada capítulo y proporcionan referencias para complementarla. Prerrequisitos En cuanto a programación de ordenadores se refiere, se le suponen al lector conocimientos de programación estructurada imperativa y nociones de orientación a objetos. También se le presuponen conocimientos de las estructuras de datos básicas como arrays, listas, colas, árboles y sus operaciones básicas. Por otro lado, conocer más de un lenguaje de programación puede facilitar la comprensión de algunos conceptos relacionados con la teoría de los lenguajes de programación. También se le presuponen al lector conocimientos de los conceptos relacionados con la teoría de lenguajes formales: autómatas finitos, expresiones regulares y gramáticas. Aunque en el desarrollo de los contenidos de este libro se repasan los conceptos de autómatas finitos, lenguajes regulares, expresiones regulares, gramáticas regulares, autómatas a pila, y lenguajes y gramáticas independientes del contexto, si el lector ya está familiarizado con ellos, la lectura y comprensión de los capítulos dedicados a los lenguajes de programación y los procesadores de lenguajes se verá facilitada. P REFACIO 3 Organización del contenido El contenido del libro se estructura en dos partes: Parte 1. Teoría de los lenguajes de programación y procesadores de lenguajes. Parte 2. Lenguajes de computadora. La primera parte consta de dos capítulos que introducen respectivamente los aspectos fundamentales de la teoría de los lenguajes de programación y los procesadores de lenguajes: Capítulo 1. Lenguajes de programación. Capítulo 2. Procesadores de lenguajes. En la segunda parte se presentan los paradigmas y modelos de programación, los lenguajes de marcado, los lenguajes de script y los principales aspectos pragmáticos relacionados con los lenguajes de programación. Esta segunda parte consta a su vez de cuatro capítulos: Capítulo 3. Paradigmas y modelos de programación. Capítulo 4. Lenguajes de marcado. XML. Capítulo 5. Lenguajes de script. Capítulo 6. Aspectos pragmáticos de los lenguajes de programación. El contenido más detallado de cada capítulo junto con las recomendaciones para su mejor aprovechamiento es el siguiente: • Capítulo 1. Lenguajes de programación. Este capítulo expone las nociones básicas necesarias para entender los lenguajes de programación. Se presentan los conceptos de sintaxis y semántica de un lenguaje de programación, así como la notación BNF para la descripción de la sintaxis de estos lenguajes. Posteriormente se profundiza en los diferentes elementos que los componen: expresiones y enunciados, tipos de datos, ámbito de variables, etc. Cada uno de estos elementos es presentado enumerando sus principales características e indicando con ejemplos la forma en que se han incluido en diferentes lenguajes de programación. Los ejercicios de este capítulo hacen hincapié en la sintaxis de los lenguajes de programación y en el aprendizaje de los diferentes conceptos presentados a lo largo del tema. 4 P REFACIO • Capítulo 2. Procesadores de lenguajes. Este capítulo ofrece una visión general de las diferentes etapas de transformación de un programa, desde un código fuente escrito por un programador, hasta un fichero ejecutable. De entre los diferentes tipos de procesadores de lenguajes se destacan los compiladores, de los que se ofrece una explicación más en detalle como ejemplo paradigmático de un procesador de lenguajes. La descripción de las fases de un compilador se centra fundamentalmente en las etapas de análisis más que en las de síntesis. Los ejercicios de este capítulo se centran en aspectos relacionados con la fase de análisis de un compilador, así como en la posterior etapa de traducción dirigida por sintaxis. • Capítulo 3. Paradigmas y modelos de programación. Este capítulo define el concepto de paradigma de programación y describe brevemente algunos de los paradigmas existentes: imperativo, orientado a objetos, concurrente, funcional y lógico. Para introducir cada paradigma se utiliza un lenguaje representativo del mismo del cual se explican los conceptos sintácticos fundamentales para comprender el paradigma y poder realizar ejercicios sencillos. El capítulo también presenta otros lenguajes de computadora que a pesar de que no representan un paradigma concreto, son de un uso tan generalizado que merecen al menos una breve introducción: los lenguajes dinámicos y los lenguajes de marcado. Los ejercicios de este capítulo buscan reforzar el conocimiento del lector sobre cada uno de los paradigmas y lenguajes presentados. • Capítulo 4. Lenguajes de marcado. XML. Este capítulo introduce al lector en los fundamentos del lenguaje de marcado XML. Se presentan las principales tecnologías asociadas para que el lector conozca sus posibilidades y principales características. Además se presentan los procesadores de documentos XML en términos de los tipos de análisis que realizan. Los ejemplos, ejercicios resueltos, junto con el uso de las herramientas recomendadas en el propio capítulo permitirán al lector familiarizarse con este lenguaje de marcado. • Capítulo 5. Lenguajes de script. Este capítulo introduce los lenguajes de script comenzando con una breve descripción de sus orígenes. La presentación de los dominios en los que normalmente se utilizan sirve de marco para presentar y comentar algunas de las herramientas y lenguajes de script más populares. En este capítulo tienen especial relevancia los ejercicios resueltos ya que son los que van a mostrar la potencia y diferencias de este tipo de lenguajes con respecto a otros lenguajes de programación. Se tratan dos lenguajes de script con un poco más de detalle, Perl y PHP, y en particular este último se relaciona con XML y algunos procesadores de documentos. • Capítulo 6. Aspectos pragmáticos de los lenguajes de programación. Este capítulo ofrece al lector algunos aspectos que pueden resultar clave en la elección de un determinado lenguaje frente a otro, a la hora de crear un programa infor- P REFACIO 5 mático, o de estudiar la interoperabilidad entre aplicaciones escritas en diferentes lenguajes de programación. Dependencias entre capítulos Para el lector interesado en todos los contenidos abordados en el libro se recomienda una lectura secuencial siguiendo el orden de aparición de los capítulos. En el caso de que se quieran abordar sólo algunos de los temas aquí persentados o hacerlo en un orden diferente del aquí expuesto, se detallan a continuación las dependencias entre los distintos capítulos: • El capítulo 3 depende del capítulo 1, dado que en éste se introducen conceptos que son utilizados a la hora de presentar los diferentes paradigmas, como el ámbito de variables, el concepto de subprograma, tipos de datos, etc. • El capítulo 2 depende también del capítulo 1, puesto que se centra en los procesadores de lenguajes y se utiliza la notación BNF para explicar los conceptos relativos al análisis sintáctico. • El capítulo 6 hace referencia al Capítulo 1 al presentar los aspectos pragmáticos de los lenguajes de programación. Aunque el capítulo 6 podría verse después del 3, su lectura después de los capítulos 4 y 5 proporciona un panorama más amplio de los lenguajes de programación. • El capítulo 4, dedicado a los lenguajes de marcado, se podría abordar de manera independiente del resto de capítulos, sin embargo su estudio una vez leída la primera parte del libro (capítulos 1 y 2) facilita la comprensión en profundidad de las posibilidades de XML y de sus tecnologías. • Se podría decir que el capítulo 5 profundiza en algunos de los conceptos presentados en el capítulo 3, y por lo tanto se recomienda la lectura previa de éste. Al lector interesado solo en la teoría de los lenguajes de programación y procesadores de lenguajes se le recomienda la lectura de los capítulos 1, 2, 3 y 6 por ese orden. Si además tiene interés en los lenguajes de script puede incluir el capítulo 5 antes del 6. Ejercicios En cada capítulo hay un apartado de ejercicios resueltos y otro de ejercicios propuestos. El primero proporciona al lector ejemplos de cómo resolver cuestiones teóricas, problemas, utilizar ciertos lenguajes o tecnologías que cubren los aspectos fundamentales desarrollados en el capítulo. 6 P REFACIO Además, los ejercicios resueltos constituyen una herramienta fundamental para la autoevaluación en el caso de que el lector quiera comprobar su grado de comprensión de los contenidos expuestos en cada capítulo. Los ejercicios propuestos también cubren los aspectos fundamentales del capítulo. Estos ejercicios se dejan abiertos como un reto para el lector para que los intente solucionar. Los autores animamos al lector a la lectura y resolución de ambos tipos de ejercicios. Capítulo 1 Introducción a los lenguajes de programación En este capítulo se exponen las nociones básicas necesarias para entender los lenguajes de programación y se introducen conceptos que serán utilizados durante el resto del libro. Comienza explicando las notaciones sintácticas y semánticas para la descripción de lenguajes de programación y después profundiza en el diseño de lenguajes, realizando un recorrido por diferentes conceptos como tipos de datos, procedimientos y ambientes, expresiones, tipos abstractos de datos o modularización. Aunque estos conceptos se presentan de forma resumida, el texto contiene referencias a fuentes externas que el lector puede consultar para ampliar el contenido sobre algún aspecto concreto. 1.1 Introducción Sir Charles Antony Richard Hoare, inventor del algoritmo Quicksort (quizá el algoritmo de computación más utilizado del mundo) dijo en una ocasión [14]: El propósito principal de un lenguaje de programación es ayudar al programador en la práctica de su arte. Para comprender los principios de diseño de los modernos lenguajes de programación es necesario conocer algo de su historia. Esta historia comienza realmente en el siglo XIX, cuando Charles Babbage idea la máquina analítica. Esta máquina estaba diseñada para funcionar utilizando tarjetas perforadas. Estas tarjetas ya se conocían puesto que eran las utilizadas en el telar de Jacquard, una máquina inventada por Joseph Marie Jacquard que era capaz de crear telas a partir de patrones grabados en tarjetas de este tipo. Aunque Babbage no terminó nunca de construirla, su máquina analítica se considera la primera computadora. Una matemática contemporánea de Babbage, llamada Ada Lovelace, llegó incluso a crear programas para esta máquina analítica, aunque evidentemente nunca pudo comprobar si funcionaban. Por ello a Babbage se le considera 7 8 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN el padre de la computadora y a Ada la primera programadora de la historia (el lenguaje Ada, creado para el ministerio de defensa estadounidense se llamó así en su honor). Mucho más recientemente, en los años 30 del siglo XX se construyeron las primeras máquinas capaces de realizar ciertas operaciones, si bien normalmente estaban orientadas a realizar algún tipo concreto de cálculo científico. Existe cierto acuerdo en que el ENIAC, desarrollado en el año 1946, puede considerarse el primer computador realmente de propósito general. El ENIAC no se programaba utilizando un lenguaje de programación, sino cableando directamente sus circuitos. Por esta época, John von Neumann propuso una arquitectura diferente para las computadoras, donde el programa se almacena en la máquina antes de ejecutarse. Esto permitió a partir de entonces evitar tener que cablear todos los componentes para cada nuevo programa. Es entonces donde realmente empieza la historia de los lenguajes de programación. Desde un punto de vista coloquial, un lenguaje de programación es una notación para comunicarle a una computadora lo que deseamos que haga. Desde un punto de vista formal, podemos definirlo como un sistema notacional para describir computaciones en una forma legible tanto para la máquina como para el ser humano. Esta definición formal es la que ha estado guiando la evolución de los lenguajes de programación. En esta evolución podemos distinguir cinco generaciones de lenguajes de programación: Primera generación. A esta generación pertenece el lenguaje máquina. El lenguaje máquina consiste exclusivamente en secuencias de ceros y unos y es el único lenguaje que entienden las computadoras modernas. Cada computadora dispone de lo que se denomina un conjunto de instrucciones que son las operaciones que esa computadora entiende (sumar, restar, cargar de memoria, guardar en memoria, etc). Estas operaciones se indican mediante secuencias de ceros y unos. El principal inconveniente del lenguaje máquina es que es difícil de entender por un humano. Los programas escritos en lenguaje máquina generalmente sólo funcionan en un modelo de máquina específico, dado que cada computadora define su propio código de secuencias de ceros y unos. Segunda generación. Pertenecen a esta generación los lenguajes ensambladores. Estos lenguajes establecen una serie de reglas mnemotécnicas que hacen más sencilla la lectura y escritura de programas. Estas reglas mnemotécnicas consisten simplemente en asociar nombres legibles a cada una de las instrucciones soportadas por la máquina (ADD, SUB, LOAD, STORE, etc.). Los programas escritos en lenguaje ensamblador no son directamente ejecutables por la máquina, puesto que ésta sólo entiende instrucciones codificadas como ceros y unos, pero es muy sencillo traducirlos a lenguaje máquina. El lenguaje ensamblador aún se utiliza hoy en día para programar drivers para dispositivos o determinadas partes de los sistemas operativos. I NTRODUCCIÓN 9 Tercera generación. A esta generación pertenecen los lenguajes como C, FORTRAN o Java. Estos lenguajes se denominan lenguajes de alto nivel, porque están bastante alejados del lenguaje máquina y son mucho más legibles por el hombre. Para convertir los programas escritos en estos lenguajes de alto nivel en código máquina entendible por la computadora empiezan a hacer falta complejos programas que los traduzcan. Estos traductores se denominan compiladores. Los lenguajes de alto nivel son necesarios para programar grandes sistemas software, como sistemas operativos (Linux, Windows), aplicaciones para la web (Facebook, Tuenti, Twitter) o aplicaciones para móviles. Estos lenguajes facilitan el mantenimiento y la evolución del software. Los primeros lenguajes de tercera generación fueron FORTRAN, LISP, ALGOL y COBOL. Los cuatro surgieron a finales de los 50. Cuarta generación. Los lenguajes de la cuarta generación son lenguajes de propósito específico, como SQL, NATURAL o ABAP. Estos lenguajes no están diseñados para programar aplicaciones complejas, sino que fueron diseñados para solucionar problemas muy concretos. Por ejemplo, SQL es el lenguaje empleado para describir consultas, inserciones o modificaciones de bases de datos. El lenguaje está específicamente diseñado para ello. Otro ejemplo son los lenguajes que incluyen paquetes estadísticos como SPSS que permiten manipular grandes cantidades de datos con fines estadísticos. Quinta generación. Los lenguajes de quinta generación son los utilizados principalmente en el área de la inteligencia artificial. Se trata de lenguajes que permiten especificar restricciones que se le indican al sistema, que resuelve un determinado problema sujeto a estas restricciones. Algunos ejemplos de lenguajes de quinta generación son Prolog o Mercury. Cada generación ha tratado de resolver los problemas detectados en la generación anterior. Así, los lenguajes de segunda generación surgieron a partir de la necesidad de evitar tener que recordar las instrucciones de una computadora concreta como una secuencia de ceros y unos. Para los humanos es más sencillo recordar palabras (como ADD o LOAD) que estas secuencias. Los lenguajes ensambladores se crearon con este propósito. Sin embargo, los lenguajes de segunda generación eran poco convenientes para crear programas complejos. A medida que el software se hacía más complejo era más difícil mantenerlo debido a lo poco estructurado que es el lenguaje ensamblador. Los lenguajes ensambladores básicamente no permitían modularizar el código más allá de crear macros que podían reutilizarse en otras partes del programa. Esto llevó a la aparición de los lenguajes de tercera generación. Las características proporcionadas por estos lenguajes han variado muchísimo, desde la aparición de los primeros lenguajes de alto nivel como FORTRAN o ALGOL que incorporaron el concepto de subprograma, hasta los modernos lenguajes como Java, C# o Ruby que incluyen conceptos de más alto nivel como clases o paquetes. 10 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN La aparición de los lenguajes de tercera generación introdujo un problema realmente complejo: la traducción de los programas escritos en estos lenguajes a código máquina (secuencias de ceros y unos entendible por la computadora). El lenguaje ensamblador era muy sencillo de traducir, dado que la relación entre instrucciones en lenguaje ensamblador e instrucciones de código máquina era de uno a uno: básicamente las instrucciones en ensamblador no eran más que mnemotécnicos que ayudaban a recordar el nombre de las operaciones, en lugar de tener que escribir la secuencia de ceros y unos correspondiente. Sin embargo, en los lenguajes de tercera generación, una instrucción del lenguaje podía requerir varias decenas de instrucciones de código máquina. Para traducir los programas escritos en estos lenguajes se crearon los compiladores. Tradicionalmente, un compilador es un programa que traduce un programa escrito en un determinado lenguaje de programación a código máquina. De los programas traductores, y en general de los procesadores de lenguajes, se hablará en el capítulo 2. Los lenguajes de programación tienen orígenes muy diversos. En ocasiones un lenguaje se crea para explorar los límites de las máquinas. En otras ocasiones se diseñan para incorporar características deseadas por los usuarios finales (los programadores que van a utilizarlo), como en el caso de COBOL (orientado a la construcción de aplicaciones de negocios, como bancos) y Ada (diseñado para el ministerio de defensa estadounidense). El tipo de aplicación que se va a construir puede determinar fuertemente el lenguaje a utilizar. Por ejemplo, para aplicaciones en tiempo real, donde el tiempo de respuesta es primordial, como los programas que controlan centrales nucleares o el piloto automático de un avión, se suele utilizar Ada o C. Para aplicaciones web, sin embargo, no es muy habitual utilizar C y se tiende más hacia lenguajes como Java, Python, PHP o Ruby. Se puede ver un lenguaje de programación como una interfaz entre la máquina y el usuario (u otros programas). Desde este punto de vista surgen tres cuestiones a las que hay que dar respuesta: primero, cuál es la estructura (sintaxis) y el significado (semántica) de las construcciones permitidas por el lenguaje. Segundo, cómo traducirá el compilador dichas construcciones a lenguaje máquina. Tercero, cuán útil es el lenguaje para el programador. En otras palabras, si es suficientemente expresivo, legible o simple. Estas son las cuestiones que se estudiarán en el resto de este capítulo. 1.2 Sintaxis Cuando aparecieron los primeros lenguajes de tercera generación, los compiladores se construían para un lenguaje concreto basándose en la descripción del lenguaje de programación correspondiente. Esta descripción se proporcionaba en lenguaje natural (inglés) en forma de manuales de referencia del lenguaje y constaba de tres partes: la descripción léxica del lenguaje, que especifica cómo se combinan símbolos para formar palabras; la descripción sintáctica, que establece las reglas de combinación de dichas palabras para formar frases o sentencias; y la descripción semántica, que trata del significado de las frases o sentencias en sí. El problema de realizar la descripción en lenguaje natural es la S INTAXIS 11 ambigüedad: el lenguaje natural está lleno de ambigüedades y es difícil ser muy preciso en la descripción de estos elementos del lenguaje. Por ello, a finales de los años 50, mientras diseñaba el lenguaje Algol 58 para IBM, John Backus ideó un lenguaje de especificación para describir la sintaxis de este lenguaje. Posteriormente, Peter Naur retomó su lenguaje de especificación y lo aplicó para definir la sintaxis de Algol 60 y lo llamó Backus Normal Form. Finalmente, este lenguaje de especificación acabó llamándose BNF (de Backus-Naur Form) y ha sido el estándar de facto para la descripción de la sintaxis de muchos lenguajes de computadora, como lenguajes de programación o protocolos de comunicación. En lingüística, la sintaxis es la ciencia que estudia los elementos de una lengua y sus combinaciones. Es la parte que se encarga de establecer la estructura que deben tener las sentencias del lenguaje. En los lenguajes de computadora no es diferente: la sintaxis de un lenguaje establece las normas que han de cumplir los diferentes elementos del lenguaje para formar sentencias válidas de ese lenguaje. Para describir la sintaxis de los lenguajes de programación se pueden utilizar diferentes notaciones. El lenguaje BNF, o algún derivado como EBNF (Extended BNF), es la notación más habitualmente utilizada. Sin embargo, en ocasiones también se utiliza una notación gráfica denominada diagramas sintácticos. Desde un punto de vista formal la sintaxis de un lenguaje de programación es un lenguaje independiente del contexto. Un lenguaje independiente del contexto es un lenguaje que se puede describir con una gramática independiente del contexto. Este tipo de gramáticas se estudian como parte de la teoría de lenguajes formales, que define las propiedades de los diferentes tipos de lenguajes formales. Estas propiedades son las que permiten realizar razonamientos matemáticos sobre los lenguajes y, en última instancia, construir herramientas que permitan reconocer una determinada cadena de un programa como perteneciente al lenguaje de programación cuya sintaxis viene definida por una determinada gramática independiente del contexto. La construcción de compiladores, y en concreto de las diferentes fases de análisis de un compilador, se asienta en la teoría de lenguajes formales. Las notaciones BNF y EBNF, así como los diagramas sintácticos, permiten representar textualmente (las primeras) o gráficamente (la segunda) una gramática independiente del contexto. 1.2.1 Gramáticas independientes del contexto Una gramática independiente del contexto es una 4-tupla G(T, N, S, P), donde T es el alfabeto del lenguaje, N es el conjunto de símbolos no terminales, S es un símbolo no terminal especial denominado símbolo inicial, y P es el conjunto de producciones de la gramática. El lenguaje definido por una gramática independiente de contexto G se denota por L(G) y se denomina lenguaje independiente del contexto. En una gramática independiente del contexto las producciones son de la forma: 12 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN N → w, (1.1) S donde N ∈ N y w ∈ {N T }∗. Al no terminal N se le denomina antecedente, y a la cadena de símbolos w consecuente. Una producción puede interpretarse como una regla de reescritura que permite sustituir un elemento no terminal de una cadena por el consecuente de alguna de las producciones en las que ese no terminal actúa como antecedente. Esta sustitución se conoce como paso de reescritura o paso de derivación. El lenguaje definido por una gramática independiente del contexto son todas aquellas cadenas que se pueden derivar en uno o más pasos de derivación desde el símbolo inicial S. Supóngase que se desea describir formalmente el lenguaje, que denominaremos L1, de las cadenas sobre el alfabeto {a, b, +} en el que el símbolo terminal + aparece siempre entre alguno de los símbolos a o b. La siguiente gramática describe dicho lenguaje: S → A + A|A A → a|b|S (1.2) El lenguaje L1 incluye, por ejemplo, la cadena a + b + b. Si la gramática describe de forma precisa el lenguaje, entonces esta cadena debe poder ser generada en sucesivos pasos de derivación a partir del símbolo inicial S: S ⇒ A+A ⇒ a+A ⇒ a+S ⇒ a+A+A ⇒ a+b+A ⇒ a+b+b (1.3) Las gramáticas pueden utilizarse para decidir si una cadena pertenece al lenguaje: basta comprobar si es posible generar la cadena partiendo del símbolo inicial realizando sustituciones utilizando las producciones de la gramática. 1.2.2 Notación BNF La notación BNF representa una forma compacta de escribir gramáticas independientes de contexto. Con este formalismo, los símbolos no terminales se encierran entre los paréntesis angulares (< y >), como en < expresion >, y las producciones se expresan mediante el siguiente formato: < antecedentes >::=< consecuentes > (1.4) No existe un consenso claro sobre la forma de representar los símbolos terminales, pero en ocasiones se expresan entre comillas simples o dobles, como en ′ i f ′ , ′ f or′ o ′ int ′ , aunque esto no es obligatorio. Las gramáticas BNF se utilizan para definir la sintaxis de los lenguajes de programación. Por ejemplo, la siguiente gramática en notación BNF define la parte de declaraciones de variables de un lenguaje: S INTAXIS 13 < declaracion >::=< lista − variables >′ :′ < tipo > < lista − variables >::=< variable > | < variable >′ ,′ < lista − variables > < variable >::=′ a′ |′ b′ |′ c′ < tipo >::=′ bool ′ |′ int ′ |′ f loat ′ |′ char′ (1.5) Árboles de análisis sintáctico Para determinar si una cadena representa una expresión o enunciado válido en un lenguaje cuya sintaxis viene definida por una gramática BNF se utilizan las producciones de la gramática. Así, se trata de obtener la cadena buscada a partir del símbolo inicial en sucesivos pasos de derivación. Este proceso da lugar a lo que se conoce como árbol de análisis sintáctico. Por ejemplo, supóngase que se tiene el lenguaje definido por la gramática 1.5, la cadena a, b : bool tiene el siguiente árbol de análisis sintáctico: <declaracion> <lista-variables> ’:’ <tipo> ’bool’ <variable> ’a’ ’,’ <lista-variables> <variable> ’b’ Este árbol de análisis sintáctico se corresponde con la siguiente derivación utilizando las producciones de la gramática: 1 2 3 4 5 6 7 < declaracion > => < lista - variables > ’:’ <tipo > => < variable > ’,’ <lista - variables > ’:’ <tipo > => ’a ’ ’,’ <lista - variables > ’:’ <tipo > => ’a ’ ’,’ < variable > ’:’ <tipo > => ’a ’ ’,’ ’b ’ ’:’ <tipo > => ’a ’ ’,’ ’b ’ ’:’ ’bool ’ Nótese que en ocasiones en un determinado paso de derivación podemos elegir entre varios no terminales. En este caso se ha utilizado la regla de sustituir el no terminal más a la izquierda primero, y proceder sustituyendo de izquierda a derecha. Determinados algoritmos de análisis sintáctico proceden realizando sustituciones por la izquierda 14 I NTRODUCCIÓN a) A LOS LENGUAJES DE PROGRAMACIÓN S b) S S S b S a b S S S b b a a S S a a a Figura 1.1: Dos árboles sintácticos para la cadena ababa (sustituyen en cada paso el no terminal más a la izquierda), mientras que otros proceden sustituyendo el no terminal más a la derecha. Considérese a continuación la gramática BNF para el lenguaje de las cadenas de aes y bes donde toda b aparece siempre entre dos aes: < S >::=< S > b < S > |a (1.6) La cadena ababa pertenece al lenguaje definido por la gramática 1.6, o lo que es lo mismo, puede obtenerse mediante sucesivos pasos de derivación desde el símbolo inicial: < S >⇒< S > b < S >⇒< S > b < S > b < S >⇒ ab < S > b < S >⇒ abab < S >⇒ ababa (1.7) Esta derivación se puede representar mediante dos árboles sintácticos diferentes, como se muestra en la Figura 1.1. Cuando una misma cadena se puede representar mediante dos o más árboles sintácticos se dice que la gramática es ambigua. La ambigüedad es un problema en el contexto de los lenguajes de programación porque un analizador sintáctico no puede decidir cuál de los dos árboles sintácticos construir. Análogamente, cuando para toda cadena del lenguaje existe únicamente un árbol sintáctico se dice que la gramática es no ambigua. El problema de la ambigüedad radica en que cambia el significado dado a la cadena. Si se considera el lenguaje de las expresiones aritméticas de sustracción, podría pensarse en construir una gramática como la siguiente: < expresion >::=< expresion >′ −′ < expresion > | < constante > (1.8) Según la gramática 1.8, la expresión 2 − 1 − 1 se puede representar como los dos árboles sintácticos de la Figura 1.2. Estos dos árboles representan diferente asociatividad para la resta. El árbol a) representa la expresión 2 − (1 − 1) = 2 (asociatividad por la derecha), mientras que el árbol b) representa la expresión 2 − 1 − 1 = 0 (asociatividad por la izquierda). Cuando una gramática para un lenguaje de programación es ambigua, S INTAXIS a) 15 <expresion> - <expresion> <expresion> <constante> <expresion> - <expresion> 2 b) <constante> <constante> 1 1 <expresion> - <expresion> <expresion> <constante> <expresion> - <expresion> 1 <constante> <constante> 2 1 Figura 1.2: Dos árboles sintácticos para la expresión 2 − 1 − 1 es necesario eliminar la ambigüedad y, además, hacerlo de tal forma que prevalezca el árbol sintáctico adecuado en cada caso. Es posible escribir una gramática que no tenga los problemas de la gramática ambigua 1.8 eliminando las producciones que son recursivas por la derecha: < expresion >::=< expresion >′ −′ < constante > | < constante > (1.9) Esta nueva forma de describir el lenguaje de las expresiones aritméticas de sustracción no presenta problemas de ambigüedad. La cadena 2 − 1 − 1 ahora sólo tiene un árbol posible, el mostrado en la Figura 1.3. 16 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN <expresion> <expresion> - <constante> 1 <expresion> - <constante> <constante> 1 2 Figura 1.3: Árbol sintáctico para la expresión 2 − 1 − 1 según la gramática 1.9 Notación EBNF La notación EBNF (Extended BNF) es una forma derivada de BNF que fue introducida para describir la sintaxis del lenguaje Pascal, y que actualmente es un estándar ISO (ISO/IEC 14977). EBNF simplifica la notación BNF mediante el uso de paréntesis para agrupar símbolos (( )), llaves para representar repeticiones ({ }), corchetes para representar una parte optativa ([ ]) –correspondiente a una cardinalidad de 0 ó 1–, o representando los símbolos terminales entre comillas sencillas. Además, se permite el uso del cuantificadores: {} ∗ para representar 0 ó más veces, y {} + para representar una cardinalidad de 1 ó más veces. La gramática EBNF 1.10 representa una posible gramática para expresiones aritméticas. Como puede verse, no se explicita la recursividad en la definición de < expresion > y de < termino >. < expresion >::=< termino > {(′ +′ | ′ −′ ) < termino >}∗ < termino >::=< f actor > {(′ ∗′ |′ /′ ) < f actor >}∗ < f actor >::=′ a′ |′ b′ |′ c′ | ′ (′ < expresion >′ )′ (1.10) Árboles de sintaxis abstracta En ocasiones no es necesario representar cada detalle sintáctico en el árbol. Por ejemplo, para conocer la estructura de un determinado fragmento de código no es necesario representar en el árbol sintáctico cada elemento sintáctico (paréntesis, llaves, operadores). Por ello los traductores muchas veces trabajan sobre una versión reducida del árbol de análisis sintáctico denominada árbol de sintaxis abstracta. En este tipo de árboles gran parte de los símbolos han sido suprimidos, y el árbol es mucho más conciso. Por ejemplo, normalmente en un árbol de sintaxis abstracta las expresiones aritméticas binarias se representan con un nodo del que cuelgan los operandos, como se muestra en S INTAXIS 17 <expresion> 1 2 1 Figura 1.4: Árbol de sintaxis abstracta para la expresión 2 − 1 − 1 según la gramática 1.9 ✲ ✲ hterminoi ✲✛ ❄ ’+’ ’-’ hterminoi Figura 1.5: Diagrama sintáctico correspondiente a la gramática 1.11 la Figura 1.4. Este árbol es mucho más conciso que el árbol sintáctico de la Figura 1.3, pero representa exactamente la misma expresión aritmética. Diagramas sintácticos Los diagramas sintácticos representan en forma gráfica una gramática independiente del contexto. En un diagrama sintáctico, los símbolos no terminales se representan habitualmente mediante rectángulos y los símbolos terminales se representan por círculos. Una palabra reconocida se representa como un camino entre la entrada (izquierda) y la salida (derecha). Sin embargo, en ocasiones los símbolos de la gramática se representan con la notación BNF. Esta última será la convención que se utiliza en este texto. La Figura 1.5 muestra la siguiente producción mediante un diagrama sintáctico: < expresion >::=< termino > {(′ +′ |′ −′ ) < termino >} (1.11) La gramática 1.12, correspondiente a la sintaxis de for de Pascal, se representaría en forma de diagrama sintáctico como se muestra en la Figura 1.6. < sent − f or >::=′ f or′ < var >′ :=′ < exp > (′ to′ |′ downto′ ) < exp >′ do′ < sent > (1.12) ✲ ✲ ’for’ hvari ’:=’ hexpi ’to’ ’downto’ hexpi ’do’ hsenti Figura 1.6: Diagrama sintáctico correspondiente a la gramática 1.12 ✲✛ 18 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN 1.3 Semántica básica La sintaxis de un lenguaje especifica cómo deben disponerse los componentes elementales del lenguaje para formar sentencias válidas. La semántica se encarga de comprobar el correcto sentido de las sentencias construidas. La especificación semántica del lenguaje establece cómo se comportan los diferentes elementos del lenguaje. Considérese la siguiente sentencia del castellano: He comido un poco de todo. Esta sentencia es válida desde el punto de vista sintáctico: está bien formada, tiene un sujeto y un predicado, un verbo, etc; también lo es desde el punto de vista semántico, la frase tiene un significado concreto. La sentencia que se muestra a continuación, pese a ser correcta sintácticamente (sólo cambia el verbo respecto al ejemplo anterior), no es correcta semánticamente: He dormido un poco de todo. La semántica del lenguaje se encarga de determinar qué construcciones sintácticas son válidas y cuáles no. Para ello existen varias notaciones, como la semántica denotacional, la semántica axiomática o la semántica operacional. Sin embargo, estas notaciones son bastante complejas y se apoyan fuertemente en formalismos matemáticos. Por ello, para la definición semántica del lenguaje generalmente se utiliza una variación denominada gramáticas atribuídas. Las gramáticas atribuídas son una extensión de las gramáticas independientes del contexto utilizadas para la definición sintáctica de los lenguajes de programación. Se verán más en detalle estos tipos de gramáticas en el capítulo 2. 1.4 Tipos de datos Un tipo de datos representa un conjunto de valores y un conjunto de operaciones definidas sobre dichos valores. Casi todos los lenguajes de programación incluyen tipos de datos primitivos (o predefinidos) para cuyos valores disponen de construcciones sintácticas especiales. Además, la mayoría de los lenguajes de programación permiten al programador definir nuevos tipos a partir de los ya existentes. Los tipos de datos se pueden agrupar en dos clases: Tipos de datos simples. Son aquellos que no pueden descomponerse en otros tipos de datos más elementales. Se incluyen dentro de esta clase los tipos de datos entero, carácter, lógico (booleano), real, enumerado o subrango. Tipos de datos estructurados. Son aquellos que se componen de tipos de datos más básicos. Ejemplos de tipos de datos estructurados son los registros, arrays, listas, cadenas de caracteres o punteros. T IPOS DE DATOS 19 1.4.1 Tipos de datos simples Los tipos de datos simples son tipos básicos que vienen definidos por el lenguaje de programación y que sirven de base para la construcción de otros tipos de datos (como los tipos estructurados o los tipos definidos por el usuario). El número de tipos de datos simples proporcionados varía de un lenguaje a otro. Además, en algunos casos, el mismo tipo de dato está presente en el lenguaje como dos tipos distintos con distinta precisión. Esto sucede en Java con los tipos de datos float y double, que representan números reales pero con precisiones distintas. Lo mismo sucede en Haskell con los tipos enteros Int e Integer: Int representa un valores de tipo entero dentro del rango (−229 , 229 − 1); mientras que Integer representa números enteros de precisión arbitraria. Entero El tipo entero en los lenguajes de programación es normalmente un subconjunto ordenado del conjunto infinito matemático de los enteros. El conjunto de valores que el tipo entero puede representar en un lenguaje viene determinado por el diseñador del lenguaje en ciertos casos, o por el diseñador del compilador en otros. Por ejemplo, en Java el tamaño del conjunto de los enteros viene determinado por la especificación del lenguaje, mientras que en C la precisión de la mayoría de los tipos viene determinada por el diseñador del compilador y suele depender de la arquitectura específica para la que se construyó ese compilador. El tipo de datos de los enteros suele incluir las operaciones típicas entre valores de este tipo: aritméticas (tanto unarias como binarias), relacionales y de asignación. La implementación del tipo de datos suele consistir típicamente en una palabra de memoria (32 bits en arquitecturas x86) donde se almacena el valor entero en complemento a 2, aunque no cualquier combinación de bits es válida para representar valores. Es posible que ciertos bits estén reservados para propósitos específicos de la implementación. Por ejemplo, algunos lenguajes reservan dentro de estos 32 bits un bit para indicar desbordamiento (una operación cuyo resultado no cabe dentro del rango de enteros soportado), otros pueden utilizar algún bit como descriptor del tipo de datos, etc. Algunos lenguajes soportan operaciones a nivel de bit sobre operandos de tipo entero, como desplazamiento, desplazamiento circular, etc. El siguiente código, escrito en C, equivale a multiplicar el primer operando por 2 elevado a la potencia indicada por el segundo operando: 1 y = x << 4; Real El tipo de datos real representa números decimales en notación de punto flotante o punto fijo. Se trata de un subconjunto del conjunto infinito matemático de los números reales. 20 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN Este tipo de datos incluye las operaciones típicas: aritméticas (unarias y binarias), relacionales y de asignación. Generalmente los lenguajes proporcionan al menos dos tipos de datos reales con distinta precisión, típicamente uno de 32 y otro de 64 bits. La implementación de números reales suele estar basada en el estándar IEEE 754, donde el número real se divide en una mantisa y un exponente. Diferentes implementaciones de este tipo de datos pueden establecer diferente tamaño de mantisa y exponente. El estándar IEEE 754 define valores especiales para representar los conceptos de infinito, -infinito y Not a Number (NaN). Por ejemplo, en Java la siguiente expresión da como resultado NaN: 1 System . out . println (0.0 / 0.0) ; Esta otra expresión da como resultado Infinity: 1 System . out . println (1.0 / 0.0) ; Booleano El tipo de datos booleano consiste en dos únicos valores posibles: verdadero y falso. Incluye las operaciones lógicas típicas: and, or, not, etc. Aunque teóricamente estos valores podrían almacenarse en un único bit, dado que no es posible direccionar un único bit, la implementación suele consistir al menos en un byte. Algunos lenguajes no incluyen este tipo de dato y en su lugar lo representan mediante otros tipos. Por ejemplo, en C los valores booleanos se representan con enteros, donde todo valor distinto de cero es equivalente al valor verdadero, y el valor cero representa el valor falso. Carácter El tipo de datos carácter es la principal vía de intercambio de información entre el programador (o el usuario del programa) y la computadora. Los valores de este tipo de dato se toman de una enumeración fija. En algunos casos puede ser la tabla ASCII (como en C), en otros casos puede ser una tabla más amplia (como Unicode). La implementación en memoria dependerá del tamaño de la tabla. La tabla ASCII se puede representar con un solo byte. Los caracteres Unicode, en cambio, pueden requerir uno, dos o cuatro bytes dependiendo de la implementación utilizada. Los valores del tipo de datos carácter están ordenados (según la ordenación de la tabla en la que están basados), por lo que habitualmente pueden compararse utilizando operadores relacionales. Normalmente, los lenguajes soportan ciertos caracteres especiales que pueden representar, por ejemplo, saltos de línea, tabulados, etc. Además, en algunos casos, el progra- T IPOS DE DATOS 21 mador dispone de funciones predefinidas que le permiten discriminar si cierto carácter es un dígito, una letra, un signo de puntuación, etc. Enumerados Los tipos enumerados definen un número reducido de valores ordenados. El tipo se define por enumeración de todos los valores posibles. Es habitual encontrar dentro de este tipo operaciones como sucesor o predecesor, además de los operadores relacionales. El siguiente es un ejemplo de definición de un tipo enumerado en Pascal: 1 2 type DiaSemana = ( Lunes , Martes , Miercoles , Jueves , Viernes , Sabado , Domingo ); Algunos lenguajes implementan internamente los valores del tipo enumerado como enteros, como es el caso de C. Subrango Los valores de tipo subrango se crean especificando un intervalo de otro tipo de datos simple. Soportan las mismas operaciones que el tipo de datos básico del que derivan. Pascal es uno de los lenguajes que permiten la definición de subrangos de tipos de datos básicos. Las siguientes definiciones proporcionan un nuevo tipo basado en algunos de los tipos básicos de Pascal: 1 2 3 4 type DiaSemana = ( Lunes , Martes , Miercoles , Jueves , Viernes , Sabado , Domingo ); Laborable = Lunes .. Viernes ; Mes = 1 .. 12 La definición de tipos subrango permite en ocasiones hacer el código más legible y reducir la posibilidad de errores. En el ejemplo anterior, la definición del tipo Mes como un subrango de enteros entre 1 y 12, podría ser utilizada en un procedimiento setMes del tipo Fecha. El compilador asegurará que la función setMes no es invocada con un valor entero fuera del rango especificado para Mes. 1 2 3 4 procedure setMes (m : Mes ; var fecha : Fecha ); begin fecha . mes := m; end; 22 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN 1.4.2 Tipo de datos estructurados Los tipos de datos estructurados son tipos de datos que se construyen a partir de otros tipos, que pueden ser a su vez simples o estructurados. Esto permite crear jerarquías de composición de tipos tan complejas como sea necesario. Estos tipos de datos presentan ciertas propiedades: Número de componentes. Puede ser fijo, como suele suceder en el caso de arrays o registros, o variable, como en el caso de listas, pilas, colas, etc. Tipo de cada componente. El tipo de los componentes puede ser homogéneo, como en el caso de los arrays, o heterogéneo, como en el caso de los registros. Acceso a los componentes. La forma de acceder a los componentes puede ser mediante un índice (arrays, listas) o mediante el nombre de un campo (registros). Organización de los componentes. Los componentes de una estructura de datos pueden organizarse linealmente (como arrays o ficheros), o bien se pueden disponer en múltiples dimensiones. Las operaciones sobre estos tipos de datos estructurados suelen ser: Selección de componentes. Se selecciona un componente de la estructura. En este sentido hay que distinguir entre estructuras de acceso directo (como los arrays) y estructuras de acceso secuencial (como los ficheros). Inserción o eliminación de componentes. Los tipos de datos estructurados de tamaño variable permiten la inserción y eliminación de elementos. Creación y destrucción de tipos de datos estructurados. El lenguaje propociona construcciones sintácticas específicas para crear y destruir valores de tipos de datos estructurados. Con estructuras completas. No es habitual que existan operaciones sobre estructuras completas, sin embargo algunos lenguajes sí permiten operaciones como, por ejemplo, sumas de vectores (arrays), asignación de registros (copia campo a campo de un registro) o unión de conjuntos. Atendiendo al formato de almacenamiento de los valores de estos tipos de datos podemos distinguir entre dos tipos de representación interna: secuencial y vinculada. En la representación secuencial toda la estructura de datos se encuentra almacenada en memoria en un único bloque contiguo que incluye tanto el descriptor del tipo como los valores de sus componentes. En la representación vinculada el valor se divide en varios bloques de memoria no contiguos, interrelacionados por un puntero llamado vínculo. La estructura de datos tiene que contener un espacio de memoria reservado para este vínculo. T IPOS DE DATOS 23 Las estructuras de datos de tamaño fijo generalmente utilizan la representación secuencial, mientras que las de tamaño variable usan la representación vinculada. Es importante notar que, en general, es más sencillo obtener varios bloques de memoria no contiguos más pequeños que uno contiguo más grande. Por tanto, el sistema puede indicar que no hay memoria suficiente para reservar espacio para un tipo de datos con representación secuencial, pero sí la hay para tipos de datos con representación vinculada. Esto se debe a la fragmentación de la memoria. En los lenguajes con gestión automática de la memoria estos problemas pueden atenuarse, ya que el sistema desfragmenta la memoria automáticamente de forma periódica. Durante la desfragmentación, los objetos de datos en memoria se mueven, agrupando los bloques libres de memoria no contiguos. Un ejemplo conocido de lenguaje con gestión automática de memoria es Java. Arrays Los arrays son estructuras de datos de tamaño fijo donde todos los elementos son del mismo tipo. Esto hace muy apropiada la representación secuencial para representarlos. Conociendo la dirección de memoria del primer elemento del array y el tamaño de cada uno de sus elementos, la posición del i-ésimo elemento es posicion[i] = posicion[0] + i × T , donde T es el tamaño del tipo de datos que contiene el array. En determinados lenguajes es posible definir exactamente el rango de índices que se desean utilizar para el array, como en el caso de Pascal, donde es posible una declaración como la siguiente: 1 var a : array [ -5..5] of integer; El array a de la declaración anterior contiene 11 elementos. El índice del primer elemento es -5 y el índice del último elemento 5. Otros lenguajes no permiten este tipo de construcciones, en cuyo caso normalmente el índice del primer elemento es cero, y si el array tiene tamaño n, el índice del último elemento es n − 1. Algunos lenguajes de programación chequean en tiempo de ejecución que la posición del array accedida es correcta, es decir, que dicha posición se encuentra dentro de los límites de declarados. En estos lenguajes el acceso a una posición determinada es un poco menos eficiente, pero aseguran que no se accederá a una posición de memoria no válida (una posición de memoria que no corresponde a la memoria reservada para el array). La mayoría de los lenguajes permiten declarar arrays bidimensionales, tridimensionales, etc. Estos arrays se declaran indicando el tamaño (o los índices) para cada dimensión. En el siguiente ejemplo en Java se declara un array bidimensional cuya primera dimensión es de tamaño 10 y cuya segunda dimensión es de tamaño 100: 1 int[][] matriz = new int[10][100]; 24 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN Los arrays bidimensionales pueden verse como arrays de arrays. Cada posición del array original es a su vez un array del tamaño que indica la segunda dimensión. Igual que sucede con los arrays unidimensionales, el tipo de datos que contiene un array bidimensional es siempre el mismo. Los arrays bidimensionales también se representan en memoria de forma secuencial. Podemos ver el ejemplo anterior como una matriz de 10 filas y 100 columnas. La representación secuencial de esta matriz consistiría en disponer en primer lugar las 100 columnas de la fila 0 (dado que en Java los arrays comienzan siempre en 0), a continuación las 100 columnas de la fila 1, y así sucesivamente hasta la fila 9. Es importante notar que en algunos lenguajes de programación es necesario reservar memoria para cada una de las columnas de cada fila. Este enfoque también es útil si no se conoce de antemano el tamaño del array, dado que generalmente los índices sólo se pueden especificar mediante una constante. Así, el fragmento anterior podría haber sido escrito en Java de la siguiente forma (donde numColumnas es una variable definida en alguna parte del método o pasada como parámetro): 1 2 3 4 5 // No se especifica el tamaño de la segunda dimensión int[][] matriz = new int[10][]; for(int i = 0; i < 10; i ++) { matriz [i] = new int[ numColumnas ]; } Registros Un registro es una estructura de datos compuesta de un número fijo de elementos que pueden tener distinto tipo. Cada uno de los elementos de un registro se denomina campo y tiene asociado un nombre que permite identificarlo, como en el siguiente ejemplo: 1 2 3 4 struct Fraccion { int numerador ; int denominador ; }; El acceso a los campos de un registro se realiza habitualmente mediante lo que se denomina la notación punto: anteponiendo la variable del tipo registro al nombre del campo y separándolos por un punto: 1 2 3 Fraccion f1 ; f1 . numerador = 1; f1 . denominador = 2; Los registros se representan en memoria de forma secuencial. Es posible calcular en tiempo de compilación la dirección de memoria de todos los campos como un desplaza- E XPRESIONES Y ENUNCIADOS 25 miento desde la dirección de memoria del comienzo del registro, dado que éstos tienen tamaño fijo y se conoce el tamaño de cada uno de los campos del mismo. Listas Una lista es una colección ordenada de datos. En este sentido se parecen a los arrays. Sin embargo las listas son dinámicas: pueden crecer y disminuir durante la ejecución del programa conforme se añaden y eliminan elementos de la misma. Estos elementos pueden ser del mismo tipo o de tipos distintos, generalmente dependiendo del lenguaje. La representación más común para las listas es la representación vinculada. La lista se representa como un puntero al primer elemento. Este elemento típicamente contiene el dato propiamente dicho y un puntero al siguiente elemento. A este tipo de implementación se la denomina lista enlazada. También es posible implementar una lista con dos punteros, uno apuntando al elemento siguiente y otro apuntando al elemento anterior, por motivos de eficiencia. Este tipo de listas se denominan doblemente enlazadas. En determinados lenguajes las listas representan un tipo de datos más del propio lenguaje y por tanto se pueden declarar variables de dicho tipo. Esto es especialmente habitual en los lenguajes funcionales (como LISP o Haskell) y en los lenguajes con tipado dinámico (como PHP o Ruby). En otros lenguajes las listas son un tipo de dato definido por el programador que generalmente se proporcionan como librerías. Es el caso de lenguajes como Java o C, en los que es necesario incluir dichas librerías para poder utilizar las definiciones incluidas en las mismas. 1.5 Expresiones y enunciados Una expresión en un lenguaje de programación es un bloque de construcción básico que puede acceder a los datos del programa y que devuelve un resultado. A partir de las expresiones es posible construir bloques sintácticos más complejos, como enunciados. Las expresiones son el único bloque de construcción de programas de que disponen algunos lenguajes como es el caso de los lenguajes funcionales (como Haskell, LIPS o Scheme). Un enunciado, también denominado sentencia, es el bloque de construcción en el que se basan los lenguajes imperativos (o de base imperativa como los lenguajes orientados a objetos). En este tipo de lenguajes, mediante enunciados de diferentes tipos se establece la secuencia de ejecución de un programa. Estos enunciados pueden ser de asignación, de alternancia, de composición, etc. 1.5.1 Expresiones Además de tipos de datos y variables de esos tipos, en un lenguaje hay también constantes (o literales). Una constante es un valor, normalmente de un tipo primitivo, expresada literalmente. Por ejemplo, 5 y ’Hola Mundo’ son constantes. Algunas constantes están 26 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN predefinidas en el lenguaje, como nil en Pascal, null en Java o NULL en C. También las constantes true y false, en aquellos lenguajes que soportan los tipos booleanos, suelen estar predefinidas. Una expresión puede ser una constante, una variable, una invocación de subprograma que devuelva un valor, una expresión condicional (como ?: en C o Java), o un operador cuyos operandos son a su vez expresiones. Las expresiones representan valores, es decir, su evaluación da como resultado un valor. Este valor puede ser almacenado en una variable, pasado como argumento a un subprograma o utilizado a su vez en una expresión. Los operadores utilizados en las expresiones tienen una aridad que se refiere al número de operandos que esperan. Un operador puede verse como un tipo especial de función, donde los argumentos son los operandos. Normalmente los operadores son unarios (un operando), binarios (dos operandos) o ternarios (tres operandos). Un ejemplo de operador unario es el operador de cambio de signo (-): -4. Los operadores unarios pueden ir antes o después del operando. Por ejemplo, el operador ++ que incrementa en una unidad el operando sobre el que se aplica, puede ir antes o después, con comportamientos ligeramente diferentes en cada caso. No hay unanimidad sobre cómo se utilizan los operandos unarios. Por ejemplo, desreferenciar un puntero en Pascal se hace utilizando el operador ↑ en notación postfija (a continuación del operando): miPuntero↑ := miValor. En C sin embargo el operador de desreferenciación, *, es prefijo: *miPuntero := miValor. En los lenguajes imperativos, es habitual que los operadores binarios sean infijos (como los operadores matemáticos usuales): +, -, *, %, div, mod, etc. Un caso especial es la asignación. En algunos lenguajes la asignación es también una expresión, que devuelve el valor que es asignado. En estos lenguajes es posible encadenar asignaciones: a = b = c. También es posible utilizar asignaciones allí donde se espere una expresión: if(a = getChar()). El problema es que la legibilidad disminuye y es fácil cometer errores como: while(a = NULL), en lugar de while(a == NULL) en un lenguaje donde el operador de comparación por igualdad es ==. Por otro lado, los operadores suelen estar sobrecargados: + puede aplicarse a enteros y reales. Incluso puede aplicarse en algunos lenguajes a valores de tipos diferentes: en C puede sumarse un entero y un real. Algunos lenguajes permiten sobrecargar los operadores, es decir, redefinirlos para un nuevo tipo de datos. C++ por ejemplo permite redefinir el operador + para actuar sobre un tipo de datos matriz. 1.5.2 Efectos colaterales Las expresiones suelen estar exentas de efectos colaterales, es decir, frente a las sentencias que son instrucciones que cambian el estado de la máquina, las expresiones no deberían cambiar el estado de los elementos que participan en ellas. Sin embargo, esta regla no se cumple siempre, y existen operadores que cambian el estado de los operandos sobre los que actuan. Un caso concreto son los operadores de incremento y decremento en C: ++i incrementa i en una unidad. La modificación de i aquí es un efecto colateral. E XPRESIONES Y ENUNCIADOS 27 Compárese esto con la decisión tomada en Pascal de que el incremento y decremento fueran sentencias: inc(i) y dec(i), respectivamente. Como una sentencia no puede utilizarse en una expresión, se evitan así efectos colaterales. En determinados lenguajes la frontera entre expresiones y sentencias se ha difuminado mucho, pudiendo en ocasiones utilizar un operador como si fuera una sentencia (donde el cambio de estado es el efecto colateral causado por el operador). En C, puede escribirse i++; como si fuera una sentencia. Lo contrario también ocurre. Otro ejemplo en C es la asignación que es una sentencia y una expresión. En lenguajes como C y derivados (Java, C++), la asignación, además de cambiar el valor, devuelve ese mismo valor, de forma que se pueden encadenar asignaciones o utilizarse asignaciones donde sea que se espera una expresión: a = b = c. El operador de asignación tiene precedencia de derecha a izquierda, de forma que primero se asigna el valor de c a b, ese valor se devuelve y se asigna a a. 1.5.3 Evaluación de expresiones Los lenguajes pueden realizar la evaluación de expresiones de diferentes formas. Aunque desde un punto de vista matemático está claro cuál es la ordenación de las expresiones en una expresión mayor, los lenguajes pueden elegir no evaluarla completamente por motivos de eficiencia (como ocurre en las expresiones condicionales en algunos lenguajes) o demorar el cálculo de una expresión hasta que el resultado de la misma es realmente necesario. Ambos tipos de políticas de evaluación se describen a continuación. Evaluación en cortocircuito En ocasiones se puede conocer el valor de determinadas expresiones sin evaluarlas completamente. Este es el caso de las expresiones con conectivas lógicas como and y or. Si el primer operando toma un determinado valor entonces el valor de la expresión completa a veces se puede conocer sin necesidad de evaluar el segundo operando. Algunos lenguajes aprovechan esta característica para evitar evaluar el segundo operando, si no es estrictamente necesario para conocer el valor de la expresión. Esto se conoce como evaluación en cortocircuito. Los lenguajes con evaluación en cortocircuito permiten hacer cosas como la siguiente: 1 2 3 while (i < a. length and a[i] == 0) { ... } En este tipo de lenguajes se garantiza que el segundo operador no se va a evaluar si el primero es falso, porque la expresión false and x evalúa siempre a falso independientemente del valor de x. Sabemos que nunca se va a producir un desbordamiento del índice del array. 28 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN Evaluación diferida y evaluación estricta Dependiendo del momento de evaluación de las expresiones podemos hablar de evaluación diferida o evaluación estricta. La evaluación diferida consiste en retardar el cálculo de las expresiones hasta que realmente son necesarias. La evaluación diferida es utilizada habitualmente en los lenguajes funcionales. Los lenguajes imperativos (como C, Java o Pascal) utilizan evaluación estricta, lo que quiere decir, por ejemplo, que los argumentos de una función son completamente evaluados antes de la invocación de la función. En evaluación diferida, esto no tiene porqué ser necesariamente así. Considérese el siguiente ejemplo de código C: 1 2 3 4 5 6 int maxMin (int a , int b , int exprA , int exprB ) { if(a > b) return exprA else return exprB } La siguiente invocación del método fuerza la completa evaluación de los argumentos del mismo (dado que C implementa evaluación estricta): 1 result = maxMin (a , b , factorial (a) , factorial (b)); En lenguaje Haskell el mismo ejemplo puede escribirse de la siguiente forma: 1 2 (Integer,Integer,Integer,Integer) ->Integer maxMin (a ,b , exprA , exprB ) = if(a > b) then exprA else exprB Sin embargo, la misma invocación del método maxMin difiere la evaluación de los argumentos hasta saber cuál de las dos expresiones será devuelta (evaluación diferida): 1 maxMin (a , b , factorial (a) , factorial (b)) Si a resultó mayor que b, entonces sólo el factorial de a fue evaluado. La evaluación diferida se conoce también como evaluación perezosa y la evaluación estricta como evaluación ansiosa. 1.5.4 Enunciados En los lenguajes imperativos los enunciados se disponen en secuencias formando subprogramas e incluso programas enteros. Algunos enunciados permiten controlar el orden de ejecución de otros enunciados, posibilitando así repetir conjuntos de enunciados un número determinado de veces, o escoger entre una de varias secuencias de enunciados en base a los datos manejados por el programa. E XPRESIONES Y ENUNCIADOS 29 El enunciado más básico es el enunciado de asignación que cambia el estado del programa asignando el valor obtenido por alguna expresión a alguna variable del programa. Como se ha destacado anteriormente este enunciado a veces es considerado también una expresión. Enunciado compuesto Un enunciado compuesto es una secuencia de enunciados que se ejecuta secuencialmente comenzando por el primer enunciado y acabando en el último. Los enunciados compuestos pueden incluirse allí donde se espere un enunciado para construir enunciados más grandes. En Pascal, un enunciado compuesto se forma agrupando una serie de enunciados entre las palabras reservadas begin y end: 1 begin enunciado_1 ; enunciado_2 ; ... 2 3 4 5 end Enunciados condicionales Los enunciados condicionales permiten controlar cuál, de un grupo de enunciados (ya sean simples o compuestos) se ejecuta en base a una determinada condición que es necesario evaluar. El enunciado condicional más habitual es el if. Este enunciado tiene dos formas. En la forma más básica, if evalúa una condición y de cumplirse ejecuta la secuencia de enunciados que contiene. El siguiente es un ejemplo de este tipo de construcción en Java: 1 2 3 if ( denominador == 0) { System . out . println (" Division por cero "); } En el ejemplo anterior la secuencia de enunciados (entre llaves) a continuación de la condición del if sólo se ejecuta en caso de que la variable denominador sea cero en el momento de evaluar la condición. La segunda forma del if contiene también una secuencia de enunciados a ejecutar en caso de que la condición no se cumpla (generalmente separada por la palabra reservada else): 1 if ( denominador == 0) { 30 } else { System . out . println ( numerador + "/" + denominador ); 4 5 A LOS LENGUAJES DE PROGRAMACIÓN System . out . println (" Division por cero "); 2 3 I NTRODUCCIÓN } Otro enunciado condicional habitual en los lenguajes de programación es el enunciado case. Este enunciado contiene una condición que es evaluada y a continuación se comprueba la concordancia de dicho valor con una serie de alternativas. Estas alternativas exponen una constante (o rango de valores constantes) que se consideran válidos para ejecutar la secuencia de enunciados de dicha alternativa. El siguiente ejemplo muestra la sintaxis de Pascal para este tipo de enunciados: 1 2 case (a / 2) of 0: begin enunciado_1 ; enunciado_2 ; ... 3 4 5 end; 1: begin 6 7 enunciado_3 ; enunciado_4 ; ... 8 9 10 end; else begin enunciado_5 ; enunciado_6 ; ... end; 11 12 13 14 15 16 17 end; Enunciados de iteración Los enunciados de iteración permiten repetir un enunciado un determinado número de veces o hasta que se cumpla una condición. Los enunciados de repetición con contador pertenecen al primer caso. Estos enunciados utilizan una variable como un contador. El programador establece el valor inicial de dicha variable, cuál es el valor máximo que puede tomar y el enunciado que se debe ejecutar. Se comprueba si la variable ha superado el valor máximo y en caso contrario se ejecuta el enunciado especificado y se incremente el contador. Cuando éste llega al valor máximo se abandona el enunciado de iteración y se sigue ejecutando por el siguiente enunciado. A continuación se muestra un ejemplo de enunciado de repetición con contador en Pascal: 1 2 for i := 1 to 10 do writeln(i); E XPRESIONES Y ENUNCIADOS 31 En algunos lenguajes es posible controlar la forma de actualizar el valor del contador, como es el caso de C o Java: 1 2 3 for(int i = 0; i < 10; i +=2) { System . out . println (i); } Los enunciados iterativos con condición ejecutan el enunciado especificado por el programador mientras se cumpla la condición: 1 2 3 4 int i = 0; do { System . out . println (i); } while (i < 10) ; Nótese que en el ejemplo anterior los enunciados que se encuentran dentro del enunciado iterativo se ejecutan al menos una vez, dado que la condición en este caso se evalúa al final de cada iteración. Normalmente los lenguajes proporcionan otra versión de este enunciado iterativo donde la condición se evalúa al principio. En este caso, si la condición no se cumple no se ejecutan las sentencias del enunciado iterativo. El siguiente ejemplo en Java es equivalente al anterior, pero la condición se comprueba antes de comenzar cada iteración: 1 2 3 4 int i = 0; while (i < 10) { System . out . println (i); } Manejo de excepciones Cualquier programa debe ser capaz de enfrentarse en un momento u otro de su ejecución a determinadas condiciones de error. En algunos casos los errores son irrecuperables (como falta de espacio en disco), en otros es posible realizar alguna acción que permita al programa recuperarse. Pero en cualquier caso no es deseable que el programa finalice sin más, al menos debería informar al usuario de lo que ha sucedido. Algunos lenguajes de programación incorporan manejo de excepciones que posibilitan al programador capturar determinadas situaciones de error con el objeto de darles el tratamiento apropiado. En Java, por ejemplo, cuando se produce una situación de error es habitual que el código donde dicho error es detectado eleve una excepción. Una excepción es un tipo de datos que contiene información sobre el error y que puede ser capturado estableciendo enunciados específicos para ello en determinadas partes clave 32 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN del programa, como por ejemplo cuando se trata de abrir un fichero. Dado que es posible que el fichero no exista, pero no se quiere que el programa termine sin más, se puede utilizar un enunciado especial para capturar el error: 1 try { 2 FileReader fr = new FileReader ( file ); } catch( FileNotFoundException e) { // Aquí típicamente utilizaríamos un fichero de log // Por motivos de legibilidad simplemente escribimos el mensaje por pantalla System . out . println (" Fichero no encontrado : " + e. getMessage () ); } 3 4 5 6 7 En el ejemplo anterior, el constructor de la clase FileReader puede lanzar distintos tipos de excepciones, entre ella FileNotFoundException, que indica que el fichero no pudo ser abierto porque no existía. Cuando un subprograma (en este caso el constructor de la clase) declara que es posible que se lancen excepciones en caso de error, el programador puede protegerse capturándolas y dándoles el tratamiento adecuado. En este caso simplemente se notifica por pantalla el error. Un enfoque más realista habría sido notificar la usuario el error y preguntarle de nuevo el nombre del fichero. 1.6 Procedimientos y ambientes Los procedimientos, denominados funciones si devuelven un valor, son subprogramas que pueden ser llamados desde otras partes del código. Típicamente tienen un nombre, una secuencia de parámetros, declaraciones locales de variables y un cuerpo de sentencias. La declaración del subprograma incluye la declaración de los parámetros, denominados parámetros formales. Éstos tienen un tipo y habitualmente también un nombre con el que pueden referenciarse posteriormente desde el cuerpo del subprograma. Así, el tipo de un subprograma viene definido por el tipo y número de los parámetros formales. Este tipo es comprobado cuando se realiza una llamada al subprograma y permite detectar errores semánticos en estas llamadas. El siguiente es un ejemplo de procedimiento en C donde a y b son parámetros formales: 1 2 3 4 5 6 void max (double a , double b) { if(a > b) return a; else return b; } P ROCEDIMIENTOS Y AMBIENTES 33 La llamada a un procedimiento debe incluir el mismo número de parámetros, del mismo tipo y en el mismo orden que los indicados en su declaración. A los parámetros de una llamada a un procedimiento se les denomina parámetros reales o parámetros actuales. En la siguiente llamada al procedimiento max, 5.0 y 10.0 son los parámetros reales: 1 double maxValue = max (5.0 , 10.0) ; La implementación, a nivel de código objeto, de cómo se realiza una llamada a un fragmento de código que se encuentra en otra posición de memoria y que, además, puede recibir parámetros, no es trivial. Normalmente, la invocación de un subprograma implica guardar todo el estado del subprograma que llama (como los valores que se encuentran en el registro del procesador), evaluar todos los argumentos (en el caso de lenguajes con evaluación estricta), apilar los valores de los mismos en la pila de ejecución para que estén accesibles al subprograma llamado, y finalmente cambiar el valor del contador de programa (que le indica al procesador la siguiente instrucción a procesar) para que apunte a la primera instrucción del subprograma llamado. Cuando éste termina su ejecución, es necesario sacar sus parámetros de la pila, guardar el valor devuelto por la función en la misma, restaurar los valores de los registros que había antes de la llamada y finalmente cambiar el contador de programa para que apunte a la instrucción siguiente a la llamada, devolviendo el control al subprograma que llama. El tamaño de la pila de ejecución es finito, y puede agotarse. Algunos lenguajes obligan a utilizar un tipo específico para indicar que un procedimiento no devuelve ningún valor, como void. Otros, utilizan una notación distinta para procedimientos y funciones, como es el caso de Pascal donde dos palabras reservadas distintas permiten diferenciar entre ambos tipos: procedure y function. Hablaremos genéricamente de subprogramas cuando no sea necesario distinguir entre los dos. Es posible que un lenguaje permita lo que se denominan funciones sobrecargadas. Una función se dice que está sobrecargada si existen varias definiciones de la misma función (con el mismo nombre). Para poder distinguirlas unas de otras, las funciones sobrecargadas tienen que poder diferenciarse por el número o tipo de sus parámetros formales. Las funciones sobrecargadas se pueden utilizar cuando existen argumentos con valores por defecto o cuando tiene sentido definir la función para distintos tipos de datos. El siguiente ejemplo en C muestra una función max sobrecargada para dos tipos diferentes de argumentos: 1 2 3 int max (int a , int b) { ... } 4 5 6 7 double max (double a , double b) { ... } 34 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN Hay dos aspectos fundamentales relacionados con los subprogramas: el paso de parámetros y el ámbito de las variables. 1.6.1 Paso de parámetros Existen diferentes formas de pasar los parámetros a los subprogramas. En el paso por valor el valor del parámetro real es copiado en el parámetro formal. Esto implica que realizar cambios en el valor del parámetro formal no afecta al parámetro actual, cuyo valor permanece invariable. En el siguiente ejemplo, la variable a definida en la línea 5 permanece inalterada, pese a que el parámetro formal correspondiente sí es modificado: 1 2 3 4 5 6 void inc (int v) { v ++; } ... int a = 10; inc (a); En el paso por referencia la posición de memoria del parámetro real es pasada al subprograma. Existen entonces dos referencias a la misma posición de memoria: el parámetro real y el parámetro formal. Esto implica que cambios en el parámetro formal se verán reflejados en el parámetro real. El siguiente es el mismo ejemplo utilizando paso por referencia, con la sintaxis de C++: 1 2 3 4 5 6 void inc (int &v) { v ++; } ... int a = 10; inc (a); C++ permite tanto paso por valor como paso por referencia. Por defecto, se realiza paso por valor. Si queremos que un determinado parámetro sea pasado por referencia es necesario anteponer el símbolo & al nombre del parámetro formal. En el paso por copia y restauración (o valor y resultado) los parámetros reales son evaluados y pasados al subprograma llamado en los parámetros formales. Cuando la ejecución del subprograma termina, los valores de los parámetros formales son copiados de vuelta en las direcciones de memoria de los parámetros reales. Evidentemente, esto sólo se puede hacer con aquellos parámetros reales que representen posiciones de memoria (como variables o indexación de arrays). Aunque Pascal no tiene paso por copia y restauración, en el siguiente ejemplo se ha intentado ejemplificar lo que pasaría con este tipo de paso de parámetros: P ROCEDIMIENTOS 1 Y AMBIENTES 35 program copiarest ; 2 3 4 var a : integer; 5 6 7 8 9 10 11 12 procedure inc ( num : integer); begin { En este punto se ha copiado a en el parámetro num } num := num +1; { En este punto sólo se ha modificado num , la variable a permanece inalterada } end; 13 14 15 16 17 18 begin a := 10; inc (a); { En este punto el valor de num ha sido copiado de vuelta en a} end. En la llamada por nombre el subprograma llamado se sustituye por la llamada y los parámetros formales se sustituyen por los parámetros reales. En este tipo de llamada puede ser necesario cambiar los nombres de las variables locales del subprograma para evitar conflictos con las variables del subprograma que hace la llamada. En el siguiente ejemplo se muestra cómo funcionaría la llamada por nombre en un lenguaje como C (aunque este lenguaje no tiene llamada por nombre). En una llamada por nombre se sustituye la llamada por el propio código de la función, cambiando los nombres de los parámetros por los nombres de las variables: 1 2 int a = 10; a = a + 1; // Se ha sustituido la llamada a inc (a) por la implementación de inc 1.6.2 Ámbito de variables En los programas se utilizan constantemente nombres para referenciar diferentes conceptos. Una variable es un identificador que se utiliza como un nombre con el que se hace referencia a una posición de memoria. Un subprograma es un identificador que hace referencia a un fragmento de código con unos parámetros de entrada y que posiblemente devuelve un valor. Una constante es un nombre para un valor que nunca cambia. Los tipos de datos definidos por el usuario (los tipos estructurados o los tipos abstractos de datos) tienen un nombre que se utiliza en declaraciones de variables o de parámetros de subprogramas. Todos estos nombres pueden estar presentes en un programa, sin embargo, no todos son accesibles al mismo tiempo. El programa puede contener, por ejemplo, algunas 36 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN variables globales que son accesibles desde cualquier parte del programa. Sin embargo, típicamente los subprogramas pueden declarar sus propias variables locales. Cuando un subprograma que declara variables locales es llamado, se enlazan los nombres de sus variables locales a sus posiciones de memoria correspondientes, de forma que puedan utilizarse en el cuerpo del subprograma. Cuando éste termina, estos enlaces se destruyen y no están accesibles desde el subprograma que llamó. La asociación de nombres a datos o subprogramas tiene lugar en lo que se denomina ámbito o alcance. Un ámbito puede tener sus propias declaraciones locales y un conjunto de enunciados. El cuerpo de un subprograma es un ejemplo de ámbito. El cuerpo de un enunciado iterativo como el for también define un ámbito: el del cuerpo del for, que en determinados lenguajes puede contener también su propias declaraciones locales, como en el ejemplo siguiente: 1 2 3 4 5 6 7 void swap (int[] values ) { int i; for(i = 0; i < values . length -1; i ++) { int temp = values [i ]; values [i] = values [i +1] } } En este ejemplo, la función swap define un ámbito en el cual están definidos los nombres values e i. El enunciado for define su propio ámbito donde además de los anteriores está definido el nombre temp. Como se puede observar los ámbitos se pueden anidar. Un ámbito más interno tiene acceso a los nombres definidos en un ámbito más externo, a menos que el ámbito más interno contenga una definición con el mismo nombre que la del ámbito externo. En el siguiente ejemplo, el parámetro formal i queda oculto por la declaración de i en el enunciado del for: 1 2 3 4 void swap (int i , int j) { for(int i = 0; i < this. values . length -1; i ++) { ... } 5 6 } Al salir del enunciado del for, vuelve a ser accesible el parámetro formal i, dado que se sale del ámbito del for y se destruyen los enlaces creados en dicho ámbito, en concreto el enlace local al for para i. Atendiendo a cómo se realice el enlazado de nombres a datos en memoria, subprogramas, etc., podemos distinguir entre ámbito estático y ámbito dinámico. En el ámbito estático (también denominado ámbito léxico), el enlace se puede determinar simple- P ROCEDIMIENTOS Y AMBIENTES 37 mente en base al código fuente. Basta echar un vistazo a los ámbitos para saber qué nombres son accesibles desde qué ámbitos. Considérese el siguiente ejemplo en Pascal: 1 2 3 4 5 6 program ambitos ; type TArray : array [1..3] of integer; var a : TArray ; procedure uno (i : integer); 7 procedure dos ; var j : integer; a : TArray ; begin a [1] := 0; a [2] := 0; a [3] := 0; intercambia (1 , 2) ; end; 8 9 10 11 12 13 14 15 16 17 procedure intercambia (i , j : integer); var aux : integer; begin aux := a[i ]; a[i] := a[j ]; a[j] := aux ; writeln(a [1] , ’ ’, a [2] , ’ ’, a [3]) ; end; 18 19 20 21 22 23 24 25 26 27 begin 28 a [1] := 1; a [2] := 2; a [3] := 3; dos ; end; { uno } 29 30 31 32 33 34 35 36 37 begin { ambitos } ... uno (1) ; end. { ambitos } El programa ambitos define un ámbito en el cual están definidos los nombres TArray y a. En el procedimiento uno, se tiene acceso a estos dos nombres, y además define un ámbito donde están definidos los nombres i, dos e intercambia. El procedimiento dos define un ámbito en el cual el nombre de la variable a del programa principal queda oculto por la variable local a de dos (línea 10). Dentro del ámbito de dos también está 38 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN definido el nombre j. En el procedimiento intercambia se tiene acceso al nombre a y además define los nombres i y j (parámetros formales) y el nombre aux (variable local). En el ámbito estático se puede extraer la asociación de nombres del propio código del programa. A la vista del código anterior, en el ámbito estático, la referencia al nombre a en el procedimiento intercambia hace referencia a la variable global a declarada en la línea 5. Por tanto el enunciado de la línea 24 daría lugar a la siguiente salida por pantalla: 1 2 1 3 En el ámbito dinámico la asociación entre nombres y objetos (datos, subprogramas, etc.) tiene lugar en tiempo de ejecución y depende del flujo de ejecución del programa. En el ejemplo anterior, utilizando ámbito dinámico, primero tendría lugar la llamada al procedimiento uno, y después éste realizaría la llamada al procedimiento dos en la línea 31. Al ejecutarse el procedimiento dos, se enlaza el nombre a con la definición local de la línea 10. Cuando se invoca el procedimiento intercambia en la línea 15, el enlace del nombre a dentro de intercambia tiene lugar con la última declaración de a, que fue la del procedimiento dos. El resultado de este programa con ámbito dinámico sería: 1 0 0 0 La diferencia con el ámbito estático es por tanto que pese a que intercambia no está dentro del ámbito de dos, al ser llamado por dos, hereda sus enlaces, y por tanto la variable a se enlaza con la declaración de a en dos y no a la declaración de a en el programa principal. La mayoría de lenguajes de programación utilizan ámbito estático, que fue el utilizado en el lenguaje ALGOL. Algunas excepciones notables son LISP (aunque las versiones más recientes han pasado a utilizar ámbito estático) y las versiones iniciales de Perl. Algunos lenguajes permiten utilizar ambos enfoques (ámbito estático y ámbito dinámico) como en el caso de Common LISP. 1.7 Tipos abstractos de datos y módulos Hasta los años 70 las definiciones de tipos de datos se limitaban a especificar la estructura de un nuevo tipo de datos en base a otros tipos ya existentes [21]. Por ejemplo, en Pascal el programador puede definir un nuevo tipo de datos Alumno, como de tipo registro con una serie de campos (nombre, apellidos, número de expediente, nota media, etc.) cada uno de los cuales es de un tipo simple o compuesto. Sin embargo, tal como se estableció en relación a los tipos de datos básicos (o primitivos) de los lenguajes, los tipos de datos estructurados definidos por el usuario típicamente también tienen un conjunto de operaciones asociadas. Si consideramos, por ejemplo, el caso de una lista rápidamente pensamos en operaciones como insertar un elemento, obtener el elemento i-ésimo, eliminar un elemento, etc. A partir de la década de los 70 T IPOS ABSTRACTOS DE DATOS Y MÓDULOS 39 comenzó a pensarse en los tipos de datos como los propios datos junto con las operaciones que se pueden realizar sobre ellos. De esta forma surgieron los tipos abstractos de datos. En un tipo abstracto de datos tenemos: • Un conjunto de datos que se acojen a una definición de tipo (que puede ser más o menos compleja). • Un conjunto de operaciones abstractas sobre datos de ese tipo. • Encapsulamiento de los datos de forma que la manipulación de los mismos sólo pueda producirse a través de las operaciones definidas para el tipo abstracto de datos. En algunos lenguajes sólo es posible simular tipos abstractos de datos, dado que la encapsulación no forma parte del propio lenguaje, como en el caso de Pascal. Sin embargo, hoy día la mayoría de los lenguajes de programación modernos proporcionan mecanismos de encapsulación que permiten implementar tipos abstractos de datos. En el caso del lenguaje Ada se introdujo el concepto de paquete que permitía encapsular definiciones de tipos de datos y las operaciones permitidas sobre estos tipos. En el caso de C++ o Java se proporciona el concepto de clase que encapsula datos y operaciones dentro de la misma unidad sintáctica. De la programación orientada a objetos se hablará en el capítulo 3. La encapsulación de la información es una parte muy importante del tipo abstracto de datos. Mediante la encapsulación se evita que los usuarios del tipo abstracto de datos tengan que conocer la implementación concreta del tipo. Esto ayuda a pensar en términos de abstracciones. Por ejemplo, considérese el siguiente programa en el cual la función estadoCivil devuelve el estado civil de una persona dado su dni: 1 program estadoCivil ; 2 3 var dni , estado : String ; 4 5 6 7 8 9 function estadoCivil ( dni : String ) : String ; begin ... end; 10 11 12 13 14 15 16 17 begin dni := readln( ’ DNI = ’); estado := estadoCivil ( dni ); if estado = ’ SOLTERO ’ then ... else ... 40 18 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN end. El problema con el fragmento de código anterior es que no podemos estar seguros de que la comparación en la línea 11 sea correcta. Podría ser que la función estadoCivil devolviera simplemente los valores ’s’ y ’c’ para denotar los estados soltero y casado respectivamente, o que utilizara las cadenas completas pero en minúsculas. El problema se deriva de que se está utilizando un tipo de datos demasiado genérico (string) para representar un conjunto muy restringido de valores (soltero/casado). Además, el compilador no nos puede proporcionar ninguna ayuda en la línea 11. Para el compilador la comparación de dos cadenas de caracteres es correcta siempre. Evidentemente, este código podría ser mejorado incluyendo una documentación adecuada para la función estadoCivil, e incluso definiendo los diferentes valores posibles como constantes. Sin embargo, con este enfoque el compilador sigue siendo de poca ayuda: si el programador que diseña la función estadoCivil se olvida de usar las constantes a la hora de devolver el valor, puede que la cadena devuelta no se corresponda con el valor de alguna de estas constantes. Estos problemas se evitan fácilmente utilizando el tipo de datos enumerado de Pascal. Un tipo enumerado define un conjunto finito de valores y asigna a cada uno de ellos un nombre, de forma que pueda ser unívocamente identificado y distinguido del resto. Una variable de un tipo enumerado sólo puede contener uno de estos valores y se puede comparar por igualdad con los nombres de cada uno de los valores del enumerado: 1 program estadoCivil2 ; 2 3 type TEstadoCivil = ( SOLTERO , CASADO ); 4 5 6 var estado : TEstadoCivil ; dni : string ; 7 8 9 10 function estadoCivil ( dni : string ) : TEstadoCivil ; 11 12 begin dni := readln( ’ DNI = ’); estado := estadoCivil ( dni ); if estado = S then ... else ... 13 14 15 16 17 18 19 end. En esta nueva versión del programa anterior el compilador puede ayudar al programador a identificar errores. Dado que la comparación en la línea 9 es incorrecta (no existe un T IPOS ABSTRACTOS DE DATOS Y MÓDULOS 41 valor S en el enumerado TEstadoCivil) el compilador señalará el error al programador. No existe ahora posibilidad de utilizar valores en la comparación que no se correspondan con los valores que devuelve la función, dado que el conjunto de valores se define como un enumerado. Ahora se utiliza la abstracción adecuada para el valor devuelto por la función estadoCivil. La encapsulación de información del tipo abstracto de datos también ayuda a olvidarnos de las partes constituyentes de un tipo para pensar en el todo. El siguiente ejemplo podría ser una implementación para dar de alta un alumno en una base de datos de alumnos: 1 2 3 4 procedure insertar ( nombre , apellidos , dni : string ); begin ... end; Este procedimiento obliga al programador a conocer los detalles de todos los campos de datos de un tipo Alumno. Es más, sin información del contexto se podría pensar que este procedimiento se puede invocar, en el contexto de una aplicación para gestionar la información de una universidad, tanto para alumnos como para profesores, porque no hay ninguna información que limite la aplicabilidad del procedimiento. Si invocáramos el procedimiento con los datos de un profesor, este profesor acabaría dado de alta en la base de datos de alumnos. Considérese ahora el mismo ejemplo utilizando un tipo abstracto de datos Alumno: 1 2 3 4 procedure insertar ( alumno : TAlumno ); begin ... end; Ahora no hay ninguna duda de que el procedimiento insertar sólo se puede utilizar con alumnos. Si se considera que existe el tipo abstracto de datos TProfesor (análogamente al tipo TAlumno), entonces un intento de invocar el procedimiento insertar con una variable de tipo TProfesor resultaría en un error de compilación. Pero además, desde el punto de vista del programador resulta mucho más cómodo fijar su atención en la abstracción alumno en lugar de tener que manejar directamente los datos de los que se compone dicha abstracción (nombre, apellidos, dni). Algunos lenguajes, como Java, mediante el uso de la herencia y el polimorfismo (que se estudiarán en el capítulo 3) permiten llevar más allá aún este concepto de abstracción. Java proporciona, como parte del conjunto de librerías incluido en la plataforma Java Standard Edition, implementaciones de diferentes estructuras de datos como listas, pilas, colas, tablas hash, etc. Por ejemplo, en el caso concreto del tipo de datos lista, este lenguaje proporciona dos implementaciones distintas de listas, una basada en arrays y otra basada en listas enlazadas: 42 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN 1. ArrayList es una implementación basada en arrays. Para conseguir el comportamiento dinámico de una lista, cuando el array se acerca a su tamaño máximo, se reserva espacio para un array mayor y se copian todos los elementos en este nuevo array. Esta operación es costosa, por eso se recomienda utilizar esta clase en situaciones donde insertar y eliminar elementos es poco habitual y lo habitual es realizar consultas, porque una consulta en una lista de tipo ArrayList equivale a acceder a una posición del array. 2. LinkedList es una implementación basada en listas enlazadas. En esta implementación cada elemento de la lista incluye una referencia hacia el siguiente elemento. Esta implementación está recomendada cuando se realizan numerosas actualizaciones de la lista (inserciones y borrados), pero es mas ineficiente cuando se quiere acceder a una posición concreta de la misma, dado que hay que recorrer la lista desde el principio hasta la posición pedida. Los detalles concretos de implementación de una lista en Java pueden quedar ocultos al programador si éste utiliza la interfaz List común a ambas clases. Esta interfaz define un conjunto de operaciones que se pueden realizar sobre cualquier lista sin especificar cómo se realizan. La implementación de las operaciones queda delegada a las clases ArrayList y LinkedList. Por tanto, si al programador se le proprociona una variable de tipo List, no solamente se le está proporcionando una abstracción de una lista que le permite pensar en términos de operaciones sobre listas, sino que se le oculta la implementación concreta de esa lista. En el siguiente ejemplo, no se puede saber a priori (ni en la mayoría de los casos hace falta saberlo) qué tipo de lista se está utilizando: 1 public class Algoritmos { 2 public static void quicksort ( List lista ) { ... } 3 4 5 6 7 } La mayoría de lenguajes de programación permiten agrupar definiciones de tipos abstractos de datos en unidades que proporcionan un nivel de abstracción aún mayor: los módulos, también denominados unidades o paquetes en otros lenguajes. Un módulo generalmente es un conjunto cohesivo de definiciones de tipos abstractos de datos (o clases en los lenguajes orientados a objetos). En Ada los módulos se denominan paquetes. En ocasiones los paquetes se dividen en dos ficheros: un fichero que contiene las declaraciones de los tipos de datos y sus operaciones y otro fichero que contiene las implementaciones de dichas operaciones. En Java, por ejemplo, los paquetes siguen una estructura jerárquica que se corresponde con la estructura de directorios donde se encuentran las clases de cada paquete. Cada paquete puede contener uno o varios ficheros E JERCICIOS RESUELTOS 43 con definiciones de clases o interfaces, y un paquete puede contener a su vez otros paquetes formando una estructura jerárquica. El hecho de utilizar paquetes posibilita además disponer de un espacio de nombres que ayuda a evitar colisiones. En Java, por ejemplo, el paquete java.util contiene la definición de una clase List. Pero una clase con el mismo nombre existe también en el paquete java.awt que define el comportamiento y la apariencia de una lista de elementos seleccionables en una interfaz gráfica de usuario. Las dos clases se pueden distinguir porque están definidas en diferentes paquetes. Cada paquete define lo que se denomina un espacio de nombres. Para hacer referencia a la clase List del paquete java.util, el nombre de la clase se cualifica con el nombre del paquete, es decir, el nombre completo de la clase sería la concatenación del nombre del paquete y el nombre de la clase (habitualmente separados por puntos): java.util.List. Si se quisiera hacer referencia a la clase List del paquete java.awt se utilizaría el nombre java.awt.List. La mayoría de lenguajes de programación proporcionan alguna forma de importar el espacio de nombres de forma que no haga falta cualificar el nombre de cada tipo de datos con el nombre del paquete donde está definido. Continuando con el ejemplo anterior, en Java es posible importar la clase List del paquete java.util mediante el enunciado: 1 import java . util . List ; A partir de ese momento todas las referencias a List se resuelven como referencias a java.util.List. 1.8 Ejercicios resueltos 1. Escribe una gramática BNF para la definición de registros en Pascal. Se considera que el símbolo <id> representa un identificador válido del lenguaje. El siguiente es un ejemplo de definición de registro en Pascal: 1 2 3 4 5 6 1 2 3 TAlumno = record nombre : string ; apellidos : string ; edad : integer; asignaturas : TListaAsignaturas ; end; < registro > ::= <id > ’=’ ’record ’ <lista - campos > ’end ’ ’;’ <lista - campos > ::= <id > ’:’ <id > ’;’ < lista - campos > <lista - campos > ::= <id > ’:’ <id > ’;’ 44 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN 2. Escribe la gramática del ejericio anterior utilizando la notación EBNF. ¿Es más compacta esta representación? ¿Cuál es la principal diferencia? 1 2 < registro > ::= <id > ’=’ ’record ’ <lista - campos > ’end ’ ’;’ <lista - campos > ::= { <id > ’:’ <id > ’;’ }+ La gramática es más compacta, dado que sólo necesita una producción para el no terminal <lista-campos>. La principal diferencia estriba en la posibilidad de especificar repeticiones sin tener que introducir reglas recursivas. 3. ¿Cuál de las siguientes gramáticas independientes del contexto genera el lenguaje an bn ? a) I ::= aIb|ab b) I ::= abI|ε c) I ::= aB|ε; B ::= Ib Tanto la gramática a) como la gramática c) generan el lenguaje an bn . 4. Considérese la siguiente gramática BNF que define la forma de construir expresiones con conectivas lógicas en algún lenguaje de programación: 1 2 3 4 <E > ::= <E > ’or ’ <T > | <T > <T > ::= <T > ’and ’ <F > | <F > <F > ::= ’not ’ <E > | ’(’ <E > ’) ’ | <id > <id > ::= ’a ’ | ’b ’ | ’c ’ Construye los árboles de análisis sintáctico de las siguientes cadenas: (a) (′ b′ or′ c′ )′ and ′ ′ not ′ ′ a′ (b) ′ a′ and ′ b′ or′ c′ E JERCICIOS 45 RESUELTOS (a) <E> <T> ’and’ <T> <F> ’not’ <F> <E> <T> ’(’ <E> ’)’ <F> <T> <id> <T> <F> ’a’ <F> <id> <id> ’c’ <E> ’or’ ’b’ (b) <E> <E> ’or’ <T> <T> ’and’ <T> <F> <F> <F> <id> <id> ’b’ <id> ’c’ ’a’ 5. Dada la siguiente gramática BNF, indica si esta gramática es ambigua. Justifícalo. 1 2 3 4 <S > <B > <A > <C > ::= ::= ::= ::= <B > ’a ’ <S > ’a ’ | <C > <C > | <A > ’a ’ <S > ’a ’ ε 46 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN La gramática es ambigua. La cadena aaaa se puede derivar de varias formas diferentes. Las siguientes son dos derivaciones posibles para esta cadena: 1 2 3 1 2 3 <S > => <B > => ’a ’ A => ’a ’ <S > ’a ’ => ’a ’ <B > ’a ’ => ’a ’ ’a ’ <A > ’a ’ => ’a ’ ’a ’ <S > ’a ’ ’a ’ => ’a ’’a ’’a ’’a ’ <S > => <C > <C > => ’a ’ <S > ’a ’ <C > => ’a ’ <S > ’a ’ ’a ’ <S > ’a ’ => ’a ’ ’a ’ ’a ’ <S > ’a ’ => ’a ’ ’a ’ ’a ’ ’a ’ 6. Dado el siguiente código en C, ¿cuál sería la salida por pantalla? 1 2 int errorCode ; char * message ; 3 4 5 6 void log () { printf (" Found error %d: %s\n" , errorCode , message ); } 7 8 9 10 void checkPositive (int * array , int length ) { int errorCode = 0; char * message ; 11 int i; for(i = 0; i < length ; i ++) { if( array [i] < 0) { errorCode = 20; } } if( errorCode != 0) { message = " Check positive failed "; log () ; } 12 13 14 15 16 17 18 19 20 21 22 } 23 24 int main (void) { 25 26 27 28 errorCode = 10; message = " File not found "; log () ; 29 30 int test [5] = {1 ,2 ,3 ,4 , -1}; E JERCICIOS PROPUESTOS 47 checkPositive ( test , 5) ; 31 32 return EXIT_SUCCESS ; 33 34 } La línea 1 es generada en la llamada a log en la línea 28 del programa. En la llamada a log se utilizan las variables errorCode y message definidas en las líneas 1 y 2, respectivamente, y cuyos valores han sido asignados en las líneas 26 y 27. 1 2 Found error 10: File not found Found error 10: File not found La línea 2 de la salida por pantalla es generada por la llamada a log dentro de la función checkPositive en la línea 20. La función log accede a las variables definidas en las líneas 1 y 2, cuyos valores no han variado, por tanto se imprimen exactamente los mismos valores. Las variables errorCode y message definidas en la función checkPositive en las líneas 9 y 10 no son accesibles desde log, dado que en C el ámbito es estático, y por tanto, las variables definidas dentro de la función checkPositive sólo son accesibles desde dentro de esta función, y su ámbito se pierde al salir de ésta o pasar el control a otra función (como en el caso de log). 7. ¿Cuál sería la salida por pantalla del código del ejercicio anterior si C tuviera ámbito dinámico? 1 2 Found error 10: File not found Found error 20: Check positive failed En el caso de la línea 1, los nombres de las variables errorCode y message utilizados en la llamada a log de la línea 28 se corresponden con las definiciones de las líneas 1 y 2. Sin embargo, en el caso de la línea 2, si consideramos ámbito dinámico, en las la llamada a log de la línea 20 se habrían enlazado las nombres de las variables errorCode y message con las definiciones locales de las líneas 9 y 10. En el momento de producirse la llamada a log estas variables tienen los valores 20 y “Check positive failed”, respectivamente. De ahí el resultado. 1.9 Ejercicios propuestos 1. JSON (JavaScript Object Notation - Notación de Objetos de JavaScript) es un formato ligero de intercambio de datos. Leerlo y escribirlo es simple para humanos, 48 I NTRODUCCIÓN A LOS LENGUAJES DE PROGRAMACIÓN mientras que para las máquinas es simple interpretarlo y generarlo. Está basado en un subconjunto del lenguaje de programación JavaScript. Se puede encontrar información sobre este formato en la página web http://www.json.org. En este ejercicio se considerará un subconjunto de JSON denominado SimpleJSON que sólo permite letras, números y los símbolos { } : . , [ ]. El lenguaje SimpleJSON tiene las siguientes características: • Un objeto es una lista de pares nombre-valor. Un objeto comienza con { (llave de apertura) y termina con } (llave de cierre). Cada nombre se repersenta con un string y está seguido por : (dos puntos) y un valor. Los pares nombre-valor están separados por , (coma). Es posible que un objeto no tenga ningún par nombre-valor. • Un fichero está formado por un único objeto. • Un array es una colección de valores. Un array comienza por [ (corchete izquierdo) y termina con ] (corchete derecho). Los valores se separan por , (coma). Es posible que un array no tenga ningún valor. • Un valor puede ser una cadena de caracteres, un número, true, false, null, o un objeto o un array. A continuación se muestran algunos ejemplos en formato SimpleJSON: 1 2 3 4 1 2 3 4 5 6 7 1 2 3 4 5 6 7 8 { " nombre " : " antonio ", " apellido " : " gonzalez ", " edad " : 34 , " conduce " : true } { " valor1 " : 0.254 , " valor2 " : 34 , " arrayValores " : [ " arrayObjetos " : [ { " nombre " : { " nombre " : { " nombre " : 3, -0.3 , 3e +13 , 1.1 e33 ], " antonio ", " apellido " : " gonzalez " }, " felipe ", " apellido " : " perez " }, " fernando ", " apellido " : " rubio " } ] } {" menu ": { " header ": " svg " items ": [ {" id ": {" id ": null , {" id ": {" id ": viewer ", " open "} , " openNew ", " label ": " open new "} , " zoomin ", " label ": " zoom in "} , " zoomout ", " label ": " zoom out "} , E JERCICIOS 49 {" id ": " originalview ", " label ": " original view "} , null , {" id ": " quality "} , {" id ": " pause "} , {" id ": " mute "} 9 10 11 12 13 ] 14 15 PROPUESTOS }} Se pide diseñar una gramática independiente del contexto en notación BNF que defina el lenguaje SimpleJSON. Cuando sea necesario indicar en la gramática que puede aparecer una cadena de caracteres se puede hacer uso del no terminal < cadena >. No es necesario definir el no terminal cadena. En el caso de los números se puede utilizar el no terminal < numero >. 2. Se pide escribir la gramática independiente del contexto del ejercicio anterior en notación EBNF. 3. Dibuja el árbol de derivación para los ficheros que se muestran a continuación: 1 2 3 4 1 2 3 4 5 6 7 { " nombre " : " antonio ", " apellido " : " gonzalez ", " edad " : 34 , " conduce " : true } { " nombre " : " antonio ", " edad " : 34 , " conduce " : true , " pareja " : null , " hijos " : [ { " nombre ": " juan ", " edad " : 3 }, { " nombre ": " lucia ", " edad " : 2 }]} En el caso de que haya que derivar el no terminal < cadena > o < numero >, se pondrá una línea desde el no terminal hasta la palabra o el número que represente, respectivamente. Por ejemplo: <cadena> ’antonio’ 4. Dada la siguiente gramática para un lenguaje de programación con manejo de excepciones, donde < sent > representa cualquier sentencia del lenguaje, incluyendo la sentencia try. . . catch e < id > representa un identificador válido: 50 I NTRODUCCIÓN 1 2 3 4 5 A LOS LENGUAJES DE PROGRAMACIÓN <sent - try > ::= ’try ’ ’{’ <sent - list > ’}’ <catch - list > <sent - list > ::= <sent > <sent - list > | <sent > <sent > ::= <sent - try > | ... <catch - list > ::= < catch > <catch - list > | <catch > <catch > ::= ’ catch ’ ’(’< id > <id > ’) ’ ’{’< sent - list > ’} ’ Se pide dibujar los árboles de análisis sintáctico de los siguientes fragmentos de código: 1 try { 2 6 int v1 = matriz [i ][ j ]; int v2 = matriz [j ][ i ]; } catch ( ArrayIndexOutOfBoundsException e) { System . out . println (" Índice fuera de límites : " + e. getMessage () ); } 1 try { 2 9 FileReader fr = new FileReader (" message . eml "); readMessage ( fr ); } catch ( FileNotFoundException e) { // Un sistema de logging muy básico System . out . println (" No se encuentra el fichero : " + e. getMessage () ); } catch( IOException e) { System . out . println (" Excepción no esperada : " + e. getMessage () ); } 1 try { 2 fr = new FileReader (" config . ini "); } catch ( FileNotFoundException e) { try { fr = new FileReader ("/ home / user / config . ini " ); } catch( FileNotFoundException e) { System . out . println (" No se encuentra el fichero de configuración "); } } 3 4 5 3 4 5 6 7 8 3 4 5 6 7 8 9 5. Reescribe la gramática BNF del ejercicio 4 en formato EBNF. N OTAS BIBLIOGRÁFICAS 51 1.10 Notas bibliográficas Para la elaboración de este capítulo se han consultado diferentes fuentes. Para la primera parte (sintaxis y semántica) se ha utilizado sobre todo el material del libro [1]. Para la parte de teoría de lenguajes, se utilizaron las fuentes [17, 21]. Estas fuentes profundizan mucho más en los tipos de datos y su implementación de lo que se ha profundizado aquí. Capítulo 2 Procesadores de lenguajes En este capítulo se pretende ofrecer una visión general de las diferentes etapas de transformación de un programa, desde un código fuente escrito por un programador, hasta un fichero ejecutable que pueda “entender” directamente una máquina. El capítulo se inicia con un breve repaso a los principales tipos de procesadores de lenguajes, ofreciendo una definición sucinta de cada uno de ellos, así como una enumeración de sus objetivos y características más importantes. De entre todos los tipos de procesadores de lenguajes existentes se destacan los compiladores, de los que se ofrecerá una explicación más detallada y serán usados como modelo para ejemplificar las diferentes fases que pueden encontrarse en un traductor de lenguajes. 2.1 Introducción Por procesadores de lenguajes se entiende el conjunto genérico de aplicaciones informáticas en las cuales uno de los datos de entrada es un programa escrito en un lenguaje de programación. Se trata, por tanto, de cualquier programa informático capaz de procesar documentos escritos en algún lenguaje de programación. La historia de los procesadores de lenguajes ha ido siempre de la mano del desarrollo de los lenguajes de programación. Según iban apareciendo artefactos de programación o propiedades nuevas en el diseño y desarrollo de los lenguajes, la tecnología asociada a los procesadores ha tenido que adaptarse a ellos para dar respuesta a las nuevas realidades que se presentaban. Puede decirse, por tanto, que los avances alcanzados en el desarrollo de los procesadores de lenguajes han sido consecuencia directa del desarrollo de nuevos modelos y paradigmas de programación. Como se indicaba en el capítulo anterior, las primeras computadoras ejecutaban instrucciones consistentes únicamente en códigos binarios. Con dichos códigos los programadores establecían los estados de los circuitos integrados correspondientes a cada una de las operaciones que querían realizar. Esta expresión mediante ceros y unos se llamó lenguaje máquina y constituye lo que se conoce como lenguajes de primera generación. 53 54 P ROCESADORES DE LENGUAJES Pero estos lenguajes máquina no resultaban naturales para el ser humano y, por ello, los programadores se inclinaron por tratar de escribir programas empleando abstracciones que fueran más sencillas de recordar que esos códigos binarios que se empleaban hasta ese momento. Después simplemente había que traducir esas abstracciones de forma manual a lenguaje máquina. Estas abstracciones constituyeron los llamados lenguajes ensamblador, y se generalizaron en el momento en que se pudo hacer un proceso automático de traducción a código máquina por medio de un ensamblador. Estos lenguajes ensamblador constituyeron lo que se conoce como lenguajes de segunda generación. Sin embargo, para un programador el lenguaje ensamblador continuaba siendo el lenguaje de una máquina, aunque hubiera supuesto un avance respecto a los lenguajes máquina propiamente dichos, expresados con códigos binarios. El desarrollo de los lenguajes se orientó entonces hacia la creación de lenguajes de programación capaces de expresar las distintas acciones que quería realizar el programador de la manera más sencilla posible. Se propuso entonces un lenguaje algebraico, el lenguaje FORTRAN (FORmulae TRANslating system), que permitía escribir fórmulas matemáticas de manera traducible por un ordenador. Representó el primer lenguaje de alto nivel y supuso el inicio de los lenguajes de tercera generación. Surgió entonces por primera vez el concepto de traductor como un programa que transformaba un lenguaje en otro. Cuando el lenguaje a traducir es un lenguaje de alto nivel y el lenguaje traducido es de bajo nivel, entonces se dice que ese traductor es un compilador, y como alternativa al uso de compiladores se propusieron también los llamados intérpretes. Mediante el uso de intérpretes no existe un proceso de traducción previo al proceso de ejecución; al contrario que en el caso de los compiladores, las instrucciones presentes en el código fuente se traducen mientras se van ejecutando. Compiladores e intérpretes suponen ejemplos paradigmáticos de lo que es un procesador de lenguajes y se verán en detalle a lo largo de este capítulo. 2.2 Tipos de procesadores de lenguajes A partir de la definición dada en el apartado anterior, como procesadores de lenguajes se pueden incluir una gran variedad de herramientas software como las que se describen a continuación. 2.2.1 Traductores Un traductor es un tipo de procesador en el que tanto la entrada como la salida son programas escritos en lenguajes de programación. Su objetivo es procesar un código fuente y generar a continuación un código objeto. Se dice que el código fuente está escrito en un lenguaje fuente (LF), que en la mayoría de los casos es un lenguaje de alto nivel, aunque podrían ser también de bajo nivel; por tanto, el lenguaje fuente es el lenguaje origen que transforma el traductor. T IPOS DE PROCESADORES DE LENGUAJES 55 Figura 2.1: Notación en T, esquema básico de un traductor. El código objeto se dice que está escrito en un lenguaje objeto (LO) que podría ser un lenguaje máquina de un microprocesador determinado, un lenguaje ensamblador o, incluso, un lenguaje de alto nivel. Este lenguaje objeto es, por tanto, el lenguaje al que se traduce el texto fuente. Por último, se conoce como lenguaje de implementación (LI) al lenguaje en el que está escrito el propio traductor, y puede ser cualquier tipo de lenguaje de programación, desde un lenguaje de alto nivel a un lenguaje máquina. En general para mostrar el esquema básico de un traductor se suele utilizar la notación en T (tal como se muestra en la Figura 2.1), que puede representarse de forma abreviada como: LFLI LO [7]. 2.2.2 Ensambladores Cuando se tiene un traductor donde el lenguaje fuente es lenguaje ensamblador y el lenguaje objeto es lenguaje máquina, dicho programa se dice que es un ensamblador. Los ensambladores son traductores en los que el lenguaje fuente tiene una estructura sencilla que permite la traducción de una sentencia en el código fuente a una instrucción en lenguaje máquina. Hay ensambladores que tienen macroinstrucciones en su lenguaje que deben traducirse a varias instrucciones máquina. A este tipo de ensambladores se les conoce como macroensambladores y representan ensambladores avanzados con instrucciones complejas que resultaron muy populares en los años 50 y 60, antes de la generalización de los lenguajes de alto nivel. Para que un programa fuente pueda ser ejecutado, el código máquina generado debe ser ubicado en memoria a partir de una determinada dirección absoluta. En el caso de programas formados por un único módulo fuente, y que no utilizan librerías, es posible generar código ejecutable a partir de código ensamblador de forma sencilla. Sin embargo, con un ensamblador se crea por lo general código reubicable, es decir, con direcciones de memoria relativas. Cuando se tienen subprogramas o subrutinas se genera un conjunto de códigos reubicables que después hay que enlazar en un único código reubicable para su ejecución. Esto se hace en una fase posterior conocida como enlazado. 56 P ROCESADORES DE LENGUAJES A continuación, en la fase de carga, este código reubicable se transforma en un código ejecutable que se almacena en la memoria principal para su ejecución. Se carga en una posición de memoria y el cargador es el encargado de sumar a las direcciones relativas, el valor de la dirección de carga, con lo que se tiene un código máquina listo para su ejecución. A lo largo del capítulo se dan más detalles sobre estos programas enlazadores y cargadores. 2.2.3 Compiladores Los procesadores de lenguajes más habituales son los compiladores, aplicaciones capaces de transformar un fichero de texto con código fuente en un fichero en código máquina ejecutable. Un compilador es, por tanto, un programa traductor que transforma un código fuente escrito en un lenguaje de alto nivel a un código en lenguaje de bajo nivel. El programa ejecutable generado por un compilador deberá estar preparado para su ejecución directa en una arquitectura determinada. Esta fase de traducción supone un proceso bastante complejo y el código máquina generado se espera que sea, en la medida de lo posible, rápido y con un consumo de memoria lo más reducido posible. Además, generalmente se dispone únicamente de un conjunto limitado de instrucciones de bajo nivel de la máquina destino, mientras el código fuente puede estar escrito en un lenguaje abstracto que requiera realizar transformaciones intermedias. Estos aspectos, entre otros, revelan la dificultad que puede suponer el proceso de compilación de un código fuente en una arquitectura determinada. Por todo esto, el diseño de un compilador se suele dividir en etapas –o fases– que facilitan su construcción, y donde en cada etapa se reciben los datos de salida de la etapa anterior, generando a su vez datos de salida para la etapa posterior. Las etapas en las que se divide un compilador pueden agruparse en: • Etapas de análisis del programa fuente (análisis léxico, sintáctico y semántico), encargadas de la comprobación de la correcta utilización de los elementos del lenguaje, la combinación de los mismos y su significado. • Etapas de síntesis del programa compilado (generación de código intermedio, optimización y generación de código final). En el apartado 2.3 se explicarán en detalle cada una de estas etapas, así como sus fases. Como se verá a lo largo del capítulo nos centramos más en las etapas de análisis que en las de síntesis. En el diseño de un compilador hay que tener en cuenta ciertos factores como pueden ser: • La complejidad propia del lenguaje fuente. • Diferencia de complejidades entre el lenguaje fuente y el objeto. T IPOS DE PROCESADORES DE LENGUAJES 57 • Memoria mínima necesaria para realizar la compilación. • Velocidad y tamaño requerido para el compilador. • Velocidad y tamaño requerido para el programa objeto. • Necesidad o no de realizar posteriores tareas de depuración. • Necesidad de aplicar técnicas de detección y reconocimiento de errores. • Recursos físicos y humanos disponibles para el desarrollo del compilador. Respecto a la complejidad del lenguaje fuente, y como se verá más adelante en este mismo apartado, esto puede implicar la necesidad de que el compilador necesite realizar varias lecturas del código fuente para poder obtener toda la información necesaria para la fase de análisis. Esto en su momento resultaba costoso y, por ello, remarcable. Sin embargo, hoy en día y con la capacidad de cómputo de los ordenadores actuales, el hecho de realizar varias pasada en el proceso de compilación no supone un gran problema en términos de eficiencia del compilador. A partir de la descripción que se ha dado de un compilador podría parecer que todos los compiladores son prácticamente iguales, y que las únicas diferencias vendrán dadas por las características de los lenguajes que se quieren compilar y de la arquitectura de la máquina en la que se compila. Sin embargo, existen diferencias más allá de las que implica considerar diferentes lenguajes fuente y objeto. Los compiladores pueden clasificarse considerando diferentes criterios como, por ejemplo, el tipo de código máquina que generan o cómo se realiza en cada caso el proceso de compilación. Así, dependiendo del tipo de código máquina que generan, pueden clasificarse entre compiladores que generan: • Código máquina puro; es decir, los compiladores que generan código para un conjunto de instrucciones máquina particulares, sin asumir la existencia de ningún sistema operativo o librería de rutinas. Este tipo de compiladores son de rara aplicación, casi exclusiva en la implementación de sistemas operativos y otro tipo de software de bajo nivel. • Código máquina aumentado; cuando los compiladores generan código máquina utilizando rutinas del sistema operativo y de soporte al lenguaje. En este caso, para poder ejecutar un programa generado con un compilador de este tipo, es necesaria la presencia de un determinado sistema operativo (del que se han tomado parte de las rutinas), así como de las rutinas propias del lenguaje. El conjunto de rutinas del sistema operativo, junto con las del lenguaje, se podrían considerar como una primera definición de una máquina virtual, es decir, una “máquina” que existe solo como una combinación de hardware y software. 58 P ROCESADORES DE LENGUAJES • Código máquina virtual; este caso supone el extremo de la situación anterior, donde el código generado por el compilador estará compuesto completamente de instrucciones virtuales. La verdadera aplicación y razón del éxito que han alcanzado este tipo de compiladores es la capacidad de generar programas portables, compiladores cuyos códigos generados puedan ser ejecutados en diferentes plataformas y sistemas operativos. En el contexto de Internet este hecho supone una verdadera necesidad, ya que un programador no sabe sobre qué arquitectura se va a ejecutar su programa dentro de la Red. Para ello bastará con escribir una máquina virtual para cada arquitectura. Se obtiene otra posible clasificación de tipos de compiladores según la forma en que se realiza el proceso de compilación. Así, siguiendo este criterio se pueden definir los siguientes tipos de compiladores: • Compilador cruzado. Éste es el caso en el que se genera código objeto para una arquitectura diferente a la que se utiliza en el proceso de compilación. Cuando se desea construir un compilador para un nuevo procesador, ésta es la única forma de construirlo. • Compilador con montador. En este caso la compilación se realiza por partes. Se compilan diferentes módulos de forma independiente y después se enlazan por medio de un programa montador. • Compilador en una o varias pasadas. Hay compiladores que necesitan realizar varias lecturas del código fuente para poder generar el código objeto. • Compilador incremental. Se conocen así a los compiladores que, tras una primera fase de compilación en la que se detectan errores, vuelve a compilar el programa corregido pero analizando únicamente las partes del código fuente que habían sido modificadas. • Auto-compilador. Este tipo de compiladores están escritos en el mismo lenguaje que va a compilar. Permite realizar ampliaciones del lenguaje. • Meta-compilador, o “compilador de compiladores”. Se trata de programas que toman como entrada las especificaciones del lenguaje para el que se quiere construir un compilador, y generan como salida un compilador para dicho lenguaje. Por último, cuando se usa un compilador, el programa fuente y los datos se procesan en diferentes momentos, de modo que al tiempo que se requiere para traducir el lenguaje de alto nivel a lenguaje objeto se le denomina tiempo de compilación, y al tiempo que se emplea en ejecutar el programa objeto sobre los datos de entrada se le conoce como tiempo de ejecución (véanse las Figuras 2.2 y 2.3). T IPOS DE PROCESADORES DE LENGUAJES 59 Figura 2.2: Tiempo de compilación. Figura 2.3: Tiempo de ejecución. Enlazadores En ocasiones, como hemos visto, un código objeto en lenguaje máquina no puede ser ejecutado directamente ya que necesita ser enlazado con librerías propias del sistema operativo. Para ello, entre el proceso de compilación y ejecución existe un proceso de montaje de enlaces, posible siempre y cuando en un lenguaje fuente se permita una fragmentación del código en partes, denominados de diferente modo según sea el lenguaje de programación empleado: módulos, unidades, librerías, procedimientos, funciones, subrutinas, . . . De este modo, el enlazador (también conocido como montador de enlaces) genera un archivo binario final que ahora sí podrá ejecutarse directamente. Las diferentes partes en las que se divide el código fuente pueden compilarse por separado, produciéndose códigos objetos para cada una de ellas. El montador de enlaces se encargará entonces de realizar la unión de los distintos códigos objeto, produciendo un módulo de carga que será el programa objeto completo, y siendo después el cargador quien lo coloque en memoria para iniciar su ejecución. Por tanto, un enlazador deberá tomar los ficheros de código objeto generados en los primeros pasos del proceso de compilación, así como la información de todos los recursos necesarios para la compilación completa, y deberá eliminar recursos que no necesite 60 P ROCESADORES DE LENGUAJES y enlazar el código objeto, con lo que finalmente producirá un fichero ejecutable (véase Figura 2.4). En el caso de programas enlazados dinámicamente, el enlace entre el ejecutable y las librerías se realiza en tiempo de carga o ejecución del programa. Cargadores Un cargador tiene como función principal asignar el espacio necesario en memoria a un programa, pasando después el control a la primera de las instrucciones a ejecutar, y comenzando a continuación el proceso de ejecución. Como ya se ha visto, los procesos de ensamblado y carga están muy relacionados, y así algunos tipos especiales de cargadores incluyen, además, los procesos de reubicación y enlazado. En este sentido, las funciones generales de un cargador según [9] son: 1. Asignar espacio en memoria para el programa (asignación). 2. Resolver referencias simbólicas entre módulos (enlazado). 3. Ajustar todas las direcciones del código de acuerdo al espacio disponible en memoria (reubicación). 4. Colocar físicamente todas las instrucciones y los datos en la memoria (carga). Otros sistemas tienen estas funciones separadas, con un enlazador para realizar las operaciones de montaje y un cargador para manejar la reubicación y carga de los programas en memoria. 2.2.4 Intérpretes Los intérpretes son programas que analizan y ejecutan una a una las instrucciones que encuentran en un código fuente. Por tanto coexisten en memoria con el programa fuente, de modo que el proceso de traducción se realiza en tiempo de ejecución. En la Figura 2.5 se muestra el esquema funcional de este tipo de procesadores de lenguajes. En general, este tipo de procesadores de lenguajes realiza dos tipos de operaciones. En primer lugar, se realiza la traducción del código fuente a un formato intermedio (aunque en realidad esta operación no sería obligatoria) y, a continuación, se interpreta el código traducido. En el caso de realizarse esta transformación a un formato intermedio, éste podría ser simplemente el resultado del análisis sintáctico-semántico como es la traducción a notación posfija (véanse apartados 2.3.2 y 2.3.3). En este caso, la primera fase de análisis se correspondería con la compilación a código intermedio. En algunos lenguajes las dos operaciones descritas anteriormente se han separado por completo, de modo que se tiene un compilador que traduce el código fuente a un código intermedio, comúnmente denominado bytecode, y un intérprete que ejecuta dicho código intermedio. T IPOS DE PROCESADORES DE LENGUAJES Figura 2.4: Proceso de compilación, montaje y ejecución 61 62 P ROCESADORES DE LENGUAJES Figura 2.5: Esquema funcional de un intérprete A continuación se muestra un ejemplo de cómo un intérprete analizaría y ejecutaría la siguiente instrucción: X := Y + Z 1. El intérprete analiza la sentencia y determina que se trata de una operación de asignación. 2. Se llama a la rutina encargada de evaluar la expresión a la derecha. 3. Dicha rutina toma los símbolos Y y Z, determina en qué registros de la memoria están almacenados, toma sus valores y los suma. 4. El intérprete toma el valor resultante de la suma y lo almacena en la dirección de memoria a la que hace referencia el símbolo X . Algunos lenguajes de programación, debido a sus características, no suelen compilarse y son interpretados; tal es el caso de algunos lenguajes funcionales y lenguajes de script. Esto se debe a diversas razones como, por ejemplo, que dichos lenguajes contengan operadores que requieran de la presencia del intérprete, como los que ejecutan en tiempo de ejecución cadenas de caracteres que representan instrucciones del lenguaje fuente, los que han eliminado de sus sintaxis la declaración de variables, de modo que se toma el tipo del último valor que se asignó, o los lenguajes que dejan la gestión de memoria al propio intérprete. Algunos ejemplos de lenguajes interpretados son: Lisp, APL, Prolog, o Smalltalk. El hecho de que no sea necesaria la carga de todo el programa en memoria supone una ventaja de los intérpretes, ya que permite su aplicación en sistemas con recursos limitados de memoria. Facilitan también la metaprogramación, ya que un programa puede modificar su propio código fuente en tiempo de ejecución, definiendo nuevos tipos, etc. La principal desventaja de un intérprete es que la ejecución de un programa compilado será más rápida que la del mismo programa interpretado. T IPOS DE PROCESADORES DE LENGUAJES 63 Figura 2.6: Esquema de funcionamiento de una máquina virtual. 2.2.5 Máquinas virtuales En los últimos años han tomado especial relevancia lo que se conoce como máquinas virtuales. El término fue acuñado por IBM en 1959 y se trata de aplicaciones software capaces de crear una capa de abstracción que simula una máquina diferente a la original, es decir, ofrecen a un sistema operativo o a un programador la percepción de una máquina física diferente a la real. El sistema operativo de la máquina virtual se conoce como invitado, mientras que el de la máquina real se conoce como anfitrión. De este modo, la propia máquina virtual se comporta como una aplicación más dentro el sistema operativo anfitrión, mientras que el invitado ve a la máquina virtual como si se tratara de hardware real. En una máquina virtual, tanto las aplicaciones como los usuarios se relacionan con la capa de abstracción y no con la plataforma real. Las máquinas virtuales se pueden clasificar en dos grandes categorías según su funcionalidad: las máquinas virtuales de sistema –también llamadas “de hardware”– y de aplicación –conocidas también como “de proceso”–. En la Figura 2.6 se muestra un ejemplo de ambos tipos. 64 P ROCESADORES DE LENGUAJES Las máquinas virtuales de aplicación se han extendido enormemente gracias, entre otras cosas, al desarrollo de Internet y de las aplicaciones web programadas en Java. En este caso, la máquina virtual es la encargada de traducir el código intermedio Java, conocido como bytecode, y generado por el compilador de Java a partir de un código fuente, a instrucciones en código máquina para la arquitectura hardware sobre la que esté corriendo la máquina virtual. En este caso, la máquina virtual se ejecuta como un proceso dentro de un sistema operativo. Su objetivo es proporcionar un entorno de ejecución independiente de la plataforma y del sistema operativo, que oculte los detalles de la arquitectura y permita que un programa se ejecute siempre de la misma forma sobre cualquier arquitectura y sistema operativo. En el ejemplo de la Figura 2.6 se muestra cómo una aplicación Java como es el entorno de desarrollo Eclipse (después de haber sido compilado el fuente .java y obtenido el bytecode .class) se ejecuta sobre una máquina virtual de Java (Java Virtual Machine, JVM) en un sistema operativo Linux (el sistema operativo 1 del ejemplo). Si el sistema operativo 1 hubiera sido otro, por ejemplo un MacOS, entonces la JVM sería la encargada de transformar el mismo bytecode a instrucciones en dicho sistema operativo. Por otro lado, también se han popularizado mucho las máquinas virtuales de sistema entre usuarios que quieren correr aplicaciones compiladas en un sistema operativo en otro no compatible; por ejemplo, si se quiere ejecutar una aplicación de Windows en una máquina con sistema operativo Linux o MacOS. De este modo, gracias a las máquinas virtuales de sistema pueden coexistir en el mismo ordenador varios sistemas operativos distintos. El uso de este tipo de software es muy común, por ejemplo, para probar un sistema operativo nuevo sin necesidad de instalarlo directamente, o para tener en una misma máquina instalados varios servidores, de modo que todos ellos accedan a recursos comunes. Algunos ejemplos de este tipo de máquinas virtuales son aplicaciones como VirtualBox, VMWare, etc. En el ejemplo de la Figura 2.6 se muestra cómo es posible ejecutar una aplicación compilada (en el ejemplo, la suite ofimática Office de Microsoft compilado en un sistema operativo Windows) en una máquina con un sistema nativo diferente (en el ejemplo, Ubuntu GNU/Linux). De este modo, cualquier aplicación Windows podrá ejecutarse en el sistema del ejemplo, ya que la máquina virtual de sistema (en el ejemplo, la aplicación VirtualBox) se encargará de transformar los ejecutables de Windows a instrucciones propias del sistema operativo Linux. Por último, también podemos observar en el ejemplo que las aplicaciones compiladas para el sistema operativo nativo se ejecutan directamente. Las máquinas virtuales de sistema permiten disponer de dispositivos virtuales (discos, dispositivos de red, etc.) diferentes a los de la plataforma real sobre la que se está trabajando. Otra característica importante es que no es necesaria realizar ninguna configuración de red entre las máquinas con sistema operativo anfitrión y invitado, ya que la configuración que se tenga en el anfitrión es tomada directamente por el invitado. Además, los sistemas de ficheros empleados por las máquinas virtuales permiten el acceso y modificación de archivos desde el sistema operativo anfitrión hasta el invitado. T IPOS DE PROCESADORES DE LENGUAJES 65 En cuanto a posibles inconvenientes, el primero y evidente es la pérdida de rendimiento, ya que al tiempo de ejecución de una aplicación en el sistema operativo invitado hay que sumarle el tiempo de ejecución de la propia máquina virtual en el anfitrión. Pero esto es algo que recuerda a lo que ya ocurrió con el paso del ensamblador a los lenguajes de alto nivel. Un programa escrito en ensamblador siempre será más eficiente, pero las ventajas que ofrece hacer lo mismo en un lenguaje de alto nivel son muchas y evidentes. 2.2.6 Otros tipos Una vez descritas las características de los principales tipos de procesadores de lenguajes, a continuación se detallan otros tipos menos generales y que, en algunos casos, pueden ser etapas dentro de un compilador. Decompiladores Los decompiladores realizan la tarea inversa a los compiladores: obtienen un código fuente a partir de un programa compilado. Por tanto, representan un caso particular de traductores en los que el programa fuente es un lenguaje de bajo nivel y el lenguaje objeto es un lenguaje de mayor nivel. La tarea de decompilación puede ayudar a encontrar errores y vulnerabilidades en el código, mejorarlo y optimizarlo, aumentar la interoperabilidad con otros componentes software, encontrar software malicioso o, simplemente, ser aplicado en una situación en la que se haya perdido el código fuente y queramos recuperarlo. Normalmente los decompiladores se emplean para tratar de obtener el código fuente de un programa a partir de su ejecutable, pero para ello es necesario conocer algunos detalles acerca de cómo fue compilado dicho código fuente. Si no se conoce el modo en el que se obtuvo el programa de bajo nivel, es decir, si no se conocen detalles relativos al compilador empleado, o no se tiene el esquema de generación de código del compilador, esa tarea puede ser muy difícil y complicada. Existe además una técnica, conocida como ofuscación del código, que se utiliza para tratar de evitar este proceso de decompilación. Algunas de las técnicas empleadas en este proceso de ofuscación son las siguientes: • Que los nombres de las funciones y variables empleadas carezcan de sentido. • La inclusión ocasional de cálculos y bucles sin sentido. • La creación de métodos de gran tamaño, en lugar de usar métodos más pequeños. • La distribución de métodos entre subclases, etc. El objetivo de estas técnicas es añadir complicaciones al proceso de decompilación y posterior interpretación del código fuente de los programas. 66 P ROCESADORES DE LENGUAJES Desensambladores Un caso particular de decompilador son los desensambladores, programas que traducen de código máquina a ensamblador. En este caso, hay una correspondencia directa entre las instrucciones ensamblador y código máquina, por lo que el proceso suele ser más sencillo que en el caso anterior. Como es necesario reconocer las instrucciones del código binario, este proceso dependerá fundamentalmente del microprocesador que se esté utilizando, de la arquitectura de la máquina y del sistema operativo en uso. Por tanto, un desensamblador transforma el código binario en instrucciones básicas del procesador en el que se ejecuta el programa. Depuradores Se dice que un programa informático no tiene errores cuando cubre completamente su especificación funcional, es decir, cuando realiza lo que se espera de él. Sin embargo, salvo en el caso de programas muy sencillos, podría decirse que no existe un programa sin errores. Para comprobar si un programa funciona correctamente, el programador suele preparar una batería de pruebas que el programa debe superar. El objetivo de estas pruebas es detectar todo posible mal funcionamiento antes de que la aplicación entre en producción. Un error detectado durante el proceso de desarrollo de un programa es siempre menos costoso que si el error le aparece al usuario final. Éste es el objetivo de los depuradores, ayudar al programador a encontrar la causa de un error detectado durante el proceso de desarrollo. Se trata, por tanto, de un tipo especial de procesadores de lenguajes que permite encontrar y depurar errores en el código fuente de un programa. Los depuradores suelen usarse junto con los compiladores, de modo que un programador pueda chequear y visualizar la correcta ejecución de un programa mientras desarrolla el código fuente. Si un programador pudiera probar con todos los posibles datos de entrada a un programa, tendría una batería de pruebas perfecta, pero esto casi nunca es posible. Por ello, el programador pasa diferentes tipos de pruebas, encargadas cada una de ellas de probar diferentes aspectos del código implementado. Los depuradores permiten observar la traza de ejecución de los programas, visualizando el valor de cualquier variable, dirección o expresión en cualquier momento de la ejecución. De este modo, el programador puede ir registrando todo lo que va sucediendo durante la ejecución del programa y comprobando que no haya errores. Por tanto, los depuradores permiten seguir la traza del ejecutable a bajo nivel, visualizando en tiempo de ejecución los valores de las distintas posiciones de memoria que están siendo utilizadas, el contenido de los registros del procesador, o examinando la pila de llamadas que ha llevado al programa al punto en el que se encuentra en ese momento. Algunos permiten cambiar el contenido de los campos o variables en ese momento, forzando así la ejecución del programa para tratar de ver cómo responde éste o para corregir valores incorrectos. También permiten cambiar el punto de ejecución de un programa, de ma- T IPOS DE PROCESADORES DE LENGUAJES 67 nera que éste continúe en un punto diferente al punto en el que fue detenido. En algunos casos, los depuradores permiten incluso modificar el código fuente introduciendo nuevas instrucciones para continuar después con su ejecución. Además, permiten comprobar el código objeto generado por cada instrucción del código fuente. Por otro lado, es importante también destacar que un programa en depuración puede presentar un comportamiento diferente al que tendría si se ejecutara directamente. Esto es debido a que el depurador puede cambiar ligeramente los tiempos internos del programa, algo que puede afectar especialmente a sistemas complejos. Analizadores de rendimiento Los analizadores de rendimiento permiten examinar el comportamiento de los programas en tiempo de ejecución, de modo que podemos saber qué partes del código son más eficientes y cuáles deberían mejorar su rendimiento y, por tanto, deberían ser reprogramadas o simplemente revisadas. La mayor parte de los compiladores incorporan analizadores de rendimiento. Optimizadores de código Los optimizadores de código son programas que realizan modificaciones sobre el código intermedio para mejorar la eficiencia de un programa. Se trata de programas que suelen estar incluidos en los compiladores, de modo que pueden ser llamados por medio de opciones específicas de compilación. Entre estas opciones de optimización destacan la velocidad de ejecución y el tamaño final del código ejecutable; otras opciones posibles son: eliminar la comprobación de rangos o desbordamientos de pila, evaluación en cortocircuito para expresiones booleanas, eliminación de código y rutinas no utilizadas, etc. En definitiva, el objetivo final de un programa optimizador es crear un nuevo código más compacto y eficiente, eliminando instrucciones que no se ejecutan nunca, simplificando expresiones aritméticas, etc. Existen teoremas que demuestran que la optimización perfecta es indecidible, es decir, que no hay manera de decidir cuál es la mejor forma de optimizar un código [2]. Las optimizaciones que pueden realizarse en un código pueden clasificarse entre las dependientes de la máquina, como son la asignación de registros o la reordenación de código, y las independientes de la máquina, como la eliminación de redundancias. Este tipo de procesos resultan especialmente complejos y consumen la mayor parte del tiempo de ejecución del compilador. A continuación se muestra un ejemplo de una de las técnicas más comunes de optimización de código: la eliminación de operaciones redundantes en subexpresiones. Si, por ejemplo, se tiene una expresion como x = A ∗ f (y) + B ∗ f (y)2 , realmente se puede considerar como t = f (y) y x = A ∗ t + B ∗ t 2 . Existe una redundancia, de forma que f (y) se puede evaluar una sola vez en lugar de dos. Esta eliminación de subexpresiones comunes se realiza frecuentemente al evaluar expresiones. 68 P ROCESADORES DE LENGUAJES Preprocesadores Los preprocesadores son un caso particular de traductor que generalmente produce el código de entrada para el compilador. Se trata, por tanto, de programas invocados por el propio compilador antes de que comience la traducción real, y son los encargados de realizar operaciones como sustituciones de macros predefinidas, eliminación de comentarios del código fuente o la inclusión de código proveniente de otros archivos. Por medio de un preprocesador se realizan sustituciones, pero no hace ningún análisis del contexto donde se realiza; ésta es una diferencia importante entre un preprocesador y otros tipos de procesadores de lenguaje. Un ejemplo de este tipo de programas es el preprocesador del lenguaje C. Se trata del primer programa en ser invocado en un proceso de compilación en C, y es el encargado de procesar directivas como, por ejemplo, #include, #define o #if. Por medio de un #include se indica al preprocesador que debe incluir en el código fuente el contenido del archivo .h que se indique (por ejemplo, #include <stdio.h>). En dicho archivo pueden estar escritas las declaraciones de todas las funciones de entrada y salida de la biblioteca estándar de C, como puede ser la función printf. Si el compilador no sabe qué es lo que hace la función printf, no podrá generar el código objeto del programa. La directiva #define se usa para definir una macro, por medio de la cual se proporcionan mecanismos de reemplazo de tokens. En el caso de la directiva #if, se ofrece la posibilidad de realizar compilación condicional, de forma que el compilador puede ignorar o compilar determinadas líneas del código en función de ciertas condiciones que son evaluadas durante el preproceso. Editores de lenguajes de programación Otro tipo muy común de procesadores de lenguajes son los llamados editores de lenguajes de programación. A diferencia de otros tipos de procesadores que forman parte del proceso de compilación, los editores de lenguajes son programas independientes cuya función es ayudar al programador en el proceso de creación de un código fuente. Los editores de lenguajes de programación suelen ofrecer resaltado de sintaxis, es decir, que permiten llamar la atención del programador mientras éste está escribiendo un programa; y lo hacen por medio del uso de diferentes colores para diferenciar diferentes tipos de tokens como palabras reservadas, declaración de variables, etc. Algunos permiten incluso la función de auto-completar código. Resultan muy útiles para un programador, mostrando sugerencias según el contexto. Además del resaltado de sintaxis, los editores de lenguajes de programación suelen ofrecer otras funcionalidades, como la posibilidad de editar varios documentos a la vez u ofrecer una multi-vista, lo que significa que se puede tener más de una vista de un mismo código, de modo que el programador puede visualizar simultaneamente dos versiones o dos partes de un mismo documento. Permiten realizar también acciones de búsqueda y reemplazo, pudiéndose en muchos casos utilizar incluso expresiones regulares para E STRUCTURA DE UN COMPILADOR 69 definir los patrones que se deseen reemplazar. En muchos casos ofrecen, ademnás, menús contextuales y detección automática del estado del documento, algo muy útil en caso que se desee guardar un archivo que había sido modificado por otro usuario o programa. Otras características son los asistentes de código para diferentes lenguajes, lo que suele realizarse por medio de menús contextuales, o la existencia de herramientas integradas para el trabajo con base de datos. Algunos editores permiten incluso realizar tareas de refactorización del código, es decir, que permite cambiar la estructura interna del programa sin modificar su comportamiento funcional, con el fin de hacerlo más fácil de entender y de modificar en un futuro. Esta función de refactorización es especialmente útil si queremos hacer modificaciones o actualizaciones en el código que afecten a diferentes partes del mismo. Un ejemplo sería renombrar el nombre de un método, de forma que resulte más claro. Se podrían modificar automáticamente todas las llamadas al método presentes en el código de un programa. Otra refactorización muy común es convertir un fragmento de código en un método. Para ello el programador debería crear un nuevo método, copiar el fragmento de código en el cuerpo del método, buscar en el código extraído referencias a variables locales en el código original y convertirlas en parámetros y variables locales del nuevo método, reemplazar el código extraído por una llamada al nuevo método y, por último, eliminar las declaraciones correspondientes a las variables que ahora son variables locales del nuevo método. Algunos editores como Eclipse, Komodo Edit o JEdit permiten realizar estas operaciones de forma automática. 2.3 Estructura de un compilador Si queremos que un ordenador entienda un código fuente es necesario diseñar métodos que traduzcan a código máquina, tanto la estructura como el significado de las sentencias contenidas en él. Un código fuente no es más que un conjunto de símbolos (letras, dígitos, caracteres especiales) que representan construcciones del lenguaje como variables, palabras reservadas, constantes, operadores, etc. Un compilador tendrá, por tanto, que identificar los significados de las diferentes construcciones presentes en la definición del propio lenguaje. Como ya se ha avanzado, este proceso debe realizase en diferentes fases. Artefactos de programación como declaraciones, tipos, identificadores, etc. representan abstracciones que los diseñadores de lenguajes utilizan para que un programador pueda pensar y entender mejor lo escrito en el código fuente de un programa. Por otro lado, la utilización de un vocabulario restringido y de unas reglas fijas dentro del lenguaje facilitan el proceso de traducción de un lenguaje de alto nivel a un lenguaje máquina. Como hemos visto en el apartado anterior, en un compilador pueden distinguirse dos fases principales: una fase de análisis, en la que la estructura y el significado del código fuente se analiza; y otra fase de síntesis, en la que se genera el programa objeto. En la Figura 2.7 se muestran las fases típicas en las que se divide un compilador. Las tres 70 P ROCESADORES DE LENGUAJES Figura 2.7: Etapas de traducción de un compilador: análisis y síntesis primeras fases se corresponden con el análisis de un programa fuente y las últimas con la síntesis de un programa objeto. De las dos fases, la síntesis es la que requiere técnicas más elaboradas. Otra forma alternativa de estructurar las fases de las que se compone un compilador es distinguiendo entre: front-end, parte en la que se analiza el código, se comprueba su validez, se genera el árbol de derivación y se rellena la tabla de símbolos; y backend, donde se genera el código máquina. La idea de esta división es poder aprovechar la parte del front-end si lo que se busca es implementar compiladores de un mismo lenguaje para diferentes arquitecturas. De este modo, toda la fase relativa al análisis del código y generación de código intermedio sería común para todos los compiladores de este lenguaje, pudiendo ser reutilizada, al contrario que las fases relacionadas con la generación del código objeto final, que dependerán de la arquitectura del ordenador y, por tanto, deberán ser diferentes en cada caso. De este modo, el front-end se compone de las fases de análisis léxico, sintáctico, semántico y generación de código intermedio, mientras que el back-end comprende las fases de generación y optimización de código. En la Figura 2.8 se muestra un esquema de las etapas de análisis y síntesis de un compilador, así como de las fases de front-end y back-end. 2.3.1 Análisis léxico La primera de las fases de un compilador es la fase de análisis léxico, etapa en la que se realiza un análisis a nivel de caracteres. El objetivo de esta fase es reconocer los componentes léxicos –o tokens– presentes en el código fuente, enviándolos después, junto con sus atributos, al analizador sintáctico. E STRUCTURA DE UN COMPILADOR Figura 2.8: Fases de un compilador 71 72 P ROCESADORES DE LENGUAJES En el estudio de esta fase de análisis léxico es importante que distinguir entre: • Lexema, definido como la secuencia de caracteres del programa fuente que coincide con el patrón que describe un token, es decir, cada una de las instancias de un token que el analizador léxico identifica. • Patrón, que define la forma que pueden tomar los diferentes lexemas. • Token, definido como un par formado por un nombre de token y un valor de atributo opcional. El nombre del token es un símbolo abstracto que representa un tipo de unidad léxica, por ejemplo, una secuencia de caracteres que representa un identificador [2]. Por tanto, un token se describe por medio de un patrón y un lexema representa un conjunto de caracteres que concuerdan con dicho patrón. Los tokens pueden definirse entonces como secuencias de símbolos que tienen un significado propio que representan símbolos terminales (constantes) de la gramática del analizador sintáctico. Cada token puede tener una información asociada a su lexema con un significado coherente en un determinado lenguaje de programación. Entre esta posible información asociada podemos encontrar un valor concreto, un literal, un tipo de datos, etc. Ejemplos de tokens, podrían ser palabras clave (if, else, while, int, ...), identificadores, números, signos, o un operador (:=, ++, -). La cadena de símbolos que constituye el programa fuente se lee de izquierda a derecha, y durante este proceso de análisis léxico se leen los caracteres de la entrada y se genera la secuencia de tokens encontrados. A continuación se muestra un ejemplo ilustrativo del proceso llevado a cabo en esta primera fase de análisis léxico. Sea una sentencia de un lenguaje: velocidad := velocidad_inicial + aceleracion ∗ 8 ; los componentes léxicos detectados en esta fase serían los mostrados a continuación. En este ejemplo, un token sería el par < ID,′ velocidad ′ >, mientras que un lexema sería la sucesión de caracteres velocidad. 1. Identificador “velocidad” < ID,′ velocidad ′ > 2. Símbolo de asignación “:=” < ASIG > 3. Identificador “velocidad_inicial” < ID,′ velocidad_inicial ′ > 4. Signo de suma “+” < OP,′ +′ > 5. Identificador “aceleracion” < ID,′ aceleracion′ > 6. Signo de multiplicación “*” < OP,′ ∗′ > E STRUCTURA DE UN COMPILADOR 73 7. Constante “8” < CT E_INT, 8 > 8. Símbolo de terminación “;” < PT O_COMA > Durante esta fase se ignoran también los comentarios que pudiera haber en el código fuente y que no forman parte de la semántica funcional del programa, así como los delimitadores (espacios, caracteres de fin de línea, . . . ). También se relacionan los mensajes de error que se pudieran producir con las líneas del programa fuente y se introducen los identificadores encontrados, así como sus valores, en la tabla de símbolos. Como muestra la Figura 2.8, la tabla de símbolos se utiliza durante todo el proceso de compilación, pero es en la fase ulterior de análisis semántico en la que quizá tiene más importancia. Por ello se ha decidido describirla y tratarla con más en detalle en el apartado correspondiente al estudio del análisis semántico. La detección de tokens llevada a cabo en esta fase de análisis léxico de un compilador se realiza con gramáticas y lenguajes regulares. Es necesario, por tanto, introducir una serie de conceptos teóricos relacionados con gramáticas y patrones que nos servirán para comprender cómo se realiza esta detección de tokens. A continuación se definen los conceptos de alfabeto, cadena, gramática regular y lenguaje regular. Alfabetos y cadenas Se define alfabeto, o vocabulario, como un conjunto finito de símbolos. Algunos ejemplos de alfabetos son: V1 = {A, B,C, D, E, F, . . . , X ,Y, Z} V2 = {0, 1} Se define cadena como una secuencia finita de símbolos de un determinado alfabeto. Algunos ejemplos de cadenas a partir de los vocabularios anteriores son: ABABDCD es una cadena posible del alfabeto V1 con longitud 7. 01001100 es una cadena posible del alfabeto V2 con longitud 8. El conjunto de todas las cadenas que se pueden formar con los símbolos de un alfabeto se denomina universo del discurso, W , y se representa por W (V ), donde V es el alfabeto del lenguaje. Gramáticas regulares Se conoce como gramática formal a un conjunto de reglas que permite formar cadenas de caracteres a partir de un alfabeto dado. Su objetivo no es describir el significado de cadenas bien formadas, sino simplemente su forma. Más específicamente, una gramática 74 P ROCESADORES DE LENGUAJES regular es una gramática formal que se define como una cuadrupla formada por un vocabulario terminal T (constantes), un vocabulario no terminal N (variables), un símbolo inicial S, y un conjunto de producciones o reglas de derivación P. G = (T, N, S, P) donde todas las cadenas del lenguaje definidas por dicha gramática estarán formadas por símbolos del vocabulario terminal T . Este vocabulario terminal se define por la enumeración de símbolos terminales. El vocabulario no terminal N es el conjunto de símbolos introducidos como elementos auxiliares para la definición de las producciones de la gramática, y que no figuran en las sentencias del lenguaje. La intersección entre los vocabularios T y N es el conjunto vacío. El símbolo inicial S es un símbolo no terminal a partir del cual se pueden obtener todas las sentencias del lenguaje definido por la gramática. Las producciones o reglas de derivación P son transformaciones de cadenas de símbolos que se expresan mediante unos antecedentes y consecuentes separados por una flecha. A la izquierda –como antecedente– está el símbolo o conjunto de símbolos a transformar, y a la derecha –como consecuente– los símbolos obtenidos a partir de la transformación. El conjunto de producciones se define por medio de la enumeración de las distintas producciones. Un ejemplo producción sería: A→w S donde A ∈ N, es decir, A es un no terminal; w es una cadena sobre T N, con un único no terminal como máximo, y debiendo estar éste siempre situado al final de la producción. Por tanto, una producción puede interpretarse como una regla de reescritura que permite sustituir un elemento no terminal por la cadena de símbolos expresada en el consecuente de una de las producciones y con ese no terminal como antecedente. Esta sustitución se conoce como paso de reescritura o paso de derivación. Dado el símbolo inicial de una gramática pueden realizarse varios pasos de derivación hasta llegar a una palabra. Como ejemplo de paso de derivación se tiene lo siguiente. A partir de una gramática con un vocabulario terminal T = {a, b}, un vocabulario no terminal N = {S, A}, y el siguiente conjunto de producciones P: S → bA S→b A → aaA A→b La palabra baaaab se puede obtener a partir de la siguiente secuencia de pasos de derivación: E STRUCTURA DE UN COMPILADOR 75 S ⇒ bA ⇒ baaA ⇒ baaaaA ⇒ baaaab siendo S el símbolo inicial y baaaab una palabra generada por la gramática. Por tanto, podemos decir que una palabra pertenece a un lenguaje si existe una derivación posible desde el símbolo inicial de la gramática que permita obtener esa palabra. Pero como puede verse, existen varias derivaciones posibles para una misma gramática; y para algunas gramáticas existirían infinitas derivaciones posibles. Por tanto, se dice que dos gramáticas son equivalentes si generan el mismo lenguaje. Notese que, por convención, los símbolos no terminales se escriben con mayúsculas, los terminales en minúsculas, y el símbolo inicial se representa siempre como S. Otro aspecto interesante relativo a la notación es que cuando se quiere indicar que existen varios pasos de reescritura, se emplean los símbolos “⇒ ∗”, del siguiente modo: S ⇒ bA ⇒ baaA ⇒ baaaaA ⇒ baaaab se puede abreviar como S ⇒ ∗ baaaab Además, una gramática se puede escribir de una forma más compacta, de modo que las producciones siguientes: S → bA S→b A → aaA A→b pueden abreviarse como: S → bA | b A → aaA | b Es posible tener un símbolo terminal ε que represente la ausencia de terminal, de modo que si el conjunto de producciones fuera, en este caso: S → bA | b A → aaA | b | ε entonces se podrían tener, por ejemplo, las siguientes derivaciones: S ⇒ bA ⇒ b S ⇒ bA ⇒ baaA ⇒ baa S ⇒ bA ⇒ baaA ⇒ baaaaA ⇒ baaaa S ⇒ bA ⇒ baaA ⇒ baaaaA ⇒ baaaab 76 P ROCESADORES DE LENGUAJES A partir de las gramáticas regulares se definen lo que se conoce como lenguajes regulares, que se verán en detalle a continuación. Lenguajes regulares Desde un punto de vista lingüístico, y a partir de la definición que dió Noam Chomsky en su libro “Estructuras sintácticas” de 1957, un lenguaje es un conjunto finito o infinito de oraciones, donde cada una de las cuales posee una extensión finita y está construida a partir de un conjunto finito de elementos [6]. Reduciendo el ámbito de la definición, un lenguaje formal se define como un conjunto de cadenas de símbolos de un alfabeto determinado; por tanto, se define como el conjunto de todas las sentencias formadas por símbolos terminales que se puedan generar a partir de una gramática. Un lenguaje L(G) generado por una gramática G se expresa como: L(G) = {α ∈ T /S ⇒ ∗α} donde α representa una cadena de símbolos terminales. Entonces se dice que una sentencia pertenece a un lenguaje si está compuesta de símbolos terminales, y si es posible derivarla a partir del símbolo inicial S por medio de la aplicación de producciones de la gramática G. Un lenguaje regular es un tipo de lenguaje formal definido a partir de una gramática regular o una expresión regular. Como ejemplo de cadena válida dentro de una gramática regular, un identificador de un lenguaje de programación se puede definir como una letra seguida de cero o más letras o dígitos. Decimos entonces que el lenguaje regular está formado por todos los nombres de identificadores posibles generados con las reglas de la gramática regular. De este modo, un identificador sería cualquier palabra derivada de las siguientes producciones: S → aR | bR | . . . | zR | a | b | . . . | z R → aR | bR | . . . | zR | a | b | . . . | z | 0R | 1R | . . . | 9R | 0| . . . | 9 Un lenguaje regular se puede definir a partir de expresiones regulares, que son equivalentes a las gramáticas regulares pero con una notación mucho más compacta. Las expresiones regulares se construyen utilizando los operadores unión ( | ), concatenación (.) y cierre de Kleene (∗) -el carácter que lo precede puede aparecer cero, una, o más veces-. Además, se pueden emplear cuantificadores para especificar la frecuencia con la que un carácter puede ocurrir. Los cuantificadores más comunes son los siguientes: +, que indica que el carácter al que sigue debe aparecer al menos una vez; y ?, que indica que el carácter al que sigue puede aparecer como mucho una vez. El uso de paréntesis permite definir el ámbito y precedencia de estos operadores que pueden combinarse libremente dentro de la misma expresión. E STRUCTURA DE UN COMPILADOR 77 Algunos ejemplos de expresiones regulares son: a|b → {a, b} = {a} ∪ {b} a.b, ab → {a, b} a∗ → {ε, a, aa, aaa, aaaa, . . .} a+ → {a, aa, aaa, aaaa, . . .} a? → (a|ε) Respecto de los operadores empleados en las expresiones regulares, se establece la siguiente precedencia: • 1o : Operador ‘*’ (cierre de Kleene) • 2o : Operador ‘.’ (concatenación) • 3o : Operador ‘|’ (unión) De este modo, para la expresión regular a ∗ b|c se tendría: a ∗ b|c → {c, b, ab, aab, aaab, . . .} Además podrán usarse paréntesis si fuera necesario. Otros ejemplos de expresiones regulares: a | b → {a, b} (a | b)(a | b) → {aa, ab, ba, bb} aa | ab | ba | bb → {aa, ab, ba, bb} (a | b)∗ → {ε, a, b, aa, bb, ab, ba, abab, . . .} Las expresiones regulares cumplen las siguiente propiedades: 1. La unión | es conmutativa: r|s = s|r 2. La concatenación . es asociativa: (rs)t = r(st) 3. La concatenación . es distributiva por la derecha sobre | : r(s|t) = rs|rt 4. La concatenación . es distributiva por la izquierda sobre | : (s|t)r = sr|tr 5. Elemento neutro o identidad: εr = r = rε 6. Relación entre * y ε : r∗ = (r|ε)∗ 7. ∗ es idempotente: r ∗ ∗ = r∗ 78 P ROCESADORES DE LENGUAJES La comprobación de si una sentencia o instrucción pertenece o no a un determinado lenguaje se lleva a cabo por medio de una herramienta formal denominada autómata finito. Se dice entonces que un lenguaje regular es un tipo de lenguaje formal que puede ser reconocido por un autómata finito determinista o no determinista. Por tanto, un analizador léxico es un autómata finito. Los errores léxicos son detectados cuando el analizador trata de reconocer componentes léxicos pero la cadena de entrada que está tratando no encaja con ningún patrón definido por una expresión regular. Esta situación se suele dar cuando se emplea un caracter no válido que no pertenece al vocabulario del lenguaje o cuando se escribe mal un identificador, palabra reservada u operador. Algunos de los errores léxicos más comunes, producidos en la mayoría de los casos por despistes del programador, son el uso de caracteres no válidos en identificadores, cuando se encuentran números con formato incorrecto o errores ortográficos en palabras reservadas. Por último, cabe destacar que las gramáticas regulares permiten expresar repeticiones fijas o infinitas de una palabra (por ejemplo: (abc)n con n > 0), así como expresar un numero fijo de repeticiones de los elementos de una palabra (por ejemplo: a3 bc3 o a4 bc4 ). Sin embargo, no permiten generar expresiones del tipo an bcn con n > 0, describir el lenguaje de las cadenas de paréntesis equilibrados (por ejemplo: (()) o ((()()))), ni describir el lenguaje de las expresiones de un lenguaje de programación, por lo que no podrán ser empleadas en la siguiente fase de análisis sintáctico. Para ello habrá que definir otro tipo de gramáticas más generales, las llamadas gramáticas independientes de contexto. Existen herramientas, como Lex, que permiten crear analizadores léxicos y que se verán en más detalle en el apartado 2.4.4. 2.3.2 Análisis sintáctico Como ya se ha avanzado, por sintaxis se entiende el conjunto de reglas formales que especifican cómo deben construirse las sentencias de un determinado lenguaje. Un analizador sintáctico tomará, por tanto, los tokens que le envíe el analizador léxico y creará un árbol sintáctico que refleje la estructura del programa fuente; es decir, en esta fase se comprobará si con dichos tokens se puede formar alguna sentencia válida dentro del lenguaje. Por tanto, en esta fase es en la que se identifican las estructuras del programa: declaraciones, expresiones, sentencias, bloques de sentencias, subprogramas, . . . Como ya se ha dicho, con las gramáticas regulares no es posible analizar la sintaxis de cualquier tipo de lenguaje de programación, solo de los lenguajes regulares. Sin embargo, la sintaxis de la mayoría de los lenguajes de programación se define habitualmente por medio de gramáticas libres de contexto (gramáticas de tipo 2), que ya han sido explicadas en el capítulo 1. Se puede decir que toda gramática regular es una gramática independiente de contexto, y que todo lenguaje regular es un lenguaje independiente de contexto. Sin embargo, no todas las gramáticas independientes de contexto son gramáticas regulares ni todos los lenguajes independientes de contexto son lenguajes regulares. Por este motivo se E STRUCTURA DE UN COMPILADOR 79 puede decir que las gramáticas independientes de contexto suponen una superclase de las gramáticas regulares. El término libre de contexto se refiere al hecho de que un no terminal puede siempre ser sustituido sin tener en cuenta el contexto en el que aparece. El proceso de derivación en este caso es igual que en las gramáticas regulares. Durante este proceso se sustituye el símbolo inicial por alguna cadena válida definida en alguna producción. A continuación, los no terminales de esta cadena se sustituyen por otra cadena, y así sucesivamente hasta que solo se tengan cadenas de símbolos terminales. Como ya se ha visto, la notación más frecuentemente utilizada para expresar gramáticas libres de contexto es la forma Backus-Naur (BNF) y la Extended BNF (EBNF). Siguiendo con el ejemplo utilizado en la etapa de análisis léxico, a continuación se muestra un ejemplo del proceso llevado a cabo en esta fase de análisis sintáctico. Sea una gramática independiente de contexto descrita en notación BNF: < asignacion >::=< identi f icador >′ :=′ < expresion > < expresion >::=< termino > | < expresion >′ +′ < termino > < termino >::=< f actor > | < termino >′ ∗′ < f actor > < f actor >::=< identi f icador > |′ (′ < expresion >′ )′ Un ejemplo de derivación de sentencia para el análisis sintáctico de la producción: velocidad := velocidad_inicial + velocidad ∗ 8; se muestra en la Figura 2.9. Como se vió en el Capítulo 1, en la etapa de análisis sintáctico los identificadores léxicos se agrupan en estructuras de tipo árbol. Los componentes léxicos formarán frases gramaticales que el compilador utilizará en fases ulteriores para sintetizar el programa resultado. A partir de estas gramáticas independientes de contexto se diseñan algoritmos de análisis sintáctico capaces de determinar si una determinada cadena de tokens pertenece al lenguaje definido por una gramática dada. Ejemplos de estos algoritmos son los analizadores sintácticos descendentes (LL) y ascendentes (LR), que se verán con más detalle en el apartado 2.4.3, y que permiten tratar con subconjuntos restringidos de gramáticas libres de contexto. El proceso de comprobación de si una secuencia de tokens pertenece o no a un determinado lenguaje independiente de contexto se lleva a cabo por medio de autómatas a pila, y se dice entonces que un lenguaje formal es un lenguaje libre de contexto si puede ser reconocido por un autómata a pila. A diferencia de los autómatas finitos, los autómatas a pila cuentan con una memoria auxiliar; los símbolos (llamados símbolos de pila) pueden ser insertados o extraídos en la pila por medio de un algoritmo Last-In-First-Out (LIFO). Las transiciones entre los estados que ejecutan los autómatas a pila dependen de los símbolos de entrada y de los símbolos de la pila. El autómata acepta una cadena x si la secuencia de transiciones, comenzando en el estado inicial y con la pila vacía, conduce a un estado final después de leer toda la cadena x. 80 P ROCESADORES DE LENGUAJES Figura 2.9: Ejemplo de árbol de derivación Los errores detectados en la fase de análisis sintáctico se refieren al hecho de que la estructura que se ha seguido en la construcción de una secuencia de tokens no es la correcta según la gramática que define el lenguaje. Un ejemplo de error en esta fase de análisis sintáctico es encontrar una expresión con paréntesis no balanceados. El manejo de errores sintácticos es el más complicado desde el punto de vista de la creación de un compilador. Siempre interesa que cuando el compilador encuentre un error pueda seguir buscando errores; por tanto, el manejador de errores de un analizador sintáctico debe tener como objetivos: • Indicar y localizar cada uno de los errores encontrados. • Recuperarse del error para poder seguir examinando errores sin necesidad de cortar el proceso de compilación. • No ralentizar en exceso el propio proceso de compilación. En esta fase de análisis, y una vez detectados los errores sintácticos en el código fuente, existen varias estrategias para corregirlos. • En primer lugar se puede ignorar el problema, lo que se conoce como panicmode. Esta estrategia consiste en ignorar el resto de la entrada a analizar hasta encontrar un token especial (un ‘; ’ o un ‘END’), lo que se conoce como condición de seguridad. E STRUCTURA DE UN COMPILADOR 81 • Otra opción es tratar de realizar una recuperación a nivel de frase, es decir, intentar recuperar el error una vez ha sido detectado. En el caso en el que el error tenga su origen, por ejemplo, en la falta del símbolo ‘; ’ al final de una sentencia, el analizador podría insertar el token ‘; ’. • Otra estrategia en el tratamiento de errores sintácticos es el considerar reglas de producción adicionales para el control de errores. En este caso, la gramática se puede extender con reglas capaces de reconocer los errores más comunes. • Por último, se puede realizar una correccion global, es decir, dada una secuencia completa de tokens a ser reconocida, si hay algún error por el que no se puede reconocer la cadena a partir de una gramática, entonces se trata de encontrar la construcción sintáctica más parecida a la dada que sí pueda ser reconocida. Existen herramientas, como Yacc, que permiten la creación de analizadores sintácticos y que se verán en más detalle en el apartado 2.4.4. 2.3.3 Análisis semántico La semántica se encarga de describir el significado de los símbolos, palabras y frases de un lenguaje, ya sea un lenguaje natural o de programación. Por tanto, en todo proceso de compilación, además de comprobar que un programa cumple con las reglas de su gramática, hay que dotar de significado a lo que se ha realizado en la fase anterior de análisis sintáctico. Así, cuando un analizador sintáctico reconoce una expresión con un operador, se llama a una rutina semántica que, por ejemplo, podría comprobar que los dos operandos hayan sido declarados previamente y que tienen el mismo tipo; también puede comprobar si a los operandos se les ha asignado previamente algún valor, etc. Como se ha visto en apartados anteriores, la sintaxis de un lenguaje es posible formalizarla por medio de una gramática o una notación sintáctica formal. Sin embargo, la semántica de un lenguaje de programación, aunque también se intenta formalizar, resulta mucho más compleja. Aunque existen formalizaciones matemáticas de la semántica de un lenguaje de programación (semántica operacional, semántica denotacional, semántica axiomática y semántica de especificación), no se suelen utilizar en los lenguajes de programación imperativos como C, C++ o Java. Gramáticas atribuidas La definición de la semántica de las sentencias de un lenguaje viene detallada en los manuales de los propios lenguajes de programación y descrita en forma prosaica, es decir, no de un modo formal. Por otro lado, el uso del lenguaje natural para establecer estas reglas semánticas conlleva problemas como pueden ser la ambigüedad o la falta de precisión. 82 P ROCESADORES DE LENGUAJES Las gramáticas BNF no permiten formalizar la semántica de un lenguaje, ya que tienen carencias relacionadas con aspectos semánticos como son los tipos y el ámbito de variables. Por ejemplo, con una gramática BNF no se puede comprobar, en una determinada expresión, si los tipos de las variables son correctos o si la declaración de una variable ha sido realizada antes o después de su uso, si se usa un número real como índice de una matriz, lo que supondría un error semántico, etc. Por ello surge la necesidad de usar gramáticas atribuidas, es decir, extensiones de las gramáticas BNF capaces de extraer información semántica del árbol de derivación. Con este tipo de gramáticas se asocia a cada símbolo de la gramática un conjunto de atributos y un conjunto de reglas semánticas. Por tanto, se conoce como gramática atribuida, o gramática de atributos, a una gramática independiente del contexto cuyos símbolos terminales y no terminales tienen asociados un conjunto de atributos que representa una propiedad del símbolo. A continuación se muestra un ejemplo. Si tenemos un conjunto de producciones anotadas semánticamente: < assign >::=< var >′ :=′ < expr > i f (var.actual_type <> expr.actual_type) then Error; < expr >::=< var > [1]′ +′ < var > [2] i f (var[1].actual_type =′ int ′ ) and (var[2].actual_type =′ int ′ ) then expr.actual_type :=′ int ′ else expr.actual_type :=′ real ′ ; < expr >::=< var > expr.actual_type := var.actual_type; < var >::=′ a′ |′ b′ |′ c′ var.actual_type := busca_tipo(var.nombre); Si encontramos la siguiente expresión: a := a + b, asumiendo que a es un número real y b un entero, entonces el árbol semántico generado se muestra en la Figura 2.10. Este árbol semántico no es más que un árbol sintáctico en el que cada una de sus ramas ha adquirido el significado que debe tener. En este caso, se puede observar que cada uno de los nodos del árbol, correspondientes a expresiones o variables, son etiquetados con el tipo de dato correspondiente (real o entero). Por ejemplo, en el caso de tener un operador polimórfico como el operador “+” (un único símbolo con varios significados), es en esta fase de análisis donde se determina cuál es el significado aplicable, ya que el signo “+” puede permitir sumar enteros y reales, concatenar cadenas de caracteres o unir conjuntos. En esta fase se debe comprobar, por tanto, que a y b sean de un tipo común o compatible, como es el caso, y que se les pueda aplicar dicho operador; en el ejemplo mostrado, la suma de un entero y un real. Por tanto, en esta fase se decide si a y b son enteros o reales, y entonces sus valores serán sumados, o si se trata de cadenas de caracteres y entonces serán concatenados. En el caso de que fueran conjuntos, entonces se calcularía su unión. E STRUCTURA DE UN COMPILADOR 83 Figura 2.10: Ejemplo de árbol de análisis semántico Tabla de símbolos Tal y como se avanzó en la seccion correspondiente al análisis léxico, la tabla de símbolos es una estructura de datos que se usará a lo largo de todo el proceso de traducción llevado a cabo por un compilador. Se ha decidido explicar con mayor detalle en este punto porqué las tareas llevadas a cabo en la fase de análisis semántico son de mayor calado que en el resto de fases, ya que por ejemplo, en el caso del análisis léxico, su acceso se limitaría a incluir información en la propia tabla de símbolos, sin ninguna otra función adicional. La tabla de símbolos es una estructura de datos que contiene información por cada identificador detectado en la fase de análisis léxico. Entonces, por medio de la tabla de símbolos, el compilador registra los identificadores utilizados en el programa fuente reuniendo información sobre las características de dicho identificador. Estas características pueden proporcionar información necesaria para realizar el análisis semántico. De este modo se puede tener información relativa a la memoria asignada a una variable, el tipo, el ámbito, etc. En el caso de las funciones también se guarda el número y tipo de argumentos, el tipo del valor devuelto, etc. Por tanto, se puede decir que la tabla de símbolos contiene información sobre los identificadores del programa con todos los atributos que puedan resultar necesarios para realizar correctamente el proceso de compilación. Específicamente, entre la información que se guarda en la tabla de símbolos está: la clase del identificador, ya sea variable, función o procedimiento, etiqueta, tipo, valor de enumeración, etc.; el tipo de dato, si es entero, real, booleano, carácter o cadena, registro, un tipo definido por el usuario, etc.; si se trata de una función, si es un operador, cuántos argumentos tiene, etc. Otra información susceptible de ser almacenada en la tabla de símbolos es si el símbolo es un escalar, un “array” o una lista, etc.; si es un parámetro de una función, si se le ha asignado valor, si tiene valor inicial, si es estático, dinámico, etc. En la Tabla 2.1 se 84 P ROCESADORES DE LENGUAJES Figura 2.11: Código a partir del cual se genera la tabla de símbolos de la Tabla 2.1 muestra un ejemplo de una tabla de símbolos. Nombre entero1 entero2 Tipo integer integer Dirección A41E1 3E0A16 Tamaño 32 bits 32 bits Tabla 2.1: Ejemplo de entradas en una tabla de símbolos empleada en el análisis del código que se muestra en la Figura 2.11 Las funciones asociadas a las tablas de símbolos están relacionados con las tareas de búsqueda, inserción, cambio de valor, y borrado de una entrada. La búsqueda consiste en encontrar, a partir del nombre de un elemento, su valor dentro de la tabla de símbolos. En el caso de la inserción, dado un par nombre-valor, el objetivo es introducir un elemento nuevo a la tabla. El cambio de valor consiste en buscar un elemento y modificar su valor dentro de la tabla de símbolos. Por último, con el borrado se busca la eliminación de un elemento de la tabla. Normalmente los lenguajes de programación tienen lo que se conoce como ámbitos. En el caso de las tablas de símbolos de estructuras de bloques, empleadas en lenguajes de estructura de bloques, toda línea dentro de un programa está contenida en uno o más bloques que definen ámbitos de validez de nombres. El ámbito definido por el bloque más profundo que contiene la instrucción que estamos analizando se llama ámbito actual. E STRUCTURA DE UN COMPILADOR 85 Los ámbitos que incluyen a una línea de un programa son abiertos respecto a esa línea. Los que no la incluyen se dice que son cerrados respecto a esa línea. Por ejemplo, si se tiene el siguiente código por bloques: 1 { 3 4 5 6 7 8 9 10 11 12 13 14 // Bloque 1 int a , b , c , d; { int e , f; L1 : } { int g , h; L2 : { int a; } L3 : } 2 // Bloque 2 // Bloque 3 // Bloque 4 } Si tomamos como referencia la línea 10 ( int a;), los ámbitos abiertos serían los correspondientes a los bloques 1, 3 y 4, mientras que el bloque 2 es un ámbito cerrado. Gracias a la tabla de símbolos y a la información contenida en ella, se puede recuperar correctamente el valor que tiene, por ejemplo, una variable a, dependiendo del ámbito en el que nos encontremos. Hay dos modos de implementar las tablas de símbolos de bloques: una tabla por ámbito y una tabla común para todos los ámbitos. La tabla única se suele utilizar en compiladores de un solo paso en los que se descarta la información referente a un ámbito cuanto éste se cierra. Un compilador de múltiples pasos suele requerir una tabla individual por ámbito. Tratamiento de errores De un modo similar al de la tabla de símbolos, el tratamiento de errores se realiza a lo largo de todas las fases de traducción, pero es en la fase de análisis semántico en la que tiene más importancia. Este es el motivo de que se explique en más detalle en este punto. Los errores se pueden encontrar en cualquiera de las fase de la compilación. Aunque las fases de análisis sintáctico y semántico, por lo general, manejan una gran porción de los errores detectados por el compilador, ya se ha visto que al manejador de errores se puede acceder desde cualquier fase de la compilación. Cuando se encuentra un error, el proceso de compilación continúa realizándose, permitiendo así la detección de más errores en el programa fuente. Durante el análisis semántico el compilador intenta detectar construcciones que tengan estructura sintáctica correcta pero no tengan significado para la operación implicada; por ejemplo, sumar un identificador de matriz y un identificador de procedimiento. Ejemplos típicos de errores detectados durante el análisis semántico son la comprobación de 86 P ROCESADORES DE LENGUAJES tipos, como encontrar operadores aplicados a operandos incompatibles, asignación de tipos incompatibles, llamadas a funciones con tipos no adecuados, el uso de variables no declaradas, etc. 2.3.4 Generación de código intermedio Como se vió en la Figura 2.7, en un modelo en el que se realice una separación de fases en análisis y síntesis dentro de un compilador, la etapa inicial traduce un programa fuente a una representación intermedia a partir de la cual se genera después el código objeto. Aunque un programa fuente se puede traducir directamente al lenguaje objeto, hay ventajas si se utiliza una forma intermedia independiente de la máquina. Como se avanzó en relación al front-end y back-end (Figura 2.8), los detalles propios del lenguaje objeto se consideran en la etapa final del compilador. Entre las ventajas de usar un código intermedio destaca el hecho de que se pueda crear un compilador para una arquitectura distinta sin tocar el front-end de un compilador ya existente para otra arquitectura; de este modo se permite después aplicar un optimizador de código independiente de la máquina a la representación intermedia. Por último, esta representación debe ser fácil de traducir a código objeto, es decir, al código máquina de un procesador concreto. 2.3.5 Optimización de código intermedio La segunda etapa del proceso de síntesis trata de optimizar el código intermedio, para posteriormente generar código máquina más rápido de ejecutar. Casi todos los compiladores ejecutan optimizaciones sencillas que mejoran sensiblemente el tiempo de ejecución del programa objeto sin aumentar demasiado el tiempo de compilación. También existen compiladores-optimizadores en los que la mayor parte del tiempo de compilación se consume en tareas de optimización de código. Unos de los tipos de optimización de código más habituales son la eliminación de variables no usadas y el desenredado de bucles. Por ejemplo, cuando una instrucción if-then-else no tiene bloque else, siempre se genera un código en el bloque then con un salto innecesario a la instrucción siguiente. Esta traducción se puede optimizar generando el código correspondiente a ese salto solo si el bloque else tiene contenido. Por otro lado, el desenredado de bucles consiste en solapar distintas iteraciones de un mismo bucle, es decir, hacer una nueva versión del bucle incluyendo las operaciones correspondientes a varias iteraciones. Este desenredado no siempre es fácil de aplicar, debido a que en ciertas situaciones el número de veces que se ejecuta un bucle no es un valor estático, sino que depende de una variable cuyo contenido no se conoce en tiempo de compilación. Además, hay que tener cuidado con las dependencias de datos entre iteraciones. También resulta muy habitual traducir las expresiones lógicas (o booleanas) para que tenga que calcularse simplemente el valor de aquellos operandos necesarios poder evaluar la expresión, lo que se conoce como evaluación en corto circuito (véase capítulo T RADUCCIÓN DIRIGIDA POR LA SINTAXIS 87 1). La idea que subyace en esta optimización es la misma que en las sentencias condicionales que se han visto antes. Para facilitar la generación de código optimizado es conveniente modificar la gramática independiente de contexto para discriminar explícitamente los operadores lógicos del resto. De esa forma se pueden discriminar las expresiones lógicas del resto de expresiones del lenguaje, permitiendo optimizar las primeras mediante la generación de un código con evaluación en cortocircuito. Otra optimización de código típica es la traducción con precálculo de expresiones constantes. Por ejemplo, hay reglas de optimización para realizar el reemplazo de una constante por su valor o el reemplazo de variables con valor true y false por expresiones constantes de valor true y false. Una instrucción if-then-else, si tiene una expresión constante como condición, es equivalente a su bloque then si la condición es true, y a su bloque else si la condición es false. 2.3.6 Generación y optimización de código objeto La fase final de un compilador es la generación de código objeto, es decir, la creación de un programa ejecutable. Para ello, cada una de las instrucciones presentes en el código intermedio se debe traducir a una secuencia de instrucciones máquina, donde un aspecto decisivo es la asignación de variables a registros físicos del procesador. Tradicionalmente, la generación de código objeto se efectuaba de un modo muy ad-hoc, es decir, que no existían métodos formales generales para realizar esta tarea, de modo que este proceso resultaba particular para cada lenguaje y código objeto. Fue a partir de principios de los años 60, con la definición de los lenguajes formales, cuando surgió una técnica general para realizar la generación automática de código objeto, técnica basada en asociar a cada regla sintáctica una serie de reglas de traducción, lo que se conoce como “traducción dirigida por la sintaxis” (se verá en más detalle en el apartado 2.4). Por otro lado, también desde principios de los años sesenta se comenzaron a plantear técnicas de generación de código objeto mediante análisis sintáctico recursivo descendente, que también se verán en detalle en el siguiente apartado. 2.4 Traducción dirigida por la sintaxis El principio de traducción dirigida por la sintaxis establece que el significado de una construcción está directamente relacionado con su estructura sintáctica, que a su vez está representada en su árbol de análisis sintáctico. En este proceso de traducción dirigida por la sintaxis se asocia información a una construcción del lenguaje, es decir, a una producción; y se hace proporcionando atributos a los símbolos de la gramática que representan la construcción. Un atributo puede representar cualquier cosa: un valor, un tipo de dato, una cadena, una posición de memoria, etc. Los valores de los atributos se calculan mediante la aplicación de las reglas semánticas asociadas a las reglas gramaticales. Usando gramáticas 88 P ROCESADORES DE LENGUAJES atribuidas hay dos mecanismos para especificar la semántica y traducción de las construcciones del lenguaje, es decir, existen dos tipos de notaciones que permiten asociar reglas semánticas con reglas de producción. • Definiciones dirigidas por la sintaxis: generalización de una gramática independiente de contexto en la que cada símbolo gramatical tiene asociado un conjunto de atributos. En este caso, la traducción de una construcción se especifica en función de los atributos asociados con sus componentes sintácticos. A continuación, se muestra un ejemplo de definición dirigida por sintaxis: E → E1 + T E.code = E1 .code||T.code||′ +′ En este ejemplo, la regla semántica especifica que el código de E se formará concatenando los códigos de E1 (a la derecha) y T . Las || se usan para concatenar los valores de los atributos. • Esquemas de traducción: gramática independiente de contexto en la que se asocian atributos con los símbolos gramaticales y se insertan acciones semánticas encerradas entre llaves dentro de los consecuentes de las reglas de producción. Se trata, por tanto, de una notación orientada a procedimientos que se utiliza para especificar traducciones. A continuación se muestra un ejemplo de esquema de traducción: E → E1 + T {print E1 .code; print T.code; print ′ +′ } Entre otras características diferenciables entre ambos tipos de notaciones, se puede destacar que con las definiciones dirigidas por la sintaxis se ocultan muchos detalles de la implementación y no es necesario que el usuario especifique explícitamente el orden en el que tiene lugar la ejecución de las acciones. Sin embargo, con esquemas de traducción sí se indica el orden en el que tiene lugar la traducción, el orden en que se deben evaluar las reglas semánticas y, por tanto, en este caso algunos detalles de la implementación sí son visibles. En ambos casos se sigue el siguiente esquema. Primero se analiza sintácticamente la cadena de componentes léxicos de entrada y se construye el árbol de análisis sintáctico. El árbol se recorre para evaluar las reglas semánticas en sus nodos. Luego, esta evaluación de reglas semánticas puede dar lugar a generar código, guardar información en una tabla de símbolos, emitir mensajes de error, etc. Por tanto, la traducción de la cadena de componentes léxicos es el resultado obtenido tras evaluar las reglas semánticas. Es decir, que a partir de la cadena de entrada se genera el árbol de análisis sintáctico, un grafo de dependencias y se establece el orden de evaluación de las reglas semánticas. A continuación se verá este proceso con un poco más de detalle. T RADUCCIÓN DIRIGIDA POR LA SINTAXIS 89 2.4.1 Definiciones dirigidas por la sintaxis Las definiciones dirigidas por la sintaxis utilizan una gramática independiente de contexto para especificar la estructura sintáctica de la entrada, asociando a cada símbolo de la gramática un conjunto de atributos. A cada regla de la gramática se le asocia un conjunto de reglas semánticas que permiten calcular los valores de los atributos asociados con los símbolos de esa regla. Tanto la gramática como el conjunto de reglas semánticas constituyen la definición dirigida por la sintaxis. Se trata entonces de una transformación de una entrada. Por ejemplo, partiendo de una entrada X la salida se especificaría como sigue. Primero se construye un árbol sintáctico para la secuencia de tokens. Suponiendo que un nodo n del árbol está etiquetado con el símbolo X de la gramática, entonces escribimos X .a para indicar el valor del atributo a del símbolo X en ese nodo. Si por ejemplo se considera un nodo de un símbolo gramatical de un árbol sintáctico como un registro para guardar información, entonces un atributo se corresponde con el nombre de un campo. El valor de X .a en n se calcula aplicando la regla semántica asociada a la producción usada en el nodo. Finalmente, se obtiene un árbol de análisis sintáctico que muestra los valores de los atributos en cada nodo, y al cual se denomina árbol de análisis sintáctico con anotaciones. El proceso por el cual se calculan los valores de los atributos en los nodos se denomina anotar ( o decorar) el árbol de análisis sintáctico. El conjunto de atributos asociado a cada símbolo gramatical se divide en dos subconjuntos: atributos sintetizados y atributos heredados. • Los atributos sintetizados son aquellos que se pueden calcular durante un solo recorrido ascendente del árbol de análisis sintáctico, es decir, que con una definición con atributos sintetizados se puede decorar un árbol sintáctico mediante la evaluación de las reglas semánticas de forma ascendente, calculando los atributos desde los nodos hoja a la raíz. Por tanto, un atributo es sintetizado si su valor depende de los valores de los atributos de sus hijos. Más formalmente, un atributo a es sintetizado si, dada una producción A → X1 X2 . . . Xn , la única ecuación de atributos que tenemos es de la forma: A.a = f (X1 .a1 , . . . X1 .ak , . . . , Xn .a1 , . . . , Xn .ak ) Una definición dirigida por la sintaxis que use exclusivamente atributos sintetizados se denomina definición con atributos sintetizados, o gramática S-atribuida. • Un atributo heredado, por el contrario, es aquél cuyo valor en un nodo de un árbol de análisis sintáctico está definido a partir de los atributos de su padre y/o de sus hermanos. Más formalmente, un atributo a es heredado si, dadas unas producciones: 90 P ROCESADORES DE LENGUAJES B → AY A → X1 X2 . . . Xn la ecuación de atributos que tenemos es de la forma: A.a = f (Y, B) Los atributos heredados sirven para expresar la dependencia de una construcción de un lenguaje respecto del contexto en el que aparece. Por tanto, el valor de un atributo sintetizado se calcula a partir de los valores de los atributos de los hijos de ese nodo en el árbol de análisis sintáctico, mientras que el valor de un atributo heredado se calculará a partir de los valores de los atributos de los hermanos y padre del nodo. En una definición dirigida por la sintaxis se asume que los terminales solo tienen atributos sintetizados, ya que la definición no proporciona ninguna regla semántica para los terminales. Estos valores para los atributos de los terminales son proporcionados generalmente por el analizador léxico. Por otro lado, las reglas semánticas establecen las dependencias entre los atributos que se representan mediante un grafo. Si cierto atributo en un nodo depende de uno o varios atributos, entonces se deben evaluar primero las reglas semánticas para los atributos de los que depende, para después aplicar la regla semántica que define al atributo dependiente. Las interdependencias entre atributos heredados y sintetizados de un árbol de análisis sintáctico se pueden representar mediante un grafo dirigido llamado grafo de dependencias. A continuación se muestra un ejemplo. Un grafo de dependencias tiene un nodo por cada atributo y una arista que va desde el nodo a al nodo b si el atributo b depende del atributo a. Por ejemplo, si la regla A.a = f (X .x,Y.y) es una regla semántica asociada a la producción A → XY , entonces existe un subgrafo como el que se muestra en la Figura 2.12 (las líneas discontínuas representan el árbol sintáctico, mientras que las contínuas representan relaciones de dependencia). Los atributos se representan junto a los nodos del árbol sintáctico. Si la producción A → XY tiene asociada una regla semántica X .x = g(A.a,Y.y), entonces habrá una arista hacia X .x desde A.a y también desde Y.y, puesto que X .x depende tanto de A.a como de Y.y (véase Figura 2.13). El grafo de dependencias proporciona el orden de evaluación de las reglas semánticas, y la evaluación de las reglas semánticas define los valores de los atributos de los nodos del árbol. Una regla semántica puede tener también efectos colaterales, es decir, puede tener asociada acciones como imprimir un valor, actualizar una variable global, etc. Así, finalmente, se puede decir que una gramática con atributos es una definición dirigida por la sintaxis en la que las funciones de las reglas semánticas no pueden tener efectos colaterales. En la Figura 2.14 se muestra un ejemplo típico de definición dirigida T RADUCCIÓN DIRIGIDA POR LA SINTAXIS 91 Figura 2.12: Ejemplo de construcción de un subgrafo de dependencias para una regla del atributo sintetizado A.a = f (X.x,Y.y) asociada a una producción A → XY Figura 2.13: Ejemplo de construcción de un subgrafo de dependencias para una regla del atributo heredado X.x = g(A.a,Y.y) asociada a una producción A → XY 92 P ROCESADORES DE LENGUAJES Figura 2.14: Ejemplo de Definición Dirigida por la Sintaxis por la sintaxis para el caso de una calculadora. Hay que hacer notar que en este ejemplo, la primera producción sí tiene efectos colaterales. 2.4.2 Esquemas de traducción Respecto a los esquemas de traducción, como ya se ha adelantado, se trata de gramáticas independientes de contexto en las que se encuentran intercalados, en el lado derecho de las reglas de producción, fragmentos de programa que se conocen como acciones semánticas. Es como una definición dirigida por la sintaxis pero con la diferencia de que el orden de evaluación de las reglas semánticas ahora se muestra explícitamente. Los esquemas de traducción pueden tener tanto atributos sintetizados como heredados. A continuación, en la Tabla 2.2, se muestra un ejemplo de un esquema de traducción sencillo. El símbolo || indica concatenación de cadenas y llamamos trad al atributo que almacena la expresión traducida. E → T E ′ {E.trad = T.trad||E ′ .trad} E ′ → +T E1′ {E ′ .trad = T.trad||′′ +′′ ||E1′ .trad} E ′ → −T E1′ {E ′ .trad = T.trad||′′ −′′ ||E1′ .trad} E ′ → ε {E ′ .trad =′′ ′′ } T → num {T.trad = num.val} Tabla 2.2: Ejemplo de esquema de traducción que transforma expresiones infijas con suma y resta en las expresiones posfijas correspondientes T RADUCCIÓN DIRIGIDA POR LA SINTAXIS 93 Figura 2.15: Ejemplo de árbol sintáctico anotado a partir de un esquema de traducción En la Figura 2.15 se muestra el árbol sintáctico anotado correspondiente a la cadena de entrada 3 + 2 − 1 para su transformación de notación infija a posfija. 2.4.3 Métodos de análisis Como se ha visto a lo largo de este capítulo, el proceso consistente en reconocer la estructura sintáctica de un código fuente se conoce como análisis sintáctico (o parsing). Se trata de un análisis a nivel de sentencias y es, por tanto, más complejo que el análisis léxico. Se toma el contenido del código fuente en forma de secuencia de tokens y se trata de determinar la estructura de las sentencias del programa agrupándolas en clases sintácticas como expresiones, procedimientos, etc. (lo que hemos llamado no terminales en la definición de la gramática). Tras esta fase de análisis se obtiene un árbol sintáctico, o una estructura equivalente, en la cual las hojas representan los tokens, mientras que cualquier otro nodo que no sea una hoja representa un tipo de clase sintáctica. Por tanto, un árbol de análisis sintáctico indica cómo, a partir de una gramática, se deriva una sentencia del lenguaje. Es decir, que dada una gramática independiente del contexto, un árbol de análisis sintáctico es un árbol tal que: 1. La etiqueta con el símbolo inicial representa la raíz del árbol. 2. Cada hoja se etiqueta con un componente léxico y las hojas de izquierda a derecha forman la sentencia encontrada. 3. Cada nodo interior está etiquetado con un símbolo no terminal. 94 P ROCESADORES DE LENGUAJES Figura 2.16: Árbol de análisis sintáctico y árbol abstracto de análisis sintáctico de las expresiones id ∗ id e (id + id) ∗ id 4. Si A es un no terminal y X1 , X2 , . . . Xn son sus hijos de izquierda a derecha, entonces existe la producción A → X1 X2 . . . X n, con Xi ∈ (T ∪ N). El árbol de análisis sintáctico contiene generalmente más información de la estrictamente necesaria para generar el código. Por ello, se puede construir una estructura más sencilla, los llamados árboles abstractos de análisis sintáctico, que tienen igual semántica pero menor complejidad. En la Figura 2.16 se muestran dos ejemplos de árboles sintácticos y sus correspondientes árboles abstractos de análisis sintáctico. Existen distintas clases de analizadores o reconocedores sintácticos, pero en general se pueden clasificar en dos grandes grupos: los analizadores sintácticos ascendentes y descendentes [2], [15]. Los dos tipos de análisis se corresponden, respectivamente, con los tipos de recorrido de un árbol en preorden y postorden, y se verán en detalle más adelante. Atendiendo a cómo se procesa la cadena de entrada, los métodos de análisis se pueden clasificar también como métodos direccionales y métodos no-direccionales. Los direccionales procesan la cadena de entrada símbolo a símbolo de izquierda a derecha y los T RADUCCIÓN DIRIGIDA POR LA SINTAXIS 95 métodos no-direccionales acceden a cualquier lugar de la cadena de entrada para construir el árbol. En este último caso necesitan tener toda la cadena de componentes léxicos en memoria; resultan, por tanto, mucho más costosos y por eso no suelen implementarse. Si nos fijamos en el número de alternativas posibles en una derivación, los métodos de análisis se pueden clasificar como métodos deterministas, donde a partir de un símbolo de la cadena de entrada se puede decidir en cada paso cuál es la alternativa/derivación adecuada a aplicar. En este caso solo hay una derivacion posible, no se produce retroceso y el coste el lineal. Se consideran métodos no-deterministas cuando en cada paso de la construcción del árbol se prueban diferentes alternativas/derivaciones para ver cuál es la adecuada, con el correspondiente aumento de coste computacional. En el diseño de compiladores destacan especialmente los métodos direccionales y deterministas. Los deterministas por la necesidad de eficiencia, ya que supone un coste lineal frente al exponencial de los métodos no-deterministas, y los direccionales por la propia naturaleza secuencial en que se van procesando los símbolos del fichero fuente de izquierda a derecha. A continuación se decribe en más detalle el análisis sintáctico descendente. Análisis sintáctico descendente En el análisis sintáctico descendente se parte del símbolo inicial de la gramática, situada en la raíz del árbol sintáctico, y se va construyendo el árbol desde arriba hacia abajo y hasta las hojas, eligiendo en cada nodo la derivación que da lugar a una concordancia con la cadena de entrada. Se basa, por tanto, en la idea de predecir una derivación y establecer una concordancia con el símbolo de la entrada, lo que se conoce como proceso predict-match (predice-concuerda). Como ya se ha avanzado, el análisis sintáctico descendente se corresponde con un recorrido en preorden del árbol de análisis sintáctico; primero se expande el nodo que se visita y luego se procesan sus hijos de izquierda a derecha. Es como construir el árbol sintáctico empezando por la raíz, lo que se conoce como análisis LL(k) o top-down. A continuación, se muestra un ejemplo sencillo de análisis descendente. A partir de las siguientes producciones: S→aAb A→ab|c se quiere analizar la cadena acb. Para ello, primero se toma la primera producción (S → a A b), con lo que se obtiene el árbol: S a A b Se toma entonces la primera opción de la segunda producción (A → a b) y se aplica sobre al hoja A del árbol, con lo que se obtiene el árbol: 96 P ROCESADORES DE LENGUAJES S a A b a b Ahora la cadena acb se compara con la cadena formada por las hojas del árbol de izquierda a derecha. La primera hoja concuerda, por lo que se avanza a la siguiente hoja del árbol, etiquetada como a. Como no concuerda con acb, se detecta el error y se vuelve a A por si hubiera otra alternativa entre las producciones con A como antecedente. Se aplica entonces la otra alternativa en la producción (A → c), obteniéndose un árbol: S a A b c Y ahora sí coincide la cadena acb con las etiquetas de las hojas del árbol sintáctico, por lo que el análisis ha concluido de forma exitosa. En este caso hemos visto un ejemplo sencillo de método de análisis descendente con retroceso. Análisis LL(1) En este apartado se va a describir brevemente el método de análisis descendente LL(1) como ejemplo sencillo de técnica de análisis predictivo. El objetivo es crear un analizador descendente de orden lineal en complejidad. El analizador debe realizar la previsión de la regla de producción a partir del primer símbolo que produce; de este modo se asegura que la complejidad será lineal. Se dice que las gramáticas susceptibles de ser analizadas sintácticamente de forma descendiente mediante un análisis predictivo, y consultando únicamente un símbolo de entrada, pertenecen al grupo LL(1). Antes de nada, es necesario definir una serie de conceptos que nos ayudarán a entender este tipo de análisis sintáctico descendente. Los llamados conjuntos de predicción, formados por símbolos terminales, ayudan a predecir qué regla se debe aplicar para saber cuál es el no terminal que hay que derivar, y se construyen a partir de los símbolos de las partes derechas de las producciones de la gramática. Ésta es la base del método de análisis LL(1). A partir de un símbolo de la entrada, el analizador consulta el símbolo siguiente y, si pertenece al conjunto de predicción de una de las reglas, la aplica; si no, da error. Por ejemplo, a partir de las siguientes producciones: A → aBc | xC | B B → bA C→c T RADUCCIÓN DIRIGIDA POR LA SINTAXIS 97 Si se tiene como entrada la cadena babxcc y ya ha sido analizada la subcadena inicial bab, entonces tenemos que la secuencia de derivaciones ha sido la siguiente: A ⇒ B ⇒ bA ⇒ baBc ⇒ babAc Ahora habría que seguir desarrollando A usando los conjuntos de predicción. Como la siguiente letra en la cadena de entrada es una x se elige la segunda opción de la primera producción, es decir, A ⇒ xC. Si, por el contrario, tuviéramos una producción como: A → aBc | ac | B donde hay dos opciones que comienzan con la letra a, entonces no se cumplirían los requisitos para LL(1), por lo que el análisis no podría ser predictivo y la gramática no sería LL(1). Por tanto, estos conjuntos de predicción se calculan en función de los primeros símbolos terminales que puede generar la parte derecha de la regla. En el caso de que la parte derecha pueda generar la cadena vacía, entonces estos conjuntos se calculan a partir de los siguientes símbolos que aparecen en la parte izquierda de la regla. Para poder definir estos conjuntos de predicción hay que determinar dos conjuntos: el conjunto de PRIMEROS y el de SIGU IENTES. • Conjunto de primeros (PRIMEROS(α)): el conjunto de los primeros términos que genera una cadena de terminales y no terminales. Sea una gramática G = (T, N, S, P), el conjunto PRIMEROS(α) se aplica al conS junto de terminales y no terminales, α ∈ (T N)∗ . Si α es una sentencia compuesta por una concatenación de símbolos, PRIMEROS(α) es el conjunto de terminales (o cadenas vacías ε) que pueden aparecer iniciando las cadenas que pueden derivar de α. Más formalmente, a ∈ PRIMEROS(α) si a ∈ (T ∪ {ε}) y α ⇒ ∗aβ, con β ∈ (N ∪ T )∗ . Las reglas que cumple este conjunto son: 1. Si α coincide con ε, entonces PRIMEROS(ε) = {ε} 2. Si α ∈ (T ∪ N)+, α = a1 a2 . . . an′ , entonces (a) Si a1 ≡ a ∈ T ′ , entonces PRIMEROS(α) = {a} (b) Si a1 ≡ A ∈ N ′ , entonces es necasario obtener los PRIMEROS de todas las partes derechas de las producciones de A. S PRIMEROS(A) = ni=1 PRIMEROS(αi) donde αi ∈ P 98 P ROCESADORES DE LENGUAJES Si después de calcular PRIMEROS(A), α ∈ PRIMEROS(A) y A no es el último símbolo de α, entonces S PRIMEROS(α) = (PRIMEROS(A) − {ε}) PRIMEROS(a2 . . . an ) Tanto si A es el último símbolo de α como si ε ∈ / PRIMEROS(A), PRIMEROS(α) = PRIMEROS(A). • Conjunto de siguientes (SIGU IENT ES(A)): el conjunto de símbolos que pueden seguir a un no terminal en una forma sentencial. Si A es un símbolo no terminal de la gramática, SIGU IENTES(A) es el conjunto de terminales (y del símbolo $, que representa el final de la cadena de entrada) que pueden aparecer a continuación de A en alguna forma sentencial derivada del símbolo inicial. Se aplica al conjunto de no terminales (N) de la gramática. Más formalmente, a ∈ SIGU IENT ES(A) si a ∈ (T S { $ }) y ∃α, β tal que S ⇒ ∗αAaβ para algún par de cadenas α y β. Las reglas que cumple este conjunto son: 1. Inicialmente SIGU IENTES(A) = 0/ S 2. Si A es símbolo inicial, SIGU IENT ES(A) = SIGU IENT ES(A) { $ } 3. Para cada regla B → αAβ S SIGU IENTES(A) = SIGU IENTES(A) (PRIMEROS(β) − {ε}) 4. Para cada regla B → αA o B → αAβ en la que ε ∈ PRIMEROS(β) S SIGU IENTES(A) = SIGU IENTES(A) SIGU IENTES(B) 5. Se repiten los pasos 3 y 4 hasta que no se puedan añadir más símbolos al conjunto SIGU IENTES(A). A partir de estos conjuntos PRIMEROS y SIGU IENT E, se puede establecer la función PRED que establece los conjuntos de predicción que se aplican a producciones de la gramática (A → α), y devuelve un conjunto que puede contener cualquiera de los terminales de la gramática y el símbolo $, pero nunca puede contener el símbolo ε. Cuando se tiene que derivar un símbolo no terminal, se consulta el símbolo de entrada y se busca en los conjuntos de predicción de cada regla de ese no terminal. Si los conjuntos de predicción son disjuntos, el árbol sintáctico podrá construir una derivación por la izquierda de la cadena de entrada. Por ejemplo, si se quiere hallar el conjunto PRED(A → α), entonces si ε ∈ PRIMEROS(α), entonces PRED(A → α) = T RADUCCIÓN DIRIGIDA POR LA SINTAXIS 99 S = (PRIMEROS(α) − {ε} SIGU IENTES(A); de lo contrario es PRED(A → α) = PRIMEROS(α) En el apartado de ejercicios propuestos (véase apartado 2.6) se muestran algunos ejemplos del cálculo de conjuntos PRIMEROS, SIGU IENT ES y PRED. Como hemos visto, para poder aplicar un método de análisis LL(1) debemos tener una gramática LL(1), es decir, no puede haber ningún símbolo terminal que pertenezca a dos o más conjuntos de predicción de las reglas de un mismo no terminal. En el caso de tener una gramática que no cumpla con la condición LL(1) habrá que transformarla, y para ello habrá que eliminar la ambigüedad que pueda tener la gramática, así como factorizar y eliminar la recursividad por la izquierda, ya que una gramática LL(1) no puede ser ambigua, ni recursiva por la izquierda, ni tener símbolos comunes por la izquierda. No existe una metodología general para resolver el problema de la eliminación de la ambigüedad en una gramática, es decir, cuando tiene más de un árbol sintáctico posible. La solución es replantear la definición de la gramática para tratar de encontrar una alternativa que no sea ambigua y que genere el mismo lenguaje. Por ejemplo, si dos producciones alternativas de un mismo símbolo A empiezan igual, en principio no se sabrá cuál de ellas elegir. La solución en este caso será reescribir las producciones que producen la ambigüedad, de modo que se retrase lo más posible la decisión de elegir la producción en cada momento hasta haber visto lo suficiente de la entrada como para poder elegir correctamente. Para ello se realiza una factorización por la izquierda, consistente en encontrar el prefijo más largo común a dos o más producciones de A, pero siempre que sea común a más producciones. Si existe un prefijo común más corto en varias producciones y otro más largo en un par de ellas, hay que eliminar primero el más corto. Por ejemplo, si se tiene una producción: A → αβ1 |αβ2 | . . . | αβn |δi donde δi representa el conjunto de alternativas que no empiezan por α, entonces la solución sería hacer: A → αA′ | δi A ′ → β1 | β2 | . . . | βn Una gramática es recursiva por la izquierda si tiene alguna producción recursiva por la izquierda, o si a partir de una sentencia Aδ se obtiene una forma sentencial Aβδ en la que el no terminal A vuelve a ser el primer símbolo por la izquierda. Más formalmente, ∃A ∈ N tal que A ⇒ ∗Aα 100 P ROCESADORES DE LENGUAJES Figura 2.17: Modelo de análisis descendente predictivo dirigido por tabla La regla general para modificar una gramática y que deje de ser recursiva por la izquierda es la siguiente. Si se tiene una gramática: A → Aα1 |Aα2 | . . . | Aαm |β1 |β2 | . . . |βn se debe sustituir por: A → β1 A′ |β2 A′ | . . . | βn A′ (creando un nuevo no terminal A′ ) A′ → α1 A′ |α2 A′ | . . . | αm A′ |ε (producción recursiva por la derecha) A′ → ε Una vez vistos los conjuntos de predicción y la forma que tenemos de asegurar que una gramática sea LL(1), ahora ya podemos entender el análisis sintáctico descendente predictivo dirigido por tabla. Este tipo de análisis se toma como ejemplo de análisis LL(1), sigue el modelo de análisis sintáctico predictivo y requiere de la construcción de las llamadas tablas de análisis sintáctico. La construcción de este tipo de analizadores se basa en el uso de una pila de símbolos terminales y no terminales. A partir de un token que se toma como entrada, se buscará en la tabla de análisis. Para ello, primero es necesario construir la tabla y después realizar el proceso de análisis. En la Figura 2.17 se muestra un ejemplo, donde A representa el símbolo de la cima de la pila y a el símbolo inicial de la entrada a analizar. El programa de análisis inicialmente comprueba si A = a = $ (siendo $ el símbolo de fin de cadena), en cuyo caso se detendría el análisis y se anunciaría un fin de análisis exitoso. T RADUCCIÓN DIRIGIDA POR LA SINTAXIS 101 Si, por el contrario, A = a 6= $ (es decir, la cima de la pila coincide con el siguiente símbolo de la entrada, pero no con el símbolo de fin de cadena), entonces el programa analizador sacaría A de la cima de la pila y movería el puntero al siguiente símbolo de la misma. Si A 6= a 6= $ (es decir, la cima de la pila no coincide con el siguiente símbolo de la entrada ni con el símbolo de fin de cadena), entonces el programa consulta la entrada de la tabla correspondiente a los índices A y a, de modo que si encuentra una producción en la tabla, sustituye A por dicha producción, mientras que si encuentra una cadena Error llama a la rutina de recuperación de errores. Por tanto, es necesario crear la tabla de análisis sintáctico. Para ello, a partir de un conjunto de producciones, lo primero que se hace es etiquetar las filas de la tabla de análisis sintáctico con los no terminales, mientras que las columnas se etiquetan con los terminales y con el símbolo fin de cadena. En cada celda se copia la regla a aplicar para analizar la variable de esa fila cuando el terminal de esa columna aparezca en la entrada. Entonces se calculan los conjuntos de predicción para cada regla. Por ejemplo, para cada símbolo no terminal a ∈ PRED(A → α) se introduce la producción A → α en la posición [A, a] de la tabla. Cada entrada sin valor representa un error sintáctico. A continuación se muestra un ejemplo de cómo calcular la tabla de análisis de una gramática. Si tenemos la siguiente gramática: E → T E′ E ′ → +T E ′ | − T E ′ | ε T → FT ′ T ′ → ∗FT ′ | /FT ′ | ε F → num | (E) Calculamos los siguientes conjuntos de predicción para poder obtener la tabla de análisis sintáctico que se muestra en la Tabla 2.3: PRED(E → T E ′ ) = PRIM(T ) = PRIM(F) = {num, (} PRED(E ′ → +T E ′ ) = {+} PRED(E ′ → −T E ′ ) = {−} PRED(E ′ → ε) = SIG(E ′ ) = SIG(E) = {), $ } PRED(T → FT ′ ) = PRIM(F) = {(, num} PRED(T ′ → ∗FT ′ ) = {∗} PRED(T ′ → /FT ′ ) = {/} PRED(T ′ → ε) = {+, −, ), $ } PRED(F → (E)) = {(} PRED(F → num) = {num} De este modo, si se quisiera analizar sintácticamete, por ejemplo, la cadena 5 + 7, se empezaría tomando el 5 como primer símbolo de la cadena de entrada. Inicialmente la pila está vacía ($). Se toma entonces la producción correspondiente al símbolo inicial 102 P ROCESADORES DE LENGUAJES (en este caso E → T E ′ ) y se mete en la pila. Se toma entonces el primer símbolo no terminal que tenemos en la pila (T ) y se ve la correspondencia que tiene con num en la tabla de análisis sintáctico. Esta regla (T → FT ′ ) se mete en la pila, por lo que ahora se tiene FT ′ E ′ . Seguimos con 5. Se toma entonces F como primer símbolo no terminal de la pila y, de nuevo, se busca la correspondencia con num (en este caso: F → num). Se apila de nuevo, de modo que ahora se tiene numT ′ E ′ en la pila. Como num ahora coincide con la entrada (5), entonces se produce un desplazamiento y se toma el siguiente símbolo de la entrada (+). El siguiente no terminal en la pila es T ′ , por lo que se busca la correspondencia entre + y T ′ en la tabla de análisis sintáctico (T ′ → ε). Se quita entonces T ′ al tratarse de una producción que genera la cadena vacía. Ahora en la pila ya se está en el símbolo E ′ con un + como símbolo de la cadena de entrada. Se busca entonces la correspondencia entre E ′ y + en la tabla (en este caso: E ′ → +T E ′ ). Se introduce de nuevo en la pila, por lo que ahora se tiene +T E ′ . Como coincide el símbolo + en la pila y en la entrada, se quitan los + y se toma el siguiente símbolo de la cadena de entrada (7), quedando en la pila T E ′ . Se busca la correspondencia entre T y num (que es T → FT ′ ) y se sustituye, por lo que ahora se tiene FT ′ E en la pila. Se toma F y se busca la correspondencia con num, ya que seguimos con 7 como símbolo de la cadena de entrada, y se obtiene F → num. Se apila y, como ahora num coincide con la entrada (7), se realiza otro desplazamiento. En la pila ahora tenemos T ′ E ′ . Entonces, se aplican sucesivamente las producciones T ′ → ε y luego E ′ → ε y se llega a la cadena vacía en la pila ($), con lo que la fase de análisis termina de forma exitosa. Análisis sintáctico ascendente En el análisis sintáctico ascendente se construye el árbol de análisis sintáctico desde las hojas hasta la raíz. En las hojas del árbol siempre está la cadena a analizar, una secuencia de tokens que se intenta reducir al símbolo inicial de la gramática que está en la raíz. Se trata, por tanto, de ir desplazándose en la cadena de entrada tratando de encontrar una subcadena para aplicar una reducción, o regla de producción a la inversa, lo que conoce como proceso shift-reduce (desplazar-reducir). El análisis sintáctico ascendente se corresponde con un recorrido en postorden del árbol; primero se reconocen los hijos y luego, mediante una reducción, se reconoce el padre. Es como si se construyera el árbol sintáctico empezando por las hojas y que se conoce como análisis LR(k) o bottom-up. Los metodos de análisis sintáctico LR permiten reconocer la mayoría de las construcciones de los lenguajes de programación y son métodos con mayor poder de procesamiento que los LL. Hay que hacer notar que las gramáticas LL representan un subconjunto de las LR. − Error E ′ → −T E ′ Error T′ → ε Error ∗ Error Error Error T ′ → ∗FT ′ Error / Error Error Error T ′ → /T F ′ Error ( E → T E′ Error T → FT ′ Error F → (E) Tabla 2.3: Ejemplo de tabla de análisis sintáctico ) Error E′ → ε Error T′ → ε Error $ Error E′ → ε Error T′ → ε Error DIRIGIDA POR LA SINTAXIS + Error E ′ → +T E ′ Error T′ → ε Error T RADUCCIÓN E E′ T T′ F num E → T E′ Error T → FT ′ Error F → num 103 104 P ROCESADORES Pila 1: $ 2: $a 3: $ab 4: $A 5: $Aa 6: $Aab 7: $AA 8: $AAa 9: $Aab 10: $Aaba 11: $AB 12: $S Entrada ababa baba aba aba ba a a $ a $ $ $ DE LENGUAJES Salida Desplazar Desplazar Reducir a A → ab Desplazar Desplazar Reducir a A → ab Desplazar Retroceso a $Aab Desplazar Reducir a B → aba Reducir a S → AB Aceptar Tabla 2.4: Ejemplo de pila en análisis ascendente con retroceso A continuación, se muestra un ejemplo sencillo de análisis ascendente. A partir de las siguientes producciones: S → AB A → ab B → aba se quiere analizar la entrada ababa representada en el siguiente árbol sintáctico: S A B a b a b a En este caso, durante el análisis se van leyendo los tokens de entrada de uno en uno, y se utiliza una pila para almacenar los símbolos que se van reconociendo y los estados por los que pasa el analizador. Los símbolos terminales se introducen en la pila mediante desplazamientos de la cadena de entrada a la pila, mientras que los no terminales se apilan como resultado del proceso de reducción. Las modificaciones en la pila durante el proceso de análisis de la cadena del ejemplo anterior pueden verse en la Tabla 2.4. Como se indica en la propia tabla, en caso de que una determinada reducción no lleve a ningún símbolo no terminal de las producciones de la gramática, se debe realizar un retroceso para volver a la situación anterior a la última reducción llevada a cabo, y para ello se emplea la pila. En el ejemplo, una vez se introduce en la pila la cadena AAa, se comprueba que no es posible alcanzar ningún no terminal de la gramática, por lo que se desapila hasta Aab y se toma otra opción, en este caso, se realiza un nuevo desplazamiento. T RADUCCIÓN DIRIGIDA POR LA SINTAXIS 105 Figura 2.18: Modelo de análisis ascendente En la Figura 2.18 se muestra gráficamente el modo en el que opera un método de análisis ascendente. El programa conductor lee tokens de la cadena de entrada de uno en uno, y utiliza una pila para almacenar los símbolos que va reconociendo y los estados por los que pasa el analizador. La pila contiene, por tanto, símbolos terminales y no terminales S (Xi ∈ T N), mientras que la cadena a analizar, o entrada, contiene solo símbolos terminales (ai ∈ T ). El contenido de la pila es, en realidad, de la forma s0 X1 s1 X2 s2 . . . sm Xm , donde cada Xi representa los símbolos de la gramática que se van reconociendo y si los estados por los que pasa el analizador. En la Tabla 2.4 no se han incluido los estados en la pila por simplicidad. Los símbolos terminales se introducen en la pila mediante desplazamientos de la cadena de entrada a la pila, y los no terminales se apilan como resultado de hacer una reducción. Se define asidero, o mango, al conjunto de símbolos terminales y no terminales en la cima de la pila que forman la parte derecha de una producción y que se pueden reducir sacándolos de la pila y sustituyéndolos por el no terminal de la parte izquierda de la producción. Por ejemplo, en la línea 3 de la Tabla 2.4, la secuencia de símbolos ab que se encuentra en la cima de la pila es un asidero de la producción A → ab. La tabla de análisis sintáctico, en este caso, está formada por dos partes. La primera es de la forma accion[Sm , ai ] = {d, r, error, aceptar} e indica la acción que debe realizarse si encontramos un token ai en la entrada y el analizador está en el estado sm . Las posibles acciones son: “desplazar” (d), “retroceder” (r), “error” y “aceptar”. La segunda parte, ir_a[Sm , ai ] = Sn , indica el estado al que hay que ir tras hacer una reducción. Es 106 P ROCESADORES DE LENGUAJES decir, que en todo análisis ascendente se necesita un mecanismo que determine el tipo de acción a realizar (desplazar o reducir) y, en el caso de que se deba reducir, nos debe proporcionar la subcadena de símbolos a reducir (el asidero) y qué producción utilizar. Este mecanismo lo proporcionan los autómatas a pila deterministas. Fundamentalmente existen tres tipos muy comunes de analizadores LR por desplazamiento - reducción: SLR(k), LALR(k) y LR(k), donde k identifica el número de símbolos de preanálisis utilizados. Y dentro de estos tipos de análisis ascendente, se distinguen principalmente tres técnicas a la hora de construir una tabla de análisisis sintáctico LR para una gramática: • Método LR(k). Representa el método más potente y costoso de implementar de los tres. Como características principales: lee la entrada de izquierda a derecha, aplica derivaciones por la derecha para cada entrada y utiliza k componentes léxicos de búsqueda hacia adelante. • Método SLR(k) (Simple LR). En este caso se trata de un LR(k) más sencillo de implementar, aunque también es un método con menor potencia de análisis. Este método no es capaz de asimilar ciertas gramáticas que los otros métodos sí se puenen tratan, gramáticas que aún sin ser ambiguas pueden producir resultados ambiguos en la tabla de análisis sintáctico. • Método LALR(k) (Look Ahead LR(k)). Este método supone una simplificación del método LR(k) que combina la eficiencia de los métodos SLR, en cuanto a tamaño del autómata a pila, con la potencia del método LR(k) canónico. En definitiva supone una solución de compromiso entre los métodos LR(k) y SLR(k), obteniéndose tablas de análisis sintáctico más compactas que en el caso del método LR(k). En el siguiente subapartado se describirá más en detalle un ejemplo de análisis SLR(1). Análisis SLR(1) Este tipo de análisis usa un autómata finito determinista construído a partir de elementos LR(0) y usa el token de la cadena de entrada para determinar el tipo de acción a realizar. Un elemento de análisis sintáctico LR(0) de una gramática es una producción con un punto en alguna posición del lado derecho. Por ejemplo: A → X •Y siendo la producción: A → XY T RADUCCIÓN DIRIGIDA POR LA SINTAXIS 107 El punto indica la parte que se ha analizado hasta ese momento y que, por tanto, se encuentra en la parte alta de la pila. Más formalmente, siendo S el estado de la cima de la pila, el algoritmo de análisis sintáctico SLR(1) realizaría los siguientes pasos: • Si el estado S contiene cualquier item de la forma A → α • X β con X ∈ T , y X es el siguiente terminal de la entrada, entonces la acción a realizar es “desplazar” X a la pila. Como nuevo estado al que se pasa, se apila aquel que contenga un item de la forma A → αX • β • Si el estado S contiene un item de la forma A → α• y el siguiente token en la entrada está en el conjunto SIGU IENTES(A), entonces lo que hay que hacer es reducir aplicando la producción A → α. Se saca de la pila un número de elementos doble a la longitud de la parte derecha de la producción, y el estado que queda al descubierto debe contener un item de la forma B → γ • Aσ. Se introduce el no terminal A en la pila y como nuevo estado aquel que contenga el item de la forma B → γA • σ • Si el símbolo en la entrada es tal que no se puede realizar ninguna de las acciones anteriores, enconces se devuelve “error”. Se dice que una gramática es SLR(1) si la aplicación de las reglas anteriores no es ambigua; es decir, si cumple las siguientes condiciones: • Para cada item de la forma A → α • X β en S con X ∈ T , no existe un item completo B → γ• en S, con X ∈ SIGU IENTES(B). En caso contrario tendríamos un conflicto desplaza-reduce. • Para cada par de items completos A → α• y B → β• en S , entonces SIGU IENT ES / En caso contrario tendríamos un conflicto desplaza(A) ∩ SIGU IENT ES(B) = 0. reduce. Al tratar con la tabla de análisis sintáctico hay que tener en cuenta que un estado puede admitir desplazamientos o reducciones, por lo que cada entrada deberá tener una etiqueta de desplazamiento o reducción. A continuación se muestra un ejemplo. Sea una gramática: r0 : S → A r1 : A → A + n r2 : A → n 108 P ROCESADORES DE LENGUAJES Figura 2.19: Autómata determinista correspondiente a la gramática S → A + n|n Pila $0 $0n2 $0A1 $0A1+3 $0A1+3n4 $0A1 $0A1+3 $0A1+3n4 $0A1 $0S Entrada n + n + n$ +n + n$ +n + n$ n + n$ +n$ +n$ n$ $ $ $ Acción Desplazar d2 Reducir r2 (A → n) Desplazar d3 Desplazar d4 Reducir r1 (A → A + n) Desplazar d3 Desplazar d4 Reducir r1 (A → A + n) Reducir r0 (S → A) Aceptar Tabla 2.5: Ejemplo de tabla de análisis sintáctico correspondiente al autómata finito determinista de la Figura 2.19 El autómata finito determinista de elementos LR(0) se muestra en la Figura 2.19. Según el análisis SLR, se comprueba que no hay conflicto desplaza-reduce en el estado I1 , ya que {+} ∈ / SIGU IENTES(S) = {$}. Si ahora consideramos la entrada n + n + n$, la pila, la entrada y la acción a realizar en cada paso del análisis se muestra en la Tabla 2.5. Como hemos visto, el análisis SLR(1) es bastante simple y eficiente; además, es capaz de describir prácticamente todas las estructuras de los lenguajes de programación. Sin embargo, existen algunas situaciones donde se hace necesario un análisis más complejo como es el análisis LR(1). En definitiva, la diferencia entre los diferentes tipos de analizadores sintácticos estriba fundamentalmente en cómo se construyen las tablas de análisis sintáctico y en el tipo de gramáticas (lenguajes) que admiten. T RADUCCIÓN DIRIGIDA POR LA SINTAXIS 109 2.4.4 Herramientas para la construcción de compiladores Existen diferentes tipos de herramientas que facilitan la tarea de crear compiladores o intérpretes de lenguajes de programación. Entre éstas se pueden encontrar desde generadores de analizadores léxicos y sintácticos, hasta sistemas detectores de gramáticas. A continuación se repasan algunos de estos tipos de herramientas, aportando una breve descripción de su funcionalidad. • En general, un generador de analizadores es un programa que toma como entrada la especificación de las características de un determinado lenguaje y produce como salida un analizador específico para dicho lenguaje. La especificación de la entrada puede estar referida a la especificación léxica, sintáctica o semántica de un lenguaje. De este modo, el analizador que se genere será capaz de analizar las características que se le hayan especificado. • Los generadores de analizadores sintácticos producen analizadores sintácticos partiendo de gramáticas independientes del contexto. Algunos ejemplos de este tipo de generadores los podemos encontrar en las herramientas Yacc (Yet Another Compiler-Compiler), bison o JavaCC. • Los generadores de analizadores léxicos permiten crear automáticamente analizadores léxicos a partir de una especificación basada en expresiones regulares. Algunos ejemplos de este tipo de generadores son las herramientas Lex, Flex o JFlex. • Los generadores de árboles sintácticos proporcionan también funcionalidades para la creación de árboles sintácticos. Éste es el caso de las herramientas JJTree o JTB. Existen también herramientas que asisten al programador en la fase de traducción dirigida por la sintaxis, que producen grupos de rutinas que recorren el árbol de análisis sintáctico generando código intermedio. • Los generadores automáticos de código también forman parte de este conjunto de herramientas, ya que parten de un conjunto de reglas que definen la traducción de cada operación del lenguaje intermedio al lenguaje código objeto. Pero entre todo este tipo de herramientas destacan, como las más populares, los generadores de analizadores léxicos y sintácticos, especialmente las herramientas Lex y Yacc, que se verán en más detalle a continuación. Llegados a este punto, hay que tener cuidado con la nomenclatura, ya que cuando se emplea el término lex, realmente se están considerando dos posibles significados: 1. Una notación para especificar las características léxicas de un lenguaje de programación; y 110 P ROCESADORES DE LENGUAJES 2. La herramienta traductora de especificaciones léxicas. Esto mismo sucede en relación al término yacc. La herramienta Lex es un generador de programas que realizan el análisis léxico de cadenas de caracteres. Su objetivo es leer un flujo de entrada de datos y dividirlo en un conjunto de unidades léxicas mínimas, los tokens, que puede constituir en sí mismo la salida del sistema, o simplemente formar parte de la entrada para un programa de ejecución posterior como pudiera ser un analizador sintáctico. Como salida de Lex se genera un programa en lenguaje C capaz de reconocer aquellas cadenas que se ajustan a las expresiones regulares especificadas en la gramática. Además, se proporciona la posibilidad de que el usuario pueda insertar declaraciones o sentencias adicionales en la función que contiene las acciones, así como añadir funciones fuera de esta función de acción. De este modo, con Lex es posible generar un autómata finito a partir de las expresiones regulares de la especificación léxica. Respecto a Yacc, sus siglas significan “Otro generador de compiladores más”. Se trata de una herramienta que genera un analizador sintáctico en el Lenguaje C a partir de una gramática libre de contexto. Junto con Lex permite construir rápidamente las primeras etapas de un traductor. Tanto Lex como Yacc son herramientas que fueron desarrolladas junto con el sistema operativo UNIX, pero es posible encontrar implementaciones en otras plataformas, a la vez que existen versiones software libre bajo los nombres de Flex y Bison. La estructura de todo archivo de Lex se comparte con la estructura de los archivos Yacc y se compone de tres secciones separadas por líneas que contienen únicamente dos símbolos “%”. Estas secciones presentes en todo código Lex y Yacc son las siguientes: 1 2 3 4 5 Declaraciones %% Reglas de producción %% Código adicional En la sección de declaraciones se definen las variables que el analizador empleará y permite definir una serie de directivas de Yacc para establecer: • Los símbolos terminales que empleará la gramática. • El símbolo inicial de la misma. • Los diferentes atributos que cada uno de los símbolos de la gramática podría tener. • El atributo que tiene cada símbolo. En esta sección se pueden encontrar especificaciones escritas en el lenguaje destino y deben estar definidas entre los caracteres “% {” y “% }”. Es posible, por ejemplo, T RADUCCIÓN DIRIGIDA POR LA SINTAXIS 111 escribir cualquier código de C en esta sección, y luego será copiado en el archivo fuente generado. Permite también importar archivos de cabecera escritos en C. Pueden encontrase entonces declaraciones de token si se ha usado la palabra clave %token, o puede declararse el tipo de terminal por medio de la palabra reservada %union. En esta sección también se puede incluir información sobre las precedencias de los operadores y su asociatividad. Para especificar el símbolo inicial de la gramática se emplea la palabra clave %start, y en caso de no especificarse ninguno, se tomará por defecto la primera regla de la siguiente sección: las reglas de producción. La sección de reglas de producción es la única parte obligatoria en este tipo de archivos si sirve de entrada para Yacc, es decir, que siempre debe aparecer en la entrada a un programa Yacc. Puede contener desde declaraciones y/o definiciones encerradas entre los caracteres %{ y %}, hasta reglas de producción de la gramática. Esta sección de reglas, en la que se asocian patrones a sentencias de C, es la sección más importante. Los patrones son expresiones regulares y cuando se encuentra un texto en la entrada que encaja con un patrón dado, entonces se ejecuta el código C asociado. En la última sección se puede incluir código adicional. Contiene sentencias en C y funciones que serán copiadas en el archivo fuente generado. Es bastante común que esta parte del código contenga un método, por ejemplo, main() desde donde se pueda llamar a otras funciones como, por ejemplo, funciones que manejen errores sintácticos, etc. Un analizador gramatical construido en Yacc genera una función que realizará el análisis gramatical con el nombre de yyparse(); esta función solicita un token al analizador léxico por medio de la función yylex(). A continuación se muestra un ejemplo de un código yacc para la creación de un programa calculadora con los operadores básicos: 1 2 3 4 %{ #include < math .h > %} 5 6 %union{ double dval ; 7 8 } 9 10 11 12 13 % token % token % token % token <dval > NUMBER PLUS MINUS TIMES LEFT_PARENTHESIS END % left % left % left PLUS TIMES NEG 14 15 16 17 18 MINUS DIVIDE DIVIDE POWER RIGHT_PARENTHESIS 112 19 P ROCESADORES % right DE LENGUAJES POWER 20 21 22 % type <dval > Expression % start Input 23 24 %% 25 26 Input : Line | Input Line ; Line : END | Expression END { printf (" Result : %f\n" ,$1 ); } ; 27 28 29 30 31 32 33 34 Expression : NUMBER { $$ = $1 ; } 35 42 | | | | | | | 43 ; 36 37 38 39 40 41 Expression PLUS Expression { $$ = $1 + $3 ; } Expression MINUS Expression { $$ =$1 - $3 ; } Expression TIMES Expression { $$ = $1 * $3 ; } Expression DIVIDE Expression { $$ = $1 / $3 ; } MINUS Expression % prec NEG { $$ =- $2 ; } Expression POWER Expression { $$ = pow ($1 , $3 ); } LEFT_PARENTHESIS Expression RIGHT_PARENTHESIS { $$ = $2 ; } 44 45 46 47 48 %% int yyerror (char *s) { printf ("%s\n" ,s); } 49 50 51 52 int main (void) { yyparse () ; } En el código anterior pueden verse las secciones anteriormente descritas. Así, se pueden observar: • Declaraciones: – En la línea 3 se puede encontrar la cabecera de la librería estándar del lenguaje de programación C diseñado para operaciones matemáticas básicas. – Con %union se establece que, por defecto, los valores devueltos por el analizador léxico son de tipo double (líneas 6-8). T RADUCCIÓN DIRIGIDA POR LA SINTAXIS 113 – Se definen los tokens que deben ser usados por el analizador por medio de la directiva %token (líneas 10-13). – Se definen los operadores y su precedencia con %le f t y %right (líneas 1619). – La directiva %type define el tipo de dato para símbolos no terminales de nuestra gramática (línea 21). – %start establece el símbolo inicial de la gramática; en este caso, Input (línea 22). • Reglas de producción, donde se define el conjunto de reglas que analizan la cadena de entrada (a partir de la línea 24 hasta la 44). • Código adicional (a partir de la línea 45). Contiene el código de las funciones yyerror() (que devuelve los errores sintácticos) y main() (programa que llama a la función yyparse() cuando se inicia el análisis). A continuación se muestra un ejemplo de código lex en el que se especifican las reglas para generar los tokens de entrada: 1 2 3 4 5 %{ #include "y. tab .h" #include < stdlib .h > #include < stdio .h > %} 6 7 8 9 white digit integer [ \t ]+ [0 -9] { digit }+ 10 11 %% 12 13 14 15 16 17 18 { white } { /* Ignoramos espacios en blanco */ } " exit "|" quit "|" bye " { printf (" Terminando programa \n"); exit (0) ;} { integer } { yylval . dval = atof ( yytext ); return( NUMBER ); } 19 20 21 22 23 24 25 "+" " -" "*" "/" "^" "(" return( PLUS ); return( MINUS ); return( TIMES ); return( DIVIDE ); return( POWER ); return( LEFT_PARENTHESIS ); 114 26 27 ")" "\n" P ROCESADORES DE LENGUAJES return( RIGHT_PARENTHESIS ); return( END ); 28 29 %% En la sección de definiciones de este archivo se incluye, tanto la librería estándar de entrada/salida stdio.h y de propósito general stdlib.h, como el archivo y.tab.h que contiene algunas definiciones de yacc que va a necesitar lex (por ejemplo, las etiquetas de los tokens como PLUS, MINUS, NUMBER). Estas constantes son los valores que yylex() devolverá a yyparse() para identificar el tipo de token que acaba de ser leído. En la sección de reglas (a partir de la línea 11) podemos ver cómo, al final de cada regla, se realiza un return especificando la etiqueta que fue declarada como %token o como %le f t y %rigth en la especificación yacc. 2.5 Ejercicios resueltos 1. Crea expresiones regulares para la detección de: (a) Cualquier cadena de 2 o 4 dígitos. (b) Cualquier cadena que represente un año entre 1900 y 2099. (c) Cualquier secuencia de caracteres que represente una fecha con formato 04/07/2011. (d) Direcciones IP en formato numérico (por ejemplo, 127.0.0.1). Solución: (a) [0 − 9][0 − 9]|[0 − 9]{4} (b) (19 | 20)[0 − 9][0 − 9] (c) (0?[1 − 9] | [12][0 − 9] | 3[01])\/(0?[1 − 9] | 1[012])\/[0 − 9]{2}/. Nótese que es necesario escapar la barra /. (d) Las direcciones IP en formato numérico: • BY T E : ([0−9] | [0−9][0−9] | [0−1][0−9][0−9] | 2[0−4][0−9] | 25[0− 5]) • BY T E.BY T E.BY T E.BY T E 2. A partir de la siguiente gramática: S → SbS | ScS | a Construye dos derivaciones por la izquierda (donde solo el no terminal más a la izquierda se sustituye en cada paso) para la cadena abaca. Solución: E JERCICIOS 115 RESUELTOS • S ⇒ ScS ⇒ SbScS ⇒ abScS ⇒ abacS ⇒ abaca • S ⇒ SbS ⇒ abS ⇒ abScS ⇒ abacS ⇒ abaca 3. A partir de la siguiente gramática: A → A+B A → A−B A→B B →C∗B B → C/B B→C C → (A) C→n ¿Cómo sería el árbol de derivación asociado a la expresión 8 − 3 − 4 / 2 / 2? Solución: A A A - B B C C n n 3 B C B → CB′ B′ → ∗CB′ | ε C → (A) | ident Calcula el conjunto de PRIMEROS. Solución: / B C 4 n C 2 n 4. A partir del siguiente conjunto de producciones: A′ → +BA′ | ε B n 8 A → BA′ / 2 116 P ROCESADORES DE LENGUAJES • PRIMEROS(A) = PRIMEROS(BA′) • PRIMEROS(A) = PRIMEROS(B) • PRIMEROS(B) = PRIMEROS(C) • PRIMEROS(C) = {(, ident} Hasta aquí ya sabemos que: PRIMEROS(A) = PRIMEROS(B) = PRIMEROS(C) = {(, ident} • PRIMEROS(A′) = {+, ε} • PRIMEROS(B′) = {∗, ε} Resultado: PRIMEROS(A) = {(, ident} PRIMEROS(A′) = {+, ε} PRIMEROS(B) = {(, ident} PRIMEROS(B′) = {∗, ε} PRIMEROS(C) = {(, ident} 5. A partir de la gramática: A → BA′ A′ → +BA′ | ε B → CB′ B′ → ∗CB′ | ε C → (A) | ident Calcula el conjunto de SIGU IENTES. Solución: a partir del conjunto de PRIMEROS calculados anteriormente, PRIMEROS(A) = {(, ident} PRIMEROS(A′) = {+, ε} PRIMEROS(B) = {(, ident} PRIMEROS(B′) = {∗, ε} PRIMEROS(C) = {(, ident} Se calcula: • SIGU IENTES(A) = { $ } E JERCICIOS 117 RESUELTOS S • SIGU IENTES(A) = SIGU IENTES(A) {)} = { $ , )} • SIGU IENTES(A′ ) = SIGU IENT ES(A) = { $ , )} S • SIGU IENTES(B) = PRIMEROS(A′) SIGU IENTES(A) = {+, $ , )} • SIGU IENTES(B′ ) = SIGU IENT ES(B) = {+, $ , )} S S • SIGU IENTES(C) = PRIMEROS(B′) SIGU IENT ES(B) SIGU IENT ES(B′ ) = {∗, +, $ , )} Resultado: SIGU IENTES(A) = { $ , )} SIGU IENTES(A′ ) = { $ , )} SIGU IENTES(B) = {+, $ , )} SIGU IENTES(B′ ) = {+, $ , )} SIGU IENTES(C) = {∗, +, $ , )} 6. A partir de la siguiente gramática, escribe una definición dirigida por la sintaxis que permita traducir declaraciones de variables de C a PASCAL. S → TV V → idV V ′ → [num]V1′ V →ε T → int T → f loat T → char Solución: S → TV ; V → idV ′ ; V ′ → [num]V1′ ; V′ → ε T → int T → f loat T → char S.trad =′ var′ ||V.trad||T.trad||′ ;′ V.trad = id.lexema||′ :′ ||V ′ .trad V ′ .trad =′ array[0..′ ||num.val − 1||′ ]o f ′ ||V1′ .trad V ′ .trad = ’ ’ T.trad =′ integer′ T.trad =′ f loat ′ T.trad =′ char′ Tabla 2.6: Listado de alumnos 118 P ROCESADORES DE LENGUAJES Figura 2.20: Árbol sintáctico anotado solución al ejercicio 7 7. A partir de la solución del Ejercicio 6, se debe crear el árbol sintáctico anotado para la entrada int c[10]; Solución: Figura 2.20. 8. Sea una gramática: A → abB A → Bb B→b B→c Contesta a las siguientes preguntas: (a) ¿Es LL(1)? (b) ¿Y si se añade la regla B ⇒ a? Solución: (a) Sí, porque se cumple que: T T • PRED(A → abB) PRED(A → Bb) = {a} {b, c} = 0/ T T • PRED(B → b) PRED(B → c) = {b} {c} = 0/ T T (b) No, porque PRED(A → abB) PRED(A → Bb) = {a} {a, b, c} 6= 0/ E JERCICIOS PROPUESTOS 119 2.6 Ejercicios propuestos 1. Muestra algunas de las expresiones regulares que se pueden definir a partir de los siguientes alfabetos: • σ = {0, 1} • σ = {a, b, c} • σ = {a, b, c, 0, 1} • σ = {int, f loat, i f , else, while} 2. A partir del siguiente código: 1 2 3 4 5 6 7 8 9 if (a > (( b *3) / 4) ) { a := (b * 4) + 1000.0; } else if (a = (( b *3.4) /4) ) { b := 100; } else { a := 100.0; } Se pide: (a) Escribir la estructura de los tokens que debe reconocer un analizador léxico. (b) Determinar los patrones léxicos (expresiones regulares) de cada uno de los tokens. 3. Dada la siguiente gramática: S → aSbS S → bSaS S→ε Demuestra que es ambigua para la frase abab. 4. Dada la siguiente gramática: bexp → bexp or bterm | bterm bterm → bterm and b f actor | b f actor b f actor → not b f actor j(bexp) | true | f alse Construye el árbol de análisis sintáctico para la sentencia not (true or f alse). ¿Es una gramática ambigua? 120 P ROCESADORES DE LENGUAJES 5. A partir de la siguiente gramática que genera expresiones aritméticas con notación prefija: A → (BC) B → +| − | ∗ |/ C → D|DC D → identi f icador|numero|A • Indica cuáles son los símbolos terminales y cuáles los no terminales. • Indica cuál es el símbolo inicial. • ¿Es una gramática recursiva por la derecha o por la izquierda? 6. Construye una gramática independiente de contexto para cubrir las siguientes construcciones de un lenguaje de alto nivel: • if • if-else Comprueba si la gramática es ambigua. 7. Calcula el conjunto de PRIMEROS y SIGU IENT ES de la gramática: A → Aa | BCD B→b|ε C→c|ε D → d | Ce 8. ¿Cumple la siguiente gramática la condición LL(1)? A → A + B |B B → B ∗C |C C → num |(A) 2.7 Notas bibliográficas Para la elaboración de este capítulo se han consultado diferentes fuentes. Las principales referencias bibliográficas han sido los libros [2] y [7], aunque también se ha consultado información de [9], [16], [6] y [15]. Capítulo 3 Paradigmas y modelos de programación En este tema se introducen los cinco paradigmas más importantes desde el punto de vista de los lenguajes de programación. Concretamente, se explican las bases de los paradigmas imperativo, funcional, lógico, orientado a objetos y concurrente. Además, a estos se añaden los lenguajes dinámicos (o de script), que aun no conformando un paradigma en sí mismos, por su popularidad se han incluido en este tema. 3.1 Introducción Como mostraba el capítulo 1 numerosos lenguajes de programación han sido desarrollados desde los años cincuenta hasta la actualidad. Estos lenguajes se pueden clasificar atendiendo a diferentes criterios. Uno de esos criterios es el de paradigma al que el lenguaje pertenece. Un paradigma es un conjunto de conceptos y técnicas para usar dichos conceptos. Los conceptos se organizan en lo que se denomina un modelo de computación. Un modelo de computación es un sistema formal que define cómo se realizan las operaciones[23]. Los modelos de computación se centran en los lenguajes de programación exclusivamente. Este enfoque se vuelve demasiado restrictivo al querer dar una panorámica más amplia de lo que se conoce como lenguajes de computadora, y que no tienen porqué ser exclusivamente lenguajes de programación. Existen lenguajes de computadora que no han sido diseñados para expresar computaciones, como por ejemplo los lenguajes de marcado. Típicamente, los lenguajes dinámicos tampoco son considerados como un modelo de computación diferente, aunque tienen características que los diferencian de otros paradigmas. 121 122 PARADIGMAS Y MODELOS DE PROGRAMACIÓN 3.2 Programación funcional La programación funcional surge paralelamente a la programación imperativa. En el año 1958 se desarrolló el lenguaje LISP, primer lenguaje funcional de la historia, que estaba enfocado al área de la Inteligencia Artificial. Los lenguajes funcionales están basados en el concepto matemático de función y su diseño está influido por las matemáticas. La programación funcional a veces se agrupa junto con el paradigma lógico en lo que se denomina programación declarativa. La razón es que ambos paradigmas se centran más en lo que hay que hacer en lugar de en cómo hacerlo, que es un enfoque típico de los paradigmas basados en la programación imperativa. En los lenguajes funcionales, los programas son expresiones que pueden ser definidas como funciones y pueden recibir parámetros. La evaluación de un programa consiste entonces en evaluar dichas expresiones en base a unos parámetros de entrada. Esto es similar a como se comporta, por ejemplo, una hoja de cálculo. En una hoja de cálculo algunas celdas pueden contener expresiones que se calculan en base a los valores de otras celdas. En el paradigma funcional no existe el concepto de sentencia, y los algoritmos se especifican mediante definiciones de funciones que se pueden componer unas con otras. Esto hace particularmente difícil a los programadores acostumbrados a la programación imperativa entender la forma de describir algoritmos en lenguajes funcionales. Tampoco existe el concepto de posición de memoria, por lo que no hay variables (sólo parámetros de funciones), y por tanto no es necesario que el programador gestione la memoria. Como consecuencia, no existe la instrucción de asignación, puesto que ésta está invariablemente ligada con el concepto de variable, que no es más que un nombre para una posición de memoria. Las estructuras de control disponibles en el paradigma funcional habitualmente se limitan a composición de funciones, expresiones condicionales y recursividad. Dado que no existe el concepto de sentencia, la única forma de representar el concepto de bucle de la programación imperativa es mediante la recursividad. 3.2.1 Funciones Desde el punto de vista matemático una función establece una correspondencia entre valores de entrada y valores de salida. Así, por ejemplo, la función capital establece una correspondencia entre un país y su capital, y la función doble establece una correspondencia entre un número y ese mismo número multiplicado por dos. Además, los valores de entrada son elementos de un conjunto origen denominado dominio, y los valores de salida son elementos de un conjunto destino denominado imagen. En el ejemplo de la función capital el conjunto dominio serían los países del mundo y el conjunto imagen podría ser las ciudades del mundo. En el caso de la función doble tanto el dominio como la imagen podrían ser el conjunto de los números enteros. Es importante notar que esta regla de correspondencia que representa una función asigna a P ROGRAMACIÓN FUNCIONAL 123 cada valor del conjunto que representa el dominio un único valor del conjunto imagen. La aplicación de una función es la particularización de la regla de correspondencia a un valor concreto del dominio, lo que da como resultado un valor de la imagen. Así, doble(5) es una particularización de la función doble, cuyo resultado es 10, y capital(Francia) es una particularización de la función capital. La aplicación de una función se escribe de la siguiente forma: doble(5) = 10 Las funciones pueden componerse, de forma que el argumento de entrada de una función es el resultado de la salida de otra función. Por ejemplo, doble(doble(5)) es 20, y doble(mayor(5,6)) es 12. La definición de una función, es decir, la especificación de la regla de correspondencia que representa, puede darse de dos formas distintas: por extensión o por comprensión. Una definición por extensión consiste en proporcionar todos los valores posibles para todas las entradas. En el ejemplo de la función capital consistiría en enumerar todas las aplicaciones posibles de la función: capital(Alemania) = Berlin capital(Francia) = Paris capital(Italia) = Roma ... Es evidente que sólo en algunos casos es posible dar una definición por extensión para una función. La mayoría de las veces se proporciona una definición por comprensión. Una definición por comprensión es una ecuación algebraica en la que en la parte izquierda aparecen el nombre y los argumentos de la función y en la parte derecha aparece la expresión algebraica que permite calcular el valor de la función. En el caso de la función doble, puede representarse por comprensión de la siguiente forma: doble(x) = 2 ∗ x En lo sucesivo, utilizaremos el lenguaje Haskell[24] como lenguaje de programación funcional. Haskell es un lenguaje funcional que surge a partir de la conferencia FPCA’87 (Functional Programming Languages and Computer Architecture) y que fue desarrollado por las universidades de Yale y Glasgow. En la década de los 80 hubo una auténtica eclosión de lenguajes funcionales (SASL, Miranda, KRC, CAML, Hope, . . . ) y el objetivo era reunir en Haskell aquellas características que eran esenciales a la programación funcional, dejando fuera el resto. Haskell toma el nombre de Haskell Brooks Curry (1900-1982), cuyos trabajos en lógica matemática fueron la base para el paradigma funcional. Los programas en Haskell se escriben en módulos, y el fichero suele llevar extensión .hs. La declaración de un módulo tiene el siguiente formato: 1 module Ejemplo1 where 2 3 ... 124 PARADIGMAS Y MODELOS DE PROGRAMACIÓN Haskell tiene normas muy estrictas respecto a la forma de los nombres para módulos, tipos, funciones y argumentos. Los tipos y los módulos deben comenzar siempre por mayúscula. Eso los distingue de los parámetros y nombres de funciones que deben comenzar siempre en minúscula. Un módulo puede contener tantas funciones como sea necesario. La definición de una función se proporciona separada en dos partes. En primer lugar la declaración de los tipos de datos de la función: tipo de los parámetros de entrada y tipo de los parámetros de salida. En segundo lugar la definición de la función. El siguiente ejemplo muestra la definición de la función doble para números enteros: 1 module Ejemplo1 where 2 3 4 doble :: Integer -> Integer doble x = 2* x Tanto en la declaración como en la definición se especifica el nombre de la función. En la declaración, se indican los tipos de los parámetros de entrada (en este caso Integer), una flecha y el tipo devuelto (de nuevo Integer). Si una función tiene varios parámetros formales, éstos se especifican separados por flechas. Por ejemplo, la función suma que dados dos valores enteros devuelve la suma de ambos, se definiría de la siguiente manera: 1 2 suma :: Integer -> Integer -> Integer suma x y = x + y Como se puede observar, esta notación es diferente de la notación habitualmente utilizada en los lenguajes basados en el paradigma imperativo y en matemáticas, donde los parámetros de los subprogramas se especifican como una lista. A esta notación se la denomina notación currificada, en honor a Haskell Brooks Curry. La aplicación de una función es la operación con mayor precedencia, por tanto una aplicación de función de la función suma como la siguiente: 1 suma 5 6 + 1 debe leerse como sumar los valores 5 y 6, y al resultado sumarle uno: 1 ( suma 5 6) + 1 Es importante notar que la aplicación de funciones tiene asociatividad por la izquierda, por lo que una expresión como 5 + (6 - 5) que utilizara la función suma debe escribirse como suma 5 (6 - 5). P ROGRAMACIÓN FUNCIONAL 125 Tipos de datos básicos Haskell es un lenguaje con un tipado estricto que no permite mezclar en la misma expresión operandos de distinto tipo. Los tipos de datos básicos en Haskell incluyen: enteros, coma flotante, caracteres y booleanos. Básicamente, en Haskell hay cuatro tipos de datos numéricos: dos para enteros, Int e Integer; y otros dos para números reales, Float y Double. La diferencia entre Int e Integer radica en la implementación del tipo. Int es un tipo de dato dependiente de la arquitectura y como tal puede representar números enteros hasta un determinado tamaño. El tipo Integer, en cambio, puede representar números enteros de longitud arbitraria. Los tipos Float y Double para números en coma flotante se diferencian por la precisión: Double tiene una mayor precisión para representar números reales que Float. Los lenguajes de programación asocian habitualmente ciertas operaciones a los diferentes tipos de datos. Estas operaciones están disponibles para el programador. Es el caso de operaciones como la suma (+), la resta (-) o la multiplicación (*). En el caso de los números enteros, tenemos también la división entera (div) y el módulo (mod). En el caso de los números en coma flotante tenemos además la división real (/), la potencia de base real y exponente entero (ˆ), la potencia de base real y exponente real (**). Además, se dispone de ciertas funciones predefinidas como el valor absoluto (abs), raíz cuadrada (sqrt), parte entera (truncate), etc. El tipo de datos para caracteres es Char, y los valores de este tipo se encierran entre comillas simples: ’a’, ’b’, . . . . El tipo de datos para representar cadenas de caracteres es el tipo String, y dichas cadenas se escriben entre comillas dobles: “Nombre”, “DNI”, .... El tipo de datos Bool representa el tipo de datos lógico en el que sólo dos valores son posibles: True y False. Estos dos valores son constantes y como tales deben escribirse comenzando en mayúscula. En general, estos tipos se pueden comparar utilizando los operadores de comparación habituales: igualdad (==), distinto de (/=), mayor que (>), menor que (<), mayor o igual que (>=) y menor o igual que (<=). Además, se pueden construir expresiones condicionales complejas utilizando las conectivas lógicas habituales: conjunción (&&), disyunción (||) y negación (función not). La siguiente función determina si un carácter es una vocal (exceptuando las vocales acentuadas): 1 2 esVocal :: Char -> Bool esVocal c = (c == ’a ’) || (c == ’e ’) || (c == ’i ’) || (c == ’o ’) || (c == ’u ’) Aquellos tipos de datos cuyos valores están ordenados pueden hacer uso de los comparadores para reducir la complejidad de las expresiones. Considérese el caso de definir una función para determinar si un carácter es una letra. Los valores de tipo carácter es- 126 PARADIGMAS Y MODELOS DE PROGRAMACIÓN tán ordenados, generalmente siguiendo algún estándar como la tabla ASCII. Por tanto la siguiente definición de la función esLetra es perfectamente válida: 1 2 esLetra :: Char -> Bool esLetra l = (l >= ’a ’) && (l <= ’z ’) Esta función devolverá True para todos aquellos caracteres entre la a (minúscula) y la z (minúscula). Se deja como ejercicio al lector modificar esta función para que considere también las letras mayúsculas. Adicionalmente, Haskell también dispone del tipo tupla. Una n-tupla es una colección heterogénea de n elementos. Las tuplas se representan entre paréntesis con los elementos de la tupla separados por comas. Por ejemplo, la siguiente función devuelve el conciente y el resto de la división entera de dos enteros: 1 2 divEntera :: Integer -> Integer -> (Integer, Integer) divEntera x y = (div x y , mod x y) Transparencia referencial Una de las características diferenciadoras del paradigma funcional es el concepto de transparencia referencial. La transparencia referencial establece que el valor devuelto por una función depende exclusivamente de los parámetros de entrada, y de nada más. Es decir, aplicar una función con los mismos parámetros siempre produce el mismo resultado. Esto que parece obvio, en otros paradigmas no lo es tanto. Considérese por ejemplo el siguiente fragmento de código Pascal: 1 2 3 4 5 6 7 function compressCommand ( fileToCompress : String ) : String ; begin if( isWindows () ) then compressCommand := ’zip ’ + fileToCompress ; else compressCommand := ’gz ’ + fileToCompress ; end; La función compressCommand de este fragmento de código devuelve el comando necesario para comprimir el fichero pasado como argumento. Para componer este comando, en Windows utiliza el comando zip y en otros sistemas el comando gz. Esta función no cumple la propiedad de la transparencia referencial, dado que el resultado depende de una condición externa que no está contenida en los argumentos de la función ni en la propia definición de ésta. El hecho de ejecutarse en un entorno Windows, Linux o cualquier otro, es algo extrínseco a la función. Si un lenguaje funcional no permite crear funciones que no cumplan esta propiedad, entonces el lenguaje se denomina lenguaje funcional puro. P ROGRAMACIÓN FUNCIONAL 127 Recursividad En los lenguajes funcionales no existe el concepto de bucle como se entiende en la programación imperativa. En su lugar, se utilizan funciones recursivas. Por tanto, es muy habitual encontrar definiciones recursivas en un programa funcional, dado que muchos algoritmos se definen de manera natural de forma recursiva. Uno de los ejemplos típicos de función recursiva es la función factorial. Desde un punto de vista matemático, el factorial de un número n se define recursivamente de la siguiente forma: f actorial(n) = n ∗ f actorial(n − 1) si n > 0 1 si n = 0 (3.1) La función factorial es lo que se denomina una función definida por trozos, dado que el valor de la función depende del rango en el que se encuentre el valor del argumento. Existen distintas maneras de representar funciones por trozos. Una de ellas es la expresión condicional de Haskell: 1 2 3 4 5 factorial n = if n > 0 then n * factorial (n -1) else 1 La expresión condicional if en Haskell debe ir siempre acompañada de la parte else, dado que se trata de una expresión y por tanto debe devolver un valor. Si no se definiera la parte else el resultado de esta expresión quedaría indefinido cuando la condición se hace falsa. El ejemplo de la función factorial puede servir para mostrar las diferencias entre el tipo Int y el tipo Integer. Con el tipo Integer se puede calcular el factorial de cualquier número. Por ejemplo, el factorial de 100, resulta ser: 30414093201713378043612608166064768844377641568960512000000000000 Si utilizáramos el tipo Int en la definición de la función factorial, el resultado sería el siguiente: −3258495067890909184 El valor negativo indica que se produjo un desbordamiento: en algún momento se superó el valor máximo que se podía representar con el tipo Int (que según la documentación del lenguaje debe ser al menos 229−1 ). Parámetros de acumulación La función factorial tal como se ha definido es una función con recursividad no final. Las funciones recursivas no finales son aquellas que requieren hacer cálculos a la vuelta 128 PARADIGMAS Y MODELOS DE PROGRAMACIÓN de la recursividad. Si se considera la aplicación de la función factorial al valor 3, se obtiene la evaluación paso a paso de la Figura 3.1. Como puede observarse una vez alcanzado el caso base de la recursividad, aún quedan cálculos por hacer. Por eso la recursividad se denomina no final: el caso base de la recursividad no es la última operación que se ejecuta. Las funciones recursivas finales son aquellas que no requieren realizar cálculos al terminar las llamadas recursivas. Cualquier función recursiva no final puede convertirse en una función recursiva final introduciendo parámetros de acumulación. Estos parámetros permiten llevar los cálculos parciales que se realizarían a la vuelta de la recursividad en un parámetro de la función, de forma que al llegar al caso base, el parámetro de acumulación contiene el resultado de la función. Esta técnica se denomina técnica de los parámetros de acumulación, y es habitual en los lenguajes funcionales. El siguiente ejemplo muestra la implementación de la función factorial con recursividad final, también denominada recursividad de cola: 1 2 factorial :: Integer -> Integer factorial n = factorialFinal n 1 3 4 5 6 7 8 9 factorialFinal :: Integer -> Integer -> Integer factorialFinal n acum = if (n > 0) then factorialFinal (n - 1) ( acum * n) else acum Obsérvese que la implementación de la función con recursividad final se oculta en una función auxiliar. Esto evita al usuario de la función factorialFinal conocer la implementación del factorial, y el valor que debe pasar como segundo argumento a la función factorialFinal. El parámetro de acumulación (acum) de la función factorialFinal es inicializado en la llamada desde la función factorial con el valor 1. Cuando se alcanza el caso base de la recursividad, el parámetro de acumulación contiene al valor resultado. El uso de la técnica de parámetros de acumulación es en ocasiones la manera más sencilla de implementar funciones recursivas en lenguajes funcionales. Además, una función puede tener más de un parámetro de acumulación, permitiendo llevar varios resultados parciales simultáneamente. Métodos de evaluación En el capítulo 1 se presentaron los métodos de evaluación estricta y evaluación diferida. Haskell utiliza evaluación diferida en la evaluación de las expresiones. Esto implica que una subexpresión no se calcula mientras no sea estrictamente necesario. Además, en Haskell, una vez que se evalúa una parte de una expresión, si ésta aparece varias veces P ROGRAMACIÓN FUNCIONAL factorial 3 ↓ if (3 > 0) then 3 * factorial (3-1) else 1 ↓ if True then 3 * factorial (3-1) else 1 ↓ 3 * factorial (3-1) ↓ 3 * if (2 > 0) then 2 * factorial (2-1) else 1 ↓ 3 * if True then 2 * factorial (2-1) else 1 ↓ 3 * 2 * factorial (2-1) ↓ 3 * 2 * if (1 > 0) then 1 * factorial (1-1) else 1 ↓ 3 * 2 * if True then 1 * factorial (1-1) else 1 ↓ 3 * 2 * 1 * if(0 > 0) then 0 * factorial (0-1) else 1 ↓ 3 * 2 * 1 * if False then 0 * factorial (0-1) else 1 ↓ 3*2*1*1 ↓ 6*1*1 ↓ 6*1 ↓ 6 Figura 3.1: Evaluación de la definición recursiva no final de factorial para el valor 3 129 130 PARADIGMAS Y MODELOS DE PROGRAMACIÓN [] [1] [1,2] [1,2] [1,2,3] [[1,2], [3,4]] [] 1:[] 1:[2] 1:2:[] 1:2:3:[] [1,2]:[3,4]:[] Tabla 3.1: Correspondencia entre listas en la expresión de la que forma parte, todas las apariciones son sustituidas por el valor calculado. 3.2.2 Manejo de listas y ajuste de patrones Las listas son sin duda uno de los tipos de datos más utilizados en los lenguajes funcionales. En Haskell los elementos de una lista han de ser todos del mismo tipo. La declaración de un tipo de lista se realiza encerrando entre corchetes el tipo de datos de los elementos de la lista. La siguiente declaración corresponde a una función para calcular la longitud de una lista de enteros: 1 longitud :: [Integer] -> Integer El tipo [Integer] representa una lista de enteros. Los valores de tipo lista se representan encerrando los elementos que contiene la lista entre corchetes y separándolos por comas. La lista vacía se representa por []. Los siguientes son ejemplos de listas de enteros: [1,2,3], [-10], [7,21,121], . . . Para definir listas se puede hacer uso del constructor de listas (:) junto con el constructor de la lista vacía ([]). Un constructor es una función especial utilizada para construir valores de un determinado tipo. El constructor : es un operador binario infijo. El operando de la derecha tiene que ser una lista y el operando de la izquierda tiene que ser un elemento del mismo tipo que los elementos de la lista. La lista que se obtiene es el resultado de insertar el primer operando al principio de la lista representada por el segundo operando. Así, por ejemplo la Tabla 3.1 muestra listas equivalentes. En la columna de la izquierda se muestra la lista como una secuencia de elementos entre corchetes separados por comas. La columna de la derecha representa la misma lista de su izquierda utilizando el constructor :. El constructor : de listas es una pieza fundamental en la construcción de programas. La razón es que una técnica de definición de funciones utilizada habitualmente junto con el constructor de listas es la técnica de ajuste de patrones. Si se considera la función longitud cuya declaración se presentaba previamente, una posible definición recursiva, desde un punto de vista matemático sería la siguiente: P ROGRAMACIÓN longitud(l) = FUNCIONAL 131 0 si l = 0/ 1 + longitud(l − primerelemento) si l 6= 0/ (3.2) Se observan en esta definición dos cosas. En primer lugar, la función está definida a trozos. Existen dos expresiones algebraicas diferentes que se aplican en función de una determinada condición: la primera expresión se aplica si la lista está vacía, en cuyo caso la longitud es cero (este es el caso base de la recursividad); la segunda expresión se aplica si la lista no está vacía, y entonces la longitud se define como 1 más la longitud de la lista resultante de quitarle un elemento (por ejemplo el primero) a la lista original. En segundo lugar, necesitamos poder extraer un elemento de la lista en el paso recursivo, para poder llamar recursivamente a la función longitud cada vez con una lista más pequeña hasta que eventualmente lleguemos a tener la lista vacía. Esta definición se puede conseguir en Haskell haciendo uso del ajuste de patrones proporcionando dos definiciones para la función longitud: una para la lista vacía y otra para una lista genérica con al menos un elemento. La lista vacía se representa como ya se ha visto como [], mientras que genéricamente podemos hacer referencia a una lista con un elemento mediante el constructor : como x:resto, donde x y resto son parámetros que pueden ser utilizados en la parte derecha de la definición: 1 2 3 longitud :: [Integer] -> Integer longitud [] = 0 longitud (x: resto ) = 1 + longitud resto Durante la evaluación de una aplicación de la función longitud, Haskell buscará la primera definición (en el orden en el que aparecen en el fichero) cuyo patrón ajuste con el valor pasado a la función. Así, la primera definición solo ajustará cuando la función sea llamada con una lista vacía. En cualquier otro caso la primera definición no ajustará, y Haskell tratará de ajustar la segunda (que funcionará para cualquier lista no vacía). La Tabla 3.2 muestra la evaluación de la aplicación de la función longitud a la lista [1,2]. El ajuste de patrones es la técnica más habitual de definición de funciones con una o más listas como parámetros. Por ejemplo, la siguiente función recibe una lista de enteros y un valor entero, y devuelve una nueva lista con aquellos elementos de la lista original que son mayores que el valor dado: 1 2 3 4 5 6 7 mayores :: [Integer] -> Integer -> [Integer] mayores [] v = [] mayores (e: resto ) v = if e > v then e: mayores resto v else mayores resto v 132 PARADIGMAS longitud [1,2] ↓ 1 + longitud [2] ↓ 1 + 1 + longitud [] ↓ 1+1+0 ↓ 2+0 ↓ 2 Y MODELOS DE PROGRAMACIÓN [1,2] es ajustado a (x=1):(resto=[2]) [2] es ajustado a (x=2):(resto=[]) [] es ajustado a [] Tabla 3.2: Patrón ajustado en cada caso para la evaluación de longitud [1,2] Esta función presenta recursividad no final. Se puede conseguir una función equivalente con recursividad final introduciendo un parámetro de acumulación donde ir almacenando los elementos seleccionados: 1 2 mayores2 :: [Integer] -> Integer -> [Integer] mayores2 l v = mayoresFinal l v [] 3 4 5 6 7 mayoresFinal mayoresFinal mayoresFinal if e 8 9 10 :: [Integer] -> Integer -> [Integer] -> [Integer] [] v acum = acum (e: resto ) v acum = > v then mayoresFinal resto v (e: acum ) else mayoresFinal resto v acum Obsérvese que la función con recursividad final mayores2 devuelve la misma lista que la función mayores pero invertida. 3.2.3 Tipos definidos por el usuario Haskell permite al programador definir sus propios tipos de datos. La definición de tipos puede hacerse de dos formas distintas: renombrando un tipo que ya existe o definiendo un nuevo tipo. En este punto es importante recordar que los tipos deben comenzar siempre con mayúscula. El renombrado de tipos tiene sentido cuando se desea hacer más legible un tipo que ya existe en un contexto específico. Para renombrar tipos se utiliza la palabra reservada type seguida de un igual y el tipo que se desea renombrar. Por ejemplo, el tipo String es un renombrado del tipo [Char]: P ROGRAMACIÓN 1 FUNCIONAL 133 type String = [Char] El renombrado no introduce nuevos tipos de datos, y las operaciones disponibles son las mismas que para el tipo que ha sido renombrado. Así, el tipo String se puede utilizar como una lista de caracteres, y por tanto se pueden implementar funciones como longitud: 1 2 3 longitud :: String -> Integer longitud [] = 0 longitud (e: resto ) = 1 + longitud resto Supóngase que se desea implementar una función para calcular el área de un rectángulo dado por dos puntos que se encuentran en la diagonal del mismo. Cada punto se representa mediante sus coordenadas x e y dadas como números en coma flotante. En lugar de definir una función que reciba cuatro parámetros representando estas coordenadas, podría considerarse definir un tipo Punto que renombre una 2-tupla de dos Float, y definir la función en base a este tipo: 1 type Punto = (Float, Float) 2 3 4 area :: Punto -> Punto -> Float area (x1 , y1 ) (x2 , y2 ) = abs (x2 - x1 ) * abs (y2 - y1 ) Por otro lado, la definición de un nuevo tipo de datos se puede proporcionar de dos maneras: especificando todos los posibles valores del tipo (de manera similar a la definición de enumerados en otros lenguajes) o especificando constructores para el tipo. Un constructor permite construir valores del tipo y se puede utilizar en el ajuste de patrones. La definición mediante la enumeración de valores consiste simplemente en enumerar todos los posibles valores del tipo separándolos por una barra vertical (|). Estos valores se consideran constantes y por tanto se escriben comenzando en mayúsculas: 1 2 data DiaSemana = Lunes | Martes | Miercoles | Jueves | Viernes | Sabado | Domingo Esta definición sí introduce un nuevo tipo en el sistema de tipos, que opcionalmente podría tener sus propias operaciones. Para tipos enumerados es posible reutilizar operaciones ya definidas en Haskell. Para ello es necesario hacer derivar el tipo a partir de alguno de los tipos de Haskell. Algunos de los tipos más habituales son: • Eq: permite comparar valores por igualdad, donde un valor sólo es igual a sí mismo (Lunes == Lunes, pero Lunes /= Martes). • Ord: permite considerar los valores ordenados. El orden considerado es aquél en el que aparecen los valores en la definición del tipo. Así, según la definición 134 PARADIGMAS Y MODELOS DE PROGRAMACIÓN anterior Lunes < Martes < ...< Domingo. De esta forma se pueden utilizar los operadores de comparación habituales con el tipo de datos. • Show: permite mostrar un valor por pantalla. A menos que se especifique este tipo, Haskell no sabe cómo mostrar los valores del tipo definido por el usuario. Show permite considerar el propio nombre del valor como su representación textual para mostrarla por pantalla. De esta forma el valor Lunes se imprimirá por pantalla como la cadena “Lunes”, etc. Para incluir estas operaciones en el tipo de datos DiaSemana, es necesario indicar a continuación de la enumeración de valores la palabra reservada deriving, seguida de una tupla conteniendo los tipos: 1 2 3 data DiaSemana = Lunes | Martes | Miercoles | Jueves | Viernes | Sabado | Domingo deriving (Eq, Ord, Show) A partir de esta definición, es posible construir una función que determine si un determinado día es laborable mediante la siguiente definición: 1 2 esLaborable :: DiaSemana -> Bool esLaborable dia = dia >= Lunes && dia <= Sabado La implementación de esta función requiere haber definido la ordenación de los valores del tipo mediante la inclusión de deriving (Ord) en la definición del tipo. Además, la inclusión de Ord requiere la inclusión de Eq de forma que se puedan comparar valores por igualdad. En ocasiones no es posible definir un nuevo tipo de datos por enumeración de todos los posibles valores del tipo. Entonces es necesario definir el tipo en base a constructores que especifican cómo se construyen (genéricamente) valores de ese tipo. Generalmente, estas definiciones de tipos son recursivas. Por ejemplo, la siguiente podría ser la definición de un tipo ListaInteger definido de forma recursiva (de hecho el tipo lista de Haskell se define de forma similar) y una función longitud definida sobre este nuevo tipo de lista: 1 data ListaInteger = Vacia | Lista (Integer, ListaInteger ) 2 3 4 5 longitud :: ListaInteger -> Integer longitud Vacia = 0 longitud ( Lista (e , resto )) = 1 + longitud resto En el ejemplo anterior, Vacia y Lista son constructores de tipo. Vacia es un constructor que representa la lista vacía, mientras que Lista es otro constructor que construye P ROGRAMACIÓN FUNCIONAL 135 un valor de tipo ListaInteger a partir de un elemento de tipo Integer y un valor de tipo ListaInteger. Este último constructor es recursivo. La lista [1,2] (que también puede escribirse como 1:2:[]) se representaría en este tipo de datos como: Lista(1, Lista(2, Vacia)). Como puede observarse, la función longitud utiliza ajuste de patrones apoyándose en los constructores de tipos. Así, dado que el constructor Lista tiene dos argumentos, puede utilizarse de la misma forma que se utilizaba el constructor dos puntos (:) para listas predefinidas de Haskell. Anteriormente se dio una definición para las coordenadas de un punto y una función area que calculaba el área de un rectángulo definido por dos puntos situados en una de las diagonales. El siguiente ejemplo implementa esta misma funcionalidad, pero utilizando definición de tipos (en lugar de renombrado) con constructores: 1 data Punto = Punto (Float, Float) 2 3 4 area :: Punto -> Punto -> Float area ( Punto (x1 , y1 )) ( Punto (x2 , y2 )) = abs (x2 - x1 ) * abs (y2 - y1 ) En este caso es necesario utilizar el constructor de tipo en la definición de la función area, dado que no se trata de un renombrado. 3.2.4 Funciones de orden superior Una de las características esenciales de los lenguajes funcionales son las funciones de orden superior. En los lenguajes funcionales las funciones son como cualquier otro tipo de datos, y por tanto pueden pasarse funciones a funciones. Esto es de especial utilidad cuando se quieren crear algoritmos genéricos. Por ejemplo, supóngase que se desea tener una función compara que dados dos elementos devuelve -1 si el primero es mejor que el segundo, 0 si son iguales o +1 si el primero es peor que el segundo. Esta función podría utilizarse por ejemplo en un algoritmo de ordenación. Ahora bien, la determinación de cuándo un elemento es mejor que otro no se quiere proporcionar en la propia implementación de compara, sino que se desea dejar al programador la definición de cuándo un valor es mejor que otro. Por ejemplo, si estamos midiendo el tiempo en una maratón menos tiempo es mejor marca, sin embargo si estamos midiendo el volumen de ventas de una empresa más es mejor. La definición de la función compara debe apoyarse entonces en una función que dados dos enteros devuelva -1, 0 ó 1 en función de cuál de los dos es mejor, y simplemente devolverá el resultado de invocar esta función. Así, la comparación de dos elementos se hace independiente de la ordenación considerada para los mismos. Utilizando funciones de orden superior, esta función podría implementarse como sigue: 1 2 type Comparador = Integer -> Integer -> Integer 136 3 4 PARADIGMAS Y MODELOS DE PROGRAMACIÓN compara :: Integer -> Integer -> Comparador -> Integer compara e1 e2 comparador = comparador e1 e2 Se define un tipo Comparador que es básicamente un renombrado para una función que recibe dos valores de tipo Integer y devuelve otro valor de tipo Integer. Este renombrado se ha incluido para hacer más clara la declaración de la función compara. Esta función recibe tres argumentos: los dos primeros representan los dos valores enteros que se desean comparar. El tercero corresponde a una función (de tipo Comparador) que será utilizada para establecer cuál de los dos valores pasados es mejor. En base a estas definiciones un programador podría definir las dos posibles funciones de comparación mayorEsMejor y menorEsMejor: 1 2 mayorEsMejor :: Integer -> Integer -> Integer mayorEsMejor e1 e2 = e2 - e1 3 4 5 menorEsMejor :: Integer -> Integer -> Integer menorEsMejor e1 e2 = e1 - e2 La declaración de ambas funciones coincide con la definición del tipo Comparador: ambas reciben dos valores de tipo entero y devuelven un entero. Ahora es posible comparar valores atendiendo cualquiera de los dos criterios, de la siguiente forma: 1 2 3 4 > compara 1 2 menorEsMejor -1 > compara 1 2 mayorEsMejor 1 Las funciones de orden superior son un mecanismo muy potente en el diseño de algoritmos. Algunos lenguajes (no pertenecientes al paradigma funcional) las han incluido en su definición (por ejemplo, Ruby mediante sus bloques, o la versión 7 de Java mediante el mecanismo de closures). 3.3 Programación lógica La programación lógica es un paradigma que aplica el conocimiento proviniente del campo de la lógica matemática al desarrollo de programas. La lógica matemática permite expresar problemas que son resueltos mediante la aplicación de reglas. Este paradigma ha sido utilizado sobre todo en el campo de la Inteligencia Artifical, y sus orígenes se remontan a los años 60, cuando se empezó a trabajar en sistemas de pregunta respuesta. La programación lógica se basa en hechos, reglas y consultas. El programador define una serie de reglas y hechos, y posteriormente se le pueden plantear al sistema una serie de consultas que resolverá en base a las reglas y hechos proporcionados. Las reglas de los lenguajes que pertenecen a este paradigma se basan en lo que se denominan cláusulas P ROGRAMACIÓN LÓGICA 137 de Horn. Las cláusulas de Horn son un tipo restringido de cláusulas lógicas. El objetivo de esta restricción es poder definir un modelo computacional que a partir de dichas cláusulas y una consulta sea capaz de encontrar una respuesta a dicha consulta. Los ejemplos de esta sección se presentan en el lenguaje Prolog. Prolog fue desarrollado en el año 1972 por Alain Colmerauer, quien estaba trabajando en reconocimiento de lenguaje natural. Es uno de los lenguajes lógicos más populares. 3.3.1 Hechos Los hechos son enunciados ciertos por definición. Por ejemplo, cuando se expresa que “Ford es un coche”, se está expresando un hecho. Los hechos se utilizan en programación lógica para establecer enunciados ciertos que ayudarán en la resolución de un determinado problema. Un hecho consiste en un nombre y uno o varios argumentos, seguido de un punto. Considérese por ejemplo la siguiente lista de hechos sobre paradigmas y lenguajes: 1 2 3 funcional ( haskell ). funcional ( ml ). funcional ( hope ). 4 5 logico ( prolog ). 6 7 8 concurrente ( haskell ). concurrente ( java ). Los nombres de enunciados y valores deben ir en minúsculas. Las variables, como se verá más adelante, comienzan con mayúsculas. 3.3.2 Consultas A partir de los hechos del ejemplo anterior se pueden realizar consultas como las siguientes: 1 2 3 4 > funcional ( haskell ). true > concurrente ( java ). true Las consultas se pueden realizar utilizando variables, en cuyo caso el sistema trata de determinar qué variables hacen cierto el enunciado: 1 2 3 > funcional (X). X = haskell ; X = ml ; 138 4 PARADIGMAS Y MODELOS DE PROGRAMACIÓN X = hope . Se pueden hacer consultas más complejas. Por ejemplo, se podría determinar qué lenguajes son funcionales y concurrentes a la vez. Para ello se utiliza la conjunción, que se representa separando los diferentes enunciados con una coma: 1 2 > funcional (X) , concurrente (X). X = haskell 3.3.3 Reglas Una regla tiene una cabeza y un cuerpo, separadas por el símbolo :-, y establece que la cabeza es verdad si el cuerpo es verdad. El cuerpo puede contener predicados (como conjunciones o disyunciones). Las conjunciones se representan separando las cláusulas con comas, las disyunciones se representan separándolas con puntos y comas. La cabeza de la regla puede contener variables y éstas pueden utilizarse en el cuerpo. Continuando con el ejemplo anterior, supóngase que se desea escribir una regla para comprobar cuándo un nombre representa un lenguaje de programación. Esta regla podría considerar que dicho nombre será un lenguaje de programación si es un lenguaje funcional, un lenguaje lógico o un lenguaje concurrente: 1 2 3 4 lenguaje (X) :funcional (X); logico (X); concurrente (X). 5 6 7 8 funcional ( haskell ). funcional ( ml ). funcional ( hope ). 9 10 logico ( prolog ). 11 12 13 concurrente ( haskell ). concurrente ( java ). Cargando este programa en el intérprete de Prolog se pueden realizar consultas como las siguientes: 1 2 > lenguaje ( pepe ). false . 3 4 5 6 > lenguaje ( java ). true. P ROGRAMACIÓN 7 8 9 10 11 12 13 > X X X X X X LÓGICA 139 lenguaje (X). = haskell ; = ml ; = hope ; = prolog ; = haskell ; = java . Considérese ahora el siguiente ejemplo. Se tiene una serie de hechos que determinan qué lenguajes conocen una serie de programadores y se desea saber cuáles de estos programadores conocen el paradigma funcional. Podría entonces describirse una regla sabeFuncional que determine, para un programador dado, si este programador conoce algún lenguaje del paradigma funcional: 1 2 3 sabeFuncional (X) :programa (X ,Z) , funcional (Z). 4 5 6 7 funcional ( haskell ). funcional ( ml ). funcional ( hope ). 8 9 logico ( prolog ). 10 11 12 concurrente ( haskell ). concurrente ( java ). 13 14 15 16 programa ( pepe , java ). programa ( pepe , ml ). programa ( pedro , prolog ). Dado un programador X, la regla sabeFuncional será verdadera si dado un lenguaje funcional cualquiera Z, X sabe programar en Z. El programa establece la siguiente base de hechos: • haskell, ml y hope son lenguajes funcionales, • prolog es un lenguaje lógico, • haskell y java son lenguajes concurrentes, • pepe sabe programar en java y ml, • pedro sabe programar en prolog. Dado que ml es un lenguaje funcional, pepe sabe programación funcional. En cambio, pedro no conoce ningún lenguaje X para el cual funcional(X) sea verdadero: 140 1 2 PARADIGMAS Y MODELOS DE PROGRAMACIÓN > sabeFuncional ( pepe ). true. 3 4 5 > sabeFuncional ( pedro ). false . 6 7 8 9 > sabeFuncional (X). X = pepe ; false . En la última consulta el sistema trata de encontrar todos los valores de X que hacen verdaderos a la vez los enunciados funcional(Z) y programa(X,Z). El sistema va mostrando los resultados uno a uno, y el usuario debe introducir un ; para que se le muestre el siguiente. Cuando no es capaz de encontrar más valores de X que hagan cierto sabeFuncional(X), devuelve false. Operadores Los principales operadores de Prolog son la conjunción y la disyunción. Estos operadores pueden utilizarse, junto con los paréntesis, para romper la precedencia en el cuerpo de las reglas. Además, Prolog también incluye la negación (not). Reglas recursivas En Prolog es posible definir reglas recursivas. Un ejemplo característico son las relaciones familiares. Supóngase que se desea escribir un programa en Prolog para determinar si una persona es ancestro de otra. Ello es posible definiendo una serie de hechos padre que determinan la relaciones padre-hijo entre varias personas, y posteriormente definiendo una regla ancestro de la siguiente forma: 1 2 3 padre ( ’ pedro ’, ’ carmen ’). padre ( ’ luis ’,’ pedro ’). padre ( ’ jose ’,’ luis ’). 4 5 6 7 8 9 ancestro (A ,B) :padre (A ,B). ancestro (A ,B) :padre (A ,C) , ancestro (C ,B). La regla ancestro es recursiva. La primera definición establece que una persona es ancestro de otra si es su padre (caso base de la recursividad). La segunda definición establece que una persona A es ancestro de otra persona B, si A es padre de una tercera P ROGRAMACIÓN LÓGICA 141 persona C y ésta a su vez es ancestro de B. De esta forma se va buscando a través de las relaciones padre-hijo. Con este programa Prolog se podrían hacer consultas como las siguientes: 1 2 > padre ( ’ pedro ’,’ carmen ’). true. 3 4 5 > ancestro ( ’ jose ’,’ carmen ’). true 6 7 8 9 10 11 > ancestro (X , ’ carmen ’). X = pedro ; X = luis ; X = jose ; false . 3.3.4 Bases de reglas Una base de reglas no es más que un conjunto de reglas que se pueden utilizar como un sistema experto para extraer conocimiento del contexto. Generalmente las reglas se extraen de expertos del dominio en cuestión, se codifican en Prolog como reglas lógicas, y el programa resultante se puede utilizar para realizar consultas. Por ejemplo, considérese la siguiente base de reglas: R1 Un lenguaje funcional es aquel que tiene funciones de orden superior, no tiene estado y tiene gestión de memoria. R2 Un lenguaje imperativo es aquel que tiene evaluación ansiosa y tiene estado. R3 Un lenguaje orientado a objetos tiene estado y encapsulación. R4 Un lenguaje concurrente tiene soporte para hilos. Y supóngase que se dispone del siguiente conjunto de hechos: • Java es un lenguaje con estado, encapsulación, evaluación ansiosa, soporte para hilos y gestión de memoria. • Haskell es un lenguaje sin estado, con soporte para hilos, con gestión de memoria y con funciones de orden superior. • C es un lenguaje con estado y evaluación ansiosa. • Scala es un lenguaje con encapsulación, fuciones de orden superior, soporte para hilos y gestión de memoria. 142 PARADIGMAS Y MODELOS DE PROGRAMACIÓN Es complicado extraer información de estos hechos a partir de la base de reglas. Pero las reglas podrían escribirse en un programa Prolog, especificando también los hechos, y después podrían hacerse consultas sobre estos lenguajes: 1 % Sistema experto : 2 3 4 5 6 funcional (X) :orden_superior (X) , not( estado (X)) , gestion_memoria (X). 7 8 9 10 imperativo (X) :ansiosa (X) , estado (X). 11 12 13 14 objetos (X) :estado (X) , encapsulacion (X). 15 16 17 concurrente (X) :hilos (X). 18 19 % Base de conocimiento : 20 21 22 23 24 25 26 % java estado ( java ). encapsulacion ( java ). ansiosa ( java ). hilos ( java ). gestion_memoria ( java ). 27 28 29 30 31 32 % haskell estado ( haskell ) :- false . hilos ( haskell ). gestion_memoria ( haskell ). orden_superior ( haskell ). 33 34 35 36 % c estado (c). ansiosa (c). 37 38 39 40 41 42 % scala encapsulacion ( scala ). orden_superior ( scala ). hilos ( scala ). gestion_memoria ( scala ). P ROGRAMACIÓN ORIENTADA A OBJETOS 143 A partir del programa anterior, es posible realizar consultas como: ¿es Java un lenguaje orientado a objetos? ¿Y funcional? ¿Puede Haskell considerarse un lenguaje concurrente? ¿Qué lenguajes son concurrentes? 1 2 > concurrente ( java ). true. 3 4 5 > funcional ( java ). false . 6 7 8 > imperativo ( java ). true. 9 10 11 > objetos ( java ). true. 12 13 14 > funcional ( haskell ). true. 15 16 17 > imperativo ( haskell ). false . 18 19 20 > concurrente ( haskell ). true. 21 22 23 > funcional ( scala ). true. 24 25 26 > objetos (c). false . 27 28 29 30 31 > X X X concurrente (X). = java ; = haskell ; = scala . 3.4 Programación orientada a objetos La programación orientada a objetos surge como una evolución de la programación imperativa y los tipos abstractos de datos. En los albores de la informática, los programas eran eminentemente algorítmicos, con poco énfasis en los datos y mucho en la definición algorítmica de los problemas. Con el tiempo y la creciente complejidad del software, esto se convirtió en un problema y se hizo evidente que era necesario preocuparse también por los datos. Surgieron entonces los tipos abstractos de datos, que trataban 144 PARADIGMAS Y MODELOS DE PROGRAMACIÓN de definir conjuntamente tanto los tipos definidos por el usuario como las operaciones permitidas sobre estos tipos. Algunos lenguajes de programación, como Ada, hicieron hincapié en el concepto de tipo abstracto de dato, proporcionando incluso encapsulación para estos tipos. Sin embargo, la mayoría de los lenguajes imperativos no proporcionan ningún tipo de encapsulación para los valores del tipo. La encapsulación evita que los programadores accedan directamente a las variables que contienen la información del tipo modificándolas, sino que deben modificar estos valores exclusivamente a través de las operaciones proporcionadas por el tipo de dato. La programación orientada a objetos proporciona el concepto de clase, evolución de un tipo abstracto de datos. Cada clase representa un tipo con un estado (definido por unos atributos) y operaciones (métodos) que se pueden invocar sobre los objetos (ejemplares) de la clase. Pero además, la programación orientada a objetos introduce el concepto de herencia, que permite crear jerarquías de tipos, y polimorfismo, que permite manipular un conjunto de objetos de distinto tipo como algo uniforme sujeto a ciertas restricciones. En esta sección se utilizará a modo de ejemplo el lenguaje Java. Este lenguaje, creado en 1995 por James Gosling cuando trabajaba para Sun Microsystems (actualmente subsidiaria de Oracle Corporation) es uno de los más utilizados en la actualidad. 3.4.1 Elementos de la programación orientada a objetos En los lenguajes orientados a objetos se pueden distinguir los siguientes elementos: Clase Una clase define un nuevo tipo de datos junto con las operaciones permitidas sobre ese tipo. Estas operaciones definen el comportamiento de un conjunto de elementos homogéneos. Por ejemplo, la clase Fracción viene definida por un numerador y un denominador (datos), y puede ofrecer operaciones como simplificar, multiplicar, sumar, etc. Objeto Un objeto es un ejemplar (también denominado instancia) concreto de una clase; por ejemplo, la fracción 2/3 es un ejemplar concreto de la clase Fracción, donde el numerador es 2 y el denominador 3. Este ejemplar responde a las operaciones definidas en la clase Fracción. Método Definición de una operación de una clase. La definición es la misma para todos los objetos de la clase. Mensaje Invocación de una operación sobre un objeto. Un mensaje siempre requiere de un objeto, que es el que recibe el mensaje. El comportamiento del objeto en base a dicho mensaje viene dado por la definición del método correspondiente al mensaje. El objeto que recibe el mensaje debe tener definido dicho método entre las operaciones de su clase. P ROGRAMACIÓN ORIENTADA A OBJETOS 145 Atributo Un atributo es cada uno de los datos de una clase. En el caso de la clase Fracción, numerador y denominador son los atributos de la clase. Estado El estado de un objeto es el conjunto de valores de sus atributos en un instante dado. El estado se refiere a los valores de los datos en cada objeto particular. En el caso de la fracción 2/3, el conjunto de los valores 2 y 3 para numerador y denominador, respectivamente, es lo que conforma el estado de dicha fracción. Es importante notar que las clases asumen el principio de encapsulación, definiendo dos vistas distintas: la vista pública o de interfaz comprende las operaciones a las que responden los objetos de la clase y define su comportamiento; la vista privada o de implementación comprende los datos y las operaciones de manipulación de los mismos. La encapsulación es un aspecto muy importante en la programación orientada a objetos y establece que un objeto no podrá tener acceso a la vista privada de otro objeto. 3.4.2 Vista pública y vista privada de clases La vista pública de una clase es el nombre de la clase junto con las cabeceras de todos los métodos públicos de la clase. La vista pública también es conocida como la interfaz de la clase. En Java los métodos públicos son aquellos que llevan delante de su declaración la palabra reservada public. También forman parte de la vista pública de la clase los constructores públicos. Un constructor es un método que es invocado cuando se crea un objeto de la clase, antes que ningún otro método, y cuyo objetivo es inicializar el estado del objeto antes de comenzar a ser utilizado. Los constructores en Java tienen el mismo nombre que la clase. El siguiente es un ejemplo de vista pública de la clase Fraccion: 1 public class Fraccion { 2 3 4 5 6 public Fraccion (int numerador , int denominador ) { // Constructor . Inicializa el objeto que se está creando ... } 7 8 9 10 11 public void simplificar () { // Intenta simplifica el objeto fracción que recibe el mensaje ... } 12 13 14 15 public String toString () { // Devuelve una cadena de caracteres que es la representación textual // del objeto fracción que devuelve el mensaje 146 PARADIGMAS ... 16 } 17 18 Y MODELOS DE PROGRAMACIÓN } Generalmente en los lenguajes orientados a objetos se permite la sobrecarga de métodos. La sobrecarga de métodos permite tener dos o más métodos con el mismo nombre, siempre que se puedan distinguir por el número, orden y/o tipo de sus parámetros. Por ejemplo, en Java un objeto FileReader, que permite leer un flujo de caracteres de un fichero, se puede inicializar con un objeto File que representa el fichero que quiere leerse, o directamente con un objeto String. Se dice entonces que el constructor está sobrecargado. La vista privada de una clase la conforman los atributos de la clase y aquellos métodos que no forman parte del comportamiento público de la clase (métodos privados). La programación orientada a objetos busca impedir, mediante la encapsulación, que desde el exterior de un objeto pueda accederse a los datos del mismo, por eso los atributos forman parte de la vista privada, que es inaccesible desde otros objetos. Los métodos privados son métodos útiles en muchos contextos. Por ejemplo, supóngase que la invocación de un determinado método público produce el desencadenamiento de una operación compleja con multitud de pasos intermedios. Puede ser conveniente dividir esta operación en varios métodos (privados) que son invocados en un determinado orden desde el método público que representa dicha operación en su conjunto. Evidentemente, no sería correcto, y podría producir resultados inesperados, que alguno de los pasos intermedios fuera invocado en un orden diferente del establecido, por tanto estos métodos deben formar parte de la vista privada. El programador de la clase conoce en qué orden deben ser llamados y proporciona una operación en la vista pública para ello, pero limita el acceso a los métodos que definen las operaciones intermedias por seguridad. Otro contexto en el que los métodos privados surgen de forma natural es cuando ciertos fragmentos de código se repiten en varios métodos públicos. Es aconsejable entonces, para evitar la duplicidad del código, extraer esos fragmentos en un método privado y sustituirlo en los métodos públicos por llamadas a dicho método. Por ejemplo, imagínese que se desea proporcionar el siguiente comportamiento a la clase Fraccion mostrada anteriormente: • Comprobar si una fracción es menor que otra. • Comprobar si una fracción es mayor que otra. • Comprobar si una fracción es equivalente a otra. La comparación de fracciones se realiza comparando la multiplicación del numerador de una por el denominador de la otra y viceversa. Así, 1/2 > 1/3, porque 1*3 > 1*2. P ROGRAMACIÓN ORIENTADA A OBJETOS 147 Dos fracciones son equivalentes cuando esta multiplicación cruzada da el mismo resultado: 1/2 es equivalente a 2/4, porque 1*4 == 2*2. En base a estos resultados, podría pensarse en la siguiente implementación: 1 2 3 public class Fraccion { private int numerador ; private int denominador ; 4 public Fraccion (int numerador , int denominador ) { this. numerador = numerador ; this. denominador = denominador ; } 5 6 7 8 9 public boolean esMayor ( Fraccion f) { return this. numerador * f. denominador > this. denominador * f. numerador ; } 10 11 12 13 public boolean esMenor ( Fraccion f) { return this. numerador * f. denominador < this. denominador * f. numerador ; } 14 15 16 17 public boolean esEquivalente ( Fraccion f) { return this. numerador * f. denominador == this. denominador * f. numerador ; } 18 19 20 21 } Es importante en este momento destacar una característica de Java que no está presente en todos los lenguaje orientados a objetos. En Java, dos objetos que sean de la misma clase pueden acceder uno a los atributos del otro. Este comportamiento se puede observar en la implementación de los tres métodos esMayor, esMenor y esEquivalente, donde se accede a los atributos de f que es un objeto pasado como parámetro. Esta es una decisión de diseño, y se le presupone al creador de la clase la responsabilidad de mantener el estado de los objetos coherente. Aunque esto es una violación del principio de encapsulación, en realidad el acceso está muy restringido: sólo desde las implementaciones de los métodos de la clase se puede acceder a los atributos de otro objeto de la misma clase. Siguiendo con el ejemplo anterior, es posible extraer en un método privado en el que se apoyen esMenor, esMayor y esEquivalente, el cálculo del producto cruzado: 1 2 3 public class Fraccion { private int numerador ; private int denominador ; 148 PARADIGMAS Y MODELOS DE PROGRAMACIÓN 4 public Fraccion (int numerador , int denominador ) { this. numerador = numerador ; this. denominador = denominador ; } 5 6 7 8 9 private int compara ( Fraccion f) { return this. numerador * f. denominador - this. denominador * f. numerador ; } 10 11 12 13 public boolean esMayor ( Fraccion f) { return compara (f) > 0; } 14 15 16 17 public boolean esMenor ( Fraccion f) { return compara (f) < 0; } 18 19 20 21 public boolean esEquivalente ( Fraccion f) { return compara (f) == 0; } 22 23 24 25 26 } En principio, con las operaciones de la vista pública debería ser suficiente para interactuar con los objetos de una clase concreta. No debería ser necesario acceder a los atributos de una clase. Sin embargo, en ocasiones puede ser necesario consultar los atributos. Esto debe evitarse en la medida de lo posible, pero si fuera necesario, la forma de proporcionar acceso a los atributos es mediante métodos de acceso. Existen dos métodos de acceso: Métodos get. Consultan el estado de un atributo concreto de un objeto. Métodos set. Modifican el valor de un atributo concreto de un objeto y, por tanto, el estado del objeto. Proporcionar métodos de acceso debe ser una decisión muy razonada. Considérese por ejemplo el caso de un objeto Colegio que mantiene una lista de alumnos. La clase tiene un método matricular que se debe utilizar para matricular alumnos en el mismo. Supóngase que se quiere proporcionar la posibilidad de obtener un listado de los alumnos del colegio. Podría pensarse en una implementación como la siguiente, que define un método de acceso para la lista de alumnos de forma que se pueda acceder a la lista de alumnos matriculados: P ROGRAMACIÓN 1 ORIENTADA A OBJETOS 149 import java . util . List ; 2 3 public class Colegio { 4 private List alumnos ; 5 6 public List getAlumnos () { return alumnos ; } 7 8 9 10 } Esta implementación es sumamente peligrosa. Para demostrarlo, supóngase un código como el siguiente, en el que se obtiene la lista de alumnos y se añade un nuevo alumno a la misma: 1 import java . util . List ; 2 3 public class Main { 4 Colegio colegio ; 5 6 public void algunMetodo () { List listaAlumnos = colegio . getAlumnos () ; listaAlumnos . add (" Pedro Pérez "); // ... } 7 8 9 10 11 12 } Evidentemente, el nuevo alumno no ha procedido a través del procedimiento habitual de matrícula, consitente en pasar el alumno al método matricular de Colegio. Proporcionar métodos de acceso es una decisión que no hay que tomar a la ligera, y es necesario meditar las posibles consecuencias de hacerlo. En el caso anterior, podría optarse por varias soluciones. Una de ellas sería copiar los alumnos a una nueva lista y devolver esta lista en lugar de la lista referenciada por el atributo alumnos. Otra posibilidad sería devolver una lista de sólo lectura. 3.4.3 Vista pública y vista privada de objetos La vista pública de un objeto incluye la creación del objeto (generalmente en memoria dinámica) y los mensajes que se le pueden lanzar al objeto, que corresponden con los métodos de la vista pública de su clase. En Java la creación de un objeto se lleva a cabo mediante la palabra reservada new seguida del nombre del objeto y los parámetros que requiera el constructor definido en la vista pública de la clase (si lo hay). 150 1 PARADIGMAS Y MODELOS DE PROGRAMACIÓN Colegio miguelDelibes = new Colegio () ; Como resultado de la creación de un objeto se devuelve una referencia al objeto recién creado. Esta referencia se puede utilizar para lanzar mensajes al objeto. Para invocar un mensaje sobre un objeto se utiliza la notación punto: la referencia al objeto y el mensaje que se desea invocar sobre él se separan por un punto. Si el mensaje requiere el paso de parámetros estos se especifican a continuación encerrados entre paréntesis. 1 miguelDelibes . matricular (" Juan Ruiz "); La vista privada del objeto incluye la creación, destrucción y paso de mensajes al objeto. La creación de un objeto incluye la reserva de memoria para el estado del objeto, la inicialización de cada uno de sus atributos y la invocación del constructor correspondiente. La destrucción del objeto, en el caso de Java, incluye liberar la memoria reservada para el objeto y las referencias al mismo. El paso de mensajes implica la resolución del método a invocar, la introducción de los parámetros en la pila y la transferencia de control al método correspondiente. A la salida, se restaurará el estado de la pila extrayendo los parámetros del método y devolviendo el control al método que llamó. 3.4.4 Herencia La herencia es un mecanismo de la programación orientada a objetos por el cual la vista pública y privada de una clase se transmiten a otra. Cuando esto ocurre se establece una relación de herencia, que es una relación binaria entre una clase padre y una clase hija. Esta relación de herencia establece una jerarquía por grado de clasificación, donde la clase padre es más general y la clase hija es más específica. En Java, una relación de herencia entre una clase padre Poligono y una clase hija Cuadrado se establecería de la siguiente forma: 1 2 3 public class Cuadrado extends Poligono { ... } En general, una clase hija especializa a su clase padre. En este sentido la clase hija hereda la vista pública y privada de su clase padre, pero a su vez añade su propia vista pública y privada, sumando a los atributos y métodos de su padre los suyos propios. Es por esto que la relación de herencia responde a la regla “¿es un?”. Supóngase que se establece una relación de herencia entre una clase Cuadrado y una clase Polígono como en el ejemplo anterior. Entonces, se puede decir que un cuadrado “es un” polígono, sin embargo lo contrario no es cierto. La razón es que un cuadrado es una especialización P ROGRAMACIÓN ORIENTADA A OBJETOS 151 de un polígono, por tanto, presenta todas las características del polígono más las propias de ser un cuadrado. Cuando se establece una relación de herencia, hay que distinguir entre los atributos y métodos transmitidos de la clase padre a la clase hija, y los atributos y métodos añadidos por la clase hija. Así, la vista pública de la clase hija consiste en todos los métodos públicos transmitidos desde el padre, más los métodos públicos añadidos. La vista privada son los atributos y métodos privados transmitidos más los atributos y métodos privados añadidos. Aunque la vista privada de la clase padre se transmite a la clase hija, desde la clase hija no se puede acceder a la vista privada de la clase padre. Lo contrario violaría el principio de encapsulación. Por tanto, la clase hija sólo podrá utilizar los métodos de la vista pública de la clase padre. Una de las características que introduce la herencia es la posibilidad de redefinir métodos de la vista pública de la clase padre. La redefinición de un método de la clase padre consiste en proporcionar un método con el mismo nombre y argumentos en la clase hija. El método de la clase padre queda oculto por el método de la clase hija. La redefinición hace que la invocación de un mensaje a un objeto de la clase hija conteniendo el nombre del método redefinido desemboque en la llamada al método redefinido, en lugar del método original de la clase padre. 3.4.5 Polimorfismo El polimorfismo, en programación orientación a objetos, es la capacidad de varios objetos pertenecientes a clases diferentes de comportarse de forma homogénea. Esto permite por ejemplo proporcionar diferentes implementaciones para una misma operación. Supóngase que se tiene una clase que necesita enviar ficheros a un servidor para su publicación. El servidor, sobre el cual no se tiene control, puede aceptar envío de ficheros mediante los protocolos: ftp, sftp y scp. Dado que no se tiene control sobre el servidor, la clase que envía los ficheros debe ser capaz de soportar los tres protocolos. Se podría entonces pensar en una implementación como la siguiente: 1 import java . io . File ; 2 3 public class FilePublisher { 4 5 6 public void send ( FTPSender sender ) { File [] files = computeFilesToSend () ; 7 sender . send ( files ); 8 9 } 10 11 12 public void send ( SFTPSender sender ) { File [] files = computeFilesToSend () ; 152 PARADIGMAS Y MODELOS DE PROGRAMACIÓN 13 sender . send ( files ); 14 } 15 16 public void send ( SCPSender sender ) { File [] files = computeFilesToSend () ; 17 18 19 sender . send ( files ); 20 } 21 22 private File [] computeFilesToSend () { // ... return null; } 23 24 25 26 27 28 1 } import java . io . File ; 2 3 public class FTPSender { 4 private String username ; private String password ; 5 6 7 public FTPSender ( String user , String passwd ) { this. username = user ; this. password = passwd ; } 8 9 10 11 12 public void send ( File [] files ) { // ... } 13 14 15 16 1 } import java . io . File ; 2 3 public class SFTPSender { 4 5 6 private String username ; private String password ; 7 8 9 10 11 public SFTPSender ( String user , String passwd ) { this. username = user ; this. password = passwd ; } P ROGRAMACIÓN ORIENTADA A OBJETOS 153 12 public void send ( File [] files ) { // ... } 13 14 15 16 1 } import java . io . File ; 2 3 public class SCPSender { 4 private String username ; private String password ; 5 6 7 public SCPSender ( String user , String passwd ) { this. username = user ; this. password = passwd ; } 8 9 10 11 12 public void send ( File [] files ) { // ... } 13 14 15 16 } Esta implementación tiene graves problemas. En primer lugar, la inclusión de un nuevo método de envío de ficheros (por ejemplo, mediante WebDAV) obliga a cambiar la implementación de la clase FilePublisher. En segundo lugar, las tres clases que representan cada uno de los protocolos comparten cierta información común que podría ser definida en una clase padre. El polimorfismo, en este caso, consiste en definir una clase padre de las tres clases anteriores para los diferentes protocolos en cuya vista pública se define el método (o los métodos) que serán proporcionados por todas las clases hijas, pero sin especificar una implementación para ellos. Estos métodos sin implementación se denominan métodos abstractos, y se denotan mediante la palabra clave abstract y la ausencia de cuerpo. En Java, una clase que contiene métodos abstractos no puede ser instanciada (no se pueden crear objetos de la misma), dado que le faltan implementaciones de algunos de sus métodos, y por tanto la propia clase debe ser declarada como abstracta. La implementación de los métodos abstractos debe hacerse, por tanto, en las clases hijas, que proporcionarán implementaciones concretas de los mismos. De esta forma, diferentes clases hijas pueden proporcionar diferentes implementaciones de estos métodos abstractos. Las clases que utilizan la clase padre desconocen la implementación concreta que están manejando. Sin embargo, gracias a la definición en la vista pública de esta clase padre del correspondiente método abstracto, pueden estar seguras de que el objeto que 154 PARADIGMAS Y MODELOS DE PROGRAMACIÓN es pasado responde al mensaje declarado por dicho método abstracto. Así, la implementación del ejemplo anterior utilizando polimorfismo requiere de una clase padre de FTPSender, SFTPSender y SCPSender, que denominaremos FileSender. Esta clase proporcionará un método abstracto send que deberá ser implementado en cada una de las clases hijas utilizando el protocolo correspondiente: 1 import java . io . File ; 2 3 public abstract class FileSender { 4 private String username ; private String password ; 5 6 7 public FileSender ( String user , String passwd ) { this. username = user ; this. password = passwd ; } 8 9 10 11 12 public abstract void send ( File [] files ); 13 14 protected String getUsername () { return username ; } 15 16 17 18 protected String getPassword () { return password ; } 19 20 21 22 } En la implementación de la clase FileSender se han incluido aquellas características que se considera pueden ser comunes a cualquier implementación de envío de ficheros. En este caso un usuario y una contraseña con la que conectarse con el servidor. Dado que la implementación del envío de ficheros corresponde a las clases hijas, éstas deberán tener acceso a estos atributos; por ello se hace necesario definir métodos de acceso para ellos. Sin embargo, dada la característica especialmente sensible de esta información, se ha optado por implementar estos métodos como protegidos. Los métodos y atributos protegidos son accesibles desde las clases hijas, pero no desde otras clases. Esto impide que la clase FilePublisher (o cualquier otra que hiciera uso de FileSender o cualquiera de sus hijas) pueda acceder a la contraseña encapsulada dentro del objeto correspondiente. Una vez definida la clase FileSender, la implementación de las clases hijas se limita a un constructor que invoca al constructor de la clase padre (lo que en Java es obligatorio cuando la clase padre define explícitamente un constructor), y la definición del método abstracto send con la implementación concreta correspondiente: P ROGRAMACIÓN 1 ORIENTADA A OBJETOS 155 import java . io . File ; 2 3 public class FTPSender extends FileSender { 4 public FTPSender ( String user , String passwd ) { super( user , passwd ); } 5 6 7 8 public void send ( File [] files ) { // Implementa el envío mediante ftp } 9 10 11 12 1 } import java . io . File ; 2 3 public class SFTPSender extends FileSender { 4 public SFTPSender ( String user , String passwd ) { super( user , passwd ); } 5 6 7 8 public void send ( File [] files ) { // Implementa el envío mediante sftp } 9 10 11 12 1 } import java . io . File ; 2 3 public class SCPSender extends FileSender { 4 public SCPSender ( String user , String passwd ) { super( user , passwd ); } 5 6 7 8 public void send ( File [] files ) { // Implementa el envío mediante scp } 9 10 11 12 } La clase FilePublisher utiliza la clase padre FileSender en lugar de hacer referencia a las clases hijas directamente. De esta forma se independiza esta clase de la implementación concreta de envío de ficheros utilizada. El objeto pasado al método send puede ser un objeto de cualquiera de las clases hijas de FileSender: 156 1 PARADIGMAS Y MODELOS DE PROGRAMACIÓN import java . io . File ; 2 3 public class FilePublisher { 4 public void send ( FileSender sender ) { File [] files = computeFilesToSend () ; 5 6 7 sender . send ( files ); 8 } 9 10 private File [] computeFilesToSend () { // ... return null; } 11 12 13 14 15 16 } Esta implementación basada en el uso del polimorfismo permitiría añadir nuevas implementaciones de envío de ficheros sin tener que tocar la implementación de la clase FilePublisher. Basta con añadir una nueva clase hija de FileSender e implementar el método send como corresponda. 3.5 Programación concurrente La programación concurrente es una forma de diseño de programas en la cual éstos se construyen como colecciones de procesos (o hilos) que se ejecutan concurrentemente y que interactuan entre ellos. La programación concurrente surge de forma natural en determinadas aplicaciones como aquellas que tienen interfaz de usuario. En aplicaciones no concurrentes, cuando la aplicación está realizando una tarea pesada no es posible interactuar con su interfaz de usuario, dado que la aplicación está ocupada haciendo otras cosas. Por eso las aplicaciones con interfaz de usuario suelen ser concurrentes: al menos hay un hilo que es el encargado de recoger la interacción del usuario con la misma, mientras que puede haber otros hilos que estén realizando otras acciones dentro del mismo programa. La principal problemática de la programación concurrente consiste en las interacciones entre procesos que generalmente se deben a la gestión del estado que dichos procesos deben compartir, como se verá más adelante. Muchos lenguajes incluyen entre sus características la programación concurrente, y ésta no es incompatible con otros paradigmas. Así, por ejemplo, Java es un lenguaje concurrente orientado a objetos, mientras que Haskell es un lenguaje funcional que soporta también concurrencia. En esta sección utilizaremos como lenguaje de ejemplo PascalFC. PascalFC es un lenguaje para el aprendizaje de la programación concurrente desarrollado por Alan Burns y Geoff Davies, de la Universidad de York. P ROGRAMACIÓN CONCURRENTE 157 3.5.1 Concurrencia La concurrencia es el acaecimiento de varios sucesos al mismo tiempo. Desde el punto de vista de la programación concurrente, se dice que dos sucesos son concurrentes si uno sucede entre el comienzo y el fin de otro. La forma de ejecutar un programa concurrente puede variar en función del hardware disponible. En sistemas con un único procesador estos programas se ejecutan en el único procesador disponible y sus procesos internos deben compartir el tiempo de cómputo de este procesador. A este tipo de asignación mediante compartición de tiempo se le llama multiprogramación. Por contra, en sistemas donde hay varios procesadores es posible asignar diferentes procesadores a diferentes procesos. Se habla entonces de multiproceso. Es importante notar que aunque se disponga de varios procesadores, es muy posible que el número de procesos de los programas que deben ejecutarse en ellos sobrepase el número de procesadores disponibles. En tal caso, podría necesitarse multiprogramación incluso aunque existiera multiproceso. 3.5.2 Relaciones e interacciones entre procesos En un programa multiproceso los procesos interactúan de dos maneras distintas: pueden cooperar entre ellos para conseguir un objetivo común, o pueden competir por algún recurso que deben compartir pero que no pueden utilizar todos a la vez. Supóngase, por ejemplo, que se desea subir la última versión de un sistema operativo a diferentes servidores espejo localizados en diferentes continentes, de forma que los usuarios se descarguen la última versión desde un servidor cercano a ellos en lugar de hacerlo todos desde el mismo servidor centralizado. Podría escribirse un programa concurrente donde varios procesos cooperan subiendo cada uno de ellos el fichero a un servidor distinto. En este caso los procesos deben comunicarse para no subir el fichero dos veces al mismo servidor y repartirse adecuadamente el trabajo. La comunicación es por tanto un mecanismo necesario para conseguir la cooperación. Siguiendo con este mismo ejemplo, los diferentes procesos necesitan leer un fichero del disco duro fragmento a fragmento y enviarlo por la red al servidor que le haya tocado a cada uno de los procesos. Sin embargo, el acceso a disco para leer el fichero que hay que enviar no es concurrente, y los procesos deben organizarse para leer el fichero de uno en uno. A esto se le llama sincronización: los procesos se sincronizan para leer cada vez uno del disco. El disco es un recurso compartido de acceso exclusivo: sólo un proceso puede estar leyendo del disco cada vez. 3.5.3 Instrucciones atómicas e intercalación Los procesos en PascalFC se definen de forma similar a un procedimiento, pero utilizando las palabras reservadas process type en lugar de procedure. Un proceso puede recibir parámetros, tanto por valor como por referencia. Los parámetros pasados 158 PARADIGMAS Y MODELOS DE PROGRAMACIÓN por referencia pueden ser compartidos por diferentes procesos (si la misma referencia es pasada a todos ellos). Por ejemplo, el siguiente fragmento de código en PascalFC define un proceso que escribe por pantalla el valor de una variable: 1 2 3 4 process type print (i : integer); begin write( ’Soy el proceso ’, i); end; Una vez definido un proceso es posible declarar una o varias variables de tipo proceso en la sección de declaración de variables del programa principal. En el ejemplo anterior, se podría considerar declarar dos variables de tipo proceso print, p1 y p2 de la siguiente manera: 1 var p1 , p2 : print ; 2 En ejecución, se crearán dos procesos que ejecutarán la misma secuencia de instrucciones. Éste no tiene porqué ser siempre el caso, y como se verá más adelante un programa puede contener procesos que ejecutan acciones diferentes. Los procesos deben iniciarse para que comience su ejecución. En PascalFC esto se hace en un bloque cobegin-coend dentro del programa principal. Los procesos se inician utilizando la variable declarada previamente. Si reciben parámetros, éstos se especifican entre paréntesis como en la invocación de un procedimiento. Es importante notar que el orden en el que aparecen los procesos dentro de este bloque no es importante: los procesos se iniciarán en orden aleatorio. La ejecución del bloque no termina hasta que todos los procesos hayan terminado. A continuación se presenta el programa completo: 1 program print ; 2 3 4 5 6 process type print (i : integer); begin write( ’Soy el proceso ’, i); end; 7 8 var p1 , p2 : print ; 9 10 11 begin cobegin 12 p1 (1) ; p2 (2) ; 13 14 coend ; 15 16 end. P ROGRAMACIÓN CONCURRENTE 159 Cuando se desarrollan programas concurrentes es especialmente importante conocer la secuencia de instrucciones que se ejecutan. En el ejemplo anterior, el hecho de que los procesos p1 y p2 se ejecuten concurrentemente quiere decir que sus instrucciones pueden ejecutarse en cualquier orden. En principio, dado que cada uno de ellos ejecuta solamente una instrucción, podría pensarse que el programa sólo puede producir dos salidas diferentes: 1 Soy el proceso 1Soy el proceso2 o bien: 1 Soy el proceso 2Soy el proceso 1 Sin embargo, este programa puede producir en total cuatro salidas diferentes: 1 2 3 4 Soy Soy Soy Soy el el el el proceso proceso proceso proceso 1Soy el proceso 2 2Soy el proceso 1 Soy el proceso 21 Soy el proceso 12 Como se puede observar, en las dos últimas ejecuciones se intercalan las salidas de los procesos. Esto se debe a que la instrucción write, cuando recibe dos (o más) argumentos, se ejecuta realmente como dos (o más) instrucciones de escritura: 1 2 write( ’ Soy el proceso ’); write(i); En cambio, la instrucción write con un sólo argumento se ejecuta como una instrucción atómica. Las instrucciones atómicas son aquellas que son ejecutadas completamente antes de que se ejecute ninguna otra instrucción de cualquier otro proceso del programa. Por tanto, podemos estar seguros de que la cadena Soy el proceso se escribirá completamente en pantalla antes de que cualquier otra instrucción puede ejecutarse. Para comprender qué resultados puede producir un programa, es necesario estudiar las diferentes intercalaciones de instrucciones que se pueden producir. Una intercalación de instrucciones de un programa concurrente es una secuencia concreta de ejecución de las instrucciones atómicas de los procesos del programa. Así, partiendo del ejemplo anterior, las intercalaciones de instrucciones posibles son las que se muestran en la Tabla 3.3. Normalmente no todos los resultados son correctos y es necesario limitar ciertos resultados. En el ejemplo anterior, los resultados de las intercalaciones a), b), c) y d) no son correctos. La tarea de un programador concurrente consiste en evitar los resultados no deseados limitando la concurrencia lo mínimo posible. 160 PARADIGMAS Y MODELOS DE PROGRAMACIÓN (a) 1 2 3 4 P1 write(’Soy el proceso ’) P2 write(’Soy el proceso ’) write(1) write(2) (b) P1 1 2 3 4 P2 write(’Soy el proceso ’) write(’Soy el proceso ’) write(1) write(2) (c) P1 1 2 3 4 P2 write(’Soy el proceso ’) write(’Soy el proceso ’) write(2) write(1) (d) 1 2 3 4 P1 write(’Soy el proceso ’) P2 write(’Soy el proceso ’) write(2) write(1) (e) 1 2 3 4 P1 write(’Soy el proceso ’) write(1) P2 write(’Soy el proceso ’) write(2) (f) P1 1 2 3 4 P2 write(’Soy el proceso ’) write(2) write(’Soy el proceso ’) write(1) Tabla 3.3: Posibles intercalaciones de instrucciones P ROGRAMACIÓN 1 2 3 CONCURRENTE 161 load a, R1 add R1, 1 store R1, a Tabla 3.4: Instrucciones atómicas correspondientes a la instrucción a:=a+1 La única manera de saber qué resultados puede producir un programa concurrente es estudiar todas las posibles intercalaciones, es decir, todas las posibles secuencias de instrucciones atómicas de los procesos que se pueden dar. En el programa anterior, el número de intercalaciones es relativamente pequeño (6), pero en programas más grandes esta opción puede no ser viable. A la hora de construir programas concurrentes es especialmente importante, por tanto, conocer qué instrucciones del lenguaje son realmente instrucciones atómicas y cuáles no. En PascalFC, por ejemplo, las operaciones aritméticas no son atómicas. La instrucción a:=a+1, por ejemplo, no es una instrucción atómica, sino que se puede descomponer en las tres instrucciones atómicas mostradas en la Tabla 3.4. La instrucción load carga el valor de la variable especificada como primer argumento en el registro indicado como segundo argumento. La instrucción add suma el valor de la constante indicada con el valor del registro y deja el resultado en el mismo registro. La instrucción store guarda el valor del registro que se le pasa como parámetro en la variable especificada. Estas tres instrucciones atómicas se ejecutan secuencialmente. Dos procesos que ejecutaran concurrentemente el incremento de la misma variable compartida podrían producir resultados imprevistos. Por ejemplo, considérese el caso en el que el primer proceso (P1) lee la variable y la guarda en un registro del procesador antes de realizar el incremento. Entonces el segundo proceso lee también la variable y la guarda en su propio registro. Después ambos procesos, de forma secuencial, incrementan cada uno el valor de su registro y almacenen el resultado nuevamente en memoria. Si la variable a tenía originalmente el valor 2, el resultado final de la intercalación descrita sería 3, y no 4 como podría suponerse. 3.5.4 Modelos de programación concurrente En el paradigma de la programación concurrente existen dos modelos diferentes de programación. Estos dos modelos difieren en la forma en que los procesos se comunican, y tienen requisitos diferentes. En el modelo de paso de mensajes los procesos se comunican mediante mensajes, típicamente enviados a través de una red que los conecta. Este es el modelo habitual en aplicaciones distribuidas: aplicaciones donde diferentes procesos residen en diferentes nodos de una red. En el modelo de memoria compartida, los procesos comparten una memoria de acceso común, y por tanto pueden compartir variables a través de esta memoria. El requisito indispensable es que los procesos se ejecuten dentro de un sistema con memoria compartida entre los diferentes procesadores. Este es el caso habitual de las arquitecturas 162 PARADIGMAS Y MODELOS DE PROGRAMACIÓN monoprocesador o multiprocesador muy acopladas, que son las arquitecturas típicas de los portátiles y ordenadores de sobremesa. En esta sección se verá exclusivamente el modelo de memoria compartida. En este modelo, para comunicar procesos entre sí, se utilizarán variables compartidas entre los distintos procesos. En PascalFC para compartir una variable entre procesos es necesario declararla en el programa principal y pasarla por referencia a los procesos al iniciarlos dentro del bloque cobegin...coend. 3.5.5 Sincronización de procesos Habitualmente los procesos necesitan sincronizarse entre ellos. Esta sincronización puede ser de dos tipos: sincronización condicional y exclusión mutua. En la sincronización condicional un proceso debe esperar a que se active una determinada condición que sólo puede ser activada por un proceso distinto. En la exclusión mutua varios procesos compiten por un recurso compartido de acceso exclusivo. Este recurso puede ser la pantalla, la impresora, el disco, un fichero de log, etc. En la práctica, se dice que existe un conjunto de instrucciones, denominado sección crítica, que sólo puede estar ejecutando un proceso cada vez. Los procesos deben sincronizarse de forma que mientras uno está ejecutando la sección crítica, el resto de procesos que desean entrar en ella deben quedarse esperando. La sincronización de procesos puede llevarse a cabo sin mecanismos adicionales más allá de los que proporciona cualquier lenguaje de programación. Sin embargo, los lenguajes concurrentes proporcionan herramientas que facilitan el desarrollo de programas concurrentes, pero, al contrario de lo que ocurre en otros paradigmas, este conjunto de herramientas no es igual en todos los lenguajes. Unos lenguajes pueden proveer unas determinadas herramientas y otros otras. En PascalFC existen muchos tipos distintos de herramientas de sincronización de procesos, aunque sólo se estudiará una de ellas: los semáforos. Los semáforos son la herramienta más básica de sincronización de procesos, sin embargo, su uso simplifica enormemente el desarrollo de programas concurrentes. Un semáforo en PascalFC es un tipo abstracto de datos, y como tal tiene unas operaciones y unas estructuras de datos internas. Todo semáforo tiene un contador, que toma valores positivos (incluido el cero), y una lista de procesos asociada. El semáforo tiene tres operaciones (procedimientos) que se pueden invocar sobre una variable de este tipo: initial(s,v) Inicializa el contador del semáforo s al valor v (v>=0). Este procedimiento sólo puede ser invocado una vez, y debe llamarse desde el programa principal. Todo semáforo debe ser inicializado antes de poder utilizarse. wait(s) Este procedimiento sólo puede ser invocado desde un proceso. El efecto del mismo es el siguiente: P ROGRAMACIÓN CONCURRENTE 163 • Si el contador del semáforo s tiene un valor mayor que cero, el proceso continúa su ejecución y el valor del contador es decrementado en uno. • Si el contador del semáforo s tiene un valor igual a cero, el proceso se queda bloqueado y es añadido a la lista de procesos bloqueados del semáforo. signal(s) Este procedimiento sólo puede ser invocado desde un proceso. El efecto del mismo es el siguiente: • Si no hay procesos bloqueados en el semáforo s, se incrementa el valor del contador en una unidad. • Si hay procesos bloqueados en el semáforo, se elige aleatoriamente uno de ellos y se desbloquea para que continue su ejecución. El contador del semáforo permanece inalterado (con valor igual a cero). Los semáforos de PascalFC son generales aleatorios. Generales porque pueden tomar valores mayores o iguales que cero; aleatorios, porque de entre los procesos bloqueados, se desbloquea uno aleatoriamente. Además, los semáforos deben pasarse a los procesos por referencia, de forma que puedan ser compartidos entre ellos. En el caso de PascalFC este tipo de paso de parámetro se especifica anteponiendo la palabra reservada var al parámetro correspondiente. Los semáforos se pueden utilizar para implementar sincronización condicional y exclusión mutua. Por ejemplo, en el caso de la sincronización condicional, supóngase que dos procesos deben ejecutar dos tareas, representadas respectivamente por los procedimientos tarea1 y tarea2. La tarea 1 debe completarse antes de que se comience a ejecutar la tarea 2. Por tanto, el proceso que lleva a cabo la tarea 2 debe esperarse a que el proceso que realiza la tarea 1 termine. Este es un caso de sincronización condicional entre procesos. Para conseguir este comportamiento se puede utilizar un semáforo inicializado a cero. Este semáforo es pasado por referencia a los dos procesos. El proceso que lleva a cabo la tarea 2 invoca el procedimiento wait sobre el semáforo. El proceso que ejecuta la tarea 1 invoca el procedimiento tarea1 y posteriormente invoca signal sobre el semáforo. El siguiente código muestra la implementación de este ejemplo: 1 program sinccond ; 2 3 4 5 6 procedure tarea1 ; begin writeln( ’ Tarea 1 ’); end; 7 8 9 10 11 procedure tarea2 ; begin writeln( ’ Tarea 2 ’); end; 164 PARADIGMAS Y MODELOS DE PROGRAMACIÓN 12 13 14 15 16 17 process type proceso1 (var s : semaphore ); begin tarea1 ; signal (s); end; 18 19 20 21 22 23 process type proceso2 (var s : semaphore ); begin wait (s); tarea2 ; end; 24 25 var s : semaphore ; p1 : proceso1 ; p2 : proceso2 ; 26 27 28 29 30 begin initial (s ,0) ; cobegin p1 (s); p2 (s); coend ; 31 32 33 34 35 36 end. Para ejemplarizar la implementación de la exclusión mutua con semáforos, considérese el ejemplo inicial en el que dos procesos debían imprimir por pantalla quiénes eran. Este era el código del programa: 1 program print ; 2 3 4 5 6 process type print (i : integer); begin write( ’Soy el proceso ’, i); end; 7 8 var p1 , p2 : print ; 9 10 11 begin cobegin 12 p1 (1) ; p2 (2) ; 13 14 coend ; 15 16 end. P ROGRAMACIÓN CONCURRENTE 165 Este programa tiene el problema de permitir resultados que no son correctos. Los procesos deberían ejecutar completamente la instrucción write antes de que el otro proceso comenzara a ejecutar la suya. En este contexto, el recurso compartido de acceso exclusivo es la pantalla: mientras un proceso está escribiendo por pantalla el otro proceso no debería interferir con él. Desde el punto de vista de la exclusión mutua, la sección crítica es la instrucción write. La forma de poner esta instrucción bajo exclusión mutua es utilizar un semáforo, compartido por los dos procesos, inicializado a uno. Antes de la sección crítica, los procesos hacen un wait sobre dicho semáforo. Al salir de la sección crítica, hacen un signal. El primer proceso que hace el wait decrementa el valor del contador (poniéndolo a cero) y entra en la sección crítica. Si justo después llega el otro proceso, al hacer wait sobre el semáforo se quedará bloqueado (dado que el valor del contador es cero), hasta que el proceso que está ejecutando la sección crítica haga el signal al salir de la misma. A continuación se muestra la modificación del programa anterior para poner la escritura por pantalla bajo exclusión mutua: 1 program exmutua ; 2 3 4 5 6 7 8 process type print (var s : semaphore ; i : integer); begin wait (s); write( ’Soy el proceso ’, i); signal (s); end; 9 10 var s : semaphore ; p1 , p2 : print ; 11 12 13 14 begin initial (s ,1) ; cobegin p1 (s , 1) ; p2 (s , 2) ; coend ; 15 16 17 18 19 20 end. Con esta implementación sólo hay dos resultados posibles: 1 Soy el proceso 1Soy el proceso 2 o 1 Soy el proceso 2Soy el proceso 1 166 PARADIGMAS Y MODELOS DE PROGRAMACIÓN Ahora no hay otras intercalaciones posibles que las dos que hacen que uno de los procesos escriba completamente antes del otro. En este sentido se suele decir que hemos convertido la instrucción write(’Soy el proceso ’, i) en una instrucción atómica de grano grueso, es decir, aunque esta instrucción no es atómica, al ponerla bajo exclusión mutua se comporta como si lo fuera. 3.5.6 Sincronización avanzada Además de la sincronización condicional y la exclusión mutua, existen mecanismos de sincronización de procesos más avanzados. Para algunos de estos mecanismos ciertos lenguajes proporcionan herramientas concurrentes que hacen más sencilla la implementación de programas concurrentes correctos. Considérese un ejemplo en el que varios procesos tienen que realizar dos acciones (accion1 , accion2 ), pero ningún proceso puede comenzar la accion2 hasta que todos los demás procesos hayan terminado la accion1 . Este tipo de sincronización se denomina sincronización de barrera, porque el comportamiento es como si hubiera una barrera entre las acciones 1 y 2 que evita que pasen los procesos hacia la acción 2 hasta que no lleguen todos los demás a la barrera. La sincronización de barrera en este caso se puede implementar con un contador y un semáforo para la barrera inicializado a cero. Todos los procesos se quedarán bloqueados en la barrera excepto el último. Es necesario por tanto llevar una cuenta de los procesos que han llegado ya a la barrera, para lo cual se utiliza el contador. Sin embargo, dado que incrementar una variable no es una instrucción atómica, el incremento del contador debe hacerse bajo exclusión mutua, para lo que hace falta otro semáforo. El último proceso desbloquea a los demás invocando signal sobre la barrera tantas veces como número de procesos haya menos uno. Los demás, incrementarán el contador, liberarán la exclusión mutua del mismo e invocarán wait sobre el semáforo para la barrera, quedándose bloqueados. A continuación se muestra el código completo de la solución: 1 program sincbarr ; 2 3 4 const NPROCESOS = 4; 5 6 7 8 9 procedure accion1 ; begin writeln( ’ accion 1 ’); end; 10 11 12 13 14 15 procedure accion2 ; begin writeln( ’ accion 2 ’); end; P ROGRAMACIÓN 16 17 18 19 20 CONCURRENTE 167 process type proceso (var em , sb : semaphore ; var contador : integer); var i : integer; begin accion1 ; 21 wait ( em ); if contador < NPROCESOS -1 then begin contador := contador + 1; signal ( em ); wait ( sb ); end else begin signal ( em ); for i := 1 to NPROCESOS -1 do signal ( sb ); end; 22 23 24 25 26 27 28 29 30 31 32 33 34 35 accion2 ; 36 37 38 end; 39 40 var procesos : array[1.. NPROCESOS ] of proceso ; em : semaphore ; sb : semaphore ; cont : integer; i : integer; 41 42 43 44 45 46 47 begin initial (em , 1) ; initial (sb , 0) ; cont := 0; cobegin for i := 1 to NPROCESOS do procesos [i ]( em , sb , cont ); coend ; 48 49 50 51 52 53 54 55 end. Como se puede observar, antes de incrementar el contador es necesario comprobar si se trata del último proceso. Esta comprobación debe hacerse también bajo exclusión mutua. Por tanto la sección crítica aquí incluye la comprobación de la condición del if y el incremento de la variable contador. Obsérvese que aunque la condición se 168 PARADIGMAS Y MODELOS DE PROGRAMACIÓN haga falsa (señal de que es el último proceso), hay que liberar la exclusión mutua que se adquirió antes del if. En este ejemplo se ha incluido también una forma alternativa de iniciar los procesos que resulta más cómoda. Cuando el número de procesos es un número relativamente grande y son todos del mismo tipo, puede ser muy engorroso tener que iniciarlos todos dentro del bloque cobegin..coend listándolos uno por uno. En PascalFC es posible declarar un array del tipo proceso correspondiente (en este caso el tipo se llama proceso) y posteriormente iniciar los procesos utilizando un bucle for que itera por el array. 3.6 Programación con lenguajes dinámicos Aunque no sea un paradigma en sí mismo, la programación con lenguajes dinámicos presenta ciertas características que hacen forzosa una breve referencia a los mismos. Este tipo de lenguajes se caracterizan por tener un ciclo de desarrollo más ágil, tipado dinámico, lenguajes con mucho azúcar sintáctico (existen muchas maneras distintas de expresar los mismos conceptos), ser portables, entre otras. En esta sección se presentarán algunas de estas características y se dará una panorámica general de estos lenguajes utilizando como ejemplo el lenguaje Ruby. Ruby fue creado por el japonés Yukihiro Matsumoto en 1995 y se trata de un lenguaje dinámico orientado a objetos. Este lenguaje ha alcanzado una gran popularidad especialmente en el ámbito del desarrollo de aplicaciones web gracias al framework Rails construido sobre Ruby. De hecho, se suele referir el desarrollo de aplicaciones web con Rails como Ruby on Rails. Una característica importante de los lenguajes dinámicos es que no se compilan, son directamente ejecutados por un intérprete o una máquina virtual. La ausencia de etapa de compilación hace más ágil el desarrollo pero limita la cantidad de errores que puede filtrar el programador antes de ejecutar el programa. 3.6.1 Tipado dinámico Un sistema de tipos se encarga de asignar tipos de datos a valores que aparecen en el programa. Una de las características más destacables de los lenguajes dinámicos es el hecho de tener tipado dinámico. Un sistema de tipos con tipado dinámico asocia dichos tipos en tiempo de ejecución. Los lenguajes de programación con tipado estático realizan esta asociación en tiempo de compilación. Es importante señalar que esto no quiere decir que las variables o las expresiones no tengan tipo o que se pueden mezclar en una expresión operandos de tipos no compatibles. Todas las variables tienen un tipo asociado, lo que sucede es que dicha asociación tiene lugar en tiempo de ejecución; por ejemplo, al asignar valor por primera vez a una variable. Esto hace que en la mayoría de estos lenguajes no sea necesario declarar las variables. Algunos lenguajes dinámicos permiten que el mismo nombre de variable se asocie a P ROGRAMACIÓN CON LENGUAJES DINÁMICOS 169 diferentes tipos. Por ejemplo, el siguiente es un fragmento válido de Ruby (donde puts es una instrucción que imprime por pantalla el argumento pasado como parámetro): 1 2 3 4 a = 10 puts a * 2 a = " Cadena " puts a El sistema de tipos asociará a la variable a el tipo entero en la línea 1 y el tipo cadena de caracteres en la línea 3. Una consecuencia del tipado dinámico es que los errores de tipo sólo se pueden conocer en tiempo de ejecución. Por ejemplo, el siguiente fragmento de código produce un error en tiempo de ejecución: 2 a = " Cadena " puts a + 2 1 TypeError: can’t convert Fixnum into String 1 3.6.2 Portabilidad El hecho de que estos lenguajes se ejecuten directamente en un intérprete o mediante una máquina virtual hace que sean completamente portables. Si existe un intérprete o una máquina virtual para una arquitectura y un sistema operativo específico, entonces el programa puede correr en dicha combinación de arquitectura y sistema operativo. Así, Ruby dispone de intérpretes para Windows, MacOS y Linux. El alto grado de portabilidad de estos lenguajes los hace muy atractivos de cara a los desarrolladores, que pueden programar aplicaciones sin preocuparse del sistema en el que éstas se van a ejecutar. Esto contrasta con la poca portabilidad de otros lenguajes como C, donde la compilación del programa para una arquitectura diferente de aquella para la que se diseñó puede obligar a cambiar librerías y parte del código fuente original. 3.6.3 Tipos de datos En general los lenguajes dinámicos incorporan dentro del propio lenguaje algunos tipos de datos que en otros lenguajes aparecen como librerías. En el caso de Ruby, por ejemplo, el lenguaje proporciona soporte nativo de listas y diccionarios. Las listas son colecciones heterogéneas de elementos (pueden contener elementos de diferentes tipos) y sus literales se escriben entre corchetes con los elementos separados por comas. La lista vacía se representa por los corchetes vacíos: 170 1 2 PARADIGMAS Y MODELOS DE PROGRAMACIÓN frutas = [" manzana " , " platano " , " pera " , " melocotón "] puts frutas Se pueden añadir elementos a una lista con el operador «. Este operador añade elementos al final de la lista. Alternativamente se puede utilizar también el método push. Para acceder a un elemento de la lista se utiliza el operador corchetes, indicando el índice (los índices comienzan en cero): 1 2 3 4 5 6 1 2 c = [] c << 2 c << 3 c. push (4) puts c puts c [1] [2, 3, 4] 3 Los diccionarios, o arrays asociativos, son estructuras de datos que asocian un valor a una clave. La clave sólo puede aparecer una vez en el diccionario (es única), pero un mismo valor puede aparecer asociado a diferentes claves. Los diccionarios se representan de forma literal entre llaves, indicando los pares clave-valor separados por comas y separando la clave del valor por el operador =>. Cualquier objeto puede usarse de clave. El acceso a los elementos del diccionario se realiza utilizando la clave con la notación corchetes (como en los arrays): 2 dic = {"a" => 1, "b" => 2} puts dic ["a"] 1 1 1 Las listas en Ruby son objetos de la clase Array. Cuando se crea una lista utilizando la notación literal, se está creando un objeto Array incializado con los valores proporcionados. Los diccionarios son objetos de la clase Hash. No es posible crear objetos diccionario vacíos especificando las llaves vacías como se hace con los corchetes en el caso de las listas. En su lugar tenemos que crear explícitamente el objeto Hash. Para crear un objeto en Ruby utilizamos el nombre de la clase junto con el operador punto para invocar el método new, que crea un objeto de la clase especificada e invoca su constructor automáticamente. Si el constructor requiriera parámetros se podrían especificar a continuación de new entre paréntesis: P ROGRAMACIÓN 1 2 3 4 CON LENGUAJES DINÁMICOS 171 dic = Hash . new dic ["a"] = 1 dic ["b"] = 2 puts dic ["a"] Los dos fragmentos de código anteriores son equivalentes, pero en el segundo, en lugar de proporcionar el contenido del diccionario de manera literal, se ha creado un objeto Hash y después se han creado dos claves con sus correspondientes valores. Obsérvese cómo se ha utilizado la notación corchetes para crear los dos pares clave-valor en el diccionario. Ruby es un lenguaje orientado a objetos, por tanto el usuario puede definir sus propios tipos de datos mediante la definición de clases. La siguiente es una mínima definición de la clase Intervalo en Ruby: 1 2 class Intervalo end Las clases en Ruby pueden definir un constructor, si es necesario. En caso de tenerlo, éste debe llamarse initialize y puede llevar argumentos. Este constructor será invocado cuando se cree un objeto mediante Intervalo.new. Dado que en Ruby las variables no se declaran, los atributos tampoco. Es en el constructor donde podemos inicializar los atributos. A menos que éstos sean inicializados en el constructor, Ruby no tiene forma de saber que existen. Además, este lenguaje requiere que los atributos comiencen por el carácter @. En el caso de la clase Intervalo, donde un intervalo en la recta real se representa como un límite inferior y un límite superior, será necesario inicializar dos atributos. Cuando se implementa un constructor es necesario pensar si se pueden proporcionar valores por defecto a los atributos o si el programador al crear un nuevo objeto tendrá que proporcionar los valores de los mismos. En este caso, se optará por definir un constructor con dos parámetros que representan los límites inferior y superior: 1 2 3 4 5 6 class Intervalo def initialize ( limiteInferior , limiteSuperior ) @limiteInf = limiteInferior @limiteSup = limiteSuperior end end Obsérvese que no se indica el tipo en los parámetros del método initialize. Aunque no existe declaración alguna, @limiteInf y @limiteSup son atributos. Es importante dar valor a los atributos en el constructor. De otra manera, podría darse la circunstancia de intentar acceder a un atributo en un método de la clase que no ha sido previamente inicializado. 172 PARADIGMAS Y MODELOS DE PROGRAMACIÓN Como se ha podido observar, la declaración del constructor va precedida por la palabra reservada def. La declaración de métodos de la clase es equivalente. Considérese, por ejemplo la implementación de un método contiene que dado un valor numérico determina si está contenido dentro del intervalo (considerando el intervalo como un intervalo cerrado, es decir, que contiene los extremos): 1 class Intervalo 2 3 4 5 6 def initialize ( limiteInferior , limiteSuperior ) @limiteInf = limiteInferior @limiteSup = limiteSuperior end 7 8 9 10 def contiene ( valor ) return (( @limiteInf <= valor ) and ( valor <= @limiteSup )) end 11 12 end Por defecto en Ruby los métodos definidos en una clase tienen visibilidad pública. Para definir métodos privados hay que abrir una sección privada en la clase mediante la palabra reservada private. Por ejemplo, supongamos que quisiéramos poder comprobar la validez del intervalo, es decir, que el límite inferior es estrictamente menor que el límite superior. Podríamos utilizar un método privado valido invocado desde el constructor. Si el método devuelve true, los argumentos son copiados en los atributos del objeto, en caso contrario se eleva una excepción (una condición de error que puede ser capturada desde el código que llamó al constructor): 1 class Intervalo 2 3 4 5 6 7 8 9 10 def initialize ( limiteInferior , limiteSuperior ) if( valido ( limiteInferior , limiteSuperior )) then @limiteInf = limiteInferior @limiteSup = limiteSuperior else raise (" Intervalo no válido ") end end 11 12 13 14 def contiene ( valor ) return (( @limiteInf <= valor ) and ( valor <= @limiteSup )) end 15 16 17 private P ROGRAMACIÓN 18 19 20 21 CON LENGUAJES DINÁMICOS 173 def valido (inf , sup ) return ( inf < sup ) end end En ocasiones es necesario acceder a los atributos de otro objeto. Esta situación debe evitarse en la medida de lo posible, pero considérese el siguiente escenario: se desea implementar un método incluye que dado un intervalo determine si éste está incluido completamente dentro del intervalo definido por el objeto que recibe el mensaje. Podría pensarse en una implementación como la siguiente: 1 2 3 def incluye ( otroIntervalo ) return (( @limiteInf < otroIntervalo . limiteInf ) and ( otroIntervalo . limiteSup < @limiteSup )) end Sin embargo esta implementación no funciona. La razón es que en Ruby, a diferencia de lo que pasa en otros lenguajes como Java, un objeto no tiene acceso nunca a los atributos de otro objeto, aunque sean los dos de la misma clase. Por tanto, la única forma de poder implementar el método incluye es proporcionando métodos de acceso para los atributos. En este caso, basta con proporcionar métodos de lectura. En Ruby, los métodos de acceso se llaman simplemente como los atributos: 1 class Intervalo 2 3 4 5 6 7 8 9 10 def initialize ( limiteInferior , limiteSuperior ) if( valido ( limiteInferior , limiteSuperior )) then @limiteInf = limiteInferior @limiteSup = limiteSuperior else raise (" Intervalo no válido ") end end 11 12 13 14 def contiene ( valor ) return (( @limiteInf <= valor ) and ( valor <= @limiteSup )) end 15 16 17 18 def incluye ( otroIntervalo ) return (( @limiteInf < otroIntervalo . limiteInf ) and ( otroIntervalo . limiteSup < @limiteSup )) end 19 20 21 def limiteInf return @limiteInf 174 22 PARADIGMAS Y MODELOS DE PROGRAMACIÓN end 23 24 25 26 def limiteSup return @limiteSup end 27 28 private 29 30 31 32 33 def valido (inf , sup ) return ( inf < sup ) end end 3.6.4 Clausuras Las clausuras o bloques (closures en inglés) son métodos anónimos (sin nombre) que pueden recibir parámetros y asociarse a otros métodos o pasarlos como argumentos. En este sentido son similares a las funciones de primer orden en los lenguajes funcionales. Por ejemplo, supóngase que se desea ordenar un array de intervalos en función de su longitud. Ruby no puede saber cómo ordenar un array que contiene objetos de la clase Intervalo. Lo habitual es que el algoritmo de ordenación utilice un comparador de elementos para determinar el orden relativo de cada par de elementos a ordenar. Este comparador es proporcionado por el programador de la clase Intervalo, que conoce la lógica de ordenación de los intervalos (en este caso en base a su longitud). En Ruby, el método sort de la clase Array nos permite asociar un bloque a dicho método donde se especifique cómo se comparan dos elementos del array. Basándose en el valor devuelto por este bloque para determinados pares de elementos sort es capaz de ordenar el array completo: 1 require " Intervalo " 2 3 lista = [ Intervalo . new (5 ,10) , Intervalo . new (2 ,3) , Intervalo . new (100 ,1000) ] 4 5 listaOrdenada = lista . sort () {| e1 , e2 | e1 . longitud <=> e2 . longitud } El bloque se especifica a continuación de los argumentos, encerrado entre llaves. Si lleva argumentos, éstos se especifican entre barras verticales separados por comas. En este caso el resto del cuerpo es una expresión que utiliza el operador <=> que devuelve -1 si el primer operando es menor que el segundo, +1 si el primer operando es mayor que el segundo y 0 si son iguales. El método sort requiere que la comparación entre los dos valores pasados sea devuelta como -1, 0 ó +1. Obsérvese además que no se utiliza la P ROGRAMACIÓN CON LENGUAJES DINÁMICOS 175 palabra reservada return para devolver el valor. En Ruby, la última expresión evaluada es devuelta siempre, tanto en bloques como en métodos. En el caso de los bloques no puede utilizarse return, dado que el bloque no es un método en el sentido estricto, y la invocación de esta sentencia provocaría la salida del método sort, que es el que invoca realmente el bloque. El método sort implementa el algoritmo de ordenación y cada vez que lo requiere llama al bloque pasándole dos elementos del array que necesita comparar. El programador proporciona en este bloque la política de comparación que desee, desacoplando así la implementación del algoritmo de ordenación respecto al tipo de datos que se quiere ordenar. Para que esta ordenación funcione es necesario añadir el método longitud a la clase Intervalo. Si este método sólo fuera a utilizarse como un método interno de la clase, debería hacerse privado. En este caso, se supone que el método longitud es parte de la vista pública de la clase, y puede ser llamado por los usuarios de la clase: 1 2 3 def longitud return @limiteSup - @limiteInf end 3.6.5 Iteradores Ruby proporciona iteradores para manejar colecciones. Los iteradores son métodos que permiten recorrer una colección de elementos. El uso de iteradores oculta al programador la implementación concreta de la colección. Por ejemplo, considérese el siguiente fragmento de código Java: 1 2 3 for(int i = 0; i < lista . size () ; i ++) { print ( lista . get (i)); } En este fragmento de código demasiados aspectos internos de la implementación de la lista quedan al descubierto. En primer lugar es necesario saber si la indexación de los elementos en la lista comienza en cero o en uno. Además, también debe conocerse la longitud de la lista. Por último, se accede a los elementos utilizando el método get que dependiendo de la implementación puede ser muy costoso (si por ejemplo la lista no está implementada internamente como un array). En lugar de hacer al programador recorrer la lista, en Ruby las colecciones de elementos (como listas y diccionarios) proporcionan métodos que las recorren, y el programador sólo tiene que asociar un bloque a dichos métodos con la acción que desee invocar sobre cada elemento. El mismo fragmento de código anterior quedaría en Ruby como sigue: 1 lista = [1 ,2 ,3] 176 2 PARADIGMAS Y MODELOS DE PROGRAMACIÓN lista . each () {| e| puts e} Nótese como en este caso el programador queda liberado de recorrer la lista, o de conocer exactamente los índices o la forma de obtener elementos de la misma. El método each es un iterador de la clase Array. Por cada elemento de la lista (en este caso tres), invocará al bloque pasándole dicho elemento. El bloque se limita a imprimir por pantalla dicho elemento. Considérese un ejemplo un poco más elaborado: se desea sumar todos los elementos de la lista. Esto se puede hacer también con el iterador each, apoyándose en una variable local. Los bloques pueden utilizar variables locales definidas en el ambiente exterior al bloque. Esta es una gran ventaja de los bloques, dado que no es necesario pasar argumentos adicionales al iterador. En tiempo de ejecución el bloque creado incluye toda la información de variables accesibles desde el bloque, de forma que cuando es invocado desde el método each, tiene acceso a dicha información: 1 2 3 4 lista = [1 ,2 ,3] suma = 0 lista . each () {| e| suma = suma + e} puts suma Los diccionarios también definen un iterador each. En este caso, el iterador invoca el bloque asociado con dos argumentos: la clave y el valor. La siguiente podría ser una forma de imprimir el contenido de un diccionario por pantalla: 1 2 dic = {"a" = >1 , "b"= >2 , "c" = >3} dic . each () {|k ,v| puts k , v} Es posible que el programador defina sus propios iteradores. Supóngase que se desea implementar un iterador que recorra los valores de un intervalo (asumiendo que el intervalo se define sobre los números enteros). Para cada uno de esos valores se invocará el bloque asociado al iterador. La invocación del bloque asociado, dado que no tiene nombre, se realiza mediante la palabra reservada yield y puede llevar argumentos (se han obviado la mayoría de métodos por claridad): 1 class Intervalo 2 3 4 5 6 7 8 9 10 def initialize ( limiteInferior , limiteSuperior ) if( valido ( limiteInferior , limiteSuperior )) then @limiteInf = limiteInferior @limiteSup = limiteSuperior else raise (" Intervalo no válido ") end end E JERCICIOS RESUELTOS 177 11 def each for i in @limiteInf .. @limiteSup do yield(i) end end 12 13 14 15 16 17 private 18 19 def valido (inf , sup ) return ( inf < sup ) end 20 21 22 23 24 end Un iterador así definido podría utilizarse como sigue: 1 2 i = Intervalo . new (5 ,10) i. each () {| v| puts v} Y produciría el siguiente resultado: 1 2 3 4 5 6 5 6 7 8 9 10 3.7 Ejercicios resueltos 1. Escribe un programa funcional que dado un número real devuelva una 2-tupla con su parte entera y su parte decimal: 1 2 enteraDecimal :: Float -> (Integer, Float) enteraDecimal x = (truncate x , x - fromInteger (truncate x) ) En este ejercicio se han utilizado dos funciones predefinidas de Haskell: truncate y fromInteger. La primera, dado un número en coma flotante, devuelve su parte entera como un entero. La segunda, dado un entero, devuelve el número real correspondiente. La función fromIntegral es necesaria porque Haskell no permite mezclar operandos de distinto tipo. Como se ha comentado, truncate devuelve 178 PARADIGMAS Y MODELOS DE PROGRAMACIÓN un valor de tipo entero; por tanto, no podemos mezclar en la segunda expresión x y truncate x, puesto que tienen tipos diferentes. 2. Escribe un programa funcional que, dadas dos listas de enteros, determine si la segunda lista está incluida dentro de la primera. 1 2 3 4 esSublista esSublista esSublista if :: [Integer] -> [Integer] -> Bool [] l = False (a: restoA ) l2 = coincide (a: restoA ) l2 then True else esSublista restoA l2 5 6 7 8 9 10 11 12 coincide coincide coincide coincide :: [Integer] -> [Integer] -> Bool l [] = True [] l = False (a: restoA ) (b: restoB ) = 13 14 15 a == b && coincide restoA restoB 3. Se pide escribir un programa funcional que, dada una cadena de caracteres que contiene una frase, devuelva la lista de palabras de la frase considerando que las palabras están siempre separadas por espacios. Puede utilizarse la función reverse para invertir una lista si es necesario. 1 2 palabras :: String -> [String] palabras s = reverse ( partePalabras s [] []) 3 4 5 6 7 8 9 10 partePalabras :: String -> String -> [String] -> [String] partePalabras [] palabra listaPalabras = reverse palabra : listaPalabras partePalabras (c: restoCadena ) palabra listaPalabras = if c == ’ ’ then partePalabras restoCadena [] (reverse palabra : listaPalabras ) else partePalabras restoCadena (c: palabra ) listaPalabras La función principal de la solución es la función partePalabras. Esta función recibe tres parámetros. El primero es la cadena que hay que partir. Los otros dos son parámetros de acumulación. El segundo parámetro contiene los caracteres de E JERCICIOS RESUELTOS 179 la palabra que se está leyendo en un momento dado. El tercer parámetro contiene las palabras que ya se han partido, y es una lista de String. El caso base de la recursividad es cuando la cadena que hay que partir está vacía. Como se ha indicado anteriormente, un String en Haskell es una lista de Char, y por tanto puede tratarse como una lista a todos los efectos. Cada elemento de la lista será un elemento de tipo Char. En el caso base, la cadena se ha procesado completamente y se devuelve el tercer parámetro, que contendrá todas las palabras separadas por espacios que se han encontrado. El caso recursivo consiste en ir recorriendo la cadena de entrada hasta encontrar un espacio. Si el siguiente carácter de la cadena es un espacio, entonces el segundo parámetro contiene la palabra leída y ésta se concatena a la lista de palabras encontradas hasta el momento en el tercer parámetro. Si el siguiente carácter de la cadena no es un espacio, entonces hay que añadirlo a la palabra que se está reconociendo (segundo parámetro). Nótese que la utilización del constructor : hace que el segundo parámetro tenga la palabra escrita al revés, de ahí que al añadirla a la lista en la condición then del if se invierta primero. De igual manera, las palabras se añaden a la lista en orden contrario al que se encontraron en la cadena original, por eso en el caso base se invierte la lista resultante. 4. Implementa en Haskell el tipo Matriz y una función dimension que dada una matriz devuelva la dimensión de la misma (número de filas y número de columnas). Se permite utilizar la función predefinida length que dada una lista de cualquier tipo devuelve su longitud. 1 2 data Matriz = M ([ Fila ]) data Fila = F ([Integer]) 3 4 5 6 dimension :: Matriz -> (Int, Int) dimension (M(F( fila1 ): restoFilas )) = (length (F( fila1 ): restoFilas ) , length fila1 ) 5. Se tienen las siguientes reglas: R1 Para una empresa un programador es bueno si tiene experiencia y además o bien documenta el código o bien hace casos de test. R2 Un programador es regular si tiene experiencia pero no documenta ni hace casos de test. Además se sabe que: • Programador1 no tiene experiencia, pero hace casos de test aunque no documenta. 180 PARADIGMAS Y MODELOS DE PROGRAMACIÓN • Programador2 tiene mucha experiencia y documenta mucho el código, pero no hace casos de test. • Programador3 tiene experiencia pero no hace casos de test ni documenta. • Programador4 hace casos de test y documenta, y además tiene más de 10 años de experiencia. Se pide escribir un programa en Prolog que represente esta información y contestar a las siguientes cuestiones: (a) ¿Es programador1 un programador bueno? (b) ¿Es programador2 un programador bueno? (c) ¿Quiénes son los programadores buenos? (d) ¿Hay algún programador regular? 1 % Sistema experto 2 3 4 5 6 programador_bueno (X) :experiencia (X) , ( documenta (X); test (X)). 7 8 9 10 11 programador_regular (X) :experiencia (X) , not( test (X)) , not( documenta (X)). 12 13 % Base de conocimiento 14 15 16 17 experiencia ( programador2 ). experiencia ( programador3 ). experiencia ( programador4 ). 18 19 20 documenta ( programador2 ). documenta ( programador4 ). 21 22 23 1 2 test ( programador4 ). test ( programador1 ). programador_bueno(programador1). false. 3 4 5 programador_bueno(programador2). true E JERCICIOS RESUELTOS 181 6 7 8 9 10 programador_bueno(X). X = programador2 ; X = programador4 ; X = programador4. 11 12 13 14 programador_regular(X). X = programador3 ; false. 6. Dos ciudades están conectadas por AVE si existe una línea de AVE directa entre ellas, o se pueden coger varias líneas de AVE para llegar de una a otra. Se tiene la siguiente información: • Sevilla y Madrid están conectadas por una línea de AVE. • Madrid y Valladolid están conectadas por una línea de AVE. • Madrid y Barcelona están conectadas por una línea de AVE. • Valladolid y Gijón están conectadas por una línea de AVE. • Málaga y Valencia están conectadas por una línea de AVE. Se pide escribir en Prolog una regla que determine si dos ciudades están conectadas, ya sea directamente o a través de otras ciudades. Se pide también contestar las siguientes preguntas: (a) ¿Están conectadas Sevilla y Barcelona? (b) ¿Están conectadas Málaga y Valladolid? (c) ¿A qué ciudades está conectada Sevilla? 1 2 3 4 5 conectadas (X ,Y) :ave (X ,Y). conectadas (X ,Y) :ave (X ,Z) , conectadas (Z ,Y). 6 7 8 9 10 11 1 2 ave ( sevilla , madrid ). ave ( madrid , valladolid ). ave ( madrid , barcelona ). ave ( valladolid , gijon ). ave ( malaga , valencia ). conectadas(sevilla,barcelona). true 182 PARADIGMAS Y MODELOS DE PROGRAMACIÓN 3 4 5 conectadas(malaga,valladolid). false. 6 7 8 9 10 11 12 conectadas(sevilla,X). X = madrid ; X = valladolid ; X = barcelona ; X = gijon ; false. 7. Uno de los grandes problemas a la hora de licenciar software libre es decidir bajo qué licencia liberar dicho software. Existen ciertas compatibilidades entre licencias que pueden ayudar a tomar dicha decisión. Consultando a un experto se ha obtenido la siguiente información: • Las siguientes licencias son licencias permisivas: MIT/X11, BSD-new y Apache 2.0. • Las siguientes licencias son poco restrictivas: LGPL v2.1, LGPL v3, MPL 1.1. • Las siguientes licencias son muy restrictivas: GPL v2, GPL v3. • Software con licencia MIT/X11 se puede combinar con software con licencia BSD-new. • Software con licencia BSD-new se puede combinar con software licenciado bajo LGPL v2.1, LGPL v3, MPL 1.1 y Apache 2.0. • Software con licencia Apache 2.0 se puede combinar con software licenciado bajo LGPL v3. • Software licenciado bajo LGPL v2.1 se puede combinar con software bajo la LGPL v3 y GPL v2. • Software con licencia LGPL v3 se puede combinar con software licenciado con GPL v3. • Software con licencia GPL v2 se puede combinar con software con licencia GPL v3. Se pide contestar a las siguientes cuestiones: • ¿Se puede combinar software con licencias Apache 2.0 y GPL v3.? • ¿Con qué licencia/s se puede combinar software licenciado bajo Apache 2.0? • ¿Hay alguna licencia poco restrictiva compatible con MIT/X11? E JERCICIOS 1 2 3 4 5 6 7 8 9 10 RESUELTOS compatible_directa ( ’ MIT / X11 ’, ’BSD - new ’). compatible_directa ( ’BSD - new ’, ’ LGPL v2 .1 ’). compatible_directa ( ’BSD - new ’, ’ LGPL v3 ’). compatible_directa ( ’BSD - new ’, ’MPL 1.1 ’). compatible_directa ( ’BSD - new ’, ’ Apache 2.0 ’). compatible_directa ( ’ Apache 2.0 ’, ’ LGPL v3 ’). compatible_directa ( ’ LGPL v2 .1 ’, ’LGPL v3 ’). compatible_directa ( ’ LGPL v2 .1 ’, ’GPL v2 ’). compatible_directa ( ’ LGPL v3 ’, ’GPL v3 ’). compatible_directa ( ’ GPL v2 ’, ’ GPL v3 ’). 11 12 13 compatible (A ,B) :compatible_directa (A ,B). 14 15 16 17 compatible (A ,B) :compatible_directa (A ,C) , compatible (C ,B). 18 19 20 21 permisiva ( ’MIT / X11 ’). permisiva ( ’BSD - new ’). permisiva ( ’ Apache 2.0 ’). 22 23 24 25 poco_restrictiva ( ’ LGPL v2 .1 ’). poco_restrictiva ( ’ LGPL v3 ’). poco_restrictiva ( ’ MPL 1.1 ’). 26 27 1 2 muy_restrictiva ( ’GPL v2 ’, ’GPL v3 ’). compatible(’Apache 2.0’, ’GPL v3’). true 3 4 5 6 7 compatible(’Apache 2.0’, X). X = ’LGPL v3’ ; X = ’GPL v3’ ; false. 8 9 10 11 12 13 14 15 compatible(’MIT/X11’, X), poco_restrictiva(X). X = ’LGPL v2.1’ ; X = ’LGPL v3’ ; X = ’MPL 1.1’ ; X = ’LGPL v3’ ; X = ’LGPL v3’ ; false. 183 184 PARADIGMAS Y MODELOS DE PROGRAMACIÓN 8. Se desea implementar un carrito de la compra en Java para una tienda online que vende dos tipos de productos: libros y cds. Tanto los libros como los cds tienen un título y un precio sin IVA. En el precio final a los libros se les aplica el 8% de IVA, mientras que a los cds se les aplica el 18%. Se pide implementar un carrito de la compra de forma que se puedan añadir productos al mismo y permita calcular el precio total de los productos en el carrito (IVA incluido). 1 package es . sidelab . libro . lpp . ejercicioresuelto1 ; 2 3 public abstract class Producto { 4 private String titulo ; private float precio ; 5 6 7 public Producto ( String titulo , float precio ) { this. titulo = titulo ; this. precio = precio ; } 8 9 10 11 12 public abstract float getPrecioConIVA () ; 13 14 public float getPrecio () { return precio ; } 15 16 17 18 1 } package es . sidelab . libro . lpp . ejercicioresuelto1 ; 2 3 public class Libro extends Producto { 4 public Libro ( String titulo , float precio ) { super( titulo , precio ); } 5 6 7 8 @Override public float getPrecioConIVA () { return getPrecio () * 1.08 f; } 9 10 11 12 13 14 1 } package es . sidelab . libro . lpp . ejercicioresuelto1 ; 2 3 public class Disco extends Producto { E JERCICIOS RESUELTOS 185 4 public Disco ( String titulo , float precio ) { super( titulo , precio ); } 5 6 7 8 @Override public float getPrecioConIVA () { return getPrecio () * 1.18 f; } 9 10 11 12 13 14 1 } package es . sidelab . libro . lpp . ejercicioresuelto1 ; 2 3 4 import java . util . ArrayList ; import java . util . List ; 5 6 public class Carrito { 7 private List productos ; 8 9 public Carrito () { productos = new ArrayList () ; } 10 11 12 13 public void addProducto ( Producto p) { productos . add (p); } 14 15 16 17 public float getPrecioTotal () { float acum = 0.0 f; for(int i = 0; i < productos . size () ; i ++) { Producto p = ( Producto ) productos . get (i); acum += p. getPrecioConIVA () ; } return acum ; } 18 19 20 21 22 23 24 25 26 } 9. Se desea implementar en PascalFC una aplicación cliente/servidor. El cliente pide un dato al servidor y debe esperar hasta que éste le conteste, después lo imprime por pantalla. El servidor debe esperar hasta que le llegue una petición y contestará con un número entero generado al azar (mediante la función random(n) que genera un número aleatorio entre 1 y n). 186 PARADIGMAS 1 Y MODELOS DE PROGRAMACIÓN program cliserv ; 2 3 4 5 6 7 8 process type pcliente (var peticion , respuesta : semaphore ; var dato : integer); begin signal ( peticion ); wait ( respuesta ); writeln( ’ Datos recibidos : ’, dato ); end; 9 10 11 12 13 14 15 process type pservidor (var peticion , respuesta : semaphore ; var dato : integer); begin wait ( peticion ); dato := random (100) ; signal ( respuesta ); end; 16 17 var peticion , respuesta : semaphore ; cliente : pcliente ; servidor : pservidor ; dato : integer; 18 19 20 21 22 23 begin initial ( peticion , 0) ; initial ( respuesta , 0) ; cobegin cliente ( peticion , respuesta , dato ); servidor ( peticion , respuesta , dato ); coend ; 24 25 26 27 28 29 30 end. 10. Implementa la clase Intervalo en Ruby y una clase Gestor que gestione un conjunto de invervalos con un método addIntervalo para añadir un nuevo intervalo al gestor. Se pide también implementar un método eachMayores(n) que recorra la lista de intervalos de mayor longitud que n. 1 class Intervalo 2 3 4 5 6 7 def initialize ( limiteInferior , limiteSuperior ) if( valido ( limiteInferior , limiteSuperior )) then @limiteInf = limiteInferior @limiteSup = limiteSuperior else E JERCICIOS 8 9 10 PROPUESTOS 187 raise (" Intervalo no válido ") end end 11 12 13 14 def longitud return @limiteSup - @limiteInf end 15 16 private 17 18 19 20 21 1 def valido ( inf , sup ) return ( inf < sup ) end end require " Intervalo " 2 3 class Gestor 4 5 6 7 def initialize @intervalos = [] end 8 9 10 11 def addIntervalo (i) @intervalos << i end 12 13 14 15 16 17 18 19 20 def eachMayores (n) @intervalos . each () { |e| if e. longitud > n then yield (e) end } end end 3.8 Ejercicios propuestos 1. Amplia el programa funcional del ejercicio resuelto 3 para que se pueda partir la frase utilizando como separador cualquier carácter de una lista dada. 2. Un camino se puede definir como una secuencia de puntos que deben visitarse en orden para ir de un extremo al otro. Cada punto viene definido por sus coordenadas x e y en un plano. La distancia entre dos puntos se puede calcular 188 PARADIGMAS Y MODELOS DE PROGRAMACIÓN p como (x2 − x1 )2 + (y2 − y1 )2 . Se pide definir los tipos de datos necesarios y una función longitud, en Haskell, que dado un camino calcule la longitud total del mismo. 3. Amplia el programa Haskell del ejercicio resuelto 4 incluyendo una función suma que dada una matriz devuelva la suma de todos los elementos de la misma. 4. Auméntese el sistema de reglas del programa Prolog del ejercicio resuelto 5 con la siguiente regla: R3 Un programador es una joven promesa si no tiene experiencia pero documenta y hace casos de test. ¿Hay alguna promesa entre los cuatro programadores del ejercicio 5? 5. Un servidor contiene ficheros binarios y ficheros de texto que los clientes pueden descargar del mismo. Los ficheros binarios tienen un tamaño en bytes y no se pueden comprimir. Los ficheros de texto tienen también un determinado tamaño en bytes y un ratio de compresión, que es lo máximo que se pueden comprimir. Los ficheros de texto se envían siempre comprimidos. El administrador desea disponer de estadísticas sobre los ficheros descargados por los clientes. Concretamente, se desea saber la cantidad de bytes transferidos por la red. Escribir un programa en Java para calcular, dada una lista de ficheros, la cantidad de bytes que se transferirían si un cliente los descargara todos. 6. Modifica el programa PascalFC del ejercicio resuelto 9 para que el cliente realice 10 peticiones y el servidor las atienda. 7. Implementa en Ruby una clase Matriz que represente un array de filas que componen una matriz. La clase debe incluir los siguientes métodos: (a) addFila, que añade una fila a la matriz. (b) eachFila(i) que recorre la fila i-ésima de la matriz invocando el bloque asociado. (c) eachColumna(i) que recorre la columna i-ésima de la matriz invocando el bloque asociado. 3.9 Notas bibliográficas Para la elaboración de este capítulo se han utilizado diferentes fuentes en función del paradigma abordado. La parte de programación funcional se ha basado principalmente en [24], aunque también es recomendable la lectura de [20]. Para la parte de programación lógica se utilizó [26]; aunque un poco antiguo, presenta adecuadamente el N OTAS BIBLIOGRÁFICAS 189 lenguaje. [3] es otro libro sobre Prolog más reciente, pero que presenta los conceptos también de forma ordenada y gradual. El paradigma orientado a objetos está basado en [5]. Otro libro adecuado para aprender programación orientada a objetos desde el diseño de programas es [29]. Para la parte de programación concurrente se utilizó principalmente el libro [30]. Para una introducción a la programación concurrente con Java, [13] es uno de los libros más recomendables. La parte de lenguajes dinámicos fue preparada utilizando principalmente material de [27] (disponible también online en http://www.ruby-doc.org/docs/ProgrammingRuby/). Otro buen libro sobre Ruby es [12], co-escrito por el propio autor del lenguaje. Capítulo 4 Lenguajes de marcado. XML El objetivo de este capítulo es introducir al lector en los fundamentos del lenguaje de marcado XML. Se presentan las principales tecnologías XML, pero no de una forma exhaustiva, sino para que el lector conozca sus posibilidades y principales características. Para un estudio más profundo de dichas tecnologías se remitirá al lector a otras referencias en las notas bibliográficas. Además, este capítulo permite al alumno aplicar conceptos de lenguajes de programación estudiados en los capítulos anteriores a los lenguajes de marcado. Por ejemplo, analizar que un lenguaje es válido respecto a su gramática, en este caso esquema (DTD o XSD), cómo se definen los lenguajes concretos de marcado, los mecanismos de creación de nuevos tipos de datos, etc. 4.1 Introducción Una anotación, que también se suele denominar metadato o metainformación, es una información añadida a un documento que no forma parte del mensaje en sí mismo. Las anotaciones permiten hacer explícita una información que puede estar implícita en el propio texto, o que de alguna manera se quiere hacer patente para facilitar así el aprovechamiento y procesamiento de los documentos. Los lenguajes de marcado, o de anotaciones, son un conjunto de reglas que describen cómo deben realizarse las anotaciones, bajo qué condiciones se permiten y, en ocasiones, su significado. Las anotaciones de un lenguaje de marcado deben poder distinguirse sintácticamente del texto al que se refieren. Por ejemplo: 1 <s >El día <date > 21/11/2000 </ date > tuvo lugar ... </s > contiene un segmento de texto anotado con la etiqueta <s>, que se refiere a una sentencia, dentro del que se encuentra una fecha anotada con la etiqueta <date>. Esas anotaciones permitirán delimitar dichos elementos estructurales para, por ejemplo, seleccionar sentencias completas o recuperar las fechas que aparecen en un documento. La notación 191 192 L ENGUAJES DE MARCADO . XML utilizada para las anotaciones (<s>, <date>, </s>, </date>) permite distinguirlas sintácticamente del texto. Se pueden distinguir los siguientes tipos de lenguaje de marcado: Presentacional: describen fundamentalmente operaciones tipográficas. Una buena parte de los procesadores de textos comerciales usan este tipo de lenguajes, que no ve el usuario, y que producen el efecto WYSIWYG (What You See Is What You Get). Procedimental: las anotaciones están asociadas a macros o programas que procesan el texto. Ejemplos de este tipo de lenguajes son LaTeX y PostScript. El procesador del lenguaje analiza el documento y genera la versión "visible" resultado de aplicar las anotaciones. El usuario edita y ve las anotaciones. Estructural o descriptivo: describen la estructura lógica de un documento, pero no su tipografía. Este tipo de lenguajes hacen uso de hojas de estilo para ser interpretadas por visualizadores o impresoras. Ejemplos de este tipo de lenguajes son SGML y XML. Híbrido: combinación de los anteriores. Por ejemplo, HTML es un lenguaje de marcado estructural (por ejemplo las etiquetas head o body), pero también se puede considerar procedimental ya que buena parte de sus anotaciones tienen asociado un resultado tipográfico concreto (por ejemplo la etiqueta font). Dentro de los lenguajes de marcado en este capítulo nos centraremos en los de tipo estructural, en particular en XML. SGML y XML se suelen denominar metalenguajes porque permiten definir lenguajes de marcado. Es decir, el usuario puede definir las anotaciones que considere convenientes y a priori dichas anotaciones no tienen ningún significado concreto. Serán las aplicaciones que usen dichas anotaciones las que las doten de la semántica oportuna. Uno de los primeros metalenguajes fue SGML (Standard Generalized Markup Language). Aunque su origen es anterior a 1986, procedía del lenguaje GML de IBM, y se estandarizó en ese año en la International Organization for Standardization (ISO 8879:1986). SGML se diseñó con dos principales objetivos1 : • Las anotaciones deben describir la estructura de un documento y otros atributos en lugar de describir la manera en que van a ser procesados. Al ser anotaciones estructurales solo necesitan realizarse una vez, lo que será suficiente para futuros procesamientos. • Las anotaciones deben ser rigurosas de manera que las técnicas disponibles para procesar objetos, como programas y bases de datos, se puedan aplicar también al procesamiento de documentos. 1 http://en.wikipedia.org/wiki/Standard_Generalized_Markup_Language I NTRODUCCIÓN 193 SGML define la estructura de un tipo o clase de documento con lo que se denomina DTD (Document Type Definition). Una DTD es un conjunto de declaraciones que definen un tipo de documento perteneciente a la familia de lenguajes de marcado SGML (SGML, XML, HTML). En el apartado 4.4 veremos la sintaxis de las DTD. Un lenguaje de marcado muy conocido por su uso en la Web es HTML. HTML (HyperText Markup Language) es un lenguaje de marcado que permite formatear dinámicamente texto e imágenes de páginas web. Es un lenguaje de marcado definido en SGML y no es un metalenguaje ya que tiene un número fijo de etiquetas o anotaciones con un significado preestablecido. Entre las etiquetas estructurales están, por ejemplo, las que describen un título, párrafo, enlace, etc. Empezó a desarrollarse en 1989 por Tim Berners-Lee en el Laboratorio Europeo de Física de Partículas (CERN) con el objetivo de presentar información estática. Actualmente es el lenguaje de marcado más utilizado para páginas web. Un documento HTML puede embeber scripts para determinar el comportamiento de las páginas web HTML, que pueden estar escritos por ejemplo en JavaScript, PHP, etc. También puede tener asociada una hoja de estilo tipo Cascading Style Sheets (CSS) para definir la apariencia de los contenidos. XML (Extensible Markup Language), el lenguaje de marcado en el que nos centraremos en este capítulo, es una forma restringida de SGML optimizada para su utilización en Internet y al igual que SGML es un metalenguaje. Fue descrito en 1996 por el consorcio WWW (World Wide Web Consortium, W3C2 ) con los siguientes objetivos iniciales principales: • Abordar los retos de la publicación electrónica a gran escala a través de un lenguaje estructurado, extensible y que pudiera validarse. • Permitir el intercambio y transmisión de una amplia variedad de datos en la web. XML describe una clase de objetos de datos (documentos XML) y parcialmente el comportamiento de los programas que los procesan. XML está definido en la denominada Especificación 1.03 que proporciona su especificación y los estándares adoptados para los caracteres (Unicode e ISO/IEC 10646)4 , identificación de lenguas5 , códigos de nombres de lenguajes6 y códigos de nombres de países7 . La especificación contiene toda la información necesaria para comprender XML y para poder construir programas que procesen documentos XML. Para finalizar esta introducción vamos a distinguir entre diferentes tipos de software relacionado con XML. 2 http://www.w3.org/ 3 http://www.w3.org/TR/xml/ 4 http://www.iso.org/iso/home.htm 5 http://tools.ietf.org/html/rfc3066 6 http://www.loc.gov/standards/iso639-2/php/English_list.php 7 http://www.iso.org/iso/english_country_names_and_code_elements 194 L ENGUAJES DE MARCADO . XML • Procesador XML: módulo de software que proporciona acceso al contenido y estructura de un documento XML. • Aplicación XML: emplea un procesador XML para acceder al contenido y estructura de un documento XML. Un ejemplo sería el navegador Internet Explorer a partir de la versión 5.0. • Analizador XML: es un componente que requiere el procesador para determinar la estructura de un documento XML y si es válido o no conforme a una DTD o XSD (XML Schema Definition). 4.2 Componentes de un documento XML Los componentes de un documento XML son los siguientes: etiquetas, elementos, comentarios, sección CDATA, entidades, instrucciones de procesamiento y prólogo. A continuación vamos a ver cada uno de estos elementos en detalle. 4.2.1 Elementos Un elemento es un conjunto de datos del documento delimitado por etiquetas de comienzo, "<>", y fin de elemento, "</>". Cada elemento representa un componente lógico del documento y puede contener otros elementos. Normalmente contendrá datos de tipo carácter como texto, tablas, etc. En el siguiente ejemplo: 1 <s >El día <date > 21/11/2000 </ date > tuvo lugar ... </s > se muestra el elemento s delimitado por sus correspondientes etiquetas de principio y de fin. Su contenido es texto más el elemento date, que a su vez también está delimitado por sus etiquetas de inicio y de fin, y cuyo contenido también es de tipo texto. 4.2.2 Etiquetas Delimitan los elementos de un documento XML. La sintaxis de la etiqueta de comienzo de elemento es: <nombreElemento>; y la sintaxis de la etiqueta de fin de elemento: </nombreElemento>. El nombre del elemento es nombreElemento. En el siguiente ejemplo: 1 < titulo > Introducción </ titulo > las etiquetas de inicio y fin delimitan al elemento titulo. Las etiquetas pueden incluir uno o más atributos. Un atributo hace explícita una propiedad del elemento y se representa en la etiqueta de comienzo con la siguiente sintaxis: nombreAtributo = "valorAtributo". Por ejemplo, en: C OMPONENTES 1 < doc lang =" en " > DE UN DOCUMENTO XML 195 <p id ="1" > el elemento doc tiene el atributo lang con valor en, que según los estándares significa que la lengua del contenido de dicho elemento es el inglés. El elemento p, que suele corresponder a un párrafo, tiene el atributo id con valor 1, que es un atributo de identificación de dicho elemento en el conjunto del documento. XML permite que haya elementos vacíos, es decir, elementos que sólo constan de una etiqueta de inicio, con una sintaxis particular, y atributos. La sintaxis de un elemento vacío es la siguiente: <nombreElemento atrib1="va1" .../>. En el siguiente ejemplo: 1 < imagen fichero =" imagen . gif "/ > se presenta el elemento vacío imagen que contiene el atributo fichero, cuyo valor es el nombre de un fichero que contiene una imagen en formato gif. 4.2.3 Comentarios Los comentarios en XML tienen la misma funcionalidad que los comentarios en los lenguajes de programación. No forman parte del contenido del documento que será analizado, es decir, no serán procesados por el analizador. Un comentario es un texto que se escribe entre los símbolos de apertura de comentario "<!--" y de cierre de comentario "-->". Por ejemplo: 1 <! -- esto es un ejemplo de texto comentado -- > La cadena "--" no puede aparecer dentro del contenido de un comentario ya que forma parte de la secuencia de caracteres delimitadores. En XML los comentarios pueden aparecer en cualquier punto del documento excepto dentro de las declaraciones, dentro de las etiquetas y dentro de otros comentarios. 4.2.4 Sección CDATA Se utiliza para expresar bloques de texto que contienen caracteres que de otra manera serían reconocidos como etiquetas por el analizador. Se podrá mostrar su contenido, pero las anotaciones que contenga no serán analizadas. Su sintaxis es la siguiente: 1 <![CDATA[ texto que no se quiere analizar ]] > Veamos un ejemplo: 1 <![CDATA[ <saludo > Hola </ saludo > ]] > 196 L ENGUAJES DE MARCADO . XML <saludo> y </saludo> serán reconocidos como caracteres del contenido del documento y no como etiquetas XML. Las secciones CDATA no se pueden anidar. 4.2.5 Entidades Una entidad permite asignar un nombre (referencia) a un subconjunto de datos (texto) y utilizar ese nombre para referirnos a dicho subconjunto. Las entidades pueden aparecer en dos contextos: el documento y la DTD. La sintaxis de una entidad consta de la marca de comienzo de una referencia a una entidad "&" y la del final ";". En el apartado 4.4.3 se verá en mayor detalle el uso y declaración de los distintos tipos de entidades. XML especifica 5 entidades predefinidas que permiten representar 5 caracteres especiales, de modo que se interpreten por el analizador o el procesador con el significado que tienen por defecto en XML: • &amp; para el carácter "&". • &lt; para el carácter "<". • &gt; para el carácter ">". • &apos; para el carácter "’". • &quot; para el carácter """. Las entidades predefinidas y las secciones CDATA permiten, por ejemplo, que dentro de un documento XML se puedan escribir etiquetas de comienzo y de fin de elemento sin que sean tomadas como tales. Un caso claro de aplicación sería un libro que describiera el lenguaje XML o HTML y que estuviera escrito utilizando XML. A través de las entidades se puede también hacer referencia a caracteres utilizando el valor numérico de su codificación. La referencia puede hacerse con el valor decimal del carácter utilizando la sintaxis: &#Num;, o bien con su valor en hexadecimal con la sintaxis: &#xNum;. Por ejemplo, &#169; es una referencia al valor decimal del signo de copyright y &#xA9; es la referencia al mismo carácter en hexadecimal8 . 4.2.6 Instrucciones de procesamiento Se utilizan para proporcionar información a la aplicación o programa que va a procesar el documento XML. Su sintaxis es: 1 <? Instrucción procesamiento ? > 8 En http://www.unicode.org/charts/ se recopilan los códigos UNICODE y los caracteres asociados. C OMPONENTES DE UN DOCUMENTO XML 197 Los analizadores XML no hacen nada con las instrucciones de procesamiento: las pasan a la aplicación para que ésta decida qué hacer. El objetivo es similar al de los comentarios, pero éstos van destinados a los usuarios, mientras que las instrucciones de procesamiento van destinadas a los programas. Por último, están prohibidas las instrucciones de procesamiento que comiencen por XML, salvo la del prólogo. 4.2.7 Prólogo Los documentos XML pueden empezar con un prólogo en el que se define una declaración XML y una declaración de tipo de documento. La declaración XML es una instrucción de procesamiento de este tipo: 1 <?xml version=" 1.0 " encoding ="UTF -8 "? > que indica la versión de XML que se está utilizando y la información sobre el tipo de codificación de caracteres. En el ejemplo anterior la versión es la 1.0 y el código el ASCII de 7 bits (subconjunto del código Unicode denominado UTF-8) que es el que los analizadores manejan por defecto. Otro ejemplo podría ser: 1 <?xml version=" 1.0 " encoding ="UTF -16 "? > Hay más codificaciones de caracteres además de UTF-8 y UTF-16, pero éstas son las únicas soportadas en todos los procesadores XML. En la declaración del tipo de documento se asocia la DTD o XSD respecto a la cual el documento es conforme. La DTD puede estar en un fichero distinto del documento, siendo en este caso lo que se conoce como DTD externa. Por ejemplo: 1 2 <!DOCTYPE documento SYSTEM " ejemplo . dtd " > <!DOCTYPE coche SYSTEM " http: // www . sitio . com / dtd / coche . dtd " > Con una DTD externa el prólogo con las dos declaraciones quedaría: 1 2 <?xml version=" 1.0 "? > <!DOCTYPE coche SYSTEM " http: // www . sitio . com / dtd / coche . dtd " > La DTD también puede incorporarse en el propio documento XML denominándose en este caso interna: 1 2 3 <?xml version=" 1.0 "? > <!DOCTYPE documento [ <!ELEMENT documento ... ] > 198 L ENGUAJES DE MARCADO . XML Las dos partes del prólogo son opcionales, aunque en el caso de incluir ambas la declaración XML tiene que ir antes. 4.3 Modelado de datos en XML Un documento XML se puede ver como un medio estructurado para almacenar información. En este contexto, un esquema es el modelo utilizado para describir la estructura de los documentos. En XML existen dos tipos de esquema para modelar clases de documentos: la DTD y el XSD o XML-Schema. Un documento XML debe adherirse a un esquema para ser válido. Sin un esquema un documento puede estar bien construido de acuerdo a las normas XML, pero no será válido. Un esquema establece restricciones en la estructura del contenido del documento como: el orden y la anidación de los elementos y, en el caso del XSD, también determina los tipos de datos del documento o la cardinalidad exacta de los mismos. Las principales diferencias entre las DTD y los XSD son las siguientes: • La DTD tiene una sintaxis específica mientras que el XSD utiliza sintaxis XML. • Un XSD se puede manipular como cualquier otro documento XML. • Un XSD soporta tipos de datos (int, float, boolean, date, . . . ) mientras que las DTD tratan todos los datos como cadenas. • Un XSD soporta la integración de los espacios de nombres permitiendo asociar unidades estructurales de un documento con declaraciones de tipo de un esquema. • La DTD sólo permite una asociación entre un documento y su DTD. El uso de esquemas es importante ya que permite un procesamiento robusto del tipo de documento que describen. A continuación se describen los fundamentos de las DTD y en el apartado 4.6, una vez vistos los espacios de nombre, se presentan los XSD. 4.4 Fundamentos de la DTD Una DTD (Document Type Definition) es una formalización de la noción de esquema, tipo o clase de documento. Consiste en una serie de definiciones de tipos de elementos, atributos, entidades y notaciones, que indican cuáles de ellos son legales dentro de un documento y en qué lugar pueden ubicarse. Un documento se relaciona con su DTD en la declaración de tipo de documento en el prólogo. A diferencia del lenguaje SGML, en el que es obligatorio que un documento esté asociado a una DTD, un documento XML no requiere obligatoriamente tener una DTD o XSD. F UNDAMENTOS DE LA DTD 199 En una DTD puede haber cuatro tipos de declaraciones: tipo de elemento, atributos, entidades y notaciones. A continuación vamos a ver cada una de ellas en más detalle. 4.4.1 Declaración de tipo de elemento Estas declaraciones permiten identificar los nombres de los elementos y la naturaleza de su contenido. Todos los elementos de un documento XML deben estar definidos en la DTD y tienen la siguiente sintaxis: 1 <!ELEMENT identificadorElemento ( especificacionContenido ) > donde identificadorElemento es el identificador genérico del elemento que se declara, y especificacionContenido describe el contenido de dicho elemento. Por ejemplo: 1 <!ELEMENT receta ( titulo , ingredientes , procedimiento ) > describe el elemento <receta> que a su vez puede contener a los elementos <titulo>, <ingredientes> y <procedimiento>. Teniendo en cuenta la especificación anterior del elemento <receta>, el siguiente documento sería válido: 1 2 3 4 5 < receta > < titulo > Tortilla de calabacín </ titulo > < ingredientes > 3 huevos , ... </ ingredientes > < procedimiento > Se lavan los calabacines , ... </ procedimiento > </ receta > Sin embargo, el siguiente documento no sería válido: 1 2 3 4 5 6 < receta > < parrafo > Esta es una receta estupenda ... </ parrafo > < titulo > Tortilla de calabacín </ titulo > < ingredientes > 3 huevos , ... </ ingredientes > < procedimiento > Se lavan los calabacines , ... </ procedimiento > </ receta > ya que el elemento <parrafo> no es un elemento de <receta> según la declaración de éste en la DTD. La especificación de contenido puede ser de cuatro tipos: • EMPTY: indica que el elemento no tiene contenido pero sí puede tener atributos. Por ejemplo: 1 <!ELEMENT imagen EMPTY> 200 L ENGUAJES DE MARCADO . XML representa que el elemento <imagen> es un elemento vacío. Este tipo de elementos se pueden representar en el documento XML de dos formas: completa (<imagen atr1= "val_1"...> </imagen>) o abreviada (<imagen atr1= "val_1".../>). Nótese que en este último caso no hay etiqueta de cierre y la etiqueta de inicio acaba con los caracteres "/>". • Solo elementos: solo puede contener elementos de la especificación de contenido. La especificación de contenido del elemento receta del ejemplo anterior es de este tipo, ya que sólo contiene otros elementos XML. A continuación tenemos otro ejemplo: 1 <!ELEMENT articulo ( titulo , resumen , cuerpo , referencias ) > En este caso el elemento articulo sólo podrá contener los elementos titulo, resumen, cuerpo y referencias. • Mixto: puede tener caracteres o una mezcla de caracteres y elementos. Para indicar que el contenido de un elemento son caracteres de texto que serán analizados se utiliza #PCDATA. Por ejemplo: 1 <!ELEMENT enfasis (#PCDATA) > describe que el elemento enfasis sólo va a contener caracteres de texto válidos. En un documento podría aparecer así: 1 < enfasis > este texto está enfatizado </ enfasis > El siguiente ejemplo describe un elemento parrafo que puede contener caracteres y el elemento enfasis: 1 <!ELEMENT parrafo (#PCDATA| enfasis )* > En un documento podría aparecer así: 1 < parrafo > A comienzos de ... < enfasis > este texto está enfatizado </ enfasis > ... </ parrafo > Si aparece #PCDATA en el modelo de contenido debe hacerlo en primer lugar de la especificación. El carácter "*" debe aparecer en la especificación de contenido si se combinan caracteres y elementos. Seguidamente veremos el significado de éste y otros símbolos que pueden aparecer en la especificación de contenido. F UNDAMENTOS DE LA DTD 201 • ANY: puede tener cualquier contenido (caracteres, otros elementos o mixto). No se suele utilizar ya que es conveniente estructurar y declarar adecuadamente el contenido de los documentos XML. Un ejemplo de uso sería el siguiente: 1 <!ELEMENT detodo ANY> En la especificación de contenido pueden aparecer los siguientes símbolos: • Paréntesis "()": engloba una secuencia o grupo de subelementos. En los ejemplos anteriores ya hemos visto cómo el modelo de contenido se describe entre paréntesis. Veamos un ejemplo más: 1 <!ELEMENT nota ( titulo , parrafo ) > • Coma ",": denota una secuencia de subelementos. La secuencia de elementos entre comas es la forma de indicar el orden en el que pueden aparecer. En el siguiente ejemplo, el elemento nota debe contener un elemento titulo seguido de un elemento parrafo: 1 <!ELEMENT nota ( titulo , parrafo ) > El elemento titulo siempre debe aparecer antes que el elemento parrafo. • Canalización "|": separa los elementos de un grupo de alternativas. En el siguiente ejemplo: 1 <!ELEMENT aviso ( parrafo | grafico ) > El elemento aviso debe contener un elemento parrafo o un elemento grafico, pero no ambos. Se pueden poner tantas opciones como se desee. En el siguiente ejemplo: 1 <!ELEMENT aviso ( titulo , ( parrafo | grafico )) > El elemento aviso debe contener un elemento titulo que podrá estar seguido de un elemento parrafo o un elemento grafico. Hasta ahora hemos visto la manera de describir secuencias ordenadas, alternativas de elementos y nos queda por ver la forma de representar la frecuencia de aparición de los elementos. Ésta se representa mediante los indicadores de frecuencia o cuantificadores, que son: 202 L ENGUAJES DE MARCADO . XML • "?": representa 0 o 1 apariciones. Indica que una partícula de contenido es opcional. • "*": de 0 a n apariciones. Indica que la partícula de contenido es opcional y, en caso de aparecer, lo puede hacer de forma repetida. • "+": de 1 a n apariciones. Indica que la partícula de contenido es necesaria y puede aparecer más de 1 vez. Veamos un ejemplo de uso de indicadores de frecuencia: 1 <!ELEMENT aviso ( titulo ?, ( parrafo | grafico ) *) > El modelo de contenido del elemento aviso indica que puede contener de manera opcional un elemento titulo, seguido opcionalmente, y en caso de aparecer de forma repetida, por un elemento parrafo o por un elemento grafico. Los siguientes ejemplos son válidos de acuerdo al modelo de contenido anterior del elemento aviso: Ejemplo 1: 1 < aviso > < titulo > </ titulo > </ aviso > Ejemplo 2: 1 < aviso > < grafico > </ grafico > </ aviso > Ejemplo 3: 1 2 3 < aviso > < titulo > </ titulo > < grafico > </ grafico > < grafico > </ grafico > < grafico > </ grafico > </ aviso > Ejemplo 4: 1 < aviso > < titulo > </ titulo > < parrafo > </ parrafo > </ aviso > Ejemplo 5: 1 < aviso > < parrafo > </ parrafo > < parrafo > </ parrafo > </ aviso > Notad que sólo se representan las etiquetas de inicio y fin de los subelementos sin entrar en su contenido. F UNDAMENTOS DE LA DTD 203 4.4.2 Declaración de tipo de atributo Los atributos permiten añadir información adicional a los elementos de un documento. Esta información es corta, sencilla y no estructurada. Un atributo sólo puede aparecer una vez en un elemento y el orden, en caso de existir varios, no es relevante. Los atributos no pueden contener subatributos. Las declaraciones de los atributos empiezan con <!ATTLIST (lista de tributos), sigue el identificador del elemento al que se aplica el atributo, después el nombre del atributo, su tipo y su valor predeterminado: 1 <!ATTLIST elemento atributo tipoAtributo valorPrederminado > Un atributo puede tener de uno de los siguientes tipos de valores predeterminados: • #REQUIRED: el atributo es obligatorio. • #IMPLIED: el atributo es opcional y puede no aparecer en el elemento. • #FIXED valor: el atributo tiene un valor fijo, el de una constante que aparece en la declaración. • Lista de valores predeterminados: el atributo puede contener uno de los posibles valores especificados en una lista en la declaración. Esta lista es de la forma: (val1|val2|...|valn) valorDefecto, donde valorDefecto es opcional, pero de aparecer es uno de los valores de la lista que se asumirá como valor por defecto. Los atributos pueden ser de los siguientes tipos: • CDATA: corresponde a datos de caracteres (character data), es decir, un atributo de este tipo puede contener cualquier carácter XML (los caracteres especiales deberán representarse con entidades). • NMTOKEN: solo podrá contener un name token. Un name token es una cadena de letras, dígitos, y los caracteres punto, guión, subrayado y dos puntos. El resto de caracteres y los espacios en blanco no pueden formar parte del mismo. • NMTOKENS: podrá contener múltiples name token separados por espacios. • Enumerados: es una lista de posibles valores del atributo separados por el carácter "|". Puede haber tantos valores como se desee y deben ser de tipo name token. • NOTATION: el valor del atributo es una notación declarada en la DTD. • ENTITY: es un objeto (fichero) que no debe analizarse como un documento XML. Es una entidad externa no analizada sintácticamente. 204 L ENGUAJES DE MARCADO . XML • ENTITIES: múltiples entidades externas no analizadas sintácticamente separadas por espacios en blanco. • ID: es un identificador único para un elemento XML. El uso de este tipo de atributo permitirá acceder a un elemento concreto del documento. Los atributos ID deben declararse como #REQUIRED o #IMPLIED. Su valor deberá ser un nombre XML, que es similar a un name token, pero con la restricción añadida de que no puede empezar por un dígito. Un elemento sólo puede tener un atributo ID y su valor no puede estar repetido a lo largo del documento. • IDREF: una referencia a un elemento con un atributo ID declarado en la DTD. Puede haber más de un atributo IDREF que se refieran a un mismo ID. • IDREFS: múltiples referencias a elementos con atributos ID declarados en la DTD separados por espacios. Veamos algunos ejemplos de declaración de atributos en la DTD y de su uso en el documento. Ejemplo de declaración de atributo: 1 <!ATTLIST mensaje fecha CDATA #REQUIRED> fecha es un atributo obligatorio del elemento mensaje y su valor puede ser cualquier carácter XML. Ejemplo de uso del atributo anteriormente declarado: 1 < mensaje fecha =" 15 de julio de 1999 " > Ejemplo de una declaración distinta del mismo atributo: 1 <!ATTLIST mensaje fecha NMTOKEN #REQUIRED> en este caso fecha es un atributo obligatorio del elemento mensaje y su valor debe ser un name token. Ejemplo de uso del atributo anteriormente declarado: 1 < mensaje fecha =" 15 -7 -1999 " > Al ser fecha de tipo NMTOKEN, no puede contener espacios como en el ejemplo anterior. F UNDAMENTOS DE LA DTD 205 En una sola declaración ATTLIST se pueden declarar varios atributos. Veamos ahora unos ejemplos de declaración de elementos y atributos: 1 2 3 <!ELEMENT texto (#PCDATA) > <!ATTLIST texto idioma CDATA #REQUIRED numpalabras CDATA #IMPLIED> El atributo idioma es obligatorio, mientras que numpalabras es opcional y, si se omite, no toma ningún valor por defecto. Ejemplo de declaración de elemento y atributos: 1 2 <!ELEMENT mensaje (de , a , texto ) > <!ATTLIST mensaje prioridad ( normal | urgente ) " normal " > El atributo prioridad puede tener el valor normal o urgente, siendo normal el valor por defecto si no se especifica el atributo. Ejemplo de declaración de elemento y atributos: 1 2 <!ELEMENT mensaje (de , a , texto ) > <!ATTLIST mensaje prioridad ( normal | urgente ) #IMPLIED> El atributo prioridad puede tener el valor normal o urgente pero es opcional. Un atributo de tipo NOTATION permite al autor declarar que su valor se ajusta a una notación declarada en la DTD. Veamos dos ejemplos: 1 2 3 <!ATTLIST mensaje fecha NOTATION ( ISO - DATE | EUROPEAN - DATE ) #REQUIRED> <!ATTLIST imagen formato NOTATION ( gif | jpeg ) #REQUIRED> Los atributos fecha y formato son obligatorios y deben tomar uno de entre los dos tipos de notaciones indicados en cada caso que deberán, a su vez, estar también declarados en la DTD. Veamos un ejemplo de cómo implementar un hipervínculo en un documento utilizando atributos de tipo ID e IDREF. Empecemos con la declaración de elementos y atributos: 1 2 3 4 5 6 ... <!ELEMENT capitulo ( parrafo )* > <!ATTLIST capitulo referencia ID #REQUIRED> <!ELEMENT enlace EMPTY> <!ATTLIST enlace destino IDREF #REQUIRED> ... 206 L ENGUAJES DE MARCADO . XML El elemento capitulo consta de 0 a n elementos parrafo y tiene un atributo obligatorio, referencia, que es tipo ID o identificador único. Por otra parte, el elemento enlace es un elemento vacío que sólo consta de un atributo obligatorio, destino, que es de tipo IDREF. En este caso, el elemento enlace a través del valor de su atributo destino enlazaría al elemento capitulo cuyo atributo referencia tenga el mismo valor. Un ejemplo de lo que podría aparecer en un documento XML es el siguiente: 1 2 3 4 5 ... < capitulo referencia =" seccion -3 " > < parrafo > ... </ parrafo > </ capitulo > ... En el capítulo < enlace destino =" seccion -3 " > podrá encontrar ... El procesador XML, el navegador o el formateador podrían convertir el elemento enlace en un hipervínculo. Con el atributo de tipo IDREFS se pueden implementar múltiples referencias. A continuación se presenta un ejemplo empezando por la declaración de elementos y atributos: 1 2 3 4 <!ELEMENT capitulo ( parrafo )* > <!ATTLIST capitulo identificacion ID #REQUIRED> <!ELEMENT referenciaCapitulos EMPTY> <!ATTLIST referenciaCapitulos enlazaAvarios IDREFS #REQUIRED> El elemento capitulo tiene el mismo modelo de contenido que en el ejemplo anterior. En este caso consta de un atributo obligatorio, identificacion, de tipo ID. El elemento referenciaCapitulos es un elemento vacío que consta del atributo obligatorio enlazaAvarios de tipo IDREFS. Este elemento, a través de los valores de su atributo, enlazaría con distintos elementos capitulo. Veamos un ejemplo de contenido de un documento XML conforme a las anteriores definiciones: 1 2 3 4 5 6 7 8 9 10 ... < capitulo identificacion =" seccion -1 " > < parrafo > ... </ parrafo > </ capitulo > < capitulo identificacion =" seccion -2 " > < parrafo > ... </ parrafo > </ capitulo > < capitulo identificacion =" seccion -3 " > < parrafo > ... </ parrafo > </ capitulo > ... En los siguientes capítulos < referenciaCapitulos enlazaAvarios = " seccion -1 seccion -2 seccion -3 " > podrá encontrar ... F UNDAMENTOS DE LA DTD 207 4.4.3 Declaración de entidades Ya se ha definido lo que son las entidades en el apartado 4.2.5, su función es organizar y estructurar el contenido de un documento XML. Se pueden clasificar en dos tipos: • Entidades generales: se usan en el contenido del documento XML. • Entidades parámetro: se usan en la DTD. Declaración de entidades generales Las entidades generales internas representan y pueden sustituir a una cadena de texto que formará parte del documento XML. Son entidades analizadas sintácticamente. Las declaraciones de las entidades empiezan con <!ENTITY, sigue el nombre de la entidad y la definición de la entidad: 1 <!ENTITY nombreEntidad definicionEntidad > Por ejemplo: 1 <!ENTITY uned " Universidad Nacional de Educación a Distancia " > está declarando la entidad uned que tendrá como contenido asociado el texto "Universidad Nacional de Educación a Distancia". Una vez así declarada, se podrá utilizar en el documento XML: 1 2 3 < texto > < titulo > La & uned ; </ titulo > ... </ texto > El analizador interpretará que la entidad &uned; está sustituyendo al texto que tiene asociado. Las entidades generales analizadas, como son texto XML, también pueden contener etiquetas. Se puede hacer referencia a una entidad de este tipo en cualquier parte del documento. Una misma entidad puede declarase más de una vez pero sólo se tendrá en cuenta la primera declaración. En las entidades generales internas su contenido asociado está hecho explícito en la propia DTD. Sin embargo, las entidades generales también pueden ser externas; en este caso tienen su contenido en cualquier otro sitio del sistema (un fichero, una página web, un objeto de base de datos, . . . ). En las entidades generales externas su definición se ubica en un recurso externo, que se representa mediante la palabra reservada SYSTEM seguida por un Identificador Universal de Recursos o URI (Uniform Resource Identifier). Por ejemplo: 208 1 L ENGUAJES DE MARCADO . XML <!ENTITY introduccion SYSTEM " http: // www . miservidor . com / intro . xml " > está declarando la entidad introduccion que tendrá como contenido asociado el del recurso http://www.miservidor.com/intro.xml. El recurso que sigue a la palabra reservada SYSTEM puede estar en una ubicación local. Por ejemplo: 1 <!ENTITY introduccion SYSTEM " cap1 . xml " > Las entidades generales externas pueden estar: • Analizadas sintácticamente: su contenido es XML. • No analizadas sintácticamente: el procesador XML no tiene que analizar su contenido que puede ser un gráfico, sonido, o cualquier objeto multimedia. Uno de los principales usos de las entidades generales externas analizadas es permitir que un documento se forme con referencias a subdocumentos. Veamos un ejemplo: 1 2 3 4 5 6 7 8 9 10 11 12 <! -- DTD --> ... <!ENTITY cap1 SYSTEM " cap1 . xml " > <!ENTITY cap2 SYSTEM " cap2 . xml " > ... <! -- Documento --> ... < libro > & cap1 ; & cap2 ; ... </ libro > En este caso, el documento se ha dividido en dos partes alojadas en ficheros locales. El procesador analizará el contenido de cap1.xml y cap2.xml como si su contenido estuviera directamente escrito en el documento. Este uso de las entidades generales externas analizadas permite estructurar mejor los documentos que pueden ser muy extensos. Las entidades generales externas analizadas pueden ser referenciadas en los mismos lugares que las entidades generales internas: en el documento XML y en otras entidades generales, a excepción del valor de un atributo. Las entidades generales externas no analizadas no serán analizadas por el procesador XML. Este tipo de entidades siempre son generales y externas. Se diferencian del resto de las entidades externas en que en su declaración llevan la palabra reservada NDATA seguida de un nombre de notación. El nombre de la notación indicará a la aplicación que F UNDAMENTOS DE LA DTD 209 procese el documento cómo debe tratar o qué debe hacer con la entidad en cuestión. Este tipo de entidades se referencian por medio de un atributo de tipo ENTITY o ENTITIES. Veamos el siguiente ejemplo: 1 2 3 4 5 6 7 8 9 10 11 12 <! -- DTD --> ... <!ELEMENT persona EMPTY> <!ATTLIST persona nombre CDATA #REQUIRED foto ENTITY #IMPLIED> ... <!ENTITY csfoto SYSTEM " csfoto . jpeg " NDATA JPEG > ... <! -- Documento --> ... < persona nombre =" Clara Sanz " foto =" csfoto "/ > ... En la DTD se declara el elemento vacío persona con dos atributos: uno obligatorio nombre y uno opcional foto, que al ser de tipo ENTITY hará referencia a una entidad externa no analizada. Por otro lado, en la parte de declaración de entidades, se declara la entidad general externa csfoto que es de tipo no analizada al llevar la palabra reservada NDATA a continuación del identificador del recurso. JPEG es el nombre de una notación que también deberá estar declarada en la DTD. Ya en el documento se utiliza el elemento persona con sus dos atributos con valores correctos de acuerdo a su declaración. En el apartado 4.4.4 veremos cómo se declara una notación en la DTD y podremos completar el ejemplo anterior. Declaración de entidades parámetro Este tipo de entidades sólo se pueden usar en la DTD. Por ejemplo, si hay declaraciones que se van a utilizar en más de una DTD puede resultar cómodo agruparlas en forma de una entidad y utilizarla en tantas DTDs como sea necesario. Las entidades parámetro también permiten modularizar una DTD cuando varios elementos comparten los mismos subelementos. Su sintaxis es la siguiente: 1 <!ENTITY % nombreEntidad definicionEntidad > El carácter "%" es el que diferencia la declaración de una entidad general de una entidad parámetro. Su uso también es distinto. A continuación se muestra un ejemplo de declaración y uso de una entidad parámetro interna: 1 2 3 <! -- DTD declaración de entidades parámetro -- > <!ENTITY % dimensiones " ancho , largo " > ... 210 4 5 6 7 L ENGUAJES DE MARCADO . XML <! -- DTD declaración de elementos -- > <!ELEMENT pared ( alto , % dimensiones ;) > <!ELEMENT techo (% dimensiones ;) > ... Se ha declarado la entidad parámetro interna dimensiones con el texto asociado ancho, largo. Posteriormente, en la declaración de los elementos pared y techo se usa dicha entidad anteponiendo a su nombre el carácter "%" y finalizándolo con un ";". Así, cuando el procesador analice el documento reemplazará la referencia a la entidad por su contenido, y la declaración real de pared será alto, ancho, largo y la del elemento techo será ancho, largo. Las entidades parámetro pueden resultar muy útiles para la escritura de las DTDs, pero un uso inadecuado puede añadir complejidad innecesaria. Las entidades parámetro también pueden ser externas, por ejemplo: 1 2 3 4 5 6 <! -- DTD declaración de entidades parámetro -- > <!ENTITY % elemento - a1 SYSTEM " a1 . ent " > ... <! -- DTD declaración de elementos -- > % elemento - a1 ;> ... En este caso, elemento-a1 es una declaración de elemento que se encuentra almacenada en el recurso local a1.ent. 4.4.4 Declaración de notaciones Las notaciones describen la información necesaria para que las aplicaciones procesen las entidades externas no analizadas. La declaración de una notación asigna un nombre a una notación que se referirá a entidades externas no analizadas, instrucciones de procesamiento o declaraciones de listas de atributos. Su sintaxis es la siguiente: 1 <!NOTATION nombreNotacion definicionNotacion > La definicionNotacion puede ser pública, un identificador para documentar la notación, una especificación formal o el nombre de una aplicación. El nombre de la notación formará parte de la declaración de una entidad no analizada sintácticamente. Veamos algunos ejemplos: 1 <!NOTATION GIF SYSTEM " GIF " > Se declara la notación GIF con la definición GIF que corresponde a un tipo de imagen que las aplicaciones deberán saber cómo procesar. F UNDAMENTOS 1 DE LA DTD 211 <!NOTATION GIF SYSTEM " Iexplorer . exe " > En este caso se declara la notación GIF con la definición Iexplore.exe que es el nombre de una aplicación que puede procesar imágenes de ese tipo. La aplicación que procese documentos XML con esta notación podría usar el programa Iexplore.exe para ver imágenes de tipo GIF que tengan asociada dicha notación. Ahora podemos completar el ejemplo del apartado 4.4.3 con la declaración de notaciones: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 <! -- DTD --> ... <!ELEMENT persona EMPTY> <!ATTLIST persona nombre CDATA #REQUIRED foto ENTITY #IMPLIED> ... <!NOTATION JPEG SYSTEM " Iexplorer . exe " > ... <!ENTITY csfoto SYSTEM " csfoto . jpeg " NDATA JPEG > ... <! -- Documento --> ... < persona nombre =" Clara Sanz " foto =" csfoto "/ > ... Cuando la aplicación procese el elemento <persona nombre="Clara Sanz" foto= "csfoto"/> ya tiene información de que csfoto es una entidad externa no analizada, asociada a un recurso local, csfoto.jpeg, que puede ser procesado utilizando la definición de la notación JPEG, es decir, la aplicación Iexplore.exe. 4.4.5 DTD internas y externas La DTD de un documento puede ser interna, externa o una combinación de ambas, según se haga explícita en el propio documento XML, en una ubicación externa, o en ambas modalidades. En el caso de que un documento utilice ambos tipos de DTD la sintaxis será la siguiente: 1 <!DOCTYPE elementoRaiz SYSTEM DTDexterna [ DTDinterna ] > Por ejemplo: 1 2 <!DOCTYPE peliculas SYSTEM " peliculas . dtd " [ <!ELEMENT actor (#PCDATA) > ]> 212 L ENGUAJES DE MARCADO . XML Todos los elementos de un documento XML están dentro del elemento raíz, en este caso del elemento peliculas. La DTD externa está en el fichero local peliculas.dtd y además hay una declaración interna correspondiente al elemento actor. Cuando en una DTD conviven los dos tipos de declaraciones, las declaraciones internas prevalecen a las externas. Veamos un ejemplo completo de una DTD. Se trata de declarar la estructura de un listín de contactos en el que además de la información habitual (nombre, teléfono, dirección, email), se plasmará la posible relación entre los contactos mediante enlaces: 1 2 3 4 5 6 7 8 9 <!ELEMENT listin ( persona )+ > <!ELEMENT persona ( nombre , telefono +, direccion *, email *, relacion ?) > <!ATTLIST persona id ID #REQUIRED> <!ELEMENT nombre (#PCDATA) > <!ELEMENT telefono (#PCDATA) > <!ELEMENT direccion (#PCDATA) > <!ELEMENT email (#PCDATA) > <!ELEMENT relacion EMPTY> <!ATTLIST relacion rel - con IDREFS #IMPLIED> El elemento raíz es listin que deberá contener al menos un elemento persona. A su vez, el elemento persona constará de un elemento nombre, al menos un elemento telefono, de cero a n elementos direccion, de cero a n elementos email y uno o ningún elemento relacion. El elemento persona tiene un atributo obligatorio id que es de tipo ID, lo que significa que su contenido será un identificador único para el elemento persona. El contenido de los elementos nombre, telefono, direccion y email es #PCDATA, es decir, datos de carácter analizados. Por último, el elemento relacion es un elemento vacío que sólo podrá contener el atributo opcional rel-con de tipo IDREFS, por lo que contendrá una o más referencias a atributos de tipo ID. El siguiente documento XML sería válido con respecto a la DTD anterior (listin.dtd): 1 2 3 4 5 6 7 8 9 10 11 12 <?xml version = " 1.0 " encoding = " UTF -8 "? > <!DOCTYPE listin SYSTEM " listin . dtd " > < listin > < persona id = " ricky " > < nombre > Roberto Casas </ nombre > < telefono > 654783267 </ telefono > < telefono > 918768760 </ telefono > < email >ro . casas@direccion . com </ email > < relacion rel - con = " lara ana "/ > </ persona > < persona id = " lara " > < nombre > Lara García </ nombre > F UNDAMENTOS 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 DE LA DTD 213 < telefono > 644568321 </ telefono > < direccion >C. Fuente del Cerro , n. 3, 3A. Madrid </ direccion > < email >la . gracia@direccion . com </ email > < email >la . garcia@otra . com </ email > < relacion rel - con = " ricky "/ > </ persona > < persona id = " ana " > < nombre > Ana Aguirre </ nombre > < telefono > 655348841 </ telefono > < email >ana . aguirre@direccion . com </ email > < direccion >C. Molino , n. 40 , 2B. Bilbao </ direccion > < direccion >C. Mar Muerto , n. 11. Alicante </ direccion > < relacion rel - con = " ricky "/ > </ persona > </ listin > 4.4.6 Corrección de un documento XML Se pueden distinguir dos nociones de corrección en un documento XML: • Documento bien construido o formado. • Documento válido, respecto a una DTD o a una XSD. Un objeto de texto es un documento XML bien construido si: • Tomado como un todo, cumple la regla denominada "document". • Respeta todas las restricciones de buena formación dadas en la especificación. • Cada una de las entidades analizadas que se referencia directa o indirectamente en el documento está bien formada. Cumplir la regla "document" significa: • Que contiene uno o más elementos. • Hay un elemento llamado raíz (elemento documento) en el que se encuentran todos los demás. • Todo elemento tiene una etiqueta de inicio y de final o una etiqueta de elemento vacío. • Los elementos delimitados por etiquetas de principio y final se anidan adecuadamente. 214 L ENGUAJES DE MARCADO . XML • Los valores de los atributos van entre comillas dobles o simples (" o ’). Por ejemplo, el siguiente texto: 1 Mi documento XML No es un documento XML bien construido ya que no contiene ningún elemento. El texto siguiente: 1 <p > Mi documento XML </p > Sí es un documento XML bien construido ya que tiene al menos un elemento, p, que contiene a todo lo demás y que es el elemento raíz. Sin embargo, el siguiente texto: 1 2 <p > Mi texto XML </p > <p > Mi otro texto XML </p > No es un documento XML bien construido ya que no contiene un elemento raíz. En el caso siguiente: 1 2 3 4 < documento > <p > Mi texto XML </p > <p > Mi otro texto XML </p > </ documento > El documento XML sí está bien construido ya que se cumplen todas las condiciones. También el ejemplo final del apartado 4.4.5 es un documento XML bien construido. Veamos ahora otro ejemplo: 1 2 3 4 < documento > <p > Mi < elem > texto XML </p > </ elem > <p > Mi otro texto XML </p > </ documento > No es un documento XML bien construido ya que la etiqueta inicio del elemento elem está dentro del contenido del elemento p, pero su etiqueta final está fuera. Es decir, los elementos no están anidados de forma correcta. La siguiente versión: 1 2 3 4 < documento > <p > Mi < elem > texto XML </ elem > </p > <p > Mi otro texto XML </p > </ documento > Sí tiene los elementos anidados correctamente y al cumplir las demás condiciones sería un documento XML bien construido. E SPACIOS DE NOMBRES 215 Un documento XML es valido si: • Está bien construido. • El nombre del elemento raíz coincide con el nombre de la declaración de tipo de documento. • La DTD declara todos los elementos, atributos, notaciones y entidades que se utilizan en el documento. • El documento cumple con la gramática descrita en la DTD o en la XSD. La validez de un documento la determina un analizador o parser. El ejemplo final del apartado 4.4.5 es un documento XML válido con respecto a su DTD (listin.dtd). 4.5 Espacios de nombres Un objetivo fundamental de la Ingeniería del Software es la reutilización de bibliotecas de subprogramas, clases y patrones de diseño. Si lo trasladamos al contexto de documentos XML, se plantea la necesidad de poder reutilizar unos esquemas ya existentes en otros nuevos, o en combinarlos. Como XML 1.0 no soporta de forma estándar la combinación de esquemas hay que recurrir a un mecanismo adicional: los espacios de nombres. El concepto de espacios de nombres (namespaces) surge de la posibilidad de combinar diferentes esquemas XML. Son una forma de garantizar que los nombres que se utilizan en las DTD sean únicos, lo que permitirá que puedan combinarse DTD distintas sin que se produzca ambigüedad en los nombres de elementos y atributos. Esta posible ambigüedad en los nombres se elimina asociándoles una referencia a una URI. Los espacios de nombres son una recomendación del W3C9 . Por ejemplo: tenemos un documento XML referente a varios CD al que queremos añadir reseñas de compradores, que es otro tipo de documento. Cada uno de estos dos tipos de documentos tiene sus correspondientes DTD. Al combinar ambos puede ocurrir que algunos elementos y atributos en ambas DTD se llamen igual pero tengan distinto modelo de contenido, lo que crearía ambigüedad. Para realizar la combinación habría que usar ambas DTD y los espacios de nombre distinguirían a los elementos y atributos que no esté claro a cuál de las dos DTD pertenecen. Supongamos el siguiente documento con información de CD: 1 2 3 4 <?xml version=" 1.0 "? encoding ="UTF -8 " > <!DOCTYPE coleccion - cd SYSTEM " colCD . dtd " > < coleccion - cd > <titulo - col > Colección Camarón </ titulo - col > 9 http://www.w3.org/TR/REC-xml-names/ 216 5 6 7 8 9 10 11 12 13 14 15 16 17 18 L ENGUAJES DE MARCADO . XML <cd id =" cd34 -1979 " > < titulo > La Leyenda del tiempo </ titulo > < autor > Camarón de la Isla </ autor > < productor > Polygram Ibérica , S.A. </ productor > < fecha > 1979 </ fecha > < pista num ="1" > La Leyenda del tiempo </ pista > < pista num ="2" > Romance del amargo </ pista > ... </ cd > <cd id =" cd45 -1985 " > ... </ cd > ... </ coleccion - cd > Al combinarlo con uno de reseñas de autores podría quedar de la siguiente manera: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 <?xml version=" 1.0 "? encoding ="UTF -8 " > <!DOCTYPE comentarios SYSTEM " comentariosCD . dtd " > < comentarios > < comentario - cd > <cd id =" cd34 -1979 " > < titulo > La Leyenda del tiempo </ titulo > < autor > Camarón de la Isla </ autor > < productor > Polygram Ibérica , S.A. </ productor > < fecha > 1979 </ fecha > < pista num ="1" > La Leyenda del tiempo </ pista > < pista num ="2" > Romance del amargo </ pista > ... </ cd > < comentario > < autor > María Pinares </ autor > < puntuacion valor ="9" de =" 10 "/ > < texto > Es un disco excelente ... </ texto > </ comentario > < comentario > < autor > José Altillo </ autor > < puntuacion valor ="6" de =" 10 "/ > < texto > Rompe la tradición ... </ texto > </ comentario > </ comentario - cd > < comentario - cd > <cd id =" cd45 -1985 " > ... </ cd > < comentario > ... E SPACIOS 31 32 33 DE NOMBRES 217 </ comentario > </ comentario - cd > </ comentarios > Se puede observar cómo el elemento autor (en las líneas 7, 15 y 20) podría causar problemas de ambigüedad, ya que no es lo mismo el autor de un comentario (líneas 15 y 20) que el autor de un CD (línea 7). Los modelos de contenido de los elementos que lo contienen son distintos. Con los espacios de nombres se eliminaría esta ambigüedad. A cada DTD o XSD se le supone asociado su propio espacio de nombres; es decir, un entorno en el que todos los nombres de elementos son únicos, y todos los nombres de atributos también son únicos dentro del contexto del elemento al que pertenecen. Por lo tanto cualquier referencia a un elemento o atributo no es ambigua. Para combinar documentos de diferente tipo tendrán que convivir en un mismo documento varios espacios de nombres. Ahora veremos cómo se nombran y cómo se usan los espacios de nombres. Para garantizar la singularidad de los espacios de nombres se utilizan como identificadores las URL (Localizador de Recursos Uniforme) o las URN (Nombre de Recurso Uniforme). Esto no significa que necesariamente los espacios de nombre tengan que estar en Internet, sino que se corresponde con una cadena de texto que la aplicación sabrá identificar. 4.5.1 Definir de un espacio de nombres Los espacios de nombres se utilizan tanto en elementos como en atributos. Para definir el espacio de nombres al que pertenece un elemento se añade el atributo xmlns (xml namespace) a la definición de elemento siguiendo la siguiente sintaxis: 1 xmlns:prefijo =" nombre " La parte prefijo es opcional y sirve de referencia del espacio de nombres a lo largo del alcance del elemento en el que se declara. El uso de prefijo depende de si se van a usar elementos calificados o no calificados, y puede ser cualquier cadena que comience con un carácter alfabético, seguida por cualquier combinación de dígitos, letras y signos de puntuación, excepto ":". El valor del atributo puede ser una cadena cualquiera, aunque por convención suelen ser una URI (URL, URN), y solo se requiere que sea única. No se requiere que apunte a nada en particular ya que es simplemente una manera de identificar de forma inequívoca un conjunto de nombres. Por ejemplo: 1 < elemento xmlns:cd =" http: // www . miweb . com / cd " > Un nombre calificado consta de prefijo:parteLocalNombre. Por ejemplo, los siguientes nombres: pelicula:titulo o pelicula:director. 218 L ENGUAJES DE MARCADO . XML Un nombre no calificado no usa prefijo. Por ejemplo: titulo o director. Este tipo de nombres o están asociados a un espacio de nombres predeterminado o a ninguno. Hay dos maneras de definir un espacio de nombres: • Declaración predeterminada. El espacio de nombres se declara sin un prefijo, se usan nombres no calificados de elementos y atributos que se supone que están en el espacio de nombres. • Declaración explícita. El espacio de nombres se declara con prefijo y se usan nombres calificados de elementos y atributos. La declaración predeterminada es útil cuando se quiere aplicar un espacio de nombres a un documento completo o a una sección de un documento. El espacio de nombres se aplica a todos los elementos no calificados del ámbito en el que se declara. Para realizar este tipo de declaración es necesario añadir el atributo xmlns a la definición de elemento al que se quiera asignar el espacio de nombres. Por ejemplo: 1 2 3 <!ELEMENT titulo (% declaracion1 ;) * > <!ATTLIST titulo xmlns CDATA #FIXED " http: // www . ejemplos . xml / ejemplo1 " > El elemento titulo y todos los elementos que contenga tendrán como espacio de nombres la URL http://www.ejemplos.xml/ejemplo1. Al declarar el atributo xmlns como FIXED se evita que el documento especifique cualquier otro valor. Un elemento definido de esta manera se hace único, por lo que no genera conflictos con un elemento que tenga el mismo nombre en otra DTD. Para hacer referencia a un nombre de elemento que puede ser ambiguo se especifica el atributo xmlns. Por ejemplo: 1 < titulo xmlns =" http: // www . ejemplos . com / ejemplo1 " > Ejemplo de espacio de nombre </ titulo > El espacio de nombres se aplica a ese elemento y a cualquier otro elemento contenido dentro de él, es decir, las declaraciones se propagan a través de los elementos secundarios. La declaración explícita es útil para crear un documento que se apoya en múltiples espacios de nombres. Cuando necesitamos hacer la misma referencia varias veces, añadir siempre el atributo xmlns puede ser pesado y es mejor recurrir a un prefijo que permite diferenciar los elementos y atributos de cada espacio de nombres. Por ejemplo: 1 2 3 < libro xmlns:lib =" http: // www . ejemplos . com / ejemplo1 " > ... </ libro > E SPACIOS DE NOMBRES 219 Ahora podremos hacer referencia a los elementos del modelo de contenido de libro con el prefijo lib. Si, por ejemplo, autor es un elemento de libro podríamos referirnos a él de la siguiente manera: 1 < lib:autor > Mario Vargas Llosa </ lib:autor > Si se pretende que un espacio de nombres se refiera a todo un documento XML se definirá dentro del elemento raíz. Aunque también puede definirse dentro de cualquier otro elemento del documento. Un elemento puede declarar más de un espacio de nombres: 1 2 3 4 5 6 7 < libro xmlns:lib =" http: // www . ejemplos . com / ejemplo1 " xmlns:libBib =" http: // www . ejemplos . com / ejemplo2 " > ... < lib:autor > Mario Vargas Llosa </ lib:autor > < libBib:isbn > 0554123903 </ libBib:isbn > ... </ libro > Resumiendo las ideas principales sobre el alcance de los espacios de nombres: • Se refiere al elemento para el que se defina y los elementos que contenga, a menos que se realice otra declaración de espacio de nombres con el mismo prefijo. • El espacio de nombres predeterminado se elimina cuando se establece como nombre una cadena vacía (""). • A diferencia de los elementos, los atributos no están unidos, como opción predeterminada, a ningún espacio de nombres. Veamos otro ejemplo: 1 2 3 4 5 6 <?xml version=" 1.0 " encoding ="iso -8859 -1 " standalone=" yes "? > < doc xmlns =" http: // www . ejemplos . com / ejemplo3 " xmlns:nov =" http: // www . ejemplos . com / ejemplo4 " > ... <par nov:lang =" es " tipo =" normal "/ > ... El espacio de nombres predeterminado es http://www.ejemplos.com/ejemplo3 y el elemento par está asociado a él. Sin embargo, el atributo lang está asociado al espacio de nombres http://www.ejemplos.com/ejemplo4 por llevar el prefijo nov. Finalmente, el atributo tipo no está asociado a ningún espacio de nombres. 220 L ENGUAJES DE MARCADO . XML Cuando se utilizan espacios de nombres hay que tener en cuenta que ningún elemento debe contener dos atributos con el mismo nombre o con nombres equivalentes calificados, es decir, las mismas partes locales y los mismos prefijos que corresponden al mismo URI. Por ejemplo, dadas las siguientes definiciones de espacios de nombres: 1 2 3 < esp1:elem xmlns:esp1 =" http: // www . ejemplos . com / ejemplo5 " xmlns:esp2 =" http: // www . ejemplos . com / ejemplo5 " > ... Los dos siguientes elementos no serían válidos ya que usan el mismo nombre o nombres equivalentes: 1 2 < esp1:elem a="1" a="2"/ > < esp2:elem esp1:a ="1" esp2:a ="2"/ > Sin embargo, el siguiente sí sería válido al usar nombres distintos: 1 < esp2:elem esp1:a ="1" a="2"/ > 4.5.2 Espacios de nombres en la DTD Para validar documentos con respecto a una DTD es necesario que en la definición de los elementos se incluyan los prefijos. Por ejemplo: 1 <!ELEMENT libro ( lib:autor | editorial | lib - bib:isbn |...) * > La definición de espacio de nombres también se puede añadir como un atributo: 1 2 3 4 <!ATTLIST libro xmlns:lib CDATA #FIXED " http: // www . ejemplos . xml / ejemplo1 " > <!ATTLIST libro xmlns:lib - bib CDATA #FIXED " http: // www . ejemplos . xml / ejemplo2 " > Es el autor de la DTD que combina espacios de nombres el responsable de definirlos y de indicar a qué espacio de nombres pertenece cada elemento y atributo. 4.6 Fundamentos del XML-Schema o XSD XML-Schema es una tecnología creada inicialmente por Microsoft pero desarrollada actualmente por el consorcio W3C10 que permite definir la estructura, contenido y semán10 http://www.w3.org/XML/Schema F UNDAMENTOS DEL XML-S CHEMA O XSD 221 tica de documentos XML. La especificación actual es la 1.0 aunque se está completando la especificación 1.1. El XSD (XML Schema Definition) surgió como alternativa a la DTD y para superar algunas de las limitaciones de ésta. Las principales ventajas del XSD con respecto a la DTD son las siguientes: • Usa sintaxis XML. De hecho, los XSD son documentos XML bien formados. • Permite definir tipos de datos (int, float, boolean, date, . . . ). • Presenta un modelo de datos abierto usando conceptos de la orientación a objetos. • Soporta la integración de los espacios de nombres. • Permite expresar conjuntos, es decir, elementos que ocurren en cualquier orden. • Permite construir tipos complejos de datos. • No impone restricciones de repetición generales como la DTD (+, *, ?). En XSD hay sólo dos categorías de elementos: • Tipo simple: elementos con modelo de contenido simple y sin atributos. Sólo pueden contener información de caracteres. • Tipo complejo: elementos con cualquier otro modelo. Los atributos son siempre de tipo simple. En la Figura 4.1 se puede ver un ejemplo de instancia de documento XML referente a un archivo de informes. Un informe tendrá un código identificativo y podrá ser confidencial o no. Además, se realiza en una fecha, tiene un título, unos autores y uno o varios asuntos de los que trata. En dicho documento los elementos nombre, fecha y descripcion tienen un modelo de contenido simple. Mientras que archivo, informe, titulo, autor y asunto son de tipo complejo. Hay que tener en cuenta que el concepto de elemento de tipo simple es distinto en un XSD que en una DTD. 4.6.1 Definición de elementos Antes de estudiar cómo se definen los elementos en un XSD conviene distinguir entre espacio léxico y espacio de valores. Cada tipo de datos tiene un espacio léxico, un espacio de valores y un mapeo del espacio léxico al de valores. Dado un tipo de datos, su espacio de valores es el conjunto de sus valores posibles. Cada valor en el espacio de valores se puede representar por uno o más literales en su espacio léxico; es decir, un valor puede tener múltiples representaciones léxicas. Por ejemplo, el valor "3.14116" de tipo xs:float puede tener diversas representaciones léxicas: "03.14116", "3.141160", ".314116E1". 222 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 L ENGUAJES DE MARCADO . XML <?xml version=" 1.0 "? > < archivo > < informe confidencial = " false " id ="IN -33 A5 " > < fecha > 13 -11 -2010 </ fecha > < titulo leng = " es " > Contaminación acústica </ titulo > < autor > < nombre > Andrés Martínez </ nombre > < nombre > Sandra Columela </ nombre > </ autor > < asunto > < nombre > Efectos inmediatos </ nombre > < descripcion > La contaminación ... </ descripcion > </ asunto > < asunto > ... </ asunto > </ informe > ... </ archivo > Figura 4.1: Un documento XML Por lo tanto, los datos de la instancia documento constituyen el espacio léxico, mientras que el valor de los datos interpretados de acuerdo a su tipo de datos constituye el espacio de valores. Siguiendo con el ejemplo, el valor "3.14116" de tipo xs:string es distinto del valor de "03.14116", "3.141160", ".314116E1". En el primer ejemplo se trata de un valor numérico que es equivalente, mientras que en el segundo caso se trata de un tipo de cadena de caracteres que no resulta equivalente. Esta distinción es importante en operaciones como el test de igualdad o la ordenación. El espacio léxico es el conjunto de literales válidos o representaciones léxicas para un tipo de datos. Cada elemento del espacio de valores puede estar vinculado con 0 o n elementos del espacio léxico, mientras que cada elemento del espacio léxico está relacionado unívocamente con un elemento del espacio de valores. Por ejemplo, "100" y "1.0E2" son dos diferentes literales del espacio léxico del tipo xs:float que denotan al mismo valor. Veamos a continuación cuál es el elemento raíz de todo XSD y posteriormente cómo se definen los demás elementos. schema Es el elemento raíz del XSD. Su sintaxis es la siguiente: 1 < xs:schema xmlns:xs =" http: // www . w3 . org /2001/ XMLSchema " > F UNDAMENTOS DEL XML-S CHEMA O XSD 223 Este elemento define el espacio de nombres en el que están definidos todos los elementos y tipos de datos de XML-Schema. Como un XSD es una instancia o documento XML puede llevar un preámbulo: 1 2 <?xml version=" 1.0 "? > < xs:schema xmlns:xs =" http: // www . w3 . org /2001/ XMLSchema " > element Define los elementos que contendrán los documentos XML asociados al XSD. La sintaxis para definir un elemento de tipo simple es la siguiente: 1 < xs:element name =" nombreElemento " type =" tipoElemento "/ > Por ejemplo, el elemento nombre del documento de la Figura 4.1 se definiría de la siguiente manera: 1 2 3 4 < xs:schema xmlns:xs =" http: // www . w3 . org /2001/ XMLSchema " > < xs:element name =" nombre " type =" xs:string "/ > ... </ xs:schema > xs:string es un tipo predefinido en XML-Schema. El orden de las definiciones en un XSD no es significativo. La definición de todos los elementos simples y atributos del ejemplo quedaría: 1 2 3 < xs:element name =" nombre " type =" xs:string "/ > < xs:element name =" descripcion " type =" xs:string "/ > < xs:element name =" fecha " type =" xs:date "/ > Veamos ahora cómo se define un elemento de tipo complejo como el elemento titulo de la Figura 4.1: 1 2 3 4 5 6 7 < xs:element name =" titulo " > < xs:complexType > < xs:simpleContent > < xs:extension base =" xs:string " > < xs:attribute ref =" leng "/ > </ xs:extension > </ xs:simpleContent > 224 8 9 L ENGUAJES DE MARCADO . XML </ xs:complexType > </ xs:element > El elemento titulo es de tipo complejo porque tiene un atributo, pero su contenido es simple porque es de tipo carácter o texto. Además en la definición hay que indicar que tiene el atributo leng. En la definición anterior se describe un elemento llamado titulo que es de tipo complejo, su contenido es simple y se extiende con el atributo leng, que también deberá estar definido en el XSD. Veamos otro ejemplo de definición de tipo complejo, la del elemento archivo: 1 2 3 4 5 6 7 < xs:element name =" archivo " > < xs:complexType > < xs:sequence > < xs:element ref =" informe " maxOccurs =" unbounded "/ > </ xs:sequence > </ xs:complexType > </ xs:element > Esta definición indica que el elemento archivo es de tipo complejo, está compuesto por una secuencia de 1 a n ocurrencias de elementos informe. El atributo maxOccurs indica el número máximo de ocurrencias y el valor predefinido unbounded indica que no tiene límite. Ahora vamos a definir el elemento de tipo complejo autor: 1 2 3 4 5 6 7 < xs:element name =" autor " > < xs:complexType > < xs:sequence > < xs:element ref =" nombre " maxOccurs =" unbounded "/ > </ xs:sequence > </ xs:complexType > </ xs:element > Esta definición describe que el elemento autor es de tipo complejo y está compuesto por una secuencia de 1 a n elementos nombre. Los atributos de un elemento de tipo complejo con secuencia deben ser definidos después de la secuencia. Los atributos maxOccurs y minOccurs en la definición de elementos permiten indicar el número máximo y mínimo de ocurrencias. Su valor por defecto es 1 (el elemento debe aparecer 1 vez). Como hemos visto en los ejemplos anteriores, el valor unbounded asociado a maxOccurs indica que el número máximo de ocurrencias es ilimitado. Cuando los elementos y atributos se definen directamente dentro del elemento documento xs:schema como en el ejercicio resuelto 4, se denominan globales. Los compo- F UNDAMENTOS DEL XML-S CHEMA O XSD 225 nentes globales se pueden referenciar en cualquier parte del esquema y en otros esquemas que lo importen. También se pueden utilizar como elementos raíz de un documento. Analicemos con más detalle la siguiente definición: 1 2 3 4 5 6 7 < xs:element name =" archivo " > < xs:complexType > < xs:sequence > < xs:element ref =" informe " maxOccurs =" unbounded "/ > </ xs:sequence > </ xs:complexType > </ xs:element > En ella se hace referencia a un elemento informe definido en otra parte del XSD. Esa referencia se puede reemplazar con la definición de dicho elemento, como se puede ver a continuación: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 < xs:element name =" archivo " > < xs:complexType > < xs:sequence > < xs:element name =" informe " maxOccurs =" unbounded "/ > < xs:complexType > < xs:sequence > < xs:element ref =" titulo "/ > < xs:element ref =" autor " minOccurs ="0" maxOccurs =" unbounded "/ > < xs:element ref =" asunto " minOccurs ="0" maxOccurs =" unbounded "/ > </ xs:sequence > < xs:attribute ref =" confidencial "/ > < xs:attribute ref =" id "/ > </ xs:complexType > </ xs:element > </ xs:sequence > </ xs:complexType > </ xs:element > Se puede ver que la definición de informe es local a archivo porque está definido dentro de dicho elemento. Se trata por lo tanto de una definición local. Podría haber otras definiciones de informe en otras partes del XSD. Esta definición de informe no se puede utilizar en cualquier parte del documento, sólo como un elemento descendiente directo de archivo. El elemento informe ya no puede ser el elemento raíz de un documento que use este XSD. En el ejercicio resuelto 5 se puede ver un ejemplo completo de XSD con declaraciones locales. En él se puede observar que se han eliminado los atributos ref ya que todas las definiciones son locales. 226 L ENGUAJES DE MARCADO . XML Los dos esquemas de los ejercicios resueltos 4 y 5 permiten validar la misma instancia de documento. Sin embargo, el 5 es menos reutilizable ya que el elemento raíz, archivo, es el único elemento global y por lo tanto el único que puede ser utilizado en otro XSD. En este caso se sacrifica la modularidad en favor de una descripción más acorde con la estructura de los documentos conformes con dicho esquema. Es posible combinar los dos tipos de definiciones según el objetivo que se pretenda. Por ejemplo, si se quieren definir elementos con el mismo nombre pero con diferentes modelos de contenido en diferentes partes del XSD, habrá que utilizar definiciones locales. Si se quiere poder reutilizar elementos ya definidos en un XSD en otro, habrá que utilizar definiciones globales. Si se realiza un XSD recursivo, en el que un elemento se incluye dentro de un elemento del mismo tipo como hijo (directa o indirectamente) habrá que utilizar definiciones globales y referencias. Supongamos que queremos definir el elemento nombre con diferentes modelos de contenido en autor y asunto. Para hacerlo, al menos uno de ellos habría que definirlo de forma local. Aunque esto es posible con XSD, puede traer problemas de ambigüedad. 4.6.2 Definición de atributos Aunque ya se han visto algunos ejemplos de definición de atributos, ahora se analizarán más en detalle. attribute Define los atributos que podrán contener los elementos. La sintaxis para definirlos es la siguiente: 1 < xs:attribute name =" nombreAtributo type =" tipoAtributo "/> La definición de todos los atributos del ejemplo quedaría: 1 2 3 < xs:attribute name =" id " type =" xs:ID "/ > < xs:attribute name =" confidencial " type =" xs:boolean "/ > < xs:attribute name =" leng " type =" xs:language "/ > Como se puede observar, la declaración de los atributos es independiente de la de los elementos. En el ejemplo aparecen los tipos predefinidos ID, boolean y language. 4.6.3 Tipos de datos predefinidos Existen los siguientes tipos de datos predefinidos: cadena, numéricos, fecha, hora y lista. F UNDAMENTOS DEL XML-S CHEMA O XSD 227 Tipos de cadena Hay varios tipos posibles de cadenas de caracteres: xs:string Es una cadena de caracteres válidos Unicode e ISO/IEC 10646. En este tipo no se realiza ningún tipo de reemplazamiento de espacios en blanco, es decir, se respetan los tabuladores, espacios en blanco y retorno de carro. xs:normalizedString Al igual que el anterior también es una cadena de caracte- res válidos Unicode e ISO/IEC 10646. En este tipo se reemplazan los caracteres tabulador (#x9), linefeed (#xA), y retorno de carro (#xD) por espacio (#x20). xs:token Es similar al tipo xs:normalizedString pero en éste se eliminan los es- pacios al principio y al final, y varios espacios contiguos se sustituyen por uno simple. Por ejemplo, si tenemos: 1 2 < titulo lang = " es " > Contaminación </ titulo > acústica Probablemente los espacios en blanco no son significativos y deberían no ser tenidos en cuenta. En lugar del tipo xs:string se le puede asignar el tipo xs:token, y así el documento queda descrito de una manera más precisa. xs:language Se deriva de xs:token. Se creó para aceptar los códigos de lenguaje RFC 176611 (es, en, en-US, fr, . . . ). xs:NMTOKEN Se corresponde con el tipo NMTOKEN visto en las DTD. Se deriva de xs:token. xs:Name Es similar a xs:NMTOKEN con la restricción de que los valores deben comenzar con una letra o con los caracteres ":" o "_". No debe utilizarse con nombres que vayan a ser calificados con un prefijo de un espacio de nombres. También se deriva de xs:token. xs:NCName Es similar a xs:Name con la restricción de que los valores deben comenzar con una letra o con el carácter "_". xs:ID Se deriva de xs:NCName. Su valor debe ser único en el documento ya que se trata de un identificador único. xs:IDREF Se deriva de xs:NCName. Su valor debe emparejarse con un ID definido en el mismo documento. xs:ENTITY Se deriva de xs:NCName. Su valor debe emparejarse con una entidad ex- terna no analizada. 11 http://www.faqs.org/rfcs/rfc1766.html 228 L ENGUAJES DE MARCADO . XML xs:QName Soporta espacios de nombres con prefijo. Cada xs:QName contiene una tupla {"nombre de espacio de nombre", "nombre local"}. Soporta espacio de nombres por defecto y así el valor de la URI en la tupla será el valor por defecto. Por ejemplo, en: 1 < xs:attribute name =" leng " type =" xs:language "/ > type es de tipo xs:QName con valor {"http://www.w3.org/2001/XMLSchema", "language"} ya que "http://www.w3.org/2001/XMLSchema" fue asignada al prefijo en: 1 < xs:schema xmlns:xs =" http: // www . w3 . org /2001/ XMLSchema " > En <xs:element ref="informe" maxOccurs="unbounded"/>, el atributo ref es un xs:QName con valor {"NULL", "informe"} ya que no se ha definido ningún espacio de nombres por defecto. xs:anyURI El valor deberá cumplir las limitaciones de los caracteres admitidos en XML. xs:hexBinary Permite codificar contenido binario como una cadena de caracteres tra- duciendo el valor de cada octeto binario en 2 dígitos hexadecimales. xs:base64Binary Corresponde a la codificación "base64" que agrupa series de 6 bits en un array de 64 caracteres imprimibles. Tipos numéricos Los tipos numéricos son los siguientes: xs:decimal Representa números decimales arbitrariamente largos. El separador de- cimal es "." y puede contener un signo inicial "+" o "-". No permite notación exponencial y no puede contener ningún carácter distinto de los dígitos. xs:nonPositiveInteger Es el subconjunto de xs:integer formado por negativos y cero. xs:negativeInteger Es el subconjunto de xs:nonPositiveInteger formado por negativos. xs:nonNegativeInteger Es el subconjunto de xs:integer formado por positivos y cero. xs:positiveInteger Es el subconjunto de xs:nonNegativeInteger formado por positivos. F UNDAMENTOS DEL XML-S CHEMA O XSD 229 xs:long Enteros que pueden almacenarse en 64 bits. xs:int Enteros que pueden almacenarse en 32 bits. xs:short Enteros que pueden almacenarse en 16 bits. xs:byte Enteros que pueden almacenarse en 8 bits. xs:unsignedLong Enteros no negativos que pueden almacenarse en 64 bits. xs:unsignedInt Enteros no negativos que pueden almacenarse en 32 bits. xs:unsignedShort Enteros no negativos que pueden almacenarse en 16 bits. xs:unsignedByte Enteros no negativos que pueden almacenarse en 8 bits. xs:float Representa números en notación científica con potencias enteras de 10 de 32 bits de precisión. Pueden contener valores especiales: INF, -INF, NaN (Not a Number). xs:double Igual que xs:float salvo que en este caso la precisión es de 64 bits. xs:boolean Puede tomar valores true y false (1 y 0). Tipos fecha y hora Estos tipos son un subconjunto del estándar ISO 8601 que intenta eliminar la confusión entre los formatos utilizados en diferentes países. xs:dateTime Define un instante de tiempo concreto. El formato es: YYYY-MM- DDThh:mm:ss. Por ejemplo: 2003-10-21T20:30:13. Permite expresar zonas horarias. xs:date Define un día concreto del calendario Gregoriano. Por ejemplo: 2003-10-21. xs:gYearMonth Es xs:date sin la parte de día. Por ejemplo: 2010-10. xs:gYear xs:gYearMonth sin la parte del mes. Por ejemplo: 2010, -1340. xs:time Define una hora concreta. Por ejemplo: 10:21:23. xs:gDay Es un día del calendario Gregoriano (----DD). Por ejemplo: ----05 se refiere al quinto día del mes para representar algo que ocurre ese día todos los meses. xs:gMonthDay Es un día de un mes del calendario Gregoriano (--MM-DD). Por ejem- plo: --04-23 se refiere al 23 de abril, fecha en la que ocurre algo todos los años. xs:gMonth Es un mes del calendario Gregoriano (--MM). Por ejemplo: --10 se referiría al mes de octubre en sentido de que ocurre algo todos los años ese mes. 230 L ENGUAJES DE MARCADO . XML xs:duration Expresa una duración en un espacio de 6 dimensiones: número de años, meses, días, horas, minutos y segundos. El formato es PnYnMnDTnHnMnS. Ninguno de los elementos es obligatorio ni tiene limitación de rango y las letras mayúsculas se pueden omitir si no se usa el correspondiente elemento. Las letras P y T delimitan la descripción de fecha y tiempo respectivamente. Por ejemplo: PT130S se refiere a una duración de 130 segundos y P3Y2M25DT7H a una duración de 3 años, dos meses, 25 días, y 7 horas. Tipo lista Es una lista de campos separados por espacios en blanco. xs:NMTOKENS Lista de xs:NMTOKEN separada por espacios. xs:IDREFS Lista de xs:IDREF separada por espacios. xs:ENTITIES Lista de xs:ENTITY separada por espacios. Tipo no definido El tipo anySimpleType acepta cualquier valor. 4.6.4 Creación de nuevos tipos de datos Se pueden crear nuevos tipos de datos tomando como punto de partida los tipos de datos existentes. Este tipo de creación de datos se denomina derivación. Los métodos de derivación para los tipos simples y complejos son muy diferentes. Existen 3 mecanismos de derivación: • Por restricción. • Por lista. • Por unión. Derivación por restricción Los tipos de datos se crean añadiendo restricciones a los posibles valores de otros tipos. El propio XML-Schema usa este mecanismo. Por ejemplo, xs:positiveInteger es una derivación por restricción de xs:integer. Las restricciones de un tipo se definen mediante facetas. Una restricción se añade con el elemento xs:restriction y cada faceta se define utilizando un elemento específico dentro de xs:restriction. El tipo de dato que se restringe se denomina tipo base. Por ejemplo: F UNDAMENTOS 1 2 3 4 5 6 DEL XML-S CHEMA O XSD 231 < xs:simpleType name =" IntegerMuyCorto " > < xs:restriction base =" xs:integer " > < xs:minInclusive value =" -100 "/ > < xs:maxExclusive value =" 100 "/ > </ xs:restriction > </ xs:simpleType > El tipo base es xs:integer y xs:minInclusive y xs:maxExclusive son facetas que permiten definir la restricción. Las facetas se clasifican en 3 categorías: • Las que definen el procesamiento de espacios en blanco, tabuladores, etc. Estas facetas actúan entre el espacio de análisis y el léxico. • Las que trabajan sobre el espacio léxico. • Las que restringen el espacio de valores. Antes de que se realice la validación de un documento se deben realizar una serie de transformaciones. Facetas de cadenas con procesamiento de espacios en blanco En estas facetas se eliminan los espacios iniciales y finales, se sustituyen tab, line feed y CR por espacios y n espacios consecutivos se sustituyen por uno. Los tipos sobre los que pueden actuar estas facetas son: xs:ENTITY, xs:ID, xs:IDREF, xs:language, xs:Name,xs:NCName, xs:NMTOKEN, xs:token, xs:anyURI, xs:base64Binary, xs:hexBinary, xs:NOTATION y xs:QName. Todas las facetas de este tipo restringen el espacio de valores. xs:enumeration define una lista de posibles valores. Por ejemplo: 1 2 3 4 5 6 < xs:simpleType name =" misWebs " > < xs:restriction base =" xs:anyURI " > < xs:enumeration value =" http: // www . miweb1 . org /"/ > < xs:enumeration value =" http: // www . miweb2 . org /"/ > </ xs:restriction > </ xs:simpleType > xs:length define una longitud fija en número de caracteres o bytes (xs:hexBinary y xs:base64Binary). xs:maxLength define una longitud máxima en número de caracteres o bytes (xs:hex- Binary y xs:base64Binary). 232 L ENGUAJES DE MARCADO . XML xs:minLength define una longitud mínima en número de caracteres o bytes (xs:hex- Binary y xs:base64Binary). Por ejemplo: 1 2 3 4 5 < xs:simpleType name =" NombreMin8 " > < xs:restriction base =" xs:NCName " > < xs:minLength value ="8"/ > </ xs:restriction > </ xs:simpleType > Los elementos de tipo NombreMin8 deberán tener al menos 8 caracteres de longitud. xs:pattern define un patrón que debe emparejarse con la cadena. Por ejemplo: 1 2 3 4 5 < xs:simpleType name =" UNED - URI " > < xs:restriction base =" xs:anyURI " > < xs:pattern value =" http: // www . uned . es /.* "/ > </ xs:restriction > </ xs:simpleType > Las URI de tipo UNED-URI debrán comenzar por la cadena "http://www.uned.es/". Facetas de tipos numéricos reales Estas facetas actúan sobre los tipos xs:float y xs:double restringiendo el espacio de valores. xs:enumeration permite definir una lista de posibles valores. Por ejemplo: 1 2 3 4 5 6 7 < xs:simpleType name =" listaFloat " > < xs:restriction base =" xs:float " > < xs:enumeration value =" -2.5678 "/ > < xs:enumeration value =" 1.2997 "/ > < xs:enumeration value =" 2.876 "/ > </ xs:restriction > </ xs:simpleType > xs:maxExclusive define un valor máximo que no se puede alcanzar. xs:maxInclusive define un valor máximo que sí se puede alcanzar. xs:minExclusive define un valor mínimo que no se puede alcanzar. xs:minInclusive define un valor mínimo que sí se puede alcanzar. F UNDAMENTOS DEL XML-S CHEMA O XSD 233 xs:pattern define un patrón que debe cumplir el valor léxico del tipo de datos. Facetas de tipos de fecha y hora Son las mismas que para los tipos numéricos reales. Facetas de tipos enteros Las mismas facetas que para los tipos numéricos reales más la siguiente: xs:totalDigits define el número máximo de dígitos. Actúa sobre el espacio de va- lores. Por ejemplo: 1 2 3 4 5 < xs:simpleType name =" integer3dig " > < xs:restriction base =" xs:integer " > < xs:totalDigits value ="3"/ > </ xs:restriction > </ xs:simpleType > Derivación por lista Es un mecanismo que permite derivar un tipo de datos de lista a partir de un tipo de datos atómico. Todos los datos de la lista tienen que ser del mismo tipo. Por ejemplo: IDREFS, ENTITIES y NMTOKENS son listas predefinidas derivadas de los tipos atómicos utilizando este mecanismo, respectivamente de los tipos atómicos IDREF, ENTITY y NMTOKEN. Solo se permiten las siguientes facetas en este tipo de derivación: xs:length referida al número de elementos, xs:enumeration, xs:maxLength, xs:minLength y xs:whiteSpace. Este tipo de derivación se realiza con el elemento xs:list. Por ejemplo: 1 2 3 < xs:simpleType name =" listaInteger " > < xs:list itemType =" xs:integer "/ > </ xs:simpleType > El tipo de datos listaInteger se puede utilizar con atributos y elementos para que acepten una lista de enteros separados por espacios, como: "1 -2345 200 33". Derivación por unión Este mecanismo permite definir nuevos tipos de datos fusionando los espacios léxicos de varios tipos predefinidos o definidos por el usuario. El tipo de dato resultante pierde 234 L ENGUAJES DE MARCADO . XML la semántica y facetas de los tipos miembro. Este tipo de derivación se realiza con el elemento xs:union. Por ejemplo: 1 2 3 < xs:simpleType name =" integerUnionDate " > < xs:union memberTypes =" xs:integer xs:date "/ > </ xs:simpleType > Solo se permiten 2 facetas en este tipo de derivación: xs:pattern y xs:enumeration. 4.6.5 Espacios de nombres en XML-Schema El XML-Schema definido por el W3C asocia un espacio de nombres a todos los objetos (elementos, atributos, tipos simples, complejos, . . . ) definidos en un XSD. En este punto recuérdese que XML 1.0 no soporta de forma estándar la combinación de esquemas y que la DTD trata los espacios de nombres como identificadores distintos, pero realmente tampoco contempla la combinación de esquemas. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <?xml version=" 1.0 "? > < archivo xmlns =" http: // www . misitio . com / docsXML / archivo " > < informe confidencial = " false " id ="IN -33 A5 " > < fecha > 13 -11 -2010 </ fecha > < titulo leng = " es " > Contaminación acústica </ titulo > < autor > < nombre > Andrés Martínez </ nombre > < nombre > Sandra Columela </ nombre > </ autor > < asunto > < nombre > Efectos inmediatos </ nombre > < descripcion > La contaminación ... </ descripcion > </ asunto > < asunto > ... </ asunto > </ informe > ... </ archivo > Figura 4.2: Un documento XML con un espacio de nombres predeterminado En la Figura 4.2 el espacio de nombres http://www.misitio.com/docsXML/archivo se define como espacio de nombres por defecto porque está asociado al elemento raíz y se aplica a todos los elementos del documento. La declaración de espacio de nombres de la Figura 4.3 es equivalente a la de la Figura 4.2. Se usa el prefijo arc, por lo que es una declaración cualificada, para los elementos F UNDAMENTOS 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 DEL XML-S CHEMA O XSD 235 <?xml version=" 1.0 "? > < arc:archivo xmlns:arc =" http: // www . misitio . com / docsXML / archivo " > < arc:informe confidencial = " false " id ="IN -33 A5 " > < arc:fecha > 13 -11 -2010 </ arc:fecha > < arc:titulo leng = " es " > Contaminación acústica </ arc:titulo > < arc:autor > < arc:nombre > Andrés Martínez </ arc:nombre > < arc:nombre > Sandra Columela </ arc:nombre > </ arc:autor > < arc:asunto > < arc:nombre > Efectos inmediatos </ arc:nombre > < arc:descripcion > La contaminación ... </ arc:descripcion > </ arc:asunto > < arc:asunto > ... </ arc:asunto > </ arc:informe > ... </ arc:archivo > Figura 4.3: Un documento XML con un espacio de nombres con prefijo que pertenecen al espacio de nombres proyectado por el prefijo. A diferencia de los XML-Schema, en la DTD puede haber espacios de nombres, pero el control de ellos recae en el usuario o en las aplicaciones. Veamos un ejemplo de uso de los espacios de nombre con XML-Schema. Supongamos que se quiere añadir información al elemento informe y no se quiere rehacer el esquema y modificar las aplicaciones que lo usan. En concreto se quiere añadir un elemento a informe que pertenece a otro espacio de nombres. En la Figura 4.4 en las líneas 3 y 18 se puede ver la declaración del nuevo espacio de nombres y su elemento asociado respectivamente. Para asociar a un XSD un espacio de nombres, y que por lo tanto pertenezca a dicho espacio de nombres, se usa el atributo targetNamespace del elemento schema. Veamos el siguiente ejemplo: 1 2 3 4 5 6 7 <?xml version=" 1.0 "? > < xs:schema targetNamespace =" http: // www . misitio . com / docsXML / archivo " elementFormDefault =" qualified " attributeFormDefault =" unqualified " xmlns:arc =" http: // www . misitio . com / docsXML / archivo " xmlns:xs =" http: // www . w3 . org /2001/ XMLSchema " > ... 236 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 L ENGUAJES DE MARCADO . XML <?xml version=" 1.0 "? > < arc:archivo xmlns:arc =" http: // www . misitio . com / docsXML / archivo " > xmlns:rev =" http: // www . misitio . com / docsXML / archivo / revision " > < arc:informe confidencial = " false " id ="IN -33 A5 " > < arc:fecha > 13 -11 -2010 </ arc:fecha > < arc:titulo leng = " es " > Contaminación acústica </ arc:titulo > < arc:autor > < arc:nombre > Andrés Martínez </ arc:nombre > < arc:nombre > Sandra Columela </ arc:nombre > </ arc:autor > < arc:asunto > < arc:nombre > Efectos inmediatos </ arc:nombre > < arc:descripcion > La contaminación ... </ arc:descripcion > </ arc:asunto > < arc:asunto > ... </ arc:asunto > < rev:comentarios > El informe está ... </ rev:comentarios > </ arc:informe > ... </ arc:archivo > Figura 4.4: Un documento XML con dos espacios de nombres con prefijo 8 </ xs:schema > http://www.misitio.com/docsXML/archivo es el espacio de nombres con el prefijo arc que utilizarán las instancias de documentos válidas para el esquema. Dicho prefijo solo se usará en los documentos, no en el esquema, que tiene su propio espacio de nombres con el prefijo xs. Así, con las anteriores declaraciones, un procesador de XSD conoce el prefijo que se quiere utilizar para el espacio de nombres del W3C XMLSchema y el espacio de nombres destino u objetivo, que es el de los documentos XML. Además, la declaración del espacio de nombres objetivo (targetNamespace) permite: • Definir elementos y atributos que pertenecen a dicho espacio de nombres y que serán qualified. • Definir elementos y atributos que no pertenecen a ningún espacio de nombres y por tanto serán unqualified. Cuando se ha definido un espacio de nombres objetivo está prohibido definir elementos globales que sean de tipo unqualified. La distinción entre elementos y atributos qualified y unqualified se hace a través del atributo form. Los valores por defecto F UNDAMENTOS DEL XML-S CHEMA O XSD 237 de los atributos form se definen en el elemento schema mediante elementFormDefault y attributeFormDefault. Independientemente de las declaraciones por defecto, se puede definir elemento por elemento y atributo por atributo si van a ser de tipo qualified o unqualified. Por ejemplo: 1 2 3 < xs:element name =" informe " form =" qualified "/ > < xs:element name =" asunto " form =" unqualified "/ > < xs:attribute name =" leng " form =" unqualified "/ > Los valores por defecto de los atributos elementFormDefault y attributeFormDefault son en ambos casos unqualified. Estos valores por defecto son apropiados en el caso en que solo el elemento documento, es decir el elemento raíz, utilice un espacio de nombres. Si tenemos la siguiente instancia: 1 2 3 4 5 6 7 <?xml version=" 1.0 "? > < arc:archivo xmlns:arc =" http: // www . misitio . com / docsXML / archivo " > < informe confidencial = " false " id ="IN -33 A5 " > < fecha > 13 -11 -2010 </ fecha > < titulo leng = " es " > Contaminación acústica </ titulo > ... </ arc:archivo > Como los elementos y atributos globales deben ser qualified, la definición de un esquema para esta instancia requeriría que todos los elementos y atributos estuvieran definidos localmente. La combinación de las declaraciones elementFormDefault="qualified" y attributeFormDefault="unqualified" corresponde al caso en el que el espacio de nombres se añade al elemento raíz (espacio de nombres por defecto) y se aplica a los elementos incluidos pero no a los atributos. Por ejemplo: 1 2 3 4 5 6 7 <?xml version=" 1.0 "? > < archivo xmlns =" http: // www . misitio . com / docsXML / archivo " > < informe confidencial = " false " id ="IN -33 A5 " > < fecha > 13 -11 -2010 </ fecha > < titulo leng = " es " > Contaminación acústica </ titulo > ... </ archivo > Los esquemas que utilizan atributos de tipo qualified con frecuencia usan atributos de otros espacios de nombres. En el documento de la Figura 4.2 el espacio de nombres http://www.misitio.com/docsXML/archivo se define como espacio de nombres por defecto y se aplica a todos los elementos del documento por defecto. 238 L ENGUAJES DE MARCADO . XML 4.7 Procesadores de documentos XML Un procesador XML pone a disposición de las aplicaciones los contenidos de un documento XML, a la vez que detecta posibles errores. Hay dos enfoques para leer y acceder al contenido XML: dirigido por eventos y manipulación del árbol. En el enfoque dirigido por eventos el documento se procesa secuencialmente. Cada elemento de la corriente de datos activa un evento que, a su vez, puede precipitar alguna acción por parte de la aplicación. En el enfoque basado en la manipulación del árbol, el documento se estructura como un árbol de nodos a los que se puede acceder en cualquier orden. Los desarrolladores de aplicaciones que acceden al contenido de documentos XML deben incluir en ellas las librerías correspondientes a los paquetes que implementan el enfoque seleccionado. Existe un estándar para cada uno de los enfoques: SAX para el dirigido por eventos y DOM para el de manipulación del árbol. 4.7.1 Procesador de eventos: SAX Probablemente la manera más sencilla de procesar un documento XML es ir leyendo su contenido como una corriente de datos e interpretar las etiquetas y anotaciones a medida que se van encontrando. SAX12 es una interfaz dirigida por eventos y se puede utilizar con diferentes lenguajes de programación, entre ellos Java. La denominación SAX viene de Simple API for XML. El procesador de SAX no crea ninguna estructura de datos para representar el documento, sino que lo va analizando secuencialmente y generando eventos, como por ejemplo: comienzo de un elemento y final de un elemento. La aplicación creará objetos con métodos que el procesador o parser deberá llamar cuando se generen determinados eventos, y es la aplicación la que decide qué eventos le interesa controlar. Por ejemplo, puede que la aplicación requiera saber cuándo en la lectura del documento aparece la etiqueta de comienzo de un elemento, de final, contenido de tipo texto, llamadas a entidades externas no analizadas, declaraciones de notaciones, errores, la localización de los errores, etc. La aplicación pasará referencias a estos objetos al procesador de manera que pueda llamar a los métodos correspondientes. Como SAX funciona por eventos no es posible manipular información una vez procesada. Si quisiéramos manipular información ya procesada tendríamos que guardarla en variables o estructuras de datos, o bien volver a llamar al procesador. Por ejemplo, con el siguiente documento XML: 1 2 <?xml version=" 1.0 "? > < doc > <par > Hola mundo </ par > 12 http://www.saxproject.org/ </ doc > P ROCESADORES DE DOCUMENTOS XML 239 Una interfaz basada en eventos produciría la siguiente secuencia de eventos: inicio documento inicio elemento: doc inicio elemento: par caracteres: Hola mundo fin elemento: par fin elemento: doc fin documento SAX está compuesto por una serie de interfaces y clases. Veamos algunas de las principales: • ContentHandler • ErrorHandler • DTDHandler • DeclHandler • XMLReader ContentHandler Esta interfaz reemplaza a DocumentHandler de SAX 1.0. Contiene métodos que permiten que la aplicación reciba notificación de los eventos de marcado básicos. En la aplicación debe haber una clase que implemente esta interfaz. En la Tabla 4.1 aparece la descripción de los métodos de esta interfaz. Por ejemplo, suponiendo que la aplicación quiere informar por la salida estándar del comienzo y fin del análisis del documento, los métodos asociados a esos eventos podrían ser: 1 2 3 4 5 6 public void startDocument () { System . out . println (" Inicio documento "); } public void endDocument () { System . out . println (" Fin documento "); } El prototipo del método startElement es el siguiente: 1 public void startElement ( String URIespNombre , String nombreLocal , String nombreBase , Attributes atrs 240 L ENGUAJES DE MARCADO . startDocument() endDocument() startElement(String nombre, AttributeList atributos) endElement(String nombre) characters(char car[ ], int comienzo, int longitud) ignorableWhitespace(char car[ ], int comienzo, int longitud) processingInstruction(String destino, String datos) setDocumentLocator(Locator loc) XML Recibe la notificación del comienzo del documento Recibe la notificación del final del documento Recibe la notificación del comienzo de un elemento Recibe la notificación del final de un elemento Recibe la notificación de datos carácter Recibe la notificación de espacios que se pueden ignorar en el contenido de un elemento Recibe la notificación de una instrucción de procesamiento El objeto loc da información de la localización del evento (no línea, no columna) Tabla 4.1: Métodos de ContentHandler Se llama a este método cuando el procesador encuentra un nuevo elemento. Sus parámetros son: URIespNombre: el espacio de nombres, si lo hay. nombreLocal: nombre del elemento sin prefijo. nombreBase: nombre del elemento con prefijo. atrs: lista de atributos. Por ejemplo, si tuviéramos el siguiente elemento: 1 2 3 <a xmlns:h =" http: // www . miWeb . com " > <h:b estado =" normal "/ > </a > cuando el procesador encuentre el principio del elemento b los valores de los parámetros del método startElement contendrán: URIespNombre: "http://www.miweb.com" nombreLocal: "b" nombreBase: "h:b" atrs: estado="normal" Si nuestra aplicación quisiera escribir los atributos de todos los elementos del documento, el método startElement podría ser el siguiente: 1 2 3 4 public void startElement ( String uri , String local , String base , Attributes atrs ){ if ( atrs != null ){ int long = atrs . getLength () ; for ( int i =0; i < long ; i ++) { P ROCESADORES XML 241 out . print ( atrs . getRawName (i)); out . print ( atrs . getValue (i)); 5 6 } 7 } 8 9 DE DOCUMENTOS } ErrorHandler Esta intefaz incluye métodos para interceptar advertencias y errores. El procesador SAX puede encontrar 3 tipos de errores: normales, fatales y advertencias: 1 2 3 public void error ( SAXParseException ex ) throws SAXException public void fatalError ( SAXParseException ex ) throws SAXException public void warning ( SAXParseException ad ) throws SAXException El método del siguiente ejemplo muestra un mensaje cada vez que el procesador lanza una advertencia: 1 2 3 public void warning ( SAXParseException ex ) throws SAXException { printError (" Warning " , ex ); } DTDHandler Esta interfaz será necesaria para las aplicaciones que necesitan información de las notaciones y entidades no analizadas. Téngase en cuenta que al no tener contenido XML el procesador no las puede analizar. Esta interfaz permite a la aplicación localizar las entidades no analizadas y decidir, por ejemplo, que otra aplicación se encargue de procesarlas. Los eventos de DTDHandler ocurrirán entre startDocument y startElement. La aplicación debería almacenar los valores devueltos por los métodos de esta interfaz y utilizarlos cuando un atributo haga referencia a ellos. Veamos los métodos de esta interfaz. 1 notationDecl ( String nombre , String idPublico , String idSistema ) Informa a la aplicación de la presencia de una declaración de notación. Por ejemplo, si el procesador encuentra: 1 <!NOTATION GIF SYSTEM " Iexplorer . exe " > 242 L ENGUAJES DE MARCADO . XML hace una llamada al método notationDecl con los siguientes valores en sus argumentos: nombre: "GIF" idPublico: NULL idSistema: "Iexplore.exe" El siguiente método informa a la aplicación de la presencia de una declaración de entidad no analizada: 1 unparsedEntityDecl ( String nombre , String idPublico , String idSistema , String nomNotacion ) Por ejemplo, si el procesador se encuentra: 1 <!ENTITY csfoto SYSTEM " csfoto . jpeg " NDATA JPEG > hace una llamada al método unparsedEntityDecl con los siguiente valores en sus argumentos: nombre: "csfoto" idPublico: NULL idSistema: "csfoto.jpeg" nomNotacion: "JPEG" DeclHandler Esta interfaz proporciona información de las declaraciones de elementos y atributos en la DTD. Veamos algunos métodos: 1 elementDecl ( String nombre , String modeloContenido ) throws SAXException ) Informa a la aplicación de la presencia de una declaración de elemento. Por ejemplo si el procesador se encuentra: 1 <!ELEMENT receta ( nombre , ingre +, proced ) > hace una llamada al método elementDecl con los siguiente valores en sus argumentos: nombre: "receta" modeloContenido: "(nombre,ingre+, proced)" P ROCESADORES DE DOCUMENTOS XML 243 El método: 1 attributeDecl ( String nombreE , String nombreA , String tipo , String valorPre , String valor ) informa a la aplicación del contenido de las declaraciones de atributos. Por ejemplo, si el procesador se encuentra: 1 2 <!ATTLIST receta iden ID #REQUIRED cocina ( esp | fra | otr ) " esp " #REQUIRED> se producirían dos llamadas al método attributeDecl respectivamente con los valores: nombreE: "receta" nombreA: "iden" tipo: "ID" valorPre: "#REQUIRED" valor: null y: nombreE: "receta" nombreA: "cocina" tipo: "esp|fra|otr" valorPre: "#REQUIRED" valor: "esp" El método: 1 internalEntityDecl ( String nombre , String valor ) devuelve el nombre y el valor de cada declaración de entidad interna. Por ejemplo, si el procesador se encuentra: 1 <!ENTITY uned " Universidad Nacional de Educación a Distancia " > 244 L ENGUAJES DE MARCADO . XML se produciría una llamada al método internalEntityDecl con los valores: nombre: "uned" valor: "Universidad Nacional de Educación a Distancia" Las entidades de parámetro también se identifican de la misma manera. Recuérdese que incluyen el carácter % antes del nombre. XMLReader Esta interfaz reemplaza a Parser de SAX1. Permite a la aplicación establecer y consultar rasgos y propiedades del procesador, registrar manejadores de eventos e iniciar el procesador. Este interfaz soporta los espacios de nombre. Veamos un ejemplo de uso de sus métodos: 1 2 3 4 5 6 7 8 9 10 // creamos el parser XMLReader xr = XMLReaderFactory . createXMLReader ( nomParser ); // creamos un objeto de la clase MiAplSAX MiAplSAX handler = new MiAplSAX () ; // establecemos los manejadores que se van a usar xr . setContentHandler ( handler ); xr . setErrorHandler ( handler ); ... // Iniciamos el analizador xr . parse ( docXml ); En el apartado de ejercicios resueltos, en el ejercicio 9 se puede ver un ejemplo de un programa Java que captura los principales eventos del procesador SAX. 4.7.2 Procesador del árbol: DOM DOM es una especificación del consorcio W3C13 que proporciona un interfaz (API) al programador para acceder de forma fácil, consistente y homogénea a los elementos, atributos y otros posibles componentes de un documento. DOM permite la representación interna estándar de la estructura de un documento HTML y XML, y además es un modelo independiente de la plataforma y del lenguaje de programación. DOM presenta los documentos como una jerarquía (árbol) de objetos nodo en la que algunos tipos de nodos pueden tener nodos hijo y otros nodos son nodos hojas. Toda la jerarquía de nodos (el documento completo) se carga en memoria de forma que se puede recorrer y acceder a los nodos. Veamos un ejemplo. El siguiente documento XML: 13 http://www.w3.org/DOM/ P ROCESADORES 1 2 3 4 5 DE DOCUMENTOS XML 245 <?xml version=" 1.0 " encoding ="UTF -16 "? > < doc > < saludo > Hola < enfatico > estimados </ enfatico > oyentes </ saludo > < aplausos tipo =" sostenido "/ > </ doc > daría lugar a la jerarquía de nodos de la Figura 4.5. documento Ins. Proces. comentario elemento elemento texto elemento texto elemento atributo texto Figura 4.5: Jerarquía de nodos Un objeto DOM debe ser capaz de cargar un documento XML y debe disponer de todas las interfaces con los atributos y métodos de acuerdo con la especificación del DOM. Los interfaces de DOM para XML son los siguientes: DOMImplementation devuelve información sobre el nivel del DOM que soporta el objeto. En DOM hay tres niveles de especificación14 . Document representa al documento XML completo, la raíz del árbol. Node es la interfaz básica ya que en DOM todo puede considerarse un nodo. Define un conjunto de atributos y métodos necesarios para navegar, visitar y modificar 14 En http://www.w3.org/DOM/DOMTR se pueden encontrar los detalles de lo que cubre cada nivel. 246 L ENGUAJES DE MARCADO . XML cualquier nodo. Sus métodos y atributos se solapan con los de las demás interfaces. CharacterData datos de caracteres o texto de un documento. Attr atributos de un nodo. Element elementos y sus atributos. Text texto de un elemento, atributo o entidad. CDATAsection una sección CDATA. Notation una declaración de notación en la DTD. Entity una declaración de entidad en la DTD. EntityReference referencia a una entidad. ProcessingInstruction una instrucción de procesamiento. Comment un comentario. En la Tabla 4.2 se pueden ver los tipos de datos que utiliza DOM. Tipo de datos Entero corto sin signo Entero largo sin signo Node NodeList NamedNodeMap DOMString Boolean Void Nodos que tienen ese tipo de datos Todos los NodeTypes Longitud de una nodeList o NamedNodeMap Objeto que contiene todos los detalles del nodo: tipo, nombre, valor, hijos, ... Una lista indexada de objetos nodo Una lista no indexada de objetos nodo, se aplica a los nodos Attr, entidad y notación Cadena de caracteres unicode True o false Métodos que no devuelven valor Tabla 4.2: Tipos de datos DOM Veamos a continuación los atributos y métodos principales de algunas de las interfaces. Node Uno de los atributos fundamentales de esta interfaz es nodeType que devuelve el tipo del nodo activo. Existen 12 tipos de nodos y cada tipo se describe mediante un entero. Además, hay una constante predefinida asociada a cada tipo de nodo. En la Tabla 4.3 se presentan los tipos de nodos, su valor de atributo nodeType y su constante asociada. P ROCESADORES Tipo de nodo Elemento Attr Texto Sección CDATA Referencia de entidad Nodo entidad Instrucción de procesamiento Comentario Documento Tipo de documento Fragmento de documento Notación DE DOCUMENTOS nodeType 1 2 3 4 5 6 7 8 9 10 11 12 XML 247 Constante ELEMENT_NODE ATTRIBUTE_NODE TEXT_NODE CDATA_SECTION_NODE ENTITY_REFERENCE_NODE ENTITY_NODE PROCESSING_INSTRUCTION_NODE COMMENT_NODE DOCUMENT_NODE DOCUMENT_TYPE_NODE DOCUMENT_FRAGMENT_NODE NOTATION_NODE Tabla 4.3: Tipos de nodos DOM Otros atributos de solo lectura de esta interfaz son: nodeName, firstChild, ownerDocument, nodeValue, lastChild, previousSibling, parentNode, nextSibling, childNodes y attributes. En la Tabla 4.4 se describen los valores de los atributos nodeName y nodeValue según el tipo de nodo. Tipo de nodo Elemento Attr Texto Sección CDATA Referencia de entidad Nodo entidad Instrucción de procesamiento Comentario Documento Tipo de documento Notación nodeName nombre elemento nombre atributo #text #cdata-section nombre entidad nombre entidad el objetivo (xml) #comment #document nombre del nodo raíz nombre de la notación nodeValue null valor del atributo el texto contenido de CDATA null valor entidad de tipo Text el contenido (version="1.0") contenido comentario el 1er nodo secundario null null Tabla 4.4: Valores de los atributos nodeName y nodeValue según el tipo de nodo Algunos de los métodos de esta interfaz son: 1 insertBefore ( nodoNuevo , nodoReferencia ) Inserta un nuevo nodo hijo (secundario) antes del nodo hijo de referencia. 248 1 L ENGUAJES DE MARCADO . XML replaceChild ( nodoNuevo , nodoReferencia ) Sustituye al nodo hijo (secundario) de referencia por el nodo nuevo. 1 removeChild ( nodoReferencia ) Elimina el nodo hijo (secundario) de referencia. 1 appendChild ( nodoNuevo ) Añade el nuevo nodo al final de los hijos. 1 hasChildNodes () Devuelve un valor booleano indicando si el nodo tiene hijos. 1 cloneNode () Hace una copia de un nodo. Hay dos formas de copia: si el argumento del método es true se clona el elemento y todo su contenido, pero si el argumento es false se clona solo el elemento. Document Los atributos más destacados de esta interfaz son: doctype y documentElement. doctype devuelve la información de <!DOCTYPE. Devuelve las declaraciones de las entidades y notaciones como nodos hijo. documentElement devuelve un objeto con el elemento raíz del documento, que es el punto de partida usual en el recorrido del árbol. Los métodos de este interfaz son: createElement(), createAttribute(), createTextNode(), createProcessingInstruction(), createEntityReference(), createComment(), createDocumentFragment(), createCDATASection(), getElementsByTagName(). Los métodos que comienzan por create crean nodos con nombre, pero éstos son huérfanos cuando se crean, no forman parte del árbol. Para unirlos al árbol habrá que utilizar métodos de la interfaz Node como insertBefore() o appendChild(). Por ejemplo: 1 2 3 raiz = miDoc . documentElement nuevoElemento = miDoc . createElement (" parrafo ") nodo = raiz . appendChild ( nuevoElemento ) Añade un nuevo descendiente directo de la raíz. Lo añade al final. P ROCESADORES DE DOCUMENTOS XML 249 El siguiente método: 1 getElementsByTagName ( nombreEtiqueta ) Obtiene una lista de todos los elementos descendientes del nodo de referencia que se llamen como el argumento. Devuelve un objeto NodeList que en este caso es la lista de los nodos descendientes que se llaman nombreEtiqueta en el orden en el que se encuentran en el subárbol. NodeList Este interfaz permite acceder a una lista de objetos nodo. Por ejemplo, el método getElementsByTagName() y el atributo childNodes crean un objeto de tipo NodeList. El método item() toma un índice como argumento y devuelve el nodo que se encuentra en dicha posición. La numeración de las posiciones empieza en 0. El atributo length devuelve un entero largo sin signo que indica en número de nodos de la lista. Veamos un ejemplo de acceso a los elementos de una lista NodeList: 1 2 3 4 5 6 7 8 9 raiz = miDoc . documentElement listaHijos = raiz . childNodes numHijos = listaHijos . length for (v =0; v < numHijos ;v ++) { nodo = listaHijos . item (v) ... if ( listaHijos . item (v). hasChildNodes () == true ) ... } NamedNodeMap Esta interfaz permite acceder a la lista de atributos de un elemento. A diferencia de una lista de tipo Nodelist, a los elementos de una lista NamedNodeMap se accede por sus nombres, la posición no es importante. El atributo attributes de la interfaz Node devuelve un objeto de este tipo. El método item() y el atributo length tienen funciones equivalentes a los del mismo nombre del interfaz NodeList. Los métodos getNamedItem() y removeNamedItem() recuperan y eliminan respectivamente el nodo de la lista cuyo nombre se pasa como argumento. El método setNamedItem() añade el nodo cuyo nombre se pasa como argumento. Veamos un ejemplo de acceso a los elementos de una lista NamedNodeMap: 250 1 2 3 L ENGUAJES DE MARCADO . XML listaElementos = miDoc . getElementsByTagName (" parrafo ") listaAtributos = listaElementos (0) . attributes Atrib = listaAtributos . getNamedItem (" id ") Attr Permite acceder a un atributo de un objeto elemento. El atributo name devuelve el nombre del atributo. El atributo value devuelve su valor. El atributo specified devuelve un valor booleano que indica si el atributo tiene un valor asignado (true) o si el valor está predeterminado en la DTD (false). Veamos dos ejemplos de uso de estos atributos: 1 2 3 4 5 6 7 8 9 1 2 3 4 5 listaElementos = miDoc . getElementsByTagName (" parrafo ") listaAtributos = listaElementos (0) . attributes for (v =0; v < listaAtributos . length ;v ++) { ... document . write ( listaAtributos . item (v). name ); document . write (" = "); document . write ( listaAtributos . item (v). value ); ... } listaElementos = miDoc . getElementsByTagName (" parrafo ") listaAtributos = listaElementos (0) . attributes atrib = listaAtributos . getNamedItem (" id ") nombre = atrib . name valor = atrib . value CharacterData Esta interfaz permite acceder y modificar datos de tipo de cadena. El atributo data devuelve el texto del nodo como una cadena Unicode. El atributo length devuelve el número de caracteres de la cadena. El método subStringData() devuelve una subcadena de una cadena. Tiene el siguiente formato: 1 subStringData ( inicioSubcadena , numCaracteres ) El método appendData() añade al final del texto del nodo la cadena que se pasa como argumento. El método insertData() inserta una subcadena en una cadena. Tiene el siguiente formato: P ROCESADORES 1 DE DOCUMENTOS XML 251 insertData ( inicioInsercion , subcadena ) El método deleteData() borra una subcadena de una cadena. Tiene el siguiente formato: 1 deleteData ( inicioSubcadena , numCaracteres ) El método replaceData() reemplaza una subcadena en una cadena. Tiene el siguiente formato: 1 replaceData ( inicioSubcadena , numCaracteres , subcadena ) Element La mayoría de los nodos de un documento son de tipo elemento o texto. Buena parte de las operaciones que se realizan con nodos de tipo elemento las realiza la interfaz Node; de la misma manera, la mayoría de las operaciones que se realizan con nodos de tipo texto las realiza la interfaz CharacterData. Esta interfaz, por tanto, dispone de varios métodos y atributos similares en funcionalidad a los existentes en otras interfaces. Veamos algunos de los métodos más específicos: El método getAttribute() recupera el valor del atributo que se le pasa como argumento. Tanto el argumento como el valor devuelto son de tipo DOMString. El método setAttribute() crea un atributo y establece su valor. Ambos argumentos son de tipo DOMString. El método removeAttribute() elimina el atributo cuyo nombre se pasa como argumento. Es similar a removeNamedItem() de la interfaz NamedNodeMap. El método setAttributeNode() establece un atributo del nodo de tipo Attr que se le pasa como argumento. El nodo de tipo Attr puede haberse creado con el método cloneNode() del interfaz Node o con createAttribute() del interfaz Document. El método removeAttributeNode() borra el nodo atributo que se le pasa como argumento. Text La mayoría de las operaciones con texto las realiza la interfaz CharacterData. La interfaz Text posee solo un método, splitText() que divide un nodo de texto individual en dos nodos. Su argumento indica el punto de división. 252 L ENGUAJES DE MARCADO . XML 4.7.3 Elección del tipo de procesador SAX es un procesador bastante eficiente que permite manejar documentos muy extensos en tiempo lineal y con una cantidad de memoria constante. El precio en este caso es que necesita un mayor esfuerzo por parte de los desarrolladores. Los interfaces basados en el procesamiento del árbol, como DOM, son más sencillos de utilizar para los desarrolladores, pero a costa de aumentar el coste en memoria y tiempo. Un procesador de eventos tipo SAX será mejor que DOM cuando el documento completo no quepa en memoria, o cuando las tareas sean irrelevantes con respecto a la estructura del documento (contar todos los elementos, extraer el contenido de un elemento específico, . . . ). 4.8 Vinculación entre documentos La especificación XML 1.0 no define la forma en que se pueden establecer enlaces o vínculos entre documentos XML, es decir, cómo plasmar los hiperenlaces entre dos documentos XML. Para ello es necesario recurrir a otras tecnologías con sus respectivas especificaciones; éstas son: • XPath: lenguaje para acceder a partes de un documento XML. • XPointer: lenguaje para acceder a la estructura interna de un documento XML. Está basado en XPath. • XLink: construcciones de la vinculación avanzada entre documentos. Se apoya en XPointer. 4.8.1 XPath Es un lenguaje en sí mismo que no usa la sintaxis XML. XPath proporciona una manera de apuntar a partes de un documento XML. Forma la base del direccionamiento de documentos en otras tecnologías como XPointer y XSLT (XML Stylesheets Transformation Language, o lenguaje de transformación basado en hojas de estilo). XPath se basa en el concepto de notación de ruta (path), de ahí su nombre. Para XPath un documento XML es un árbol de nodos y los operadores del lenguaje permiten recorrer dicho árbol. El árbol asociado al documento lo crea el parser. La sintaxis concisa de XPath se diseñó para ser utilizada en URIs y valores de atributos XML. Con XPath se puede acceder a un elemento concreto del documento para darle un formato diferente (utilizando la tecnología XSLT), o crear un enlace hipertexto a dicho elemento (utilizando la tecnología XPointer), o recuperar información de elementos que cumplen ciertos patrones (utilizando la tecnología XQL -XML Query Language-). La especificación 1.0 de XPath se puede encontrar en http://www.w3.org/TR/xpath/. Veamos el siguiente documento XML: V INCULACIÓN 1 2 3 4 ENTRE DOCUMENTOS 253 < doc > < cuerpo lang =" es " > El centro ... </ cuerpo > < firma > Clara Pereda </ firma > </ doc > El árbol al que daría lugar el parser sería similar al de la Figura 4.6. Téngase en cuenta que el nodo doc no es el nodo raíz del árbol como ocurre con DOM. raíz doc cuerpo atributo firma texto texto Figura 4.6: Ejemplo de árbol generado por un analizador XPath Para XPath existen los siguientes tipos de nodos del árbol: • Raíz: se identifica por "/" y es el nodo raíz no el elemento raíz del documento. • Elementos. • Texto. • Atributos. • Espacios de nombres. • Instrucciones de procesamiento. • Comentarios. A continuación se van a describir algunos de los principales elementos del lenguaje XPath. 254 L ENGUAJES DE MARCADO . XML Expresiones El lenguaje XPath se basa en expresiones que permiten recorrer el árbol hasta llegar a un nodo determinado. El resultado de las expresiones es un objeto de datos de uno de los siguientes tipos: • node-set: un conjunto de nodos. • boolean: un valor true-false. • number: un número en coma flotante. • string: una cadena de texto. Las expresiones XPath pueden incluir diferentes operaciones sobre distintos tipos de operandos. Los operandos pueden ser, por ejemplo, llamadas a funciones y localizadores (location paths). La sintaxis de un localizador es similar a la usada para describir las rutas en Unix o Linux, pero su significado es diferente. Supongamos el documento XML de la Figura 4.7. La siguiente expresión XPath: /libro/capitulo/parrafo hace referencia a todos los elementos parrafo que cuelgan directamente de todos los elementos capitulo que cuelgan del elemento libro que cuelga del nodo raíz /. 1 2 3 4 5 6 7 8 9 10 11 12 13 < libro > < titulo > El último encuentro </ titulo > < autor > Sándor Márai </ autor > < capitulo num ="1" > ... < parrafo > ... </ parrafo > < parrafo > ... </ parrafo > </ capitulo > < capitulo num ="2" imagenes =" si " > ... < parrafo > ... </ parrafo > < parrafo > ... </ parrafo > </ capitulo > < apendice num ="a" imagenes =" si " > ... < parrafo > ... </ parrafo > < parrafo > ... </ parrafo > </ apendice > </ libro > Figura 4.7: Ejemplo de documento XML Una expresión XPath no devuelve los elementos que cumplen con el patrón que representa dicha expresión, sino la lista de apuntadores a los elementos que encajan en el patrón. En el ejemplo anterior nos devolvería los apuntadores a los 4 elementos parrafo que hay en los 2 elementos capitulo que hay en el elemento libro. V INCULACIÓN ENTRE DOCUMENTOS 255 Un localizador siempre tiene un punto de partida llamado nodo contexto. A menos que se indique un camino o ruta explícita, se entenderá que el localizador parte del nodo que en cada momento se esté procesando. Siguiendo con el ejemplo de la expresión /libro/capitulo/parrafo, un evaluador de expresiones Xpath comienza leyendo "/" por lo que selecciona el nodo raíz, independientemente del nodo contexto que en ese momento exista. Cuando el evaluador de XPath localiza el nodo raíz, éste pasa a ser el nodo contexto de dicha expresión. Después, el analizador lee libro, lo que le indica que seleccione todos los elementos que cuelgan del nodo contexto (en este punto raíz) que se llamen libro. Solo hay uno porque solo puede haber un elemento raíz. A continuación el analizador lee capitulo, lo que le indica que seleccione todos los elementos que cuelgan del nodo contexto (en este punto el nodo libro) que se llamen capitulo. El analizador continúa leyendo la expresión XPath y llega a parrafo que le indica que seleccione todos los elementos parrafo que cuelgan del nodo contexto. Pero en este punto no hay un nodo contexto, sino dos. El evaluador de expresiones recorre uno por uno los posibles nodos contexto haciendo que, mientras evalúa un determinado nodo, ése sea el nodo contexto de ese momento. Así, para localizar todos los elementos parrafo, se procesa el primer elemento capitulo y de él se extraen todos los elementos parrafo que contenga. A continuación se pasa al siguiente elemento capitulo procediendo de la misma manera. El resultado final de la expresión es un conjunto de punteros a los nodos que encajan con el patrón buscado. Ejes (Axes) Los ejes permiten realizar una selección de nodos dentro del árbol. Algunos ejes son los siguientes: Child es el eje utilizado por defecto. Se corresponde con la barra "/", la forma larga es: /child::. Ya se ha visto su uso en los ejemplos anteriores. Permite seleccionar a los hijos del nodo contexto. Por ejemplo: /libro/titulo se refiere a todos los elementos titulo del elemento libro. attribute su signo es la arroba "@", su forma larga es: attribute::. Contiene los atributos del nodo contexto en caso de que éste sea un elemento. Por ejemplo: /libro/capitulo/@num (o también /libro/capitulo/attribute::num) selecciona los elementos capitulo que posean el atributo num. Otro ejemplo: /libro/capitulo[@imagenes]/* selecciona todos los elementos hijo de los elementos capitulo que posean el atributo imagenes. descendant su signo es "//", su forma larga es: descendant::. Selecciona todos los nodos descendientes del conjunto de nodos contexto. No contiene nodos atributo ni espacios de nombres. Por ejemplo: /libro//parrafo selecciona todos los elementos parrafo de libro. Otro ejemplo: //parrafo//*[@href] selecciona todos los descendientes de parrafo que tienen un atributo href. 256 L ENGUAJES DE MARCADO . XML self el signo es ".", su forma larga es: self::. Selecciona el nodo contexto. Por ejemplo: .//parrafo selecciona todos los elementos parrafo descendientes del nodo contexto. Pruebas de nodo (Node tests) Cada eje tiene un tipo principal de nodo y las pruebas de nodo permiten seleccionar ciertos tipos de nodos. Algunas pruebas de nodos son las siguientes: * selecciona todos los nodos de tipo principal que son: elemento, atributo o espacio de nombres. Pero no selecciona los nodos de tipo texto, comentarios, e instrucciones de procesamiento. Por ejemplo: //capitulo/* selecciona todos los nodos principales descendientes de capitulo, que son los 4 elementos parrafo descendientes de los 2 elementos capitulo en el documento de la Figura 4.7. node() selecciona todos los nodos de todos los tipos. Por ejemplo: //capitulo/node() selecciona todos los nodos descendientes de capitulo. En este caso se seleccionarian los 4 elementos parrafo descendientes de los 2 elementos capitulo y además los 6 elementos de tipo texto representados por ". . . " en el documento de la Figura 4.7. text() selecciona todos los nodos de tipo texto. comment() selecciona todos los nodos de tipo comentario. processing-instruction() selecciona todos los nodos de tipo instrucción del proce- samiento. Predicados Los predicados permiten restringir el conjunto de nodos seleccionados a aquellos que cumplen ciertas condiciones. Por ejemplo, pueden seleccionar un nodo que cumple con un patrón y con un determinado valor en un atributo. Los predicados se incluyen dentro de un localizador utilizando corchetes. El resultado de un predicado es un valor booleano y la selección solo se realiza cuando el valor devuelto es verdadero. Por ejemplo: 1 / libro / capitulo [ @num ="1" ]/ parrafo apunta a todos los elementos parrafo de todos los elementos capitulo que tengan un atributo llamado num con valor "1". Los predicados se pueden suceder uno a otro teniendo el efecto de la operación lógica AND. Por ejemplo: V INCULACIÓN 1 ENTRE DOCUMENTOS 257 // capitulo [ parrafo /*[ @href ]][ @imagenes =" si "] es equivalente a: 1 // capitulo [( parrafo /*[ @href ]) and ( @imagenes =" si ")] Ambas expresiones seleccionan todos los elementos capitulo que tengan un elemento parrafo con algún elemento que contenga un atributo href y que tengan (los elementos capitulo) el atributo imagenes con valor si. Aplicado al documento ejemplo de la Figura 4.7 no seleccionaría ningún elemento. También se pueden utilizar los operadores lógicos or y not. Hay funciones que restringen el conjunto de nodos devueltos en una expresión XPath basándose en la posición del elemento devuelto. Algunas de estas funciones son: position() selecciona el elemento de la posición indicada. Por ejemplo: //capitulo[position()=2] es equivalente a //capitulo[2]. En el documento ejemplo de la Figura 4.7 devolvería el segundo elemento capitulo del documento. last() selecciona el último elemento. Por ejemplo: //capitulo[not(position()=last())] seleccionaría todos los elementos capitulo menos el último. En el documento ejemplo de la Figura 4.7 esta expresión, al haber solo dos elementos capitulo, seleccionaría el primero. id() selecciona elementos con un valor de atributo único de tipo id igual al indicado. Por ejemplo: id("capitulo1")/parrafo seleccionaría todos los elementos parrafo del elemento con valor de atributo de tipo id igual a capitulo1. En el documento ejemplo de la Figura 4.7 no hay ningún elemento que cumpla esa condición. En la especificación de XPath15 se puede encontrar una descripción más detallada de todos los elementos del lenguaje. 4.8.2 XPointer XPointer es una extensión de XPath y al igual que éste no usa la sintaxis XML. Especifica la sintaxis para crear identificadores de fragmentos de un documento con el objetivo de realizar vínculos. Para la localización de fragmentos de un documento el analizador construye y recorre la estructura de árbol de un documento XML. Al igual que XPath, XPointer es un estándar del World Wide Web Consortium (W3C). Recordemos cómo se enlazaría a un punto concreto de un documento HTML. 15 XML Path Language (XPath) Version 1.0 http://www.w3.org/TR/xpath 258 1 L ENGUAJES DE MARCADO . XML <P ID=" a1 " > Este es el párrafo identificado como a1 en el documento docA . htm </P> 2 3 <A href= " docA . htm # a1 " > Este enlace en docB . htm te llevará al párrafo identificado como a1 de docA . htm </A> En XML XPointer va a permitir añadir a una URI una expresión XPointer con la siguiente sintaxis: 1 # xpointer ( expresion ) donde expresion es una expresión XPath con algunas propiedades extra que no contempla el propio Xpath. Se pueden concatenar expresiones XPointer que se evalúan de izquierda a derecha mientras devuelvan un conjunto vacío de nodos. Por ejemplo: 1 documento . xml # xpointer (/ libro / capitulo [ @imagenes ]) xpointer (/ libro / capitulo [ @num ="2" ]) buscaría en primer lugar el conjunto de nodos delimitado por /libro/capitulo[@imagenes] y solo en el caso de que no existiese ninguno, buscaría por /libro/capitulo[@num="2"]. Para localizar un nodo con un determinado valor en un atributo id se podría utilizar la siguiente expresión: 1 documento . xml # xpointer ( id (" p1 ")) xpointer (//*[ @id =" p1 " ]) La primera expresión XPointer requeriría la existencia de un documento vinculado a una DTD o XML-Schema con un atributo declarado de tipo identificador único con valor "p1". Mientras que la segunda solo busca un atributo que se llame id y que tenga el valor "p1", no es necesario que tenga una DTD o XML-Schema asociado con el atributo declarado de tipo ID. Primero se buscaría el nodo con DTD o XML-Schema y atributo de tipo ID con el valor indicado y, si no hay DTD o no encuentra el nodo, buscaría elementos, ya que podría haber más de uno, con el atributo id="p1". Puntos y rangos Con XPath podemos seleccionar perfectamente un nodo principal (elementos, atributos,. . . ) pero no seleccionar partes de un nodo texto. Para poder hacerlo XPointer dispone de los puntos y rangos. Un punto es una posición en la información XML. Un rango es una selección contigua de toda la información XML que se encuentra entre dos puntos determinados. V INCULACIÓN ENTRE DOCUMENTOS 259 XPointer considera que existe un punto entre cualesquiera dos caracteres consecutivos de texto de un documento XML, y entre cada par de elementos también consecutivos. El fragmento de texto que existe entre dos puntos es un rango. Por ejemplo, en <saludo> Hola! </saludo> hay 13 puntos: 1. Antes del nodo raíz. 2. Antes del nodo <saludo>. 3. Antes del nodo texto Hola!. 4. Antes del espacio antes de Hola! y después de <saludo>. 5. Antes de la letra H. 6. Antes de la letra o. 7. Antes de la letra l. 8. Antes de la letra a. 9. Antes de la letra !. 10. Después de la letra !. 11. Después del espacio que divide ! y </saludo>. 12. Después de </saludo>. 13. Después del nodo raíz. La función point() permite usar los puntos en una expresión Xpointer. Se le añade un predicado indicando qué punto en concreto se desea seleccionar. Por ejemplo: 1 / point () [ position () =5] selecciona el quinto hijo (punto) del nodo raíz. La siguiente expresión: 1 / libro / autor / text () / point () [ position () =10] selecciona la décima letra de un nodo texto. Un rango puede describir cualquier zona continua de un documento, comienza en un determinado punto y finaliza en otro. Cada uno de ellos está determinado por una expresión XPath. Para especificar un rango añadimos la función /range-to(puntoFinal) a la expresión XPath del nodo inicial, siendo puntoFinal la expresión XPath del punto final. Una localización es un conjunto de nodos, más puntos, más rangos. Veamos algunas funciones de rango. 260 L ENGUAJES DE MARCADO . XML • La función range(localizacion) convierte localizaciones en rangos. El rango resultado es el mínimo necesario para cubrir la localización entera. • La función start-point(localizacion) devuelve un conjunto de localizaciones que contiene un punto. Por ejemplo: start-point(//capitulo[1]) devuelve un solo punto y start-point(//capitulo) devuelve un conjunto de puntos, cada uno de ellos anterior a capitulo. 4.8.3 XLink XLink es un lenguaje XML cuya especificación proporciona métodos para crear enlaces internos y externos a documentos XML, permitiendo además asociar metadatos a dichos enlaces. Básicamente es el lenguaje de enlaces XML que supera algunas de las limitaciones de los enlaces HTML. XLink16 tiene sintaxis XML y soporta vínculos sencillos (tipo HTML) pero también vínculos extendidos. Permite vincular dos documentos a través de un tercero y especificar la forma de atravesar un vínculo. Además, los vínculos pueden residir dentro o fuera de los documentos donde residan los recursos implicados. En XML no existen elementos de vinculación predefinidos a diferencia de HTML que tiene el elemento <A href=...>. XLink define un conjunto de atributos que se pueden añadir a elementos pertenecientes a otros espacios de nombres. Un elemento de vinculación utiliza una construcción denominada localizador para conectar recursos y define atributos de vinculación estándar. El uso de elementos y atributos XLink requiere declarar el espacio de nombres de XLink. Por ejemplo, la declaración siguiente hará que el prefijo xlink esté disponible dentro del elemento ejemplo: 1 2 3 < ejemplo xmlns:xlink =" http: // www . w3 . org /1999/ xlink " > ... </ ejemplo > Veamos los atributos de vinculación XLink: href su valor es un localizador de recurso destino, una URI, o una expresión Xpointer. Por ejemplo: "docB.htm" es un identificador de recurso (URI), pero también se le podría añadir una expresión XPointer, como en: "docB.htm#xpointer()". type su valor es una cadena predefinida que determina el tipo de vínculo. Sus posibles valores son: simple, extended, locator, arc, resource, o title. Estos tipos indican el comportamiento que deben tener las aplicaciones cuando se encuentren un elemento de dicho tipo. role su valor es una cadena de caracteres que aclara el significado o da información adicional sobre el contenido del enlace o vínculo. 16 La especificación actual es la 1.1http://www.w3.org/TR/xlink11/. V INCULACIÓN ENTRE DOCUMENTOS 261 title su valor es una cadena que sirve de nombre del enlace. show su valor es una cadena predefinida que indica cómo se revela el recurso destino al usuario. Puede tener los siguientes valores: replace: reemplaza el documento actual por aquel al que apunta el enlace. new: abre un nuevo navegador con el documento destino. parsed: el contenido del texto apuntado se incluye en lugar del enlace y se procesa como si fuera parte del mismo documento de origen. actuate su valor es una cadena predefinida que indica cuándo se inicia un vínculo, es decir, cuándo se procede a buscar el destino apuntado. Puede tener los siguientes valores: user: el vínculo se iniciará cuando el usuario pulse o dé alguna orden para seguir el enlace. auto: el enlace se sigue automáticamente. to su valor es una cadena que identifica el recurso con el que se está vinculando (des- tino). from su valor es una cadena que identifica el recurso desde el que se está vinculando (origen). En la versión 1.0 de XLink los elementos se identifican por la presencia del atributo xlink:type. Sin embargo, en la versión 1.1 los elementos se identifican por la presencia del atributo xlink:type o del atributo xlink:href. Si un elemento tiene un atributo xlink:type entonces debe tener uno de los siguientes valores: simple, extended, locator, arc, resource, o title. Si un elemento no tiene el atributo xlink:type pero sí el atributo xlink:href entonces se trata como si el valor del atributo ausente xlink:type fuera simple. XLink especifica dos tipos de hiperenlaces: simples y extendidos. Los enlaces simples conectan solo dos recursos, mientras que los extendidos pueden enlazar un número arbitrario de recursos. Un enlace simple crea un hiperenlace unidireccional del elemento origen al destino por medio de una URI. Por ejemplo: 1 2 3 4 5 6 7 8 <?xml version=" 1.0 "? > < doc xmlns =" http: // www . ejemplos . com / xmlns / doc1 " xmlns:xlink =" http: // www . w3 . org /1999/ xlink " > ... < enlace xlink:type =" simple " xlink:href =" http: // www . otraPag . com /" > otra página </ enlace > ... </ doc > 262 L ENGUAJES DE MARCADO . XML También se puede enlazar a un punto del mismo recurso: 1 2 3 4 5 6 7 8 9 <?xml version=" 1.0 "? > < doc xmlns =" http: // www . ejemplos . com / xmlns / doc2 " xmlns:xlink =" http: // www . w3 . org /1999/ xlink " > < titulo id =" titulo1 " > Introducción </ titulo > ... < para > Esto es un < enlace xlink:type =" simple " xlink:href ="# titulo1 " > enlace al título </ enlace > </ para > ... </ doc > En este caso se enlaza al elemento con valor de atributo titulo1. Veamos un ejemplo de cómo se declararía en una DTD un elemento de vinculación XLink: 1 2 3 4 5 6 7 8 9 <!ELEMENT jugadores ANY> <!ATTLIST jugadores xmlns:xlink CDATA #FIXED " http: // www . w3 . org / TR / xlink " xlink:type ( simple | extended | locator | arc ) #FIXED " simple " xlink:href CDATA #REQUIRED xlink:role CDATA #IMPLIED xlink:title CDATA #IMPLIED xlink:show ( new | parsed | replace ) " replace " xlink:actuate ( user | auto ) " user " > 4.9 Ejercicios resueltos 1. Un ejemplo de DTD conocida es la de XHTML, que define el lenguaje HTML en XML. La DTD XHTML es similar a la DTD de HTML, la principal diferencia es que un documento XHTML, al ser en el fondo un documento XML, debe estar conforme con la especificación XML, mientras que los documentos HTML no lo requieren. Para que un documento XHTML se pueda mostrar como una página web debe estar bien formado y validado con respecto a la DTD XHTML. Realmente hay tres DTD para XHTML: • La DTD XHTML Strict es la más estricta de las tres. No soporta etiquetas antiguas y el código debe estar escrito correctamente. Su ubicación: 1 <!DOCTYPE html PUBLIC " -// W3C // DTD XHTML 1.0 Strict // EN " " http: // www . w3 . org / TR / xhtml1 / DTD / xhtml1 - strict . dtd "> E JERCICIOS RESUELTOS 263 • La DTD XHTML Transitional es como la DTD XHTML Strict, pero las etiquetas en desuso están permitidas. Actualmente ésta es la DTD más popular. 1 <!DOCTYPE html PUBLIC " -// W3C // DTD XHTML 1.0 Transitional // EN " " http: // www . w3 . org / TR / xhtml1 / DTD / xhtml1 - transitional . dtd " > • La DTD XHTML Frameset es la única DTD XHTML que soporta Frameset, es decir, la separación o estructuración de la página web en frames o macros. 1 <!DOCTYPE html PUBLIC " -// W3C // DTD XHTML 1.0 Frameset // EN " " http: // www . w3 . org / TR / xhtml1 / DTD / xhtml1 - frameset . dtd " > Escribe un documento XHTML. Un ejemplo sencillo es el siguiente: 1 2 3 4 5 6 7 8 9 10 11 <?xml version=" 1.0 " encoding =" UTF -8 "? > <!DOCTYPE html PUBLIC " -// W3C // DTD XHTML 1.0 Strict // EN " " http: // www . w3 . org / TR / xhtml1 / DTD / xhtml1 - strict . dtd " > <HTML xmlns =" http: // www . w3 . org /1999/ xhtml " xml:lang=" en " lang =" en " > < HEAD > < TITLE > Un ejemplo XHTML </ TITLE > </ HEAD > < BODY > <P > En el caso ... </P > </ BODY > </ HTML > El documento anterior parece un documento normal HTML y se visualizará como cualquier otro documento HTML en la mayoría de los navegadores. Sin embargo, este documento es conforme a la especificación XML, está bien formado y es válido con respecto a su DTD. 2. Dado el siguiente documento XML: 1 2 3 <?xml version = " 1.0 " encoding = " UTF -8 "? > < listin > < persona sexo = " hombre " id = " ricky " > 264 L ENGUAJES 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 DE MARCADO . XML < nombre > Roberto Casas </ nombre > < email >ro . casas@direccion . com </ email > < relacion amigo - de = " leire pepe "/ > </ persona > < persona sexo = " mujer " id = " leire " > < nombre > Leire García </ nombre > < email >le . gracia@direccion . com </ email > < email >le . garcia@hotmail . com </ email > < relacion amigo - de = " ricky "/ > </ persona > < persona sexo = " hombre " id = " pepe " > < nombre > José Manzaneda </ nombre > < email >j. manzaneda@direccion . com </ email > < email > jman@hotmail . com </ email > < relacion enemigo - de = " leire " amigo - de = " ricky "/ > </ persona > </ listin > Escribe una DTD con respecto a la que sería válido. La DTD podría ser la siguiente: 1 2 3 4 5 6 7 8 9 10 <?xml version= ’1.0 ’ encoding = ’UTF -8 ’ ? > <!ELEMENT listin ( persona )+ > <!ELEMENT persona ( nombre , email * , relacion ?) > <!ATTLIST persona id ID #REQUIRED sexo ( hombre | mujer ) #IMPLIED > <!ELEMENT nombre (#PCDATA) > <!ELEMENT email (#PCDATA) > <!ELEMENT relacion EMPTY> <!ATTLIST relacion amigo - de IDREFS #IMPLIED enemigo - de IDREFS #IMPLIED > 3. Escribe cómo puede quedar resuelto el ejemplo ilustrativo del apartado 4.5 utilizando los espacios de nombres para combinar dos DTDs. El documento que combina varios CD y reseñas podría ser como el que sigue: 1 2 3 4 5 6 7 8 <?xml version=" 1.0 " encoding =" UTF -8 "? > < comentarios xmlns =" http: // www . ejemplos . xml / docCD " xmlns:com =" http: // www . ejemplos . xml / comen " > < comentario - cd > <cd id =" cd34 -1979 " > < titulo > La Leyenda del tiempo </ titulo > < autor > Camarón de la Isla </ autor > < productor > Polygram Ibérica , S.A. </ productor > E JERCICIOS 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 RESUELTOS 265 < fecha > 1979 </ fecha > < pista num ="1" > La Leyenda del tiempo </ pista > < pista num ="2" > Romance del amargo </ pista > ... </ cd > < comentario > < com:autor > María Pinares </ com:autor > < puntuacion valor ="9" de =" 10 "/ > < texto > Es un disco excelente ... </ texto > </ comentario > < comentario > < com:autor > José Altillo </ com:autor > < puntuacion valor ="6" de =" 10 "/ > < texto > Rompe la tradición ... </ texto > </ comentario > </ comentario - cd > < comentario - cd > <cd id =" cd45 -1985 " > ... </ cd > < comentario > ... </ comentario > </ comentario - cd > </ comentarios > Se han definido 2 espacios de nombres, uno para los elementos de una DTD y otro para los de la otra DTD. Por defecto, los elementos pertenecen al espacio de nombres http://www.ejemplos.xml/docCD, salvo aquellos que usen el prefijo com que pertenecen al otro espacio de nombres. Así, es posible eliminar la ambigüedad entre los dos tipos de elemento autor. 4. Escribe un XSD para el documento de la Figura 4.1 utilizando declaraciones globales. 1 2 3 4 5 6 7 8 9 10 11 <?xml version=" 1.0 "? > < xs:schema xmlns:xs =" http: // www . w3 . org /2001/ XMLSchema " > < xs:element name =" nombre " type =" xs:string "/ > < xs:element name =" descripcion " type =" xs:string "/ > < xs:element name =" fecha " type =" xs:date "/ > < xs:attribute name =" confidencial " type =" xs:boolean "/ > < xs:attribute name =" id " type =" xs:ID "/ > < xs:attribute name =" leng " type =" xs:language "/ > < xs:element name =" titulo " > < xs:complexType > < xs:simpleContent > 266 L ENGUAJES 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 DE MARCADO . XML < xs:extension base =" xs:string " > < xs:attribute ref =" leng "/ > </ xs:extension > </ xs:simpleContent > </ xs:complexType > </ xs:element > < xs:element name =" autor " > < xs:complexType > < xs:sequence > < xs:element ref =" nombre " maxOccurs =" unbounded "/ > </ xs:sequence > </ xs:complexType > </ xs:element > < xs:element name =" archivo " > < xs:complexType > < xs:sequence > < xs:element ref =" informe " maxOccurs =" unbounded "/ > </ xs:sequence > </ xs:complexType > </ xs:element > < xs:element name =" asunto " > < xs:complexType > < xs:sequence > < xs:element ref =" nombre "/ > < xs:element ref =" descripcion "/ > </ xs:sequence > </ xs:complexType > </ xs:element > < xs:element name =" informe " > < xs:complexType > < xs:sequence > < xs:element ref =" fecha "/ > < xs:element ref =" titulo "/ > < xs:element ref =" autor "/ > < xs:element ref =" asunto " maxOccurs =" unbounded "/ > </ xs:sequence > < xs:attribute ref =" confidencial "/ > < xs:attribute ref =" id "/ > </ xs:complexType > </ xs:element > </ xs:schema > 5. Escribe un XSD para el documento de la Figura 4.1 utilizando declaraciones locales. E JERCICIOS 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 RESUELTOS 267 <?xml version=" 1.0 "? > < xs:schema xmlns:xs =" http: // www . w3 . org /2001/ XMLSchema " > < xs:element name =" archivo " > < xs:complexType > < xs:sequence > < xs:element name =" informe " maxOccurs =" unbounded "/ > < xs:complexType > < xs:sequence > < xs:element name =" fecha " type =" xs:date "/ > < xs:element name =" titulo "/ > < xs:complexType > < xs:simpleContent > < xs:extension base =" xs:string " > < xs:attribute name =" leng " type =" xs:language "/ > </ xs:extension > </ xs:simpleContent > </ xs:complexType > </ xs:element / > < xs:element name =" autor " minOccurs ="0" maxOccurs =" unbounded "/ > < xs:complexType > < xs:sequence > < xs:element name =" nombre " type =" xs:string "/ > </ xs:sequence > < xs:attribute name =" id " type =" xs:ID "/ > </ xs:complexType > </ xs:element > < xs:element name =" asunto " minOccurs ="0" maxOccurs =" unbounded "/ > < xs:complexType > < xs:sequence > < xs:element name =" nombre " type =" xs:string "/ > < xs:element name =" descripcion " type =" xs:string "/ > </ xs:sequence > </ xs:complexType > </ xs:element > </ xs:sequence > < xs:attribute name =" confidencial " type =" xs:boolean "/ > < xs:attribute name =" id " type =" xs:ID "/ > </ xs:complexType > </ xs:element > 268 L ENGUAJES 40 41 42 43 DE MARCADO . XML </ xs:sequence > </ xs:complexType > </ xs:element > </ xs:schema > 6. Escribe cómo crearías en XML-Schema dos nuevos tipos de datos del tipo base xs:token: uno que restrinja la longitud máxima de la cadena a 10 y otro a 255 caracteres. 1 2 3 4 5 1 2 3 4 5 < xs:simpleType name =" cadena10 " > < xs:restriction base =" xs:token " > < xs:maxLength value =" 10 "/ > </ xs:restriction > </ xs:simpleType > < xs:simpleType name =" cadena255 " > < xs:restriction base =" xs:token " > < xs:maxLength value =" 255 "/ > </ xs:restriction > </ xs:simpleType > 7. Escribe cómo crearías en XML-Schema un nuevo tipo de datos del tipo base xs:language que limitara los lenguajes que se pueden asociar a un elemento a inglés y castellano. 1 2 3 4 5 6 < xs:simpleType name =" lenguajesPosibles " > < xs:restriction base =" xs:language " > < xs:enumeration value =" en "/ > < xs:enumeration value =" es "/ > </ xs:restriction > </ xs:simpleType > 8. Utilizando la API DOM escribe una forma de acceder a todos los atributos de un elemento sin conocer sus nombres. Escribe además sus nombres. 1 2 3 4 5 6 Node aNode ; NamedNodeMap atts = miElemento . getAttributes () ; for ( int i = 0; i < atts . getLength () ; i ++) { aNode = atts . item (i); System . out . print ( aNode . getNodeName () ); } E JERCICIOS RESUELTOS 269 9. Escribe un programa Java que muestre un mensaje cada vez que el procesador SAX genera un evento. 1 2 3 4 import import import import java . io .*; org . xml . sax .*; org . xml . sax . helpers .*; org . apache . xerces . parsers . SAXParser ; 5 6 public class MostrarEventos extends DefaultHandler { 7 8 9 10 public void startDocument () { System . out . println (" comienzo documento "); } 11 12 13 14 public void endDocument () { System . out . println (" fin documento "); } 15 16 17 18 19 public void startElement ( String uri , String localName , String qName , Attributes attributes ) { System . out . println (" comienzo elemento : " + qName ); } 20 21 22 23 24 public void endElement ( String uri , String localName , String qName ) { System . out . println (" final elemento : " + qName ); } 25 26 27 28 public void ignorableWhitespace (char[] ch , int start , int length ) { System . out . println (" espacios , longitud " + length ); } 29 30 31 32 public void processingInstruction ( String target , String data ) { System . out . println (" instrucción de procesamiento : " + target ); } 33 34 35 36 public void characters (char[] ch , int start , int length ){ System . out . println (" datos caracter , longitud " + length ); } 37 38 39 public static void main ( String [] args ) { MostrarEventos mos = new MostrarEventos () ; 270 L ENGUAJES XML SAXParser par = new SAXParser () ; par . setContentHandler ( mos ); try { par . parse ( args [0]) ; } catch ( Exception e) {e. printStackTrace () ;} 40 41 42 43 } 44 45 DE MARCADO . } 10. Escribe un ejemplo de documento XML con diferentes valores del atributo show de XLink. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <doc xmlns:xlink =" http: // www . w3 . org /1999/ xlink " > ... < ver xlink:type =" simple " xlink:href =" http: // www . pagEjemplo . com /" xlink:show =" replace " > Carga la página http: // www . pagEjemplo . com / </ ver > <nueva - ventana xlink:type =" simple " xlink:href =" http: // www . uned . es /" xlink:show =" new " > Abre una nueva ventana con la página de la UNED </ nueva - ventana > < incluir xlink:type =" simple " xlink:href =" instancia . xml " xlink:show =" parsed " > Incluye el fichero indicado </ incluir > ... </ doc > 4.10 Ejercicios propuestos 1. Describe utilizando una DTD un tipo de documento "Artículo" que contenga al menos un título, un resumen y secciones. Cada sección tendrá a su vez un título, podrá tener subsecciones y párrafos. 2. Dado el siguiente documento XML, escribe una DTD y un XSD con respecto a los que pueda ser válido. 1 2 3 4 <?xml version=" 1.0 "? > < biblioteca > < libro disponible = " true " > < isbn > 089567654 </ isbn > N OTAS 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 BIBLIOGRÁFICAS 271 < titulo leng = " es " > El último encuentro </ titulo > < autor id =" SMarai " > < nombre > Sándor Márai </ nombre > <fecha - nac > 13 -4 -1900 </ fecha - nac > <fecha - fall > 16 -6 -1989 </ fecha - fall > </ autor > < personaje id =" per1 " > < nombre > El general </ nombre > < descripcion > severo , triste </ descripcion > </ personaje > < personaje > ... </ personaje > ... </ libro > ... </ biblioteca > 3. Dado el documento de la Figura 4.2 escribe una DTD y un XSD con respecto a los que sea válido. 4. Escribe cómo crearías en XML-Schema un nuevo tipo de datos que limitara a 10 el número de caracteres del ISBN. 5. Escribe un programa que imprima un documento XML que recibe como entrada utilizando la API SAX. 6. Escribe un programa que imprima un documento XML que recibe como entrada utilizando la API DOM. Se trata de recorrer la jerarquía del árbol DOM e ir escribiendo los nodos. En algunos lenguajes este recorrido recibe el nombre de TreeWalker. Algunos lenguajes disponen de una clase con ese nombre que implementa este tipo de recorrido. 7. Escoger una página HTML y convertirla a XHML. Hay muchas páginas HTML que no están bien formadas; les faltan, por ejemplo, las etiquetas de cierre. Usad un navegador para comprobar si funciona correctamente cuando el código XHTML no está bien formado. 4.11 Notas bibliográficas Hay varios libros en los que se puede encontrar información y ejemplos de uso de XML y sus tecnologías asociadas. Por ejemplo, [28] es una buena referencia de introducción y consulta. Con respecto a XML-Schema, [19] es un libro muy completo. En la web de 272 L ENGUAJES DE MARCADO . XML W3C dedicada a XML, http://www.w3.org/standards/xml/, se pueden encontrar las especificaciones de XML y todas las tecnologías asociadas. En http://www.saxproject.org/ se puede encontrar el sitio oficial de SAX, con la descripción completa de la interfaz y numerosos ejemplos de uso. Capítulo 5 Lenguajes de script Este capítulo introduce los lenguajes de script, a los que ya se ha hecho referencia en el capítulo 3 en el apartado dedicado a la programación con lenguajes dinámicos. Comienza describiendo su origen, para pasar después a centrase en los dominios de aplicación en los que se utilizan. También se describen las principales características de algunas herramientas y lenguajes de script, haciendo énfasis en aquellos aspectos que más los diferencian de los lenguajes de programación tradicionales. Por ejemplo, en los lenguajes de script se enfatiza el uso de expresiones regulares, arrays asociativos, operadores específicos de emparejamiento de patrones, características de los registros y unidades lógicas de entrada/salida, y programación en www. Si el lector quiere profundizar en el uso de alguno de los lenguajes de script tratados deberá acudir a otras fuentes, algunas de las cuales se citan a lo largo y al final del capítulo. En el caso de que el lector esté interesado en la combinación de algún lenguaje de script con lenguajes de marcado, se recomienda la lectura de este capítulo después de la lectura del capítulo 4. 5.1 Introducción Un lenguaje de script es un lenguaje de programación que permite el control de otros programas o aplicaciones. Una buena parte de las tareas complejas que realizan las aplicaciones software involucran la ejecución de numerosos programas individuales que necesitan coordinarse. Esta coordinación puede requerir el uso de estructuras condicionales, repetitivas, uso de variables de determinados tipos; es decir, similares mecanismos y recursos que un lenguaje de programación de propósito general. Aunque para realizar estas tareas de coordinación se podrían utilizar lenguajes de propósito general como C o Java, los lenguajes de script presentan características que los hacen más apropiados. Los lenguajes de script dan más importancia a la flexibilidad y a la facilidad en el desarrollo de programas que los lenguajes de programación de propósito general, aunque también incorporan elementos de alto nivel como expresiones regulares y estructuras de 273 274 L ENGUAJES DE script datos como arrays y ficheros. Un programa escrito en un lenguaje de script suele recibir el nombre de script (guión). Tradicionalmente los scripts son interpretados a partir del código fuente o de código de bytes (bytecode1 ). Los lenguajes de script tienen dos familias de antecesores [25]. Por una parte provienen de los intérpretes de comandos o shells de la época en la que los procesos no eran interactivos sino por lotes o batch. Y por otra parte heredan características de las herramientas para procesamiento de textos y generación de informes. La familia de los intérpretes de comandos tiene su origen en los años sesenta. El lenguaje más emblemático de esta familia es el Job Control Language (JCL) de IBM. Este tipo de lenguajes permiten dar instrucciones al sistema sobre cómo tiene que ejecutar un trabajo por lotes. Por ejemplo, indican dónde encontrar la entrada o qué hacer con la salida, facilitando así el tradicional proceso de edición-compilación-enlazamiento-ejecución. Sus instrucciones se denominan sentencias de control de trabajos (job control statements). Este tipo de lenguajes se suele usar en los denominados Ordenadores Centrales (Mainframes) y es específico para cada sistema operativo. Ejemplos más recientes de este tipo de lenguajes son el intérprete de comandos de MS-DOS (con los ficheros .bat), o los shells sh, csh, etc. de Unix. Con respecto a la familia de las herramientas para procesamiento de textos y generación de informes, destacan sed y awk de Unix y RPG (Report Program Generator) de IBM. Aunque históricamente ha habido una clara distinción entre los lenguajes de programación tradicionales, como C, y los lenguajes de script como awk o sh, a medida que ha transcurrido el tiempo y ha evolucionado todo lo relacionado con el software, han ido surgiendo lenguajes como Perl, Python o PHP que no solo se pueden considerar lenguajes de script, sino que también se utilizan como lenguajes de propósito general. Algunos autores reservan la denominación de lenguajes de script para los que controlan y coordinan otros programas y aplicaciones. Sin embargo, esta denominación también se usa en un sentido más amplio e incluye a los scripts de las páginas webs y a los lenguajes de programación que pueden incorporarse en programas escritos en otros lenguajes para extender sus capacidades, como Tcl o Python. Estos últimos suelen denominarse lenguajes de extensión. La mayoría de los lenguajes de script presentan las siguientes características comunes: • Permiten su uso interactivo y como programas. • Sus expresiones suelen ser concisas. • No tienen declaraciones o pueden prescindir de ellas utilizando reglas de alcance simples. • Tipado dinámico y flexible. • Permiten un fácil acceso al sistema operativo subyacente. 1 El bytecode es un código intermedio más abstracto que el código máquina. D OMINIOS DE APLICACIÓN 275 • Disponen de expresiones regulares y emparejamiento de patrones sofisticados. • Incorporan tipos de datos estructurados y de alto nivel. La mayoría de los lenguajes de script disponen de reglas simples para determinar el alcance de los nombres, aunque difieren de unos lenguajes a otros. Por ejemplo, en PHP por defecto el alcance es siempre local, para que sea global requiere una declaración explícita. Sin embargo en Perl es siempre global salvo que se explicite una declaración en un ámbito local. En cuanto al tipado dinámico y flexible, la mayoría de los lenguajes de script no requieren que las variables se declaren, es decir, los valores que se asignan a las variables son los que determinan su tipo. La asociación del tipo a una variable puede tener lugar en dos momentos distintos dependiendo del lenguaje: si es en tiempo de compilación se habla de tipado estático (Haskell, Java, C, Pascal, Ada) y si es en tiempo de ejecución se trata de tipado dinámico, que es el que corresponde a buena parte de los lenguajes de script. A continuación se van a presentar las principales características de los lenguajes de script en sus diversos dominios de aplicación, y se acompañarán con ejemplos escritos en algunas de las principales herramientas y lenguajes. 5.2 Dominios de aplicación La evolución de algunos lenguajes de script ha consistido en añadirles capacidades y rasgos que los han convertido en aptos para ser utilizados como lenguajes de propósito general cuando el tiempo de ejecución no es un aspecto crítico. Muchos han mejorado sus capacidades de cálculo incorporando precisión aritmética arbitraria (Scheme, Python, Ruby), la mayoría incorporan tipos de datos estructurados y de alto nivel como arrays, cadenas, registros, listas y arrays asociativos. Algunos soportan clases y orientación a objetos aunque con distinto tipo de pureza (Ruby, Perl, PHP) y mecanismos más sofisticados como hilos y funciones de primer orden y orden superior. Así, en muchos casos, estos lenguajes se utilizan para desarrollar aplicaciones completas. Sin embargo, hay una serie de dominios de aplicación para los que los lenguajes de script resultan particularmente apropiados. Algunos están más enfocados a uno de los dominios, sin embargo, otros son apropiados para varios. Por ejemplo, Python se usa como un lenguaje de propósito general, como un intérprete de comandos y como lenguaje glue, cuya definición se verá en el apartado 5.2.4; Perl también tiene los mismos usos que Python, pero además se utiliza para procesamiento de textos y para scripts web del lado del servidor; JavaScript está más orientado a scripts web del lado del cliente y como lenguaje de extensión; Ruby tiene similares usos que Python pero es fuertemente orientado a objetos. A continuación se presentan los dominios principales de los lenguajes de script. 276 L ENGUAJES DE script 5.2.1 Intérpretes de comandos Los intérpretes de comandos han ido evolucionando a lo largo del tiempo. El JCL de IBM, que no incluía estructuras iterativas, dio paso en los años setenta a la shell sh de Unix, cuya primera versión fue desarrollada por Ken Thompson y luego mejorada por Stephen Bourne. Esta shell incorpora características tales como control de procesos, redireccionamiento de entrada/salida, listado y lectura de ficheros, protección, comunicaciones y un lenguaje de órdenes para escribir programas por lotes o scripts. Al final de los setenta Bill Joy desarrolló csh que incluye mejoras en el uso interactivo y su sintaxis es más parecida al lenguaje de programación C que las shell anteriores, por lo que se consideraba más fácil de utilizar. La siguiente evolución destacada en las shell de Unix fue tcsh, desarrollada por Ken Greer, que está basada en csh y es compatible con ella. Incorpora, entre otros aspectos, autocompletado de nombres y edición en línea de comandos. La shell de Korn, ksh, desarrollada por David Korn en los comienzos de los años ochenta, fue el siguiente paso significativo ya que se puede utilizar como lenguaje de programación. Se le han ido añadiendo numerosas capacidades manteniendo su compatibilidad con la shell de Bourne. La evolución en las shells de Unix continuó con la más popular, bash, que es una versión de código abierto de ksh. Brian Fox escribió bash en 1987 cuyas siglas significan "otra shell bourne" (Bourne-Again Shell) y es compatible con el estándar POSIX (ver apartado 5.2.2 para más detalles). Fue escrita para el proyecto GNU y es el intérprete de comandos por defecto en la mayoría de las distribuciones de Linux. Estos lenguajes, además de los rasgos diseñados para uso interactivo, disponen de mecanismos que permiten manipular ficheros, argumentos, comandos y conectar componentes software. Además de las shell de Unix, a este grupo también pertenece, por ejemplo, el COMMAND.COM de MS-DOS. A continuación vamos a ver con más detalle algunas características de la shell bash como ejemplo significativo de lenguaje de script de este dominio. bash Cuando se trabaja con los sistemas operativos hay tareas que se ejecutan de forma periódica que constan de una secuencia de instrucciones. Esta secuencia se puede almacenar en un script para ejecutarse cuando sea necesaria como un único comando del sistema operativo. Un script de shell es un fichero de texto con la siguiente estructura: 1 2 # !/ bin / bash comandos e instrucciones bash El símbolo # en general representa a un comentario, pero su uso en la primera línea del fichero sirve para indicar con qué intérprete se ejecutará el script. Esta indicación debe D OMINIOS DE APLICACIÓN 277 ser siempre la primera línea del script y no puede contener espacios. Una vez se almacena el script se puede ejecutar cuando sea necesario mediante: 1 nombreShellScript . sh dándole los permisos de ejecución correspondientes. También se puede ejecutar directamente utilizando su nombre en la línea de comandos. El siguiente script borra la pantalla y saluda al usuario: 1 2 3 4 # !/ bin / bash # borrar pantalla y saludar clear echo " Hola \ $LOGNAME " En bash existen variables predefinidas; algunas de ellas son: HOME la ruta absoluta del home del usuario. HOSTNAME el nombre del equipo. LOGNAME el alias del usuario. PATH la ruta de búsqueda. SHELL la ruta absoluta de la actual SHELL. En bash no es necesario declarar las variables, basta con asignarles directamente un valor. Las variables siempre son de tipo string. Veamos cómo se asigna un valor a una variable: 1 nomVAR =" valor " Para recuperar el valor de una variable, se escribe su nombre precedido por el signo $ o con la sintaxis ${nomVAR}. Por ejemplo, en las siguientes instrucciones: 1 2 VAR1 =" prueba " VAR2 ="${ VAR1 }" se asigna un valor a la variable VAR1 y después se asigna a la variable VAR2 el valor de VAR1. Además de las variables predefinidas anteriores hay otras relacionadas con los parámetros o argumentos de los comandos: $0 es el nombre del script, es decir del nombre del fichero que lo contiene. 278 L ENGUAJES DE script $1 es el primer argumento. $2 es el segundo argumento y así sucesivamente. $# es el número de argumentos con que se ha llamado al script. $@ retorna la lista "$1" "$2" . . . "$n", es decir todos los argumentos como una serie de palabras. $* retorna todos los argumentos como una única cadena simple. En bash hay una estructura alternativa con dos posibles formatos: 1 if expresión ; then comandosCondCierta ; [else comandosCondFalsa ;] fi o en la forma: 1 2 3 4 if condición then comandosCondCierta [else comandosCondFalsa ] fi Hay que tener en cuenta que en Unix los programas devuelven un 0 cuando se ejecutan correctamente y un valor distinto de 0 cuando ocurre algún error, al contrario que en C y otros lenguajes de programación. La variable predefinida $? es la que almacena el valor de retorno del último comando ejecutado. Veamos un ejemplo de uso de la estructura alternativa: 1 2 3 4 5 6 7 8 # !/ bin / bash # indica si el argumento es par if [ $1 % 2 = "0" ] then echo " Es par " else echo " Es impar " fi El script anterior tiene un argumento que se almacena en la variable $1. Una vez salvado en un fichero (parImpar.sh) y dándole los permisos correspondientes se podría ejecutar con: ./parImpar.sh N, siendo N un valor numérico. bash también dispone de una estructura case, pero debido a que en este apartado nos centramos en los aspectos básicos, no se describe su sintaxis. La shell bash incorpora estructuras repetitivas: while, for y until. Veamos el formato de while: D OMINIOS 1 2 3 4 DE APLICACIÓN 279 while expresión do comandosCondCierta done A continuación se muestra un ejemplo de un script que crea 4 ficheros, los lista y finalmente los elimina: 1 2 3 4 5 6 7 8 9 10 11 # !/ bin / bash # crea los ficheros fich1 , fich2 , fich3 y fich4 , los lista y los borra VAL =1 while [ $VAL -le 4 ] # mientras $VAL <= 4 do echo creando fichero fich$VAL touch fich$VAL VAL =‘expr $VAL + 1‘ done ls -l fich [0 -4] rm fich [0 -4] Los operadores para comparar valores numéricos son: -eq (igual), -neq (no igual), -lt (menor), -gt (mayor), -le (menor o igual), -ge (mayor o igual). Los operadores de archivos de uso más común son: -e (existe), -d (existe y es directorio), -f (existe y es archivo), -r (existe y tiene permiso de lectura), -s (existe y tiene una longitud mayor que cero), -w (existe y tiene permiso de escritura), -x (existe y tiene permiso de ejecución), -nt (devuelve valor cierto si el fichero que representa el operando de su izquierda es más reciente que el de su derecha), -ot (devuelve valor cierto si el fichero que representa el operando de su izquierda es más antiguo que el de su derecha). Por ejemplo, para comprobar la existencia de un fichero: 1 2 3 if [ -f fichero ]; then echo " Existe "; \ else echo " No existe "; fi En bash es posible definir subprogramas y éstos pueden tener parámetros. Su sintaxis es: 1 2 3 function nomSubprograma { instruccionesSubprograma } Los parámetros que se pasen al subprograma también se recuperan con $1, $2 y así sucesivamente. 280 L ENGUAJES DE script Otra de las posibilidades de las shells de Unix es la de encadenar comandos de manera que la salida de uno sea la entrada del siguiente. Para indicar el canal de redirección se utiliza el carácter "|". En la siguiente secuencia: 1 comando1 | comando2 se toma la entrada estándar del comando2 desde la salida estándar de comando1. La instrucción exit se utiliza para salir de un script y puede ir acompañada de un parámetro de salida. En el apartado 5.4 de ejercicios resueltos se pueden encontrar más ejemplos de uso de la shell bash. 5.2.2 Procesamiento de textos El procesamiento de cadenas de texto es una de las aplicaciones iniciales de los lenguajes de script. En este dominio tienen especial importancia las expresiones regulares. Básicamente, una expresión regular o patrón es una expresión que describe un conjunto de cadenas sin enumerar sus elementos. Ejemplos de estos primeros lenguajes son las herramientas de Unix awk, sed y grep. El lenguaje Perl se diseñó posteriormente para superar algunas de las limitaciones de estas herramientas. Las expresiones regulares describen patrones que podemos encontrar en el texto con el objetivo de localizarlos, sustituirlos, borrarlos, etc. Los patrones se especifican mediante caracteres especiales. La sintaxis precisa de las expresiones regulares cambia según las herramientas y lenguajes. El estándar IEEE POSIX Basic Regular Expressions (BRE) y el POSIX Extended Regular Expressions (ERE) para la escritura de expresiones regulares son los que han adoptado la mayoría de las herramientas Unix, como sed o grep. A continuación vamos a describir el significado de algunos de los principales caracteres especiales de estos estándares: | Una barra vertical separa alternativas. Por ejemplo la expresión "esto|eso" casa con la cadena "esto" o con la cadena "eso". + Indica que el carácter al que sigue debe aparecer al menos una vez. Por ejemplo la expresión "UF+" casa con "UF", "UFF", "UFFF", etc. ? Indica que el carácter al que sigue puede aparecer como mucho una vez. Por ejemplo la expresión "p?sicología" casa con "psicología" y con "sicología". * Indica que el carácter al que sigue puede aparecer cero, una, o más veces. Por ejemplo la expresión "0*33" casa con "33", "033", "0033", etc. () Los paréntesis se utilizan para agrupar y establecer la precedencia de los operadores. Por ejemplo, "(p|P)?sicología" casaría con "psicología", "sicología" y "Psicología". D OMINIOS DE APLICACIÓN 281 [] Agrupan caracteres en grupos o clases. Dentro de los corchetes se puede utilizar el guión "-" para especificar rangos de caracteres. Los caracteres especiales pierden su significado y se consideran literales. Por ejemplo, "[a-zA-Z]+" casa con cualquier palabra de al menos un carácter alfabético, bien en minúsculas o mayúsculas, y "[a-zA-Z0-9]" casa con un carácter alfabético o numérico. ˆ Representa el comienzo de línea o de cadena, según el contexto. La expresión "ˆ[A-Z]" casa con una línea que comience con una carácter alfabético escrito en mayúscula. Pero este símbolo utilizado dentro de corchetes, como en "[ˆa]", casa con cualquier carácter que no esté contenido en los corchetes, en el ejemplo con cualquier carácter que no sea "a". Por ejemplo, "[ˆa-z]" casa con cualquier carácter que no sea una letra en minúscula. $ Representa el final de línea o de cadena, según el contexto. . Representa un único carácter. Veamos algunos ejemplos de expresiones válidas: /./ Casará con cualquier línea que contenga al menos un carácter. /ˆA/ Casará con cualquier línea que comience con "A". /ˆ$/ Casará con cualquier línea en blanco. /}$/ Apuntará a toda línea que termine con "}". /ˆ[A-Z]/ Casará con cualquier línea que empiece con una letra mayúscula. Los dos siguientes subapartados se dedican a introducir respectivamente la herramienta sed y el lenguaje awk como representativos de este dominio. Aunque podríamos ubicar aquí el lenguaje Perl, como se utiliza también en otros dominios se deja su descripción más detallada al apartado 5.3. sed Una de las herramientas Unix más utilizadas para el procesamiento de cadenas es sed (stream editor), sucesor de grep. Un comando sed toma una cadena o fichero de texto como entrada, lo lee y procesa línea a línea, según indique la expresión regular, y envía la salida a pantalla o la redirecciona a otro fichero sin modificar el original. También puede modificar el fichero original especificándolo expresamente. sed permite buscar y reemplazar como un editor de textos, borrar líneas y añadir contenido. La sintaxis de un comando sed de sustitución es la siguiente: 1 sed ’s/cadenaOriginal/cadenaNueva/g’ ficheroEntrada > ficheroSalida 282 L ENGUAJES DE script La "s" indica que es un comando de sustitución, la "g" indica que es global, es decir, que afectará a todas las apariciones de cadenaOriginal. Si no se indica "g" solo se sustituirá la primera aparición en cada línea. También se puede indicar un número n para indicar que se sustituirán las primeras n apariciones en la línea. El significado es que cada aparición de la cadena cadenaOriginal en el fichero ficheroEntrada se va a sustituir con cadenaNueva y el resultado se escribirá en ficheroSalida. El carácter "/" se utiliza como carácter delimitador. Si no se indica ficheroSalida el resultado se muestra por pantalla. La sintaxis de un comando sed de borrado de líneas es la siguiente: 1 sed ’/cadenaBorrada/d’ ficheroEntrada > ficheroSalida La "d" al final indica que es un comando de borrado. Borra la línea que contiene cadenaBorrada. Para borrar solo una palabra o expresión, no toda la línea, se utiliza un comando de sustitución: 1 sed ’s/cadenaBorrada//g’ ficheroEntrada > ficheroSalida Por ejemplo, la siguiente expresión borra todos los espacios en blanco consecutivos (dos o más): 1 sed ’s/ *//g’ ficheroEntrada > ficheroSalida También se pueden borrar líneas indicando su posición con la siguiente sintaxis: 1 sed ’numLineaIni,numLineaFin d’ ficheroEntrada > ficheroSalida La siguiente expresión borra las líneas 2, 3 y 4 de ficheroEntrada y muestra la salida por pantalla: 1 sed ’2,4d’ ficheroEntrada Para borrar la tercera línea de ficheroEntrada y mostrar la salida por pantalla: 1 sed ’3d’ ficheroEntrada Por defecto sed escribe el fichero que procesa entero a no ser que se indique la opción "-n". Esta opción se puede combinar con "p" que permite mostrar la parte del fichero que se indica. Por ejemplo: 1 sed -n ’2,3p’ ficheroEntrada escribe las líneas 2 y 3 de ficheroEntrada. D OMINIOS DE APLICACIÓN 283 Cuando es necesario aplicar varios comandos sed a un fichero se pueden almacenar en un fichero formando un script y llamarlo a ejecución con la opción "-f": 1 sed -f script.sed ficheroEntrada donde script.sed es el fichero que contiene los comandos sed. En el apartado 5.4 de ejercicios resueltos se pueden encontrar más ejemplos de uso de sed. awk El lenguaje de programación awk, cuyo nombre viene de la letra inicial de los apellidos de sus autores, Alfred Aho, Peter Weinberger y Brian Kernighan, se diseñó inicialmente en 1977 como un lenguaje de programación para Unix, aunque ya se pueden encontrar versiones para otros sistemas operativos. Fue diseñado para superar las limitaciones de sed con el objetivo de procesar textos y cadenas. Su potencia radica en el uso que ofrece del tipo de datos cadena, los arrays asociativos (indexados por una clave) y las expresiones regulares, además de una sintaxis parecida a la de C. En awk existen variables predefinidas que facilitan enormemente el procesamiento de las cadenas de texto. Por ejemplo, la variable predefinida $0 almacena el registro que se está procesando. La función de lectura getline almacena en esta variable el registro leído, que por defecto es una línea. La variable RS (Record Separator) contiene el carácter o expresión regular que indica a awk en qué punto acaba un registro y empieza el siguiente. Por defecto el carácter separador de registros es "\n". Cuando awk analiza un registro lo separa en campos, que por defecto son palabras (cadenas de caracteres separadas por espacios o tabulador "\t"), estos campos se almacenan en las variables predefinidas $1, $2, $3, . . . según el orden de los campos en la línea. El separador de campos se almacena en la variable FS (Field Separator) por lo que su contenido se puede modificar asignándole la expresión regular que se requiera en cada caso. La variable NF (Number of Fields) almacena el número total de campos del registro activo. Otras variables predefinidas son NR (Number of Record) que almacena el número de orden del registro que se está procesando, OFS (Output FS) que hace que la instrucción print inserte en la salida un carácter de separación de campos (por defecto un espacio en blanco), ORS (Output RS) similar a OFS pero con respecto al carácter separador entre registros de salida (por defecto "\n") y FILENAME que almacena el nombre del fichero abierto. Un programa awk tiene tres secciones: 1. Bloque inicial: se ejecuta solo una vez antes de empezar a procesar la entrada. Su sintaxis es: BEGIN operaciones. 2. Bloque central: contiene las instrucciones que se ejecutan para cada uno de los registros de la entrada y que tienen la sintaxis: EXPREG operaciones. 284 L ENGUAJES DE script Con el anterior formato las operaciones se ejecutan solo sobre los registros que verifiquen la expresión regular EXPREG. Por el contrario, expresando !EXPREG las operaciones se ejecutan en los registros que no concuerden con la expresión regular EXPREG. 3. Bloque final: se efectúa sólo una vez después de procesar toda la entrada. Su sintaxis es: END operaciones. Cada una de estas partes pueden aparecer varias veces en un programa awk y si ésto ocurre se procesan en orden de aparición. Veamos el conocido programa "Hola Mundo" en awk: 1 BEGIN { print " Hola mundo "; exit } Las variables en awk pueden ser escalares si almacenan un solo valor y vectoriales si son arrays. En awk se pueden crear arrays asociativos que se caracterizan por usar una cadena como índice para referirse a un elemento dentro de un array. Los arrays asociativos se implementan internamente en los lenguajes mediante tablas hash. El siguiente programa permite calcular la frecuencia de las palabras de una entrada utilizando arrays asociativos: 1 2 3 4 5 6 7 8 9 10 11 BEGIN { FS =" [^a -zA -Z ]+ " } { for (i =1; i <= NF ; i ++) palabras [tolower( $i ) ]++ } END { for (i in palabras ) print i , palabras [i] } El bloque inicial (BEGIN) asigna al separador de campos, la variable predefinida FS, una expresión regular que casa con cualquier secuencia de caracteres no alfabéticos ya que solo se van a tener cuenta palabras. En el bloque central, para cada registro de entrada (en este caso por defecto una línea) se procesa cada campo (en este caso cada palabra) incrementando en 1 el número de veces del elemento del array asociativo palabras que corresponde a la versión en minúsculas de la palabra ($i) que se está procesando. En el bloque final, una vez que se han procesado todas las líneas, se imprime cada palabra junto con el número de veces que ha aparecido en el fichero de entrada. El bucle for (i in palabras) recorre el array palabras desde el primero al último elemento. Un script awk almacenado en un fichero deberá comenzar con una línea de comentario como la que se ve en el fichero hola.awk del siguiente ejemplo: D OMINIOS 1 2 DE APLICACIÓN 285 # !/ usr / bin / awk -f BEGIN { print " Hola mundo "; exit } hola.awk sería un script linux que imprimiría "Hola mundo". La opción -f indica a awk que lo que sigue es el programa. En awk hay dos operadores para comprobar si una cadena casa con una expresión regular: "˜" (casa) y "!˜" (no casa). Por ejemplo, en: 1 2 $0 ~ /^ Actualmente \$/ $0 !~ /^ Actualmente \$/ la primera línea busca un registro cuyo primer campo sea la palabra "Actualmente", mientras que la segunda hace lo contrario, busca un registro cuyo primer campo no sea la palabra "Actualmente". Dos expresiones regulares separadas por comas representan un rango de registros. Por ejemplo: 1 /^ Actualmente / ,/^ Finalmente / busca un registro cuyo primer campo sea la palabra "Actualmente" y casa los siguientes registros hasta llegar a uno cuyo primer campo case con "Finalmente". En el apartado 5.4 de ejercicios resueltos se pueden encontrar más ejemplos de uso de awk. 5.2.3 Lenguajes de extensión y embebidos Un lenguaje de extensión permite incrementar la utilidad de una aplicación escrita en un determinado lenguaje de programación, facilitando que el usuario añada y modifique funcionalidades de la misma. Los lenguajes de extensión suelen ser lenguajes interpretados. Hay numerosos ejemplos y variedades de este tipo de lenguajes: • Muchos juegos disponen de lenguajes de script que ayudan a determinar los eventos de los escenarios o el comportamiento de los actores. Un ejemplo de estos lenguajes de script son Lua, Python, AngelScript, Squirrel. • Microsoft Office permite que el usuario escriba macros en Visual Basic for Applications. • GNU Emacs tiene como lenguaje de extensión una versión de Lisp. El código básico está escrito en C, y con LISP se pueden ampliar y modificar las funcionalidades. 286 L ENGUAJES DE script • JavaScript, en su uso más extendido del lado del cliente (client-side), es un lenguaje de script dentro de los navegadores web que permite mejorar la interfaz de usuario y las páginas web dinámicas. También existe una forma de JavaScript del lado del servidor (Server-side Javascript o SSJS). Hay otro tipo de aplicaciones que no están relacionadas con la web en las que también se usa Javascript como lenguaje de extensión, por ejemplo en la suite gráfica de Adobe y en aplicaciones de escritorio (mayoritariamente widgets). • Tcl (Tool Command Language) es un lenguaje que se diseñó como un lenguaje de extensión, aunque hoy en día también se utiliza como un lenguaje de propósito general de forma similar a Python, Perl o Ruby. Tcl se suele utilizar con la biblioteca Tk (Tool Kit), una biblioteca que permite fácilmente desarrollar interfaces de usuario gráficas (GUI). • La suite gráfica de Adobe se puede extender con JavaScript, Visual Basic (Windows) y AppleScript (Mac). • Algunos paquetes comerciales de sofware, como AutoCAD, Maya o Flash tienen sus propios lenguajes de extensión. Para que una aplicación pueda extenderse debe incorporar o poder comunicarse con un intérprete de un lenguaje de script, a la vez que permitir que desde los scripts se pueda acceder a los comandos propios de la aplicación. Además, también debe permitir que el usuario relacione los comandos nuevos con eventos de la interfaz de usuario. 5.2.4 Lenguajes glue En el contexto de la programación basada en componentes, no solo son necesarios los componentes, sino que también hace falta una forma de enlazarlos. Un lenguaje glue es un lenguaje que se utiliza para conectar componentes software. Para esta tarea normalmente se utilizan lenguajes de script. Un buen lenguaje glue debe proporcionar una forma sencilla y eficiente para comunicar y transferir datos entre componentes que en su mayoría estarán escritos en otros lenguajes de programación. Los lenguajes glue, además de los mecanismos propios de los lenguajes de script (procesamiento de textos e intérpretes de comandos), proporcionan una librería que incorpora operaciones para acceder a elementos del sistema operativo como ficheros, directorios, entrada/salida, comunicación entre procesos y sincronización, protección y autorización, sockets, servicio de nombres, redes de comunicación, etc. Algunos lenguajes de script que se usan frecuentemente en este dominio son: las shells de Unix, Perl, Python, Ruby, AppleScript y VisualBasic para aplicaciones. D OMINIOS DE APLICACIÓN 287 5.2.5 Lenguajes de script en www En este contexto los lenguages de script permiten la generación dinánica de contenido en las páginas web. El programa, el script, está asociado con una URI de Internet. Cuando en un navegador tecleamos una URI, si su contenido se crea dinámicamente por un script éste se puede ejecutar en el ordenador cliente o en el servidor. Así, podemos encontrar scripts que residen en el lado del cliente y scripts que residen en el lado del servidor. Cada una de las dos opciones tiene sus ventajas e inconvenientes. Los scripts del lado del servidor en principio son independientes del navegador utilizado y no tienen que tener en cuenta el tipo de cliente, ya que se ejecutan en el servidor en un ambiente controlado. El código de los scripts puede ocultarse al cliente, y el resultado que se envía a éste puede estar en un formato normalizado que cualquier cliente puede mostrar. En esta modalidad todo el control del contenido de las páginas web recae en el servidor. En la práctica, el tipo de navegador que use el cliente sí determina la forma en que se visualiza la respuesta del servidor. Ejemplos de esta modalidad son: las páginas devueltas por los motores de búsqueda, servicios de banca por Internet, sitios de subastas, etc. La desventaja de esta modalidad es que el servidor se puede sobrecargar de trabajo ya que, además de servir páginas, es responsable de ejecutar los scripts, y lanzar un script es como lanzar un programa independiente a ejecución. Los scripts del lado del cliente son totalmente independientes del servidor, lo que tiene como principal ventaja evitar cargarlo de trabajo. En este caso el servidor solo envía el código y es tarea del navegador del cliente interpretarlo. Una desventaja de esta modalidad es que el código que el servidor envía es "sensible" a las capacidades del navegador para interpretarlo. Es decir, el mismo código puede no ser bien interpretado por algunos navegadores. Ejemplos de esta modalidad son las tareas que no requieren acceder a información propietaria (almacenada en la máquina que ejecuta), por ejemplo: animación interactiva, control de errores cuando se cumplimenta un impreso, etc. Además, en esta modalidad, el código tanto del hipertexto como de los scripts es accesible a cualquiera, pudiendo afectar a la seguridad. CGI El Common Gateway Interface (CGI) es una tecnología que permite a un cliente (navegador web) solicitar datos de un programa ejecutado en un servidor web. Define cómo un servidor web pasa una petición de un usuario web a un programa y cómo se trasfieren datos con el usuario. Por ejemplo, cuando un usuario hace una petición de una página web, el servidor devuelve la página solicitada. Pero cuando un usuario cumplimenta un formulario de una página web y lo envía, normalmente necesita ser procesado por un programa. El servidor pasa el formulario a un programa que procesa los datos y devuelve, por ejemplo, un mensaje de confirmación. Este mecanismo de pasar datos entre el servidor y el programa es lo que se denomina CGI y es parte del protocolo Web’s Hypertext Transfer Protocol (HTTP). Este programa puede estar escrito en cualquier lenguaje que 288 L ENGUAJES DE script soporte el servidor, aunque por razones de portabilidad se suelen usar lenguajes de script. El funcionamiento de CGI sería: 1. El servidor recibe una petición del cliente que ha activado una URL que contiene el CGI. 2. El servidor prepara el entorno para ejecutar el programa ya que puede necesitar información procedente del cliente. 3. El servidor ejecuta el programa capturando su salida estándar. 4. Se genera un objeto MIME2 que el programa escribe en su salida estándar. 5. Cuando se acaba la ejecución, el servidor envía la información generada al cliente. El programa debe informar del tipo de objeto MIME que se genera y el servidor calcula el tamaño del objeto producido. Perl es un lenguaje muy popular para la escritura de scripts CGI debido a sus potentes mecanismos de manejo de cadenas de texto y sus características de lenguaje glue que facilitan la generación de contenidos HTML. En el ejercicio resuelto 15 se puede ver el código de un formulario CGI interactivo que permite utilizar una página web para hacer una operación aritmética con operandos que teclea el usuario y generar otra página web con el resultado de la operación. Como se puede ver en dicha solución, el script no solo tiene que generar el contenido dinámico sino también las anotaciones HTML necesarias para formatearlo y mostrarlo. Actualmente la mayoría de los servidores web incorporan mecanismos que permiten incorporar a algunos intérpretes de lenguajes de scripts como un módulo en el propio servidor. Así, el servidor interpreta los scripts directamente sin tener que lanzar un programa externo. Cuando el servidor interpreta un script, lo reemplaza con la salida que genera y envía la página resultante al cliente. Ejemplos de este tipo de lenguajes de script embebidos en el servidor son: PHP, Ruby, Visual Basic, Java (Servlets). De todos ellos el más popular es PHP. En el ejercicio resuelto 16 se presenta un CGI en PHP para ver los usuarios conectados a un ordenador. Si comparamos los ejercicios resueltos 15 y 16 se pueden apreciar las siguientes diferencias: • El código PHP está embebido entre instrucciones de procesamiento que comienzan con <?php y finalizan con ?>. Estos delimitadores indican que su contenido debe ejecutarse por el intérprete de PHP. • En el ejemplo en PHP las etiquetas HTML no se generan mediante instrucciones de escritura, como en el caso de Perl (print). 2 Multipurpose Internet Mail Extensions es una serie de convenciones o especificaciones dirigidas al intercambio a través de Internet de todo tipo de ficheros (texto, audio, vídeo, etc.) de forma transparente para el usuario. D OMINIOS DE APLICACIÓN 289 • El código del script PHP puede estar en cualquier parte de la página web repartido entre anotaciones HTML. Scripts del lado cliente Algunas tareas comunes en las páginas dinámicas, como el control de ventanas, movimiento de objetos por la página, control de datos en formularios, cálculos, la modificación de los contenidos del documento dinámicamente, etc. se suelen ejecutar mediante scripts en la máquina cliente. Los scripts del lado cliente requieren que la máquina cliente disponga de un intérprete que los pueda ejecutar y es el navegador el que se encarga de interpretarlos y ejecutarlos para realizar los efectos y funcionalidades que se hayan programado. La mayoría de los navegadores soportan JavaScript y por ello este lenguaje es el más utilizado para este tipo de scripts, aunque también se utiliza Visual Basic Script pero solo con Internet Explorer y Google Chrome. Cuando el cliente web solicita una página dinámica al servidor, éste envía por la red al cliente el contenido del documento que incluirá las anotaciones HTML y los scripts que pudiera contener. Entonces el navegador del cliente va leyendo la página de principio a fin representando visualmente las anotaciones HTML y ejecutando los scripts. Por lo tanto, los scripts se incluyen dentro del mismo archivo HTML. Normalmente los scripts de una página web están asociados a eventos como la carga y descarga de una página, movimientos del ratón, pulsar un botón, entrada de datos en un formulario, la navegación por una página, etc. Por ejemplo, se puede crear una función JavaScript que permita verificar que la información introducida por el usuario en un campo de entrada de datos de un formulario (número de teléfono, código postal, número de tarjeta de crédito, etc.) tiene el formato y el rango correcto. Los scripts del lado del cliente se deben incluir en el elemento <SCRIPT>. Veamos a continuación un ejemplo del programa "Hola Mundo" como un script del lado cliente escrito en JavaScript: 1 2 3 4 5 6 7 8 9 10 11 12 13 <!DOCTYPE HTML PUBLIC " -// W3C // DTD HTML 4.01// EN " " http :// www . w3 . org / TR / html4 / strict . dtd " > <HTML> <HEAD><TITLE> Primera página </TITLE></HEAD> <BODY> <SCRIPT type=" text / javascript " > document . write (’ Hola Mundo ’) ; </SCRIPT> <NOSCRIPT> <P>El navegador no soporta JavaScript . </P> </NOSCRIPT> </BODY> </HTML> 290 L ENGUAJES DE script Buena parte de la popularidad de Javascript como lenguaje de programación de scripts del lado cliente se debe a que es una de las tecnologías incluidas en Ajax. Ajax (Asynchronous Javascript and XML) utiliza una combinación de tecnologías con el objetivo de crear aplicaciones web interactivas del lado cliente, en concreto: XHTML o HTML y hojas de estilos en cascada (CSS), Document Object Model (DOM) a través de un lenguaje de script (JavaScript y JScript) para mostrar e interactuar dinámicamente con la información presentada, el objeto XMLHttpRequest para intercambiar datos de forma asíncrona con el servidor web (también se puede utilizar iframe en su lugar en ciertos contextos) y XML. Aunque XML es el formato normalmente utilizado para la transferencia de datos solicitados al servidor, también se puede usar HTML preformateado o incluso texto plano. Se dice que la tecnología Ajax es asíncrona porque los datos adicionales se solicitan al servidor y se cargan en segundo plano sin interferir con la visualización ni el comportamiento de la página. 5.2.6 Aplicaciones científicas Se trata de lenguajes de script orientados a aplicaciones científicas que se han desarrollado a partir de las primeras calculadoras programables. Hay tres paquetes comerciales para cálculo matemático que son muy populares: Matlab, Maple y Mathematica, aunque también hay otros menos conocidos orientados al cálculo estadístico: R y S. Todos ellos proporcionan lenguajes de script que están enfocados al dominio de las aplicaciones científicas. 5.3 Algunos lenguajes de script destacados En este apartado se van a describir las principales características de dos lenguajes de script muy utilizados: Perl y PHP. El lenguaje Ruby es un lenguaje dinámico que actualmente es bastante popular y ya ha sido introducido en el capítulo 3. 5.3.1 Perl A partir de lenguajes como awk, sh y RPG se desarrollaron otros, entre los que destaca Perl, que se ha convertido en uno de los lenguajes de script de propósito general más utilizados. Perl (Practical Extraction and Report Language) es un lenguaje de programación de alto nivel, interpretado y dinámico desarrollado por Larry Wall en 1987. Desde entonces ha ido evolucionando, se le han añadido nuevas funcionalidades e incorporado características de orientación a objetos, además de ser extensible por los usuarios. Perl proporciona una gran potencia para la manipulación de textos, como sus antecesores sed y awk, y añade el tratamiento de datos de longitud arbitraria. Además, también se usa en tareas de administración de sistemas, programación en red, programación de CGI A LGUNOS LENGUAJES DE script DESTACADOS 291 y acceso a bases de datos, entre otras. Perl es software libre3 y está disponible para una amplia variedad de plataformas (Unix, Windows, Macintosh). Cabe destacar que aunque hay lenguajes de propósito general, como puede ser Java, que disponen de librerías que permiten la manipulación de cadenas y textos y otras funcionalidades, la potencia de Perl radica en que incorpora en el propio lenguaje tipos, operadores y constructores para estas tareas. Perl tiene tipado dinámico por lo que los valores que se asignan a las variables son los que determinan su tipo. Así, una misma variable escalar puede comportarse como una cadena si su contexto (operadores, funciones,. . . ) es de cadena, o como una variable numérica si su contexto es numérico. En Perl hay tres tipos de variables: • Escalares (su nombre comienza con el símbolo $). Se usan para almacenar datos simples que son números enteros, reales o cadenas. Un ejemplo de asignación a dos variables escalares: 1 2 $contador = 0; $saludo = " Hola mundo "; • Arrays o matrices (su nombre comienza con el símbolo @). Almacenan colecciones de variables escalares comenzando en la posición 0. Un ejemplo de asignación a dos arrays: 1 2 3 @contadores = (0 , 0, 0, 0, 0 ); @colores = (" amarillo " , " rojo " , " verde " , " naranja " , " violeta "); El acceso a un elemento del array se realiza indicando el nombre del array como un escalar y la posición entre corchetes. Por ejemplo: $contadores[1] hace referencia al valor escalar almacenado en la posición 1 del array @contadores. • Arrays asociativos (su nombre comienza con el símbolo %). Arrays cuya clave de acceso es una cadena y cuyo valor es un escalar. Un ejemplo de asignación a un array asociativo: 1 2 % CantidadColores = (" amarillo " , 0, " rojo " , 0, " verde " , 0, " naranja " , 0, " violeta " , 0) ; Cada elemento del array está formado por un par clave-valor. Por ejemplo, el primer elemento del array %CantidadColores es el elemento con clave de acceso 3 GPL y Licencia artística 292 L ENGUAJES DE script "amarillo" y cuyo valor escalar es 0. Para eliminar un elemento de un array asociativo se utiliza la instrucción delete. Por ejemplo: 1 delete( $CantidadColores {" verde " }) elimina el elemento de clave "verde" del array %CantidadColores. Notad que en este tipo de array para acceder a un valor se indica el nombre del array como un escalar y la clave entre llaves. Es muy importante en Perl utilizar adecuadamente la notación de array (%, @) y la de escalar ($). Cuando se utiliza un array pero de forma que no se accede o se referencia a su totalidad, sino a uno de sus elementos, se usa la notación de escalar ($). Cada sentencia en Perl termina con un ";". Cuando se asocia a una variable escalar el nombre de un array, esta variable almacena el número de elementos del array. Por ejemplo, en: 1 2 @colores = (" amarillo " , " rojo " , " verde " , " naranja " , " violeta "); $numColores = @colores ; la variable $numColores almacenaría el valor 5. Además de los operadores habituales en los lenguajes de programación, Perl dispone del operador "." que se utiliza para concatenar dos cadenas. Veamos un ejemplo del programa "Hola mundo" utilizando este operador: 1 2 3 4 5 # !/ usr / local / bin / perl $parte1 = " Hola "; $parte2 = " mundo "; $saludo = $parte1 . $parte2 ; print " $saludo \n"; En Perl no es lo mismo encerrar una cadena entre comillas dobles que simples. Las variables escalares y arrays dentro de una cadena entre dobles comillas se sustituyen por su valor. Por ejemplo, la instrucción $saludo= "Hola $user"; asigna a la variable $saludo el literal formado por el texto explícito y el valor de la variable $user. También los caracteres especiales, como "\n" o "\t", se respetan entre dobles comillas. Por el contrario, una cadena entre comillas simples se mostrará literalmente sin tener en cuenta los posibles caracteres especiales que pueda contener, y que se tratarán como literales. Para anular el significado de un carácter especial dentro de comillas dobles se utiliza el carácter "\". Cuando se llama a un programa Perl se le pueden pasar argumentos que se guardan en el array predefinido @ARGV. El primer argumento se almacenará en $ARGV[0], el segundo en $ARGV[1] y así sucesivamente. Hay otro array predefinido muy importante en Perl que es %ENV, que almacena las variables de ambiente y, como se deduce de su nombre, es A LGUNOS LENGUAJES DE script DESTACADOS 293 un array asociativo. Por ejemplo, para visualizar el contenido de la variable de ambiente PATH se podría escribir: 1 print " PATH actual : $ENV { PATH }\ n"; Se puede observar que el nombre de la variable de ambiente, en este caso PATH, hace de índice o clave del array asociativo. En los ejercicios resueltos 11 y 12 se pueden ver ejemplos de recorrido y acceso al array asociativo %ENV. Otras variables predefinidas útiles son las siguientes: @_ Lista de argumentos de un subprograma. $_ Contiene la línea de texto leída. $0 Nombre del fichero que contiene el programa Perl. $< Identificación del usuario que está ejecutando el programa. $/ Representa el separador de registros que por defecto es un carácter de nueva línea. $* Tiene dos posibles valores: 0 o 1. El valor 1 permite comparar expresiones con cadenas que ocupan más de una línea. El valor 0, que es el que tiene por defecto, asume que las cadenas contienen una línea. Por otra parte, hay variables que se refieren a la última expresión que casó: $1..$9 Contiene el subpatrón entre paréntesis de la última expresión regular que casó. Por ejemplo, $1 se refiere al primer paréntesis y así sucesivamente. $& Contiene la última cadena que casó en una expresión regular. $‘ Contiene la cadena precedente a la que casó en la última expresión regular. $’ Contiene la cadena siguiente a la que casó en la última expresión regular. $+ Contiene la cadena asociada al paréntesis que casó en la última expresión regular. Es útil cuando no se sabe cuál de una serie de posibles patrones ha casado. Veamos un ejemplo de algunas de estas variables: 1 2 3 $_ = ’ abcdefghi ’; / def /; print "$ ‘: $ &: $ ’\n"; escribiría: abc:def:ghi El patrón que aparece entre "/" y "/" es el que se compara con el valor de la variable $_. Perl lee los datos de la entrada estándar STDIN (el teclado) y muestra los resultados por la salida estándar STDOUT (pantalla). Mediante el redireccionamiento y la apertura y cierre de ficheros se podrá leer y escribir datos en ficheros. Por ejemplo, el siguiente código: 1 # !/ usr / local / bin / perl 294 2 3 4 L ENGUAJES DE script while( $linea = <STDIN >) { print " $linea "; } lee líneas de la entrada estándar y las escribe en la salida estándar. La instrucción $linea = <STDIN> lee una línea de la entrada estándar y la almacena en la variable $linea. Si en lugar de utilizar el código anterior utilizamos el siguiente: 1 2 3 4 # !/ usr / local / bin / perl while(< STDIN >) { print " $_ "; } el resultado es el mismo, pero en este caso el contenido de la línea leída se almacena en la variable predefinida $_. La asignación automática de la entrada a la variable $_ solo se produce con while. Otra forma de leer de la entrada estándar es obviar el gestor de fichero como en el siguiente ejemplo: 1 2 # !/ usr / local / bin / perl print while( < >); Si se pasa un fichero como argumento al programa anterior irá leyendo y escribiendo línea a línea. Si se pasa más de un fichero como argumento hará lo mismo con todos ellos. La instrucción de escritura print escribe en la salida estándar. Si print no tiene argumentos, por defecto escribe el contenido de $_. Existe una instrucción de escritura en fichero, printf, cuyo funcionamiento es similar a la del lenguaje C. Supongamos que el programa anterior se llama "prueba.pl"; cuando se le llama se puede redireccionar su entrada y su salida a ficheros de la siguiente manera: 1 perl prueba.pl < ficheroEntrada > ficheroSalida Veamos el significado de algunos caracteres especiales que se usan frecuentemente en las expresiones regulares en Perl: \w \W \d \D \s \S \t \b Una letra, dígito o "_". Cualquier carácter que no sea \w. Un dígito. Cualquier carácter que no sea \d. Espacio, tabulador, nueva línea, salto de página. Cualquier carácter que no sea \s. Tabulador. El principio y el final de una palabra. A LGUNOS \f LENGUAJES DE script DESTACADOS 295 Salto de página. Además de los modificadores "s" (sustitución) y "g" (global) que ya vimos en las expresiones regulares en sed, Perl dispone de otros muy útiles, por ejemplo "i". El siguiente código: 1 2 3 4 # !/ usr / local / bin / perl while( < >) {; print if /\ bcasa \b/i; } escribe las líneas en las que aparece la palabra casa escrita en mayúscula, minúscula o cualquier combinación de ellas. De este modo, el modificador "i" permite búsquedas no sensibles a mayúsculas y minúsculas. Por defecto, el emparejamiento de patrones se efectúa sobre la variable $_, pero se puede aplicar a otra variable: 1 $variable =~ s/ patrónOriginal / patrónNuevo ; En este caso, la sustitución se realizará sobre la variable $variable. En el apartado 5.4 hay ejemplos de programas Perl que el lector puede revisar. 5.3.2 PHP PHP es un lenguaje de script de propósito general de código abierto. Fue creado por Rasmus Lerdorf en 1994 y empezó siendo el acrónimo de Personal Home Page ya que se diseñó originalmente para la creación de páginas web dinámicas debido a que se puede embeber en HTML. A medida que sus capacidades fueron evolucionando y ampliándose pasó a ser el acrónimo de Hypertext Preprocessor. Actualmente la implementación que se considera estándar es la del PHP Group4 . Está inspirado principalmente en los lenguajes C y Perl. Aunque orientado a la programación web, también se puede utilizar desde la línea de comandos (como Perl o Python) en su versión PHP-CLI (Command Line Interface) o como lenguaje de propósito general con una interfaz gráfica utilizando la extensión PHP-Qt o PHP-GTK. Las versiones recientes tienen soporte para orientación a objetos, aunque no es propiamente un lenguaje orientado a objetos. PHP se puede utilizar en la mayoría de los servidores web (Apache e Internet Information Services (IIS) de Microsoft, entre otros), sistemas operativos (Linux, variantes de Unix –HP-UX, Solaris y OpenBSD–, Microsoft Windows, Mac OS X, RISC OS), así como con varios sistemas de gestión de bases de datos relacionales, bien mediante 4 http://www.php.net 296 L ENGUAJES DE script una extensión (mysql), con una capa de abstracción PDO5 , utilizando el estándar Open Database Connection a través de la extensión ODBC o sockets. PHP puede trabajar como un módulo de un servidor o como un procesador CGI. Además de generar salida HTML, PHP puede generar ficheros en otros formatos, como XHTML, XML o PDF. PHP incluye capacidades para realizar procesamiento de cadenas de texto que son compatibles con las de Perl. También incluye extensiones y herramientas para analizar y procesar documentos XML. El intérprete PHP ejecuta el código que se encuentra entre el delimitador de inicio <?php y el de fin ?>. Estos delimitadores en XML y XHTML corresponden a instrucciones de procesamiento, por lo que la mezcla de PHP y XML en un documento en un servidor web es un documento XML bien formado. Veamos el clásico programa "Hola mundo" en PHP embebido en HTML: 1 2 3 4 5 6 7 8 9 10 <! DOCTYPE HTML PUBLIC " -// W3C // DTD HTML 4.01// EN " " http :// www . w3 . org / TR / html4 / strict . dtd " > <HTML > <HEAD >< TITLE > Primera programa PHP </ TITLE > </ HEAD > <BODY > <? php echo ’Hola Mundo ’; ?> </ BODY > </ HTML > El mismo efecto se consigue sustituyendo la instrucción echo ’Hola Mundo’; por print("Hola Mundo");. En PHP se puede comentar una línea de código con "//" o con "#" y varias líneas entre los caracteres "/*" y "*/". PHP requiere que las instrucciones terminen en punto y coma. En PHP podemos encontrar datos de tipo numérico, cadenas y arrays, y todas las variables comienzan por el signo "$". Al tener tipado dinámico, las variables no se declaran antes de usarlas. En el ejercicio resuelto 17 se puede ver un ejemplo de creación y uso de un formulario HTML en PHP. Para la creación de un array en PHP se utiliza la función array(). Veamos un ejemplo: 1 2 $colores = array(" amarillo " , " rojo " , " verde " , " naranja " , " violeta "); En el array anterior, y por defecto, la primera posición es la 0, la segunda la 1 y así sucesivamente. También se puede hacer mediante asignaciones explícitas: 1 $colores [0] = " amarillo "; 5 La extensión PHP Data Objects (PDO) define una interfaz para tener acceso a bases de datos en PHP. A LGUNOS LENGUAJES DE script DESTACADOS 297 Para crear un array asociativo hay que especificar los pares clave-valor. Por ejemplo: 1 2 $CantidadColores = (" amarillo " => 0, " rojo " => 0, " verde " => 0 ,} " naranja " => 0, " violeta " => 0) ; En este caso las claves de acceso al array serán los colores. También se podrían utilizar asignaciones explícitas como en el caso anterior. PHP consta básicamente de dos funciones para emparejamiento de patrones, una sensible a mayúsculas y minúsculas y otra no, y otras dos funciones para emparejamiento y sustitución, de nuevo una sensible a mayúsculas y minúsculas y otra no. Además, se pueden utilizar los patrones PCRE6 (Perl-compatible Regular Expression Functions) y POSIX, aunque está desaconsejado el uso de estas últimas funciones desde la versión PHP 5.3.0 y en su lugar se recomienda usar las funciones de PCRE. Para comparar un patrón con una cadena se puede utilizar la función preg_match()7 que devuelve el número de veces que coinciden. Este resultado podrá ser 0 (sin coincidencias) o 1, ya que preg_match() detendrá la búsqueda después de la primera coincidencia; el valor devuelto por la función será FALSE si se produjo un error. La función preg_match_all(), por el contrario, continuará hasta que alcance el final de la cadena. El formato básico de la función es: 1 preg_match ( $patron , $cadena ) Esta función devuelve un valor entero y los dos parámetros son de tipo string. Veamos un ejemplo de su uso: 1 echo preg_match ("/ mundo /" , "/ Hola mundo /"); mostrará el valor "1" ya que hay coincidencia. Para acceder a la cadena que ha casado hay que pasar un array como tercer argumento, por ejemplo: 1 2 echo preg_match ("/ mundo /" , "/ Hola mundo /" , $cadena ) . " <br /> "; echo $cadena [0] . " <br />"; mostraría: 1 mundo 6 En http://www.php.net/manual/es/pcre.pattern.php se encuentra el manual de este tipo de patrones así como la descripción de las diferencias con Perl. 7 En http://www.php.net/manual/es/function.preg-match.php se puede encontrar el formato completo y numerosos ejemplos de uso. 298 L ENGUAJES DE script Si se necesita saber la posición en la que se produjo el casado, habrá que pasar a la función un cuarto argumento: PREG_OFFSET_CAPTURE. En este caso, el array que almacena la cadena que ha casado se convierte en su primera posición en un array de dos dimensiones: una para almacenar la cadena y la otra para almacenar la posición. El siguiente ejemplo: 1 2 3 echo preg_match ("/ mundo /" , "/ Hola mundo /" , $cadena , PREG_OFFSET_CAPTURE ) . " <br /> "; echo $cadena [0][0] . " <br /> "; echo $cadena [0][1] . " <br /> "; mostraría: 1 mundo 5 ya que 5 es la posición de comienzo de la cadena "mundo" empezando a contar desde 0. Un patrón se puede agrupar en subpatrones usando paréntesis, y así se podrá acceder a cada uno de ellos individualmente. Veamos un ejemplo: 1 2 3 4 preg_match (" /(\ d +\/\ d +\/\ d +) (\ d +\:\ d +.+) /" , " 03/03/2006 15:30 PM " , $cadena ); echo $cadena [0] . " <br />"; echo $cadena [1] . " <br />"; echo $cadena [2] . " <br />"; mostraría: 03/03/2006 15:30PM 03/03/2006 15:30PM Se recuerda al lector que al igual que en Perl \d representa un dígito y que el carácter "." representa cualquier carácter. En la primera posición del array $cadena se almacena la cadena completa que ha casado, en la siguiente posición se almacena la subcadena que ha casado con el primer subpatrón (primer subpatrón entre paréntesis) y en la tercera la que ha casado con el segundo subpatrón (segundo subpatrón entre paréntesis). PHP y XML A medida que XML se ha convertido en un estándar para intercambio y almacenamiento de información, las sucesivas versiones de PHP han ido añadiendo funciones y clases para facilitar el trabajo con documentos XML. A LGUNOS LENGUAJES DE script DESTACADOS 299 A continuación se presentan algunas de estas funciones y clases. SimpleXML La extensión SimpleXML ofrece la manera más sencilla de manipular documentos XML con PHP. Solo dispone de una clase: SimpleXMLElement. Todos los elementos de un documento XML se representan como un árbol de objetos SimpleXMLElement y a cualquier hijo de un elemento se puede acceder como una propiedad del elemento objeto SimpleXMLElement. Por ejemplo, si $receta es un objeto que representa a un elemento que tiene un hijo $ingrediente, para acceder al texto de $ingrediente bastaría escribir: 1 $ingre = $receta -> ingrediente ; En el caso de que los elementos XML tengan nombres con caracteres no permitidos por las normas de PHP para nombrar variables (por ejemplo un guión), se podría acceder a dicho elemento encerrando su nombre entre llaves y comillas simples como en: 1 $ingre = $receta - >{ ’ ingrediente - salado ’;} Para acceder a un atributo de un elemento se utiliza la notación de arrays, en el que el nombre del array es el nombre del elemento y la posición del array corresponde al nombre del atributo. En el siguiente ejemplo: 1 $valor = $elemento [ ’ atributo ’]; se accede al valor del atributo atributo de elemento. Si se quiere realizar una operación de comparación de un elemento o atributo con un string o pasarlo a una función o método que requiera un string, debe realizarse un casting a string. Si no se realiza de esta forma, el elemento o atributo será considerado un objeto y no un string. Veamos un ejemplo: 1 $valor = ( string ) $elemento ; SimpleXML dispone de tres funciones para importar un documento XML a un objeto SimpleXMLElement: simplexml_import_dom(nodo) convierte el nodo DOM del argumento en un objeto SimpleXMLElement. simplexml_load_file(nombreFichero) carga el fichero XML que se pasa como argumento como un objeto SimpleXMLElement. 300 L ENGUAJES DE script simplexml_load_string(string) carga el string XML que se pasa como argumento como un objeto SimpleXMLElement. Algunos de los métodos de la clase SimpleXMLElement son los siguientes: addAttribute(nombre, valor) añade el atributo nombre con el valor indicado en valor. addChild(nombre [, valor]) añade el elemento nombre y opcionalmente se puede asignar el valor indicado en valor. Devuelve el elemento hijo como un nuevo objeto SimpleXMLElement. asXML([nombreFichero]) a partir del objeto SimpleXMLElement genera un documento XML. Si aparece el argumento nombreFichero el documento XML se escribe en él, en caso contrario lo devuelve como un string. attributes() devuelve un array asociativo con todos los atributos del elemento en forma de pares clave-valor. children() devuelve un array con todos los hijos del nodo dado como objetos de tipo SimpleXMLElement. count() devuelve el número de hijos del nodo. getName() devuelve el nombre del elemento como un string. xpath(expXPath) devuelve los elementos que resultan de la ejecución de la expresión XPath expXPath. Veamos ejemplos con el siguiente fichero XML aviso.xml: 1 2 3 4 5 6 7 <?xml version=" 1.0 " encoding ="ISO -8859 -1 "? > < nota > <a > Javier </a > <de >Susi </ de > < titulo > Aviso </ titulo > < texto > Llámame cuando llegues </ texto > </ nota > El siguiente script escribiría el contenido del elemento texto: 1 2 3 4 <? php $xml = simplexml_load_file (" aviso . xml ") echo $xml -> texto [0] ?> A LGUNOS LENGUAJES DE script DESTACADOS La salida del script anterior sería: 1 Llámame cuando llegues Para escribir el contenido de todos los elementos hijos del nodo nota: 1 2 3 4 5 6 <? php $xml = simplexml_load_file (" aviso . xml "); foreach ( $xml -> children () as $hijo ){ echo " Nodo hijo : " . $hijo . " <br />"; } ?> La salida del script anterior sería: 1 2 3 4 Nodo Nodo Nodo Nodo hijo: hijo: hijo: hijo: Javier Susi Aviso Llámame cuando llegues Para añadir un nodo hijo al elemento texto: 1 2 3 4 5 6 7 <? php $xml = simplexml_load_file (" aviso . xml "); $xml -> texto [0] - > addChild (" fecha " , " 10 -03 -2011 "); foreach ( $xml -> texto -> children () as $hijo ) { echo " Nodo hijo : " . $hijo . " <br />"; } ?> La salida del script anterior sería: 1 10-03-2011 Para añadir un atributo al elemento texto y luego visualizarlo: 1 2 3 4 5 6 7 <? php $xml = simplexml_load_file (" aviso . xml "); $xml -> texto [0] - > addAttribute (" leng " , " es "); foreach( $xml -> body [0] - > attributes () as $a => $b ) { echo $a , ’=" ’,$b ," \" </ br >"; } ?> La salida del script anterior sería: 301 302 1 L ENGUAJES DE script leng="es" Parser XML Esta extensión de PHP permite leer y acceder al contenido XML con un enfoque dirigido por eventos. Cada elemento de la corriente de datos activa un evento y su correspondiente manejador se activa. Para crear un nuevo analizador se utiliza la función xml_parser_create(). Por ejemplo: 1 $parser = xml_parser_create() crea un analizador almacenándolo en $parser. Por defecto se asume la codificación UTF-8, pero también se pueden utilizar ISO-8859-1, UTF-8 y US-ASCII indicándolo como argumento en la función. Al menos hay que crear dos funciones manejadoras de eventos, la de principio de un elemento XML y la de final de elemento, aunque lo más común es necesitar también un manejador de datos de tipo carácter. Estas funciones son respectivamente las siguientes: 1 2 3 function startElementHandler ( $parser , $elemento , $atributos ){ // proceso de comienzo del elemento } en la que $parser es el analizador, $elemento el nombre del elemento y $atributos un array asociativo con los pares atributo-valor del elemento. 1 2 3 1 2 3 function endElementHandler ( $parser , $elemento ){ // proceso del final del elemento } function characterDataHandler ( $parser , $datos ){ // proceso de los datos de tipo carácter } siendo $datos un string con los datos de tipo carácter. Una vez se han escrito los anteriores manejadores hay que registrar los dos primeros con el analizador utilizando: 1 2 xml_set_element_handler( $parser , " startElementHandler " , " endElementHandler "); A LGUNOS LENGUAJES DE script DESTACADOS 303 y el tercero de la siguiente manera: 1 xml_set_character_data ( $parser , " characterDataHandler "); Una vez hecho lo anterior ya se puede analizar un documento XML. El documento deberá ser almacenado en una variable de tipo cadena con la función file_get_contents(), por ejemplo: 1 $xml = file_get_contents(" documentoXML . xml "); Ahora ya se podría llamar al analizador que se ha creado previamente con la variable cadena utilizando la función xml_parse(): 1 xml_parse( $parser , $xml ); Una vez que se ha acabado de analizar el documento XML conviene eliminar el analizador para liberar memoria: 1 xml_parser_free( $parser ); DOM La extensión DOM de PHP corresponde al enfoque basado en la manipulación del árbol, en la que el documento XML se estructura como un árbol de nodos a los que se puede acceder en cualquier orden (ver el apartado 4.7.2). La API DOM de PHP sigue el estándar DOM Nivel 2 pero no totalmente. Esta API difiere de la API DOM oficial de dos maneras8 . En primer lugar, todos los atributos de clase están implementados como funciones con el mismo nombre. En segundo lugar, los nombres de funciones siguen la convención de nombres de PHP. Esto significa que la función DOM lastChild() será escrita como last_child(). La extensión DOM de PHP consta de varias clases y las más comunes son9 : DOMNode representa un nodo del árbol DOM. La mayoría de las clases DOM derivan de ésta. DOMDocument almacena el documento XML completo en forma de árbol DOM. Deriva de la clase DOMNode y es la raíz el árbol. 8 En http://www.php.net/manual/es/ref.domxml.php se puede encontrar una descripción completa de las diferencias y equivalencias. 9 La descripción completa de las clases, métodos y propiedades de la extensión DOM se puede consultar en http://www.php.net/manual/es/book.dom.php 304 L ENGUAJES DE script DOMElement representa un elemento nodo. DOMAttr representa un elemento atributo. DOMText representa un nodo de texto. DOMCharacterData representa un nodo de tipo CDATA (datos carácter). Para empezar a trabajar con un documento DOM primero hay que crear un objeto de tipo DOMDocument, por ejemplo: 1 $doc = new DOMDocument () ; A partir de aquí se puede utilizar el objeto $doc para leer el documento, escribirlo y modificarlo cambiando, añadiendo o eliminando nodos. Para procesar un nodo suele ser necesario determinar de qué tipo de nodo se trata. Para ello se utiliza la propiedad nodeType del objeto DOMNode que devuelve una constante predefinida indicando su tipo. Algunos de los valores más comunes son: XML_ELEMENT_NODE el nodo es un elemento representado como un objeto DOMElement. XML_ATTRIBUTE_NODE el nodo es un atributo representado como un objeto DOMAttr. XML_TEXT_NODE el nodo es de texto representado como un objeto DOMText. XML_CDATA_SECTION_NODE el nodo es de tipo CDATA representado como un objeto DOMCharacterData. XML_COMMENT_NODE el nodo es de tipo comentario representado como un objeto DOMComment. XML_DOCUMENT_NODE el nodo es el nodo raíz del documento representado como un objeto DOMDocument. Para crear un documento XML es necesario crear nodos y añadirlos al árbol DOM. Algunos de los métodos más comunes para crear nodos pertenecen a la clase DOMDocument: createElement(nombre [, valor]) crea un nodo denominado nombre y opcionalmente se puede añadir un valor. createTextNode(contenido) crea un nodo texto con el contenido indicado. createCDATASection(datos) crea un nodo de tipo CDATA con el contenido indicado. createComment(datos) crea un nodo comentario con el contenido indicado. E JERCICIOS RESUELTOS 305 Una vez creado un nodo se añade como nodo hijo a un nodo ya existente del árbol utilizando el método appendChild(). Por ejemplo: 1 $nodoPadre -> appendChild ( $nodo ); En el apartado 5.4 hay ejemplos de programas PHP que procesan documentos XML. 5.4 Ejercicios resueltos 1. Escribe un script bash para borrar los archivos temporales. 1 2 3 4 5 #!/ bin / bash # elimina archivos temporales echo -n Borrando archivos temporales ... rm -fr / tmp /* echo ¡Hecho ! 2. Escribe un script bash para comparar la antigüedad de dos ficheros o directorios. 1 2 3 4 5 6 7 8 9 10 #!/ bin / bash # compara la antigüedad de dos ficheros o directorios FICH1 = $1 FICH2 = $2 if [ ${ FICH1 } -nt ${ FICH2 } ] then echo " más reciente " else echo " más antiguo " fi Este script tiene dos argumentos que son ficheros o directorios e indica si el primero es más reciente o más antiguo que el segundo. 3. Escribe un script bash para comprobar si un fichero que se pasa como argumento existe. En caso de que no exista, hay que crearlo. 1 2 3 4 5 6 7 #!/ bin / bash # crea un fichero que se pasa como argumento si no existe if [ -f \ $1 ] then echo el fichero $1 ya existe else touch $1 306 L ENGUAJES script if [ -f $1 ] then echo se ha creado el fichero $1 else echo no se puede crear el fichero $1 fi 8 9 10 11 12 13 14 DE fi 4. Para practicar con las variables predefinidas asociadas a los parámetros de un script bash escribe uno que los muestre y que indique además su número. 1 2 3 4 5 6 7 8 #!/ bin / bash # muestra y cuenta los argumentos que recibe echo Número de argumentos : $# echo Los argumentos son : for ARG in $@ do echo $ARG done 5. Escribe un script bash para realizar una copia de seguridad. En el nombre del fichero resultante de la copia deberá constar la fecha en la que se realiza. 1 2 3 #!/ bin / bash OF =/ var /my - backup -$( date +% Y%m%d). tgz tar -cZf $OF / home / me / bash ejecuta la expresión entre paréntesis capturando su resultado, que es la fecha. 6. Escribe un comando sed para eliminar las líneas de un fichero que comienzan con el carácter #. 1 sed ’/^#/d’ fichero | more more es un filtro de ficheros que permite visualizar la información de salida pantalla a pantalla. 7. Escribe un comando sed para insertar 8 espacios al comienzo de cada línea de un fichero. 1 sed ’s/^/ /’ fichero E JERCICIOS RESUELTOS 307 8. Escribe un comando sed que muestre un bloque de texto que comience con una línea que contenga la palabra "INICIO" y termine con una línea que contenga "FIN". 1 sed -n -e ’/INICIO/,/FIN/p’ /mi/archivo/de/pruebas | more Si la cadena "INICIO" no se encuentra no se mostrará ningún dato. Si se encuentra "INICIO" pero no se encuentra ninguna línea que contenga la cadena "FIN" a continuación, se mostrarán todas las líneas siguientes. 9. Escribe un script en awk que tenga como entrada una lista de líneas de texto numeradas, siendo este número el primer campo de cada línea, y las muestre ordenadas por dicho número. Se trata por lo tanto de ordenar las líneas de entrada. La forma más sencilla de hacerlo es utilizar un array asociativo que tenga como índice la propia línea, por lo que se ubicarán en el array en orden de numeración. 1 { if ( $1 > max ) max = $1 lineas [ $1 ] = $0 2 3 4 5 6 7 8 9 10 } END { for (x = 1; x <= max ; x ++) if (x in lineas ) print lineas [x] } En el caso de que haya líneas con números repetidos la más reciente sobrescribe a la anterior. 10. Escribe un script en awk que copie todas las líneas de entrada en la salida, excepto aquellas que contengan @include nombreFichero en cuyo caso se reemplazá dicha línea por el contenido del fichero indicado. 1 { if ( NF == 2 && $1 == " @include ") { while ((getline linea < $2 ) > 0) print linea close( $2 ) } else print 2 3 4 5 6 7 8 } 308 L ENGUAJES DE script Se usa la instrucción getline para leer desde un fichero. En concreto se usa con el formato getline var < nombreFichero para leer un registro de nombreFichero y almacenarlo en la variable var. 11. Escribe un programa Perl que visualice todas las variables de ambiente y sus valores. 1 2 3 4 5 #!/ usr / local / bin / perl foreach $nombreVar (keys(% ENV )){ $valor = $ENV { $nombreVar }; print " $nombreVar = $valor \n"; } La función keys devuelve todas las claves del array asociativo que tiene como argumento. 12. Escribe un programa Perl que visualice todas las variables de ambiente en orden alfabético junto con sus valores. 1 2 3 4 5 6 #!/ usr / local / bin / perl @nombreVar = keys(% ENV ); @nombresOrdenados = sort( @nombreVar ); foreach $nombre ( @nombresOrdenados ){ print " $nombre = $ENV { $nombre }\ n"; } La función sort devuelve el array que recibe como argumento ordenado alfabéticamente sin alterarlo, por lo que el resultado hay que almacenarlo en otro array. Si el array contiene números, los ordena en orden ascendente. Se puede utilizar con todos los tipos de arrays. 13. Escribe un programa Perl que cuente todas las apariciones de la palabra "casa" en un fichero o conjunto de ficheros. 1 2 3 4 5 6 #!/ usr / local / bin / perl while( < >) { @contLinea = /\ bcasa \b/ gi ; $contTotal += @contLinea ; } print " la palabra \" casa \" aparece " , " $contTotal veces .\ n"} 14. Sea un fichero noticias.xml que contiene numerosas noticias descritas por el elemento <DOC>. Estas noticias tienen asociada una categoría que corresponde al valor del atributo CODE del elemento CAT (ver ejemplo). Se pide escribir un E JERCICIOS RESUELTOS 309 programa Perl que dado un fichero de estas características, cree un subdirectorio denominado noticias.xml.Separadas, que contenga a su vez tantos subdirectorios como valores diferentes de categorías contenga el fichero. Los subdirectorios se denominarán por el valor de la categoría y contendrán todas aquellas noticias que pertenecen a dicha categoría. Así, cada noticia, elemento <DOC> se almacenará en un fichero en el directorio de su categoría. Hay que contemplar la posibilidad de que una noticia pueda tener asociada más de una categoría. En dicho caso la noticia se almacenará en tantos directorios como categorías tenga. El fichero noticias.xml podría tener la siguiente estructura: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 <?xml version=" 1.0 " encoding =" ISO -8859 -1 "? > < NEWSREPOSITORY COMMENT =" CorpusX " DATE =" 19/09/2002 " > < DOC SOURCE =" Madrid , 31 dic " LNG =" sp " AUTHOR =" Pepe " DOCNO ="1" DATE =" 2000/01/01 " > < CAT CODE =" 13000000 " SCHEME =" IPTC " / > < TITLE > El ... </ TITLE > < SUMMARY > <P > Los ... </P > </ SUMMARY > < BODY > <P > ... </P > <P > ... </P > </ BODY > </ DOC > < DOC ... > ... </ DOC > </ NEWSREPOSITORY > El programa Perl podría ser: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 #!/ usr / local / bin / perl # control del argumento de la llamada al programa if(! $ARGV [0]) { print " falta el fichero de entrada \n"; } else { # abre el fichero para lectura open( ARCHIVO ," $ARGV [0] ") || die " $ARGV [0] no se puede abrir \n"; $ /= " <\/ DOC >"; # crea un directorio para almacenar los documentos por categoría if (!( - d " $ARGV [0]\. Separadas ")){ mkdir(" $ARGV [0]\. Separados " , 0755) ; } $dir = " $ARGV [0]\. Separados "; # una noticia está entre las etiquetas <DOC > y </DOC > # nombre fichero noticia = fecha_numDocumento . source 310 L ENGUAJES script # lo almacena en el directorio correspondiente a su categoría while(< ARCHIVO >) { $doc = $_ ; if (/ DOCNO =" (\ d +) " /) { $numDoc = $1 ; print " reconocido DOCNO \n"; if (/ DATE =" (\ d +) \D +(\ d +) \D +(\ d +) " /) { $fecha = " $1$2$3 "; print " reconocido DATE \n"; $nombre = " $fecha \ _$numDoc \. source "; while(/ < CAT \s+ CODE =" (\ d +) "\s+ SCHEME /) { if (/ CAT \s+ CODE =" (\ d +) "\s+ SCHEME /) { print " reconocido CODE \n"; $directorio = $1 ; $_ = $ ’; # crea el directorio si no existe if (!( - d " $dir \/ $directorio ") ){ mkdir (" $dir \/ $directorio ", 0755) ; } chdir " $dir \/ $directorio "; # crea el fichero con la noticia open ( SALIDA ," > $nombre ") ; print SALIDA " $doc \n "; close SALIDA ; chdir "../.."; } } } } } close ARCHIVO ; 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 DE } 15. Escribe el código de un CGI interactivo en Perl para crear una página web que multiplique el valor de dos operandos tecleados por el usuario, devolviendo el resultado en otra página web generada automáticamente. En primer lugar, veamos cómo podría ser el código HTML para el formulario en el que se piden los operandos y se lanza la petición: 1 2 3 4 5 <HTML> <HEAD> <TITLE> Multiplicador </TITLE> </HEAD> <BODY> E JERCICIOS 6 7 8 9 10 11 12 RESUELTOS 311 <FORM action="/ cgi - bin / multiplicador . pl " method=" post " > <P> <INPUT name=" ope1 " size=4 > Primer operando <BR> <INPUT name=" ope2 " size=4 > Segundo operando </P> <P> <INPUT type=" submit " value=" multiplicar " > </P> </FORM> </BODY> </HTML> El código Perl del CGI podría ser el siguiente: 1 2 3 4 5 6 7 8 9 #!/ usr / local / bin / perl use CGI qw(: standard ); # acceso a los campos de entrada de CGI $ope1 = param (" ope1 "); $ope2 = param (" ope2 "); $multi = $ope1 * $ope2 ; print " Content - type : text / html \n\n"; print " <HTML >\ n < HEAD >\n < TITLE > Resultado </ TITLE >\n </ HEAD >\ n < BODY >\ n"; print " <P > $ope1 multiplicado por $ope2 es $multi "; print " </ BODY >\n </ HTML >\ n"; El usuario debe introducir el valor de los dos operandos en los campos de texto y hacer clic en el botón "multiplicar". Entonces, el navegador cliente envía una petición al servidor para la URI /cgi-bin/multiplicador.pl. El script Perl usa esos valores para realizar la multiplicación y generar una nueva página web. 16. Escribe un script en PHP embebido en una página web para obtener una lista de los usuarios que están utilizando un servidor junto con algunas estadísticas. 1 2 3 4 5 6 7 8 9 10 11 12 <HTML > <HEAD > <TITLE > Información de <? php echo $host = chop( ’ hostname ’) ?> </ TITLE > </ HEAD > <BODY > <H1 > <? php echo $host ?> </H1 > <PRE > <? php echo ’ uptime ’, "\n" ’who ’ ?> </ PRE > </ BODY > </ HTML > 312 L ENGUAJES DE script 17. Crea un formulario utilizando HTML y PHP para solicitar a un usuario: nombre, apellidos, dirección de correo electrónico y comentarios. Los datos del formulario, una vez cumplimentado, deberán mostrarse al usuario. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 <HTML> <HEAD> <TITLE> Datos personales </TITLE> </HEAD> <BODY> <FORM ACTION=" FormularioPersonal . php " METHOD= POST > Nombre <INPUT TYPE=text NAME=" nombre " SIZE=22 > <BR> Apellidos <INPUT TYPE=text NAME=" apellidos " SIZE=50 > <BR> e - mail <INPUT TYPE=text NAME=" email " SIZE=70 > <BR> Comentarios <TEXTAREA TYPE=text NAME=" comentarios " FILAS =6 COLS=40 > </TEXTAREA><BR> <INPUT TYPE= submit NAME=" enviar " > <BR> </FORM> </BODY> </HTML> El elemento FORM describe un formulario y el atributo ACTION el script que va a recibir los datos del formulario. El atributo METHOD puede tener dos posibles valores GET o POST que indican la forma en que pasa la información del formulario al script. El script PHP que maneje el formulario anterior mostrando los datos introducidos podría ser el siguiente: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 <HTML > <HEAD > <TITLE > Datos procesados </ TITLE > </ HEAD > <BODY > // mostrar los datos introducidos <? php echo} print " Nombre : $nombre <BR > \n"; print " Apellidos : $apellidos <BR > \n"; print "e - mail : $email <BR > \n"; print " Comentarios : $comentarios <BR > \n"; ?> </ BODY > </ HTML > En algunos casos puede resultar necesario eliminar los espacios que pueda haber antes y después de los caracteres que formen una cadena, por ejemplo en una E JERCICIOS RESUELTOS 313 contraseña, un correo electrónico, etc. Para ello PHP dispone de funciones predefinidas. La función trim() elimina los espacios extra de comienzo y fin de una cadena. Si en el script anterior utilizamos esta función quedaría: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <HTML > <HEAD > <TITLE > Datos procesados </ TITLE > </ HEAD > <BODY > // mostrar los datos introducidos <? php echo} $nombre = trim( $nombre ); $apellidos = trim( $apellidos ); $email = trim( $email ); $comentarios = trim( $comentarios ); print " Nombre : $nombre <BR > \n"; print " Apellidos : $apellidos <BR > \n"; print "e - mail : $email <BR > \n"; print " Comentarios : $comentarios <BR > \n"; ?> </ BODY > </ HTML > 18. Escribe una función PHP que determine si un documento XML almacenado en la cadena $doc es válido. 1 2 3 4 5 6 function cargaXML ( $doc ) { $xml = @simplexml_load_string ( $doc ); if (!is_object( $xml )) throw new Exception ( ’ Error en la lectura del documento XML ’ ,1001) ; return $xml ; } is_object comprueba si una variable es un objeto devolviendo TRUE si lo es y FALSE en caso contrario. 19. Dado un documento XML similar al del ejercicio resuelto 14, se pide añadir un nuevo elemento DOC a los ya existentes. Utiliza PHP y la extensión DOM. 1 2 3 <! DOCTYPE html PUBLIC " -l // W3C // DTD XHTML 1.0 Strict // EN " HTML xmlns =" http :// www . w3 . org / TR / xhtml1 / DTD / xhtml1 - strict . dtd " > <HTML xmlns = " http :// www . w3 . org /1999/ xhtml " " xml : lang =" es " lang =" es " > 314 L ENGUAJES 4 5 6 7 8 9 10 DE script <HEAD > <TITLE > Añadir un elemento nuevo usando DOM </ TITLE > <LINK > rel =" stylesheet " type =" text / css " href =" common . css "/> </ HEAD > <BODY > <H1 > Añadir un elemento nuevo usando DOM </H1 > <PRE > 11 12 <? php 13 14 15 16 17 18 // Carga el fichero XML $docu = new DOMDocument () ; $docu -> preserveWhiteSpace = false ; $docu -> load (" NEWSREPOSITORY . xml "); $docu -> formatOutput = true ; 19 20 21 22 // Accede a la raíz } $newsrepositoryElements = $docu -> getElementsByTagName (" NEWSREPOSITORY "); $newsrepository = $newsrepositoryElements -> item (0) ; 23 24 25 26 27 28 // Crea un nuevo elemento DOC y lo añade $doc = $docu -> createElement (" DOC "); $doc -> setAttribute (" SOURCE " , " Bilbao , 12 mar "); ... $newsrepository = appendChild ( $doc ); 29 30 31 32 33 34 // Crea un elemento CAT hijo de DOC y lo añade $cat = $docu -> createElement (" CAT "); $cat -> setAttribute (" CODE " , " 15000000 "); ... $doc = appendChild ( $cat ); 35 36 37 // Crea y añade los demás elementos ... 38 39 40 // Muestra el documento XML echo htmlspecialchars ( $docu -> saveXML () ); 41 42 43 44 45 ?> </ PRE > <BODY > </ HTML > E JERCICIOS PROPUESTOS 315 5.5 Ejercicios propuestos 1. Escribe un script bash que indique si un fichero que se pasa como argumento tiene permiso de lectura y escritura. Se recuerdan al lector los operadores lógicos con ficheros: -a (AND) y -o (OR). 2. Escribe un script bash que solicite a un usuario sus datos personales (nombre, apellidos, NIF), los muestre en pantalla y solicite la confirmación de ellos al usuario. Se recuerda al lector que la instrucción read var captura información de la entrada estándar, el teclado, hasta que se teclea enter y la almacena en la variable var. 3. Escribe un comando sed que añada una línea con el contenido "Línea" después de cada línea de un fichero. Se recuerda al lector que a\ añade la línea especificada a continuación tras terminar el ciclo de procesamiento del script sobre la línea actual. 4. Escribe un script awk que indique cuántos campos contiene cada registro del fichero /etc/passwd. Téngase en cuenta que este fichero contiene una línea por cada usuario con 7 campos separados por dos puntos ":". Cada uno de los campos almacena la siguiente información: • • • • • • • Nombre de usuario: una cadena de entre 1 y 32 caracteres de longitud. Contraseña encriptada: secuencia de caracteres. Identificador de usuario (UID): 3 dígitos. Identificador del grupo principal (GID): 3 dígitos. Información de usuario: nombre del usuario y otra información. Directorio de trabajo del usuario: el directorio home del usuario. El intérprete de comandos del usuario. Una línea de ejemplo de este fichero sería la siguiente: logusuario1:ZXSREEESSRRTGLOUTJBABX:500:501:Amancio González Rubio: /home/logusuario1:/bin/bash 5. Escribe un script awk que indique el número de líneas, palabras y caracteres del fichero de texto que recibe como entrada. 6. Escribe un script que permita validar direcciones IP. Las direcciones IP están formadas por cuatro secuencias de dígitos separadas por puntos, por ejemplo 192.168.1.0 es una dirección IP válida. La dirección IP se recibe como una cadena de caracteres, pero cada una de estas secuencias debe almacenarse por separado como un número entero. Estas secuencias deben estar entre 0 y 255. Si la dirección IP no es válida, se almacenarán cuatro ceros. Escribe el script en Perl y PHP. 316 L ENGUAJES DE script 7. Escribe un script que permita validar fechas. Tendrá como entrada una fecha en formato string y devolverá la misma fecha indicando si es o no válida. Se permiten como entrada los siguientes formatos de fechas: "15/1/1998", "15/01/2001", "1209-98", "22-3-08". Cuando el año se especifique con dos dígitos, se referirá a fechas entre 1960 y 2059. Escribe el script en PHP. 8. Reescribe el código del ejercicio resuelto 14 en PHP. 9. Reescribe el código del ejercicio resuelto 15 en PHP. 10. Completa el código PHP del ejercicio resuelto 19. 11. Escribe un script que implemente un analizador de textos que permita obtener las siguientes estadísticas de un fichero de texto que se pase como argumento de entrada: (a) Número total de caracteres. (b) Número de caracteres que no son espacios. (c) Número de símbolos que no son letras, dígitos, espacios o el carácter "_". (d) Número de palabras. (e) Longitud media de las palabras del texto. (f) Número de oraciones, considerando que las oraciones terminan siempre en ".", "?" o "!". (g) Número medio de palabras por oración. (h) Porcentaje de palabras útiles, teniendo en cuenta que se considerarán palabras vacías de contenido (stop words) a las siguientes: la, le, lo, el, las, los, por, para, un, una, con, y, o, pero, a, de. Además, de cada palabra se desea tener un registro de las oraciones donde aparece con el objetivo de poder realizar búsquedas rápidamente. Sin embargo, dado que esta asociación puede resultar en un ingente uso de memoria, para este registro se filtrarán aquellas palabras de longitud menor que cuatro caracteres. 5.6 Notas bibliográficas Para la elaboración de este capítulo se han consultado diversas fuentes, entre las que destaca [25]. La página web de bash es http://www.gnu.org/software/bash/bash.html, desde la que se puede acceder al manual. También en [22] el lector puede encontrar una descripción exhaustiva de la shell bash. N OTAS BIBLIOGRÁFICAS 317 En http://en.wikipedia.org/wiki/Comparison_of_computer_shells se pueden encontrar tablas comparativas de diferentes interpretes de comandos o shells. En http://sed.sourceforge.net/sedfaq.html hay una FAQ muy útil sobre la herramienta sed, con varias referencias a manuales de libre acceso, y se pueden encontrar numerosos ejemplos de uso de sed en http://sed.sourceforge.net/sed1line.txt. Además, en http://www.gnu.org/software/sed/manual/sed.html#sed-Programs se puede encontrar un manual de sed. El lector que quiera profundizar en awk puede encontrar una buena fuente en [2]. En http://www.gnu.org/software/gawk/manual/gawk.html está disponible el manual de usuario de awk. En http://www.perl.org/ se encuentra el sitio de Perl con las versiones para diferentes plataformas y una amplia documentación sobre el lenguaje. En http://www.php.net/manual/en/index.php hay un manual de PHP en inglés y desde esta misma página se puede enlazar a la versión en español. Desde ahí se puede acceder a http://www.php.net/manual/es/refs.xml.php donde se pueden encontrar extensiones para manipular documentos XML con las APIs XML de PHP 5. Una referencia bastante completa para el lenguaje PHP es [10]. Capítulo 6 Aspectos pragmáticos de los lenguajes de programación En este capítulo se pretende dar una visión general sobre aspectos pragmáticos de los lenguajes de programación, aquellos que resultan clave en la elección de un determinado lenguaje frente a otro, a la hora de crear una programa informático o de estudiar la interoperabilidad entre aplicaciones escritas en diferentes lenguajes de programación. La idea es presentar los criterios fundamentales que permitan definir o pensar en un buen lenguaje de programación, ya sea para seleccionarlo como herramienta de desarrollo o pensando en el diseño de un nuevo lenguaje. 6.1 Introducción La pragmática se define como la rama de la lingüística interesada en la manera en que el contexto influye en la interpretación del significado. Cuando se habla de lenguajes de programación, la pragmática se encarga de las técnicas y tecnologías empleadas durante la construcción de programas. Por tanto, se refiere a la relación entre el lenguaje de programación y sus usuarios, los programadores. A la hora de abordar el desarrollo de un gran proyecto informático, aparte de aspectos relativos a la ingeniería del software como la planificación del proyecto, el análisis de requisitos, etc., se considera algo fundamental el conocimiento y comprensión de la herramienta de trabajo que, en este caso, no es otra que el lenguaje de programación utilizado. 6.2 Principios de diseño de los lenguajes Una cuestión que surge de forma natural cuando se inicia el estudio de los lenguajes de programación es si existe un lenguaje perfecto. Si existiese tal lenguaje, entonces 319 320 A SPECTOS PRAGMÁTICOS DE LOS LENGUAJES DE PROGRAMACIÓN sería importante identificar esas características que lo hacen el más adecuado para el desempeño de una determinada tarea. Existe un conjunto de principios de diseño comúnmente aceptados que hacen de un lenguaje de programación un buen lenguaje ([11], [18]). No todos los lenguajes de programación incluyen todos estos principios; la razón es que ciertos lenguajes están orientados hacia un dominio específico y priman, por ejemplo, la usabilidad en ese contexto frente a la inclusión de otros principios que podrían limitar, de alguna forma, esa usabilidad. En general, en este apartado nos centraremos en los lenguajes de alto nivel, o de tercera generación. Como se ha visto a lo largo de los capítulos anteriores, existen multitud de lenguajes de alto nivel que, en principio, podrían utilizarse para realizar cualquier tarea que un programador necesitara llevar a cabo. La realidad, sin embargo, es que ciertos lenguajes se utilizan más en ciertos contextos que otros, y un programador debería ser capaz de escoger el lenguaje que más se adecúe a sus necesidades, siempre teniendo en cuenta factores que pueden influir en la decisión, como la capacidad del equipo de trabajo de asumir el aprendizaje de un nuevo lenguaje, etc. Por poner un ejemplo, en general el lenguaje C se utiliza en la programación de sistemas, donde son necesarias ciertas capacidades de bajo nivel como en el caso del desarrollo de sistemas operativos. Java y PHP, por el contrario, son lenguajes comúnmente utilizados para el desarrollo de aplicaciones web. Así, es raro encontrar aplicaciones web programadas en C, aunque podría haberlas. A continuación se presentan algunos principios de diseño relacionados con los lenguajes de programación. Es difícil que un lenguaje de programación los incluya todos, sin embargo la presencia de unos u otros puede ser de ayuda a la hora de evaluar el lenguaje. 6.2.1 Claridad y sencillez Existe un Principio de la Claridad que establece: “Dale el mismo valor a la claridad que a la corrección. Utiliza activamente técnicas que mejoran la claridad del código. La corrección vendrá casi por sí sola”. A partir de este principio se establece que un lenguaje de programación debe ser claro y sencillo, de forma que los programas escritos en ese lenguaje sean fáciles de leer y escribir. ¿Es fácil para un programador escribir un programa en el lenguaje? ¿Hasta qué punto es inteligible este programa para un lector promedio? ¿Es fácil aprender y enseñar el lenguaje? La claridad tiene que ver con lo fácil que es comprender lo que un fragmento de código hace. La sencillez, sin embargo, tiene más que ver con la capacidad de abstracción del lenguaje. ¿Tengo que escribir cien líneas de código para buscar un elemento dentro de una lista, o me basta con cuatro o cinco líneas? La sencillez también tiene que ver con cosas como: ¿tiene el lenguaje cinco o seis construcciones que hacen prácticamente lo mismo y sólo se diferencian en ligeros detalles? Si es así, el lenguaje es más complejo de lo que sería deseable. P RINCIPIOS DE DISEÑO DE LOS LENGUAJES 321 6.2.2 Fiabilidad Un programa se considera fiable si hace lo que se espera de él. Los lenguajes que fomentan la escritura de programas fiables deberían permitir detectar y corregir rápidamente los errores que se producen. Por ejemplo, la sentencia goto es una característica no fiable de un lenguaje. La permisividad de C al poder especificar una asignación en cualquier parte hace que en ocasiones se sustituya un == (comparación por igualdad) por = (asignación) en una condición de un if por ejemplo. Como los símbolos son muy parecidos este error puede pasar inadvertido hasta después de muchas revisiones del código. Además, un lenguaje fiable debe permitir al programador recuperarse de errores que se puedan producir en ejecución. Por ejemplo, el programador puede no desear que el programa termine anómalamente simplemente porque no existe un fichero, porque en su lugar el programa podría preguntar al usuario dónde se encuentra éste o si quiere crearlo en ese momento. Lo mismo sucede si en una entrada el usuario especifica un valor no válido (una letra cuando se esperaba un número o un cero como denominador de una división), el comportamiento deseado puede ser avisar del error cometido al usuario y pedirle que introduzca una entrada válida. 6.2.3 Ortogonalidad La ortogonalidad se refiere al comportamiento homogéneo de las características de un lenguaje. ¿Un símbolo o una palabra reservada tienen siempre el mismo significado, independiente del contexto en el que se utilicen? ¿Tiene el lenguaje un pequeño número de características básicas que interactúan de forma predecible, sin importar cómo se combinen en un programa? Si un lenguaje es ortogonal quiere decir que las características que presenta pueden combinarse entre ellas comportándose de igual manera en cualquier circunstancia. Un ejemplo se encuentra en los conceptos de tipo y función. Un tipo describe la estructura de los elementos de datos y una función es un subprograma que recibe un número finito de valores de parámetro y devuelve un único valor hacia el subprograma que la invoca. En un lenguaje ortogonal, los tipos son independientes de las funciones, y no se aplican restricciones a los tipos de parámetros que pueden ser pasados o al tipo de valor que puede ser devuelto. Así, idealmente podríamos ser capaces de pasar una función a una función, y recibir una función de regreso. Un ejemplo claro de falta de ortogonalidad se da en el lenguaje Pascal, donde no se permite que las funciones devuelvan registros, lo que rompe la ortogonalidad en cuanto a la combinación de tipos de datos y funciones. Uno esperaría que una función pudiera devolver cualquier tipo de dato sin excepciones. 6.2.4 Generalidad La generalidad se refiere a que se dejan en el lenguaje sólo las características necesarias, y el resto se construyen a partir de éstas combinándolas sin limitación de una manera previsible. 322 A SPECTOS PRAGMÁTICOS DE LOS LENGUAJES DE PROGRAMACIÓN Como ejemplo de carencia de generalidad se tiene, por ejemplo, el tipo de unión libre en Pascal, un registro que puede tener un campo que se denomina campo variante, y que varía dependiendo de su uso. En un registro de esta clase el campo variante puede funcionar como un puntero, mientras que en otro momento durante la misma ejecución puede ser usado como un tipo entero, con lo que su valor estaría disponible para operaciones aritméticas, etc. Esta característica no es general, porque la ubicación en memoria relacionada con las variables de campo variante no se trata de manera uniforme, por lo que pueden aparecer efectos no previsibles. 6.2.5 Notación Con frecuencia los lenguajes de programación toman de las matemáticas muchos de sus símbolos. Por ejemplo, para especificar una expresión aritmética se usan los operadores conocidos +, −. Esta notación es consistente con los conocimientos que tenemos a priori (antes de aprender el lenguaje) y ayuda al programador a entender el lenguaje. Por tanto, en general, la notación debería basarse en conocimiento que ya existe. Sin embargo, en ocasiones es complicado utilizar determinados símbolos, porque el sistema podría no soportarlos. Es el caso de símbolos como φ (conjunto vacío) o ∈ (pertenencia a un conjunto). 6.2.6 Uniformidad La idea de lenguaje uniforme es que nociones similares deberían verse y comportarse de la misma manera. Si un lenguaje exige que toda sentencia acabe con un punto y coma, se dice que es uniforme. Un ejemplo de no uniformidad lo podemos observar en el lenguaje Pascal. En este lenguaje en el cuerpo de un enunciado for sólo se permite una sentencia. Para incluir más de una sentencia es necesario encerrarlas entre un begin y un end. Sin embargo, en el cuerpo de un enunciado repeat ...until se pueden incluir múltiples sentencias. 6.2.7 Subconjuntos En ocasiones es útil poder construir subconjuntos funcionales de un lenguaje. Un subconjunto de un lenguaje es una implementación de sólo una parte del mismo (por ejemplo, C++ sin objetos). Los subconjuntos son útiles en ámbitos académicos, porque permiten focalizar el aprendizaje en unas características dejando de lado otras hasta que las primeras se han aprendido. También tiene utilidad para desarrollar incrementalmente el lenguaje. Java, por ejemplo, ha evolucionado dos veces desde la versión inicial. La versión actual (cuarta versión del lenguaje) incluye características que no estaban presentes en versiones anteriores, sin embargo programas escritos en la nueva versión son compatibles con código de versiones anteriores y pueden utilizar dicho código. P RINCIPIOS DE DISEÑO DE LOS LENGUAJES 323 Una ventaja de los subconjuntos es el desarrollo incremental de un lenguaje. La versión inicial de un lenguaje puede tener un núcleo de funciones pequeño, mientras que otras características van siendo incorporadas a medida que se van desarrollando. 6.2.8 Portabilidad La portabilidad es una característica por la cual un programa escrito en un determinado lenguaje puede ejecutarse en diferentes máquinas sin tener que reescribir el código fuente. Se trata entonces de la facilidad para ejecutar programas en distintos entornos lógicos o físicos. Para conseguir la portabilidad son de vital importancia las organizaciones de estándares, como ANSI o ISO. Estas organizaciones fijan definiciones de lenguajes a las que se pueden adherir los constructores de compiladores para asegurar que el mismo programa podrá ser compilado en diferentes arquitecturas. Los lenguajes pensados para ser ejecutados por un intérprete o por medio de una máquina virtual habitualmente suelen ser portables. 6.2.9 Simplicidad Un lenguaje de programación para considerarse simple debería contener la menor cantidad posible de conceptos. Además deberían evitarse aquellos conceptos que pueden llevar al programador a errores (= y ==), que son difíciles de entender al leer el código, o que son difíciles de traducir por parte de un compilador. Un lenguaje de programación debe esforzarse en la simplicidad sintáctica y semántica. Simplicidad semántica implica que el lenguaje contiene un mínimo número de conceptos y estructuras. Estos conceptos deben resultar naturales para un programador, de rápido aprendizaje y comprensión, tratando de minimizar errores de interpretación. Para ello es deseable tener un número mínimo de conceptos diferentes, con las reglas de combinación lo más simples y regulares posibles. Esta claridad semántica y de conceptos representa un factor determinante para la selección de un lenguaje. Por otro lado, la simplicidad sintáctica requiere que la sintaxis represente cada concepto de una única forma y que el código resulte tan legible como sea posible. 6.2.10 Abstracción Los lenguajes de programación, como se ha visto, pueden clasificarse por su nivel de abstracción. La abstracción es un principio por el cual se aísla toda aquella información que no resulta relevante a un determinado nivel de conocimiento. Una característica fundamental para la reutilización de código es el permitir crear y cargar librerías durante el proceso de desarrollo de un programa. Un buen lenguaje de programación debería ofrecer mecanismos para abstraer patrones recurrentes. La programación orientada a objetos, por ejemplo, introduce un nivel más de abstracción que 324 A SPECTOS PRAGMÁTICOS DE LOS LENGUAJES DE PROGRAMACIÓN la programación imperativa. Otros ejemplos de abstracción son las librerías de lenguajes como C y Java. 6.2.11 Modularidad Su objetivo principal es resolver un problema más o menos complejo, dividiéndolo en otros más sencillos, de modo que luego puedan ser enlazados convenientemente y nos den la solución del problema original. Cada subproblema se representará mediante uno o varios módulos según su complejidad. La idea es que estos módulos sean independientes, es decir, que se puedan modificar o reemplazar sin afectar al resto del programa o que puedan ser reutilizados dentro de otros programas. La modularidad permite desarrollar los programas como unidades independientes. Estas unidades interaccionan unas con otras de alguna manera (APIs, librerías, etc.). Los lenguajes que soportan el concepto de módulo (también llamados paquetes, o unidades) son más escalables, porque diferentes módulos pueden evolucionar de manera separada. Los módulos deben tener interfaces bien definidas para que puedan utilizarse desde otros módulos. 6.2.12 Ocultación de información La ocultación de información permite discernir entre qué partes de la abstracción están disponibles al resto de la aplicación y qué partes son internas a la abstracción. Los módulos y otras construcciones del lenguaje deberían proporcionar ocultación de información. El programador que está haciendo uso de una determinada librería no está interesado en cómo esa librería hace su trabajo, sólo en cómo debe usarla. La ocultación de información proporciona además seguridad evitando que otros módulos tengan acceso a información que no deberían. 6.3 Interacción e interoperabilidad En general, los sistemas de información necesitan comunicarse e intercambiar información entre sí para mejorar su productividad [31]. Este es el objetivo fundamental del concepto de interoperabilidad entre sistemas. Según el glosario de la Dublin Core Metadata Initiative, la interoperabilidad se define como la habilidad que tienen diferentes tipos de computadoras, redes, sistemas operativos y aplicaciones para trabajar conjuntamente de manera efectiva, sin comunicaciones previas, y con el fin de intercambiar información de una manera útil y con sentido1 . Además, se fijan tres aspectos que deben tenerse en cuenta: la interoperabilidad semántica, estructural y sintáctica. 1 http://www.sedic.es/glosario_DCMI.pdf I NTERACCIÓN E INTEROPERABILIDAD 325 Por medio de la interoperabilidad semántica los sistemas se intercambian mensajes entre sí, interpretando el significado y el contexto de los datos. Se puede entender como la capacidad de los sistemas para intercambiar información basándose en un significado común de términos y expresiones, con el fin de asegurar la consistencia, representación y recuperación de los contenidos. La interoperabilidad estructural se corresponde con los modelos lógicos comunes y con la capacidad de los sistemas para comunicarse e interactuar en ambientes heterogéneos. Esto incluye la definición y utilización de protocolos especializados. La interoperabilidad sintáctica supone la capacidad de un sistema para leer datos de otros sistemas y obtener una representación compatible. Esto se consigue por medio del uso de formatos o modelos estandarizados de codificación y estructuración de documentos y metadatos. Para ello suelen emplearse lenguajes o metalenguajes estructurados, como XML, junto con modelos de metadatos estandarizados, como Dublin Core, y cuyos elementos representan la sintaxis común entre los diferentes sistemas. Pero la interoperabilidad va más allá de la mera adecuación de estándares e interfaces. Según definición del W3C, es la capacidad de un sistema o producto de trabajar en conjunción con otros sistemas sin un esfuerzo especial por parte del usuario. En los siguientes apartados se profundizará en las características de la interoperabilidad a nivel de aplicación y a nivel de lenguaje. Sin embargo, hay que hacer notar que ambos conceptos se entremezclan constantemente, ya que si dos aplicaciones o sistemas están desarrollados en lenguajes de programación diferentes, entonces la interoperabilidad de ambos sistemas es, a la vez, un ejemplo de interoperabilidad a nivel de aplicación y a nivel de lenguaje. 6.3.1 Interoperabilidad a nivel de aplicación La interoperabilidad entre aplicaciones no incluye únicamente la habilidad o capacidad de éstas para intercambiar información, sino también su capacidad de interacción y la ejecución de tareas conjuntas. Uno de los mayores problemas que nos encontramos en la intercomunicación entre aplicaciones es cómo plantear la representación de la información enviada de un sistema a otro. Dos aplicaciones nativas que intercambien información, y desarrolladas sobre dos plataformas distintas, deberán establecer previamente la representación de la información utilizada. Por ejemplo, una variable entera en el lenguaje C puede tener una longitud y representación binaria distinta en cada una de las plataformas. A la complejidad de definir la representación de la información y traducir ésta a su representación nativa, se le une además la tarea de definir el protocolo de comunicación, es decir, el modo en el que las aplicaciones deben comunicarse para intercambiar la información. Los servicios web son ejemplos evidentes de interoperabilidad a nivel de aplicación. Un servicio web es un conjunto de protocolos y estándares que sirven para intercambiar datos entre aplicaciones. Distintas aplicaciones software desarrolladas en diferentes lenguajes de programación, y ejecutadas sobre cualquier plataforma, pueden utilizar los 326 A SPECTOS PRAGMÁTICOS DE LOS LENGUAJES DE PROGRAMACIÓN servicios web para intercambiar datos dentro de una red. La interoperabilidad se consigue, en este caso, mediante la adopción de estándares abiertos. Las organizaciones OASIS y W3C son las responsables de la arquitectura y reglamentación de los servicios web. La interoperabilidad de los Servicios Web se puede dividir en dos categorías básicas: interoperabilidad SOAP (Simple Object Access Protocol) e interoperabilidad WSDL (Web Services Description Language). SOAP es un protocolo estándar que define cómo dos objetos en diferentes procesos pueden comunicarse por medio de intercambio de datos XML, mientras que WSDL es un formato XML que se utiliza para describir servicios Web. Como vemos, en la actualidad XML tiene un papel muy importante como formato de intercambio de información, ya que permite la compatibilidad entre sistemas para compartir información de una manera segura, fiable y fácil. Por otro lado, y también dentro del mundo de los servicios web, REST (Representational State Transfer) es un modelo de arquitectura software para generar aplicaciones cliente-servidor. Si bien el término REST originalmente se refería a un conjunto de principios de arquitectura, en la actualidad se usa en un sentido más amplio para describir cualquier interfaz web que utilice XML y HTTP, pero sin las abstracciones adicionales de los protocolos basados en patrones de intercambio de mensajes como el protocolo de servicios web SOAP. A continuación se enumeran algunas aproximaciones desarrolladas a lo largo del tiempo para tratar de conseguir interoperabilidad entre aplicaciones. En algunos casos existen especificaciones estándar definidas para interconectar aplicaciones nativas sobre distintas plataformas. • Corba (Arquitectura común de intermediarios en peticiones a objetos, Common Object Request Broker Architecture) representa un estándar que establece una plataforma de desarrollo de sistemas distribuidos, facilitando la invocación de métodos remotos bajo un paradigma orientado a objetos. Entre sus características destaca que provee de un mecanismo estándar para definir las interfaces entre componentes. Permite especificar un número de servicios estándar, de objetos persistentes y de transacciones disponibles para todas las aplicaciones. Provee de los mecanismos necesarios para permitir a los componentes de las diferentes aplicaciones comunicarse entre sí. • COM, DCOM, COM+. El modelo de Objetos de Componentes (Component Object Model, COM), fue introducido por Microsoft en 1993 y es una arquitectura software que permite construir aplicaciones y sistemas a partir de componentes binarios suministrados por diferentes proveedores de software. A menudo cuando se habla de COM se están considerando el conjunto de tecnologías OLE (Object Linking and Embedding), OLE Automation, ActiveX, COM+ y DCOM (Distributed Component Object Model). I NTERACCIÓN E INTEROPERABILIDAD 327 En general se trata de estándares que permiten la incrustación y vinculación de objetos, ya sean imágenes, clips de vídeo, sonido MIDI, animaciones, . . . dentro de ficheros que pueden ser documentos, bases de datos, hojas de cálculo, etc. Con esta tecnología se permite, por ejemplo, vincular en una aplicación de envío de correo electrónico un objeto correspondiente a un documento de texto, lo que permitiría aplicar la función de corrección ortográfica al contenido del mensaje a enviar, siempre en el caso de que la función de corrección ortográfica fuera una función pública del componente correspondiente al documento de texto. El lenguaje XML, descrito en detalle en el capítulo 4, se describe como un medio para lograr la interoperabilidad de sistemas, debido al hecho de que provee medios de autodescripción para representar los datos usados y compartidos entre aplicaciones [4]. Aunque no ofrece una facilidad real de computación distribuida como las ofrecidas por Corba o COM+, provee soporte para lograr interoperabilidad entre sistemas desarrollados independientemente. 6.3.2 Interoperabilidad a nivel de lenguaje La interoperabilidad entre lenguajes es la posibilidad de que un determinado código interactúe con otro código escrito en un lenguaje de programación diferente. Este tipo de interoperabilidad puede ayudar a maximizar la reutilización de código y, por tanto, permite mejorar la eficacia del proceso de desarrollo. Pero dado que los programadores utilizan, como ya se ha visto, una gran variedad de herramientas y tecnologías, y cada una de ellas puede admitir distintos tipos de datos y características, se hace complicado garantizar la interoperabilidad entre lenguajes. Una primera dificultad a la hora de lograr la interoperabilidad entre componentes heterogéneos es que éstos suelen desarrollarse de modo independientemente, sin ningún requisito inicial de interoperabilidad. Por tanto, los sistemas que se quiere hacer interoperar tienen diferentes arquitecturas, sistemas operativos, lenguajes máquina y modelos de datos, lo que implica la dificultad de tener que desarrollar un nuevo sistema considerando los requisitos consolidados de los diferentes componentes individuales. Un ejemplo de tecnología planteada para tratar de conseguir interoperabilidad a nivel de lenguaje es Microsoft .NET, un framework pensado para resolver problemas comunes de interoperabilidad, tanto a nivel de lenguaje como de aplicaciones. Provee algunas capacidades que soportan interoperabilidad de sistemas y componentes, incluyendo un lenguaje común en tiempo de ejecución, el CLR (Common Language Runtime), sucesor de la tecnología COM, y que es un lenguaje intermedio que elimina diferencias en la implementación de componentes de sistema, constituyendo un mecanismo de interoperabilidad que permite a los programas .NET acceder a código previo. Los principales componentes de .NET son: el conjunto de lenguajes de programación (VisualBasic, C, ActiveX, . . . ), la biblioteca de clases base (o BCL) y el entorno común de ejecución para la ejecución de programas. 328 A SPECTOS PRAGMÁTICOS DE LOS LENGUAJES DE PROGRAMACIÓN Otro ejemplo de interoperabilidad a nivel de lenguaje lo encontramos en la máquina virtual de Java, o JVM. Como ya hemos visto en capítulos anteriores, la JVM es una máquina virtual de aplicación capaz de interpretar y ejecutar instrucciones expresadas en el código intermedio de Java (bytecode) sobre diferentes plataformas hardware. Aunque originalmente se diseñó para ejecutar programas Java compilados a bytecode, éste puede ser compilado también a partir de otros lenguajes de programación diferentes; por ejemplo, un código fuente escrito en ADA puede compilarse primero y ejecutarse a continuación en una JVM. Entre los lenguajes que pueden ser ejecutados por la JVM (lo que se conoce como JVM Languages) destacan los siguientes: Clojure, un dialecto de Lisp; Groovy, un lenguaje de script con características similares a Python, Ruby, Perl y Smalltalk; Scala, un lenguaje funcional y orientado a objetos; JavaFX Script, un lenguaje de script pensado en el desarrollo de aplicaciones visuales; JRuby, una implementación de Ruby para Java; Jython, una implementación de Python para Java; Rhino, una implementación de JavaScript; o AspectJ, que supone una extensión de Java orientada a aspectos; entre otros. En estos casos, la JVM verifica los códigos generados en bytecode antes de su ejecución, evitando así problemas comunes de programación. Principalmente se chequean aspectos relacionados con la inicialización de variables, acceso a métodos o atributos privados, etc. Actualmente, la utilización de una capa software intermedia es la solución más extendida para conseguir interoperabilidad entre aplicaciones desarrolladas en distintos lenguajes de programación o distintas arquitecturas hardware. La traducción entre modelos de componentes, middlewares de distribución o arquitecturas de objetos distribuidos, otorga la posibilidad de intercomunicar aplicaciones desarrolladas en distintos lenguajes de programación, sistemas operativos y plataformas. Sin embargo, la utilización de estas capas adicionales conlleva determinados inconvenientes como: una mayor complejidad a la hora de desarrollar aplicaciones, necesidad de realizar múltiples traducciones entre distintos modelos computacionales, dependencia de los mecanismos utilizados (lo que se conoce como acoplamiento), y reducción de la mantenibilidad ante futuros cambios [8]. 6.4 Lenguajes embebidos En ocasiones existe la posibilidad de programar, dentro de un determinado programa, alguna parte escrita en otro lenguaje de programación, y con ello se pretende simplificar el desarrollo software. Una forma de integrar en un mismo sistema códigos escritos en diferentes lenguajes de programación es por medio de lenguajes embebidos. Se dice que un lenguaje N está embebido dentro de un lenguaje E si una expresión en N puede ser usada como subexpresión de una construcción en E. Es decir, un código embebido es parte de un código fuente escrito en otro lenguaje de programación, pero que se incluye en el programa. L ENGUAJES EMBEBIDOS 329 Un típico ejemplo de código embebido es cuando se desea acceder a una base de datos desde dentro de un programa. En este caso, un código SQL embebido intercala sus instrucciones en el código de un programa escrito en un lenguaje de programación al que se denomina lenguaje anfitrión, y que puede ser un lenguaje como FORTRAN, COBOL, C, . . . Estas instrucciones se ejecutan en el momento en que se ejecuta el programa invitado de acuerdo a su lógica interna. En este contexto, el intercambio de información con el Sistema de Gestión de Base de Datos (SGBD) se realiza a través de variables del lenguaje, a las que se denomina variables huéspedes; por ejemplo, el resultado de las consultas se asignaría a variables del programa declaradas con ese fin. La forma de construir un programa con SQL embebido varía dependiendo del lenguaje y el SGBD utilizados. Por ejemplo, en el caso de que se deseara embeber un códido SQL en un programa escrito en C y accediendo a una base de datos Oracle, se ha definido un lenguaje especial para soportar este tipo de programación: lenguaje Pro*C. Otro claro ejemplo de uso de lenguajes embebidos es el caso de las páginas web dinámicas. En este caso se introducen códigos en algún lenguaje de programación dentro del código HTML, de modo que el navegador o un servidor web, dependiendo de si ejecutamos en el lado del cliente o del servidor, además de mostrar el contenido del documento HTML siguiendo la semántica de las etiquetas HTML, ejecuta el código embebido e intercala la salida producida por el código en el código HTML final. Hay numerosos ejemplos de lenguajes web embebidos, entre los que destacan, entre otros: • Código CSS (Cascading Style Sheets) embebido en HTML, SVG (Scalable Vector Graphics) y otros lenguajes XML. Se trata de hojas de estilo en cascada. Con ellas se describe cómo se va a mostrar un documento en la pantalla o cómo se va a imprimir; incluso cómo va a ser pronunciada la información presente en ese documento a través de un dispositivo de lectura. • Código Javascript embebido en código HTML. De este modo, el código final HTML que el usuario visualiza en su navegador es la unión de una parte HTML estática y una dinámica, generada por el propio navegador al interpretar en local el código Javascript. • Código embebido en HTML de ejecución en el lado del servidor: – ASP (Active Server Pages). Se trata de una tecnología desarrollada por Microsoft para la creación dinámica de páginas web y ofrecida junto a su servidor Internet Information Server. – JSP (JavaServer Pages). Esta tecnología Java permite generar contenido dinámico para web, en forma de documentos HTML, XML o de otro tipo. Las JSPs permiten la utilización de código Java mediante scripts. Además, es posible utilizar algunas acciones JSP predefinidas mediante etiquetas. En este modelo, y de forma similar al caso de las páginas ASP, es posible usar 330 A SPECTOS PRAGMÁTICOS DE LOS LENGUAJES DE PROGRAMACIÓN clases java previamente definidas. Además, es posible crear aplicaciones web que se ejecuten en diferentes servidores web de múltiples plataformas, ya que Java es un lenguaje multiplataforma. – PHP (Hypertext Preprocessor). Lenguaje de script usado principalmente para ejecutar en servidores web, sobre todo Apache. Puede verse como la alternativa open-source a ASP. En el capítulo 5 se ofrece una breve descripción del lenguaje. A continuación se muestra un ejemplo sencillo de código JSP embebido en un código HTML que muestra la fecha y hora actuales. Se puede ver la llamada al constructor de la clase Date() en la línea 19, clase que se encuentra dentro del paquete java.util, y que devuelve la fecha y hora actuales. El resultado de la ejecución del código JSP pasa a formar parte del código final HTML que se visualiza en el navegador del cliente. 1 2 3 4 5 6 7 <html > <body > <p > & nbsp ; </p > < div align = " center " > < center > < table border = "0" cellpadding = "0" cellspacing = "0" width = " 460 " bgcolor = "# EEFFCA " > 8 9 10 11 <tr > <td width = " 100% " >< font size = "6" color = " #008000 " >& nbsp ; Date Example </ font > </ td > 12 13 14 15 16 </tr > <tr > <td width = " 100% " ><b >& nbsp ; Current Date and time is :& nbsp ; < font color = "# FF0000 " > 17 18 19 20 21 22 23 24 25 26 <%= new java . util . Date () %> </ font > </b > </ td > </tr > </ table > </ center > </div > </ body > </ html > C RITERIOS DE SELECCIÓN DE LENGUAJES 331 6.5 Criterios de selección de lenguajes Los criterios de selección de lenguajes de programación están muy relacionados con los principios de diseño que se han detallado en el apartado 6.2, ya que tanto a la hora de diseñar un lenguaje de programación, como de seleccionarlo como herramienta de desarrollo software, un programador debe tener en cuenta las características permanentes de los lenguajes, así como las características no incluídas en un lenguaje pero con mecanismos que faciliten su inclusión. A continuación, se resumen algunos de los criterios que pueden tenerse en cuenta a la hora de seleccionar un lenguaje de programación para su aplicación en un desarrollo software: • Concisión notacional. El lenguaje debe permitir describir algoritmos con el nivel de detalle adecuado y deseado por el programador. • Integridad conceptual. Si el lenguaje proporciona un conjunto de conceptos simple, claro y unificado. Lo ideal sería disponer de un conjunto mínimo de conceptos diferentes, con una reglas lo más simples y regulares posibles que faciliten su combinación. • Ortogonalidad. Como se ha indicado anteriormente, dos características de un lenguaje son ortogonales si pueden ser comprendidas y combinadas de forma independiente. • Generalidad. Todas las características del lenguaje son generadas a partir de conceptos básicos conocidos por el programador. • Automatización. El lenguaje debe permitir al programador la automatización de tareas mecánicas, tediosas o susceptibles de producir errores. • Portabilidad. El lenguaje puede permitir que sus programas puedan ejecutarse en diferentes tipos de máquinas. • Eficiencia. Antes de seleccionar un determinado lenguaje de programación frente a otro es importante ver la eficiencia, tanto de los programas en ejecución como de las diferentes herramientas de procesamiento del lenguaje. • Entornos de desarrollo. En muchos casos, la elección de un lenguaje responde a la existencia de un entorno de desarrollo potente. De un modo similar, la existencia de documentación, de ejemplos de programas, foros activos de programación en dicho lenguaje, etc. pueden resultar factores clave de selección. Por otro lado, los diferentes paradigmas en los que se pueden clasificar los lenguajes de programación también pueden suponer un criterio claro de selección, ya que un determinado lenguaje de programación puede fomentar el uso de determinados paradigmas o disuadir del uso de otros. 332 A SPECTOS PRAGMÁTICOS DE LOS LENGUAJES DE PROGRAMACIÓN Por ejemplo, los lenguajes enmarcados dentro de la programación imperativa, como se ha visto, prestan especial atención a la secuencia de órdenes que el programador debe establecer para resolver un programa, en contraposición a lo que ofrece un lenguaje declarativo. Si se elige un lenguaje funcional, se está asumiendo el uso de la función como elemento central a la hora de resolver un problema. Recordemos que una de las características de los lenguajes funcionales es el uso de funciones de orden superior, es decir, que toman como argumento otras funciones. Estas características condicionan el planteamiento de un programador frente a la resolución de un problema. Si, por el contrario, el programador prefiere centrase en las relaciones entre los elementos que intervienen en el problema, entonces los lenguajes lógicos serían los adecuados. Por último, si se selecciona un lenguaje orientado a objetos se está asumiendo la resolución del problema por medio de la definición de objetos que intervienen y que se comunican entre sí por medio de mensajes. Dependiendo del problema que se quiera resolver, un programador podrá decantarse por el uso de un lenguaje u otro. Por otro lado, y como en el resto de ramas de la ingeniería, a la hora de resover un problema pueden aplicarse técnicas genéricas junto con técnicas de caracter específico. En este caso, ambos enfoques se corresponderían con el empleo de lenguajes de propósito general o específico. La aplicación de técnicas genéricas supone soluciones más generales, aunque no suelen ser óptimas, mientras que la aplicación de técnicas específicas aportan soluciones que se adecúan más a la solución deseada, pero para un conjunto más reducido de problemas. También, dependiendo de si el objetivo es la programación de sistemas o controladores (drivers) o si, por el contrario, se desean realizar tareas de control o auxiliares, el programador deberá de elegir entre, por ejemplo, un lenguaje como C o un lenguaje de script. El lugar de ejecución de un programa también es un aspecto a tener en cuenta para la selección de un lenguaje frente a otro. No es lo mismo desarrollar un programa para ser ejecutado en un dispositivo móvil que un programa para un servidor web. La concurrencia también puede ser un aspecto a tener en cuenta en la selección de un lenguaje de programación. Se dice que un programa es concurrente cuando tiene un conjunto de procesos autónomos que se ejecutan en paralelo, dependiendo de las limitaciones hardware. 6.6 Ejercicios resueltos 1. ¿Es posible aplicar la modularidad en un lenguaje de embebido? Solución: Sí, es posible. Por ejemplo, por medio de librerías Java llamadas desde código JSP incrustado en un documento HTML, o por medio de componente ActiveX llamado desde código ASP. 2. Expresar gráficamente el esquema de funcionamiento de un servicio web. E JERCICIOS PROPUESTOS 333 Solución: Figura 6.1. 3. Enumerar alguna características del lenguaje C que muestre falta de ortogonalidad. Solución: Por ejemplo, en C una función puede recibir un array pero no puede devolverlo directamente. Esta característica muestra una clara falta de ortogonalidad en el lenguaje. Figura 6.1: Esquema de funcionamiento de un Web Service 6.7 Ejercicios propuestos 1. Enumera las características del lenguaje PHP basándote en los principio de diseño expuestos en el apartado 6.2. 2. Enumera las principales diferencias de PHP frente a ASP y JSP como lenguajes embebidos en páginas web. 3. Enumera las principales diferencias entre las tecnologías REST y SOAP. 6.8 Notas bibliográficas Para la elaboración de este capítulo se han consultado diferentes fuentes. La principal referencia bibliográfica ha sido [21]. Bibliografía [1] Alfred V. Aho. Compiladores. Principios, técnicas y herramientas. Addison Wesley Longman, 2000. [2] Alfred V. Aho, Brian W. Kernighan, y Peter J. Weinberger. The AWK Programming Language. Addison-Wesley, 1988. [3] Max Bramer. Logic Programming with Prolog. Springer, 2005. [4] Tim Bray, Jean Paoli, C. Michael Sperberg-McQueen, Eve Maler, y François Yergeau. Extensible markup language (xml) 1.0 (fifth edition). World Wide Web Consortium, Recommendation REC-xml-20081126, November 2008. [5] Timothy Budd. Introduction to Object-Oriented Programming. Addison Wesley, 2001. [6] Noam Chomsky. Syntactic Structures (2nd Edition). Mouton de Gruyter, 2002. [7] Juan Manuel Cueva Lovelle. Conceptos bï¿ 21 sicos de Procesadores de Lenguaje. Servitec, 1998. [8] Francisco Domínguez Mateos. LIIBUS: Arquitectura de Sistemas Interoperables entre Middlewares Heterogéneos usando Máquinas Abstractas Reflectivas Orientadas a Objetos. Universidad de Oviedo. Departamento de Informática. Tesis doctoral, 2005. [9] John J. Donovan. System Programming. McGraw-Hill, 1972. [10] Matt Doyle. Beginning PHP 5.3. Wiley Publishing, Inc., 2010. [11] Alice E. Fisher y Frances S. Grodzinsky. The Anatomy of Programming Languages. Prentice-Hall International, 1993. [12] David Flanagan y Yukihiro Matsumoto. O’Reilly Media, 2008. The Ruby Programming Language. [13] Brian Goetz, Tim Peierls, Joshua Bloch, Joseph Bowbeer, David Holmes, y Doug Lea. Java Concurrency in Practice. Addison-Wesley Professional, 2006. 335 336 B IBLIOGRAFÍA [14] Charles A. R. Hoare. Quicksort. The Computer Journal, 5(1):10–16, 1962. [15] Allen Holub. Compiler design in C. Prentice-Hall, 1990. [16] Jose Emilio Labra Gayo. Desarrollo Modular de Procesadores de Lenguajes a partir de Especificaciones Semï¿ 21 nticas Reutilizables. Tesis Doctoral. Universidad de Oviedo, 2001. [17] Kenneth C. Louden. Lenguajes de Programación. Principios y práctica. Thomson, 2004. [18] Bruce J. McLennan. Principles of Programming Languages. Oxford University Press, 1987. [19] Michael Morrison. XML al descubierto. Prentice-Hall, 2000. [20] Bryan O’Sullivan, John Goerzen, y Don Stewart. Real World Haskell. O’Reilly Media, 2008. [21] Terrence W. Pratt y Marvin V. Zelkowitz. Lenguajes de Programación. Diseï¿ 21 o e Implementación. Prentice Hall, 1998. [22] Chet Ramey y Brian Fox. The GNU Bash Reference Manual. Network Theory Ltd., 2006. [23] Peter Van Roy y Seif Haridi. Concepts, Techniques, and Models of Computing Programming. MIT Press, 2004. [24] Blas Ruiz, Francisco Gutiérrez, Pablo Guerrero, y Josï¿ 21 Gallardo. Razonando con Haskell. Un curso sobre programaciï¿ 12 n funcional. Thomson, 2004. [25] Michael L. Scott. Programming Language Pragmatics. Morgan Kauffmann, 2009. [26] Leon Sterling y Ehud Shapiro. The Art of Prolog, Second Edition: Advanced Programming Techniques. The MIT Press, 1994. [27] Dave Thomas, Chad Fowler, y Andy Hunt. Programming Ruby. Pragmatic Bookshelf, 2009. [28] Eric van der Vlist. XML Schema. The W3C’s Object-Oriented Descriptions for XML. O’Reilly, 2002. [29] Josï¿ 21 F. Vï¿ 21 lez Serrano, Alberto Peï¿ 12 a Abril, Patxi Gortï¿ 12 zar Bellas, y ï¿ 12 ngel Sï¿ 12 nchez Calle. Diseï¿ 21 ar y programar, todo es empezar. Dykinson, 2011. [30] Adolfo Yela Ruiz, Fernando Arroyo Montoro, y Luis Fernï¿ 12 ndez Muï¿ 12 oz. Programaciï¿ 12 n II. Teorï¿ 12 a y prï¿ 21 ctica del mï¿ 12 dulo de programaciï¿ 21 n concurrente. Departamento de Publicaciones de la Escuela Universitaria de Informï¿ 12 tica de Madrid, 1998. B IBLIOGRAFÍA 337 [31] Carlos Mario Zapata y Guillermo González Calderón. Revisión de la literatura en interoperabilidad entre sistemas heterogéneos de software. Ingeniería e Investigación, 2009.