Lenguajes de Programación y Procesadores

Transcripción

Lenguajes de Programación y Procesadores
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.

Documentos relacionados