Computación y programación funcional

Tekst
0
Recenzje
Przeczytaj fragment
Oznacz jako przeczytane
Czcionka:Mniejsze АаWiększe Aa

3.2 PARADIGMAS CLÁSICOS DE LA PROGRAMACIÓN

Los lenguajes de programación (LP) tienen asociados uno o más paradigmas de programación. Un paradigma en sí es una forma de construir la computación que puede afectar nuestra forma de pensar en la solución a un problema. Esto mismo hace que un LP no pueda escapar de un paradigma, como una persona no puede escapar de la necesidad de un idioma para poder comunicarse.

La idea detrás de que un LP pueda tener más de un paradigma es lograr un incremento en su elasticidad, flexibilidad y dinamismo y por consiguiente, tener una mayor capacidad de resolver diferentes problemas que son más adecuados en unos en vez de otros. Esto lleva a decir que un LP que soporte muchos paradigmas no es simple de aprender, dado que la gramática tiende a ser más compleja en favor de ganar flexibilidad. Por ende, los diseñadores de lenguajes deben buscar un equilibrio en esto. (Porque es una tentación agregar cada vez más características en pos de tratar con un aumento de tipos de problemas. Pero los problemas no disminuyen, y sí puede hacerlo el ánimo y entusiasmo de la persona por aprender dicha herramienta.)

Está claro que esto es difícil de lograr, como también es difícil de conseguir montar un mueble cuando tienes múltiples herramientas a tu disposición, ¿cuál es la mejor? La múltiple elección en todos los aspectos de la vida tiende a ser algo difícil, oculta una gran complejidad y solo la experiencia te puede ayudar a elegir la correcta y hacer desaparecer las demás opciones. Al menos por un periodo limitado de tiempo.

Incluso si eliges la correcta —desde tu punto de vista—, podría ser solo la que tenga mayor probabilidad de éxito, y no la mejor de manera absoluta. En programación lo absoluto es relativo (con correspondencia al sujeto, al programador), y lo relativo, algo difícil de definir; esto quiere decir que no existen cosas que se deban creer como una verdad absoluta, sino, por el contrario, las cosas tienden a ser relativas y, si lo son, entonces entramos en la múltiple elección y eso lo vuelve angustiante. Pero para nuestra suerte, es algo que se aprende a medida que vamos construyendo diversos tipos de software, en otras palabras: cuando adquirimos una mayor experiencia.

Ahora conoceremos los principales o clásicos paradigmas de la programación usando algunos ejemplos de código, y así tendremos una definición exenta de superficialidad.

3.2.1 Programación imperativa

Los lenguajes imperativos vienen inspirados en la idea de secuencialidad de la máquina de Turing y de la arquitectura de von Neumann. Algunos ejemplos de ellos son: C, C++, Java y Python. Prácticamente cualquier lenguaje mainstream permite el paradigma imperativo y es, por tanto, el más importante en la actualidad.

Los conceptos fundamentales son:

• Variable: es la forma de representar los datos en un espacio de memoria. Con esta podemos manipular la computación.

• Secuencia: cada instrucción sigue una secuencia de transformaciones en el tiempo.

• Estados: a través de las variables y la secuencialidad se pueden cambiar de estados en el tiempo, por ejemplo, cada nuevo cambio en el valor de una variable es un cambio de estado.

El paradigma imperativo, por consiguiente, está basado en secuencias de estados que representan las transformaciones de los datos.

Esto se ve con claridad en el problema del máximo común divisor (gcd [greatest common divisor en inglés]), donde dados dos argumentos, a y b, se encuentra el gcd de estos:

1. def gcd(a, b): 2. if a > b: 3. small = b 4. else: 5. small = a 6. for i in range(1, small + 1): 7. if (a % i == 0) and (b % i == 0): 8. result = i 9. 10. return result 11. print(gcd(12, 18)) 12. # 6

En este algoritmo podemos apreciar varias cosas que son imperativas: asignación de variables, iteraciones que representan la secuencialidad y la mutabilidad del resultado (línea 8), es decir, los cambios de estado.

En suma, el paradigma imperativo es el que «explica» mejor la computación. Claro y conciso. ¿Qué significa esto? Puesto que todo es explícito, se puede entender lo que hace leyendo línea a línea, aunque, por supuesto, esto incorpora varios inconvenientes si el software crece (líneas de código). De todas formas, hoy en día es el paradigma estándar para estudiar teoría de algoritmos y complejidad computacional.

No es extraño, por tanto, ver que, en áreas de investigación o de programación de sistemas, muchos de los códigos que se construyen sean implementados en C++ o C haciendo uso de este paradigma. Porque no necesitan nada más, ya que su principal objetivo es lograr la eficiencia por sobre cualquier otra cosa. El código es fácil de leer y, dependiendo del lenguaje, el acceso a características de bajo nivel (poca abstracción para manipular instrucciones del procesador y manejo de memoria) es necesario e indispensable.

La eficiencia ha dado paso al «que funcione como sea» y algunos programadores y empresas solo se preocupan de que el software cumpla con lo mínimo en desmedro de la calidad. Y la calidad es una palabra ligada a la eficiencia. ¿Por qué ocurre esto? La eficiencia es difícil, compleja e intimida a las personas porque requiere de un amplio conocimiento, no solo de la herramienta a usar, sino también de todo el ecosistema donde dicha herramienta debe interactuar. Además, sin dejar de lado la teoría. Quizá deberíamos volver a mirar la «eficiencia» como una palabra venerada en la computación. Los clientes que usen el software lo agradecerán.

3.2.2 Programación orientada a objetos

El paradigma orientado a objetos es un derivado del imperativo y tuvo su origen en la década de los sesenta con Simula y en los setenta con Smalltalk. Su éxito ha sido tal, que hasta el día de hoy se sigue usando en la industria y es uno de los líderes a la hora de programar sistemas empresariales.

En él se intenta representar los objetos del mundo real desde un punto de vista abstracto para, así, poder modelar mejor los sistemas computacionales. Hacerlos escalables no tan solo para cuando se vayan añadiendo más funcionalidades, sino también para cuando un equipo de programadores vaya aumentando o rotando en el ciclo de vida del proyecto.

A diferencia del imperativo, la transición de estados no es simplemente a través de variables con sus respectivos valores, sino sobre «objetos», los cuales encapsulan cierta información del sistema en cuestión.

Los conceptos generales son los siguientes:

• Objetos. Todo es un objeto, lo cual significa que incluso los datos primitivos deben ser vistos como un objeto junto a sus atributos. Además, cada objeto debe tener un tipo. En sí mismo, un objeto es un encapsulador de datos.

• Envío de mensajes. Todos los objetos del mismo tipo pueden intercambiar mensajes.

Muchas de las ideas de la programación orientada a objetos, según uno de sus fundadores, Alan Kay, fueron inspiradas por los sistemas biológicos (células, comunicación entre ellas, ser parte de un sistema interconectado, etc.). Entonces, más que tener simples cambios de estado, en este caso se tienen cambios de estado de objetos, que traen consigo mucha información que se mantiene encapsulada y restringida, para reducir la complejidad. No todos los lenguajes que se consideran orientados a objetos, por ejemplo, Java, siguen estos conceptos en su totalidad. De hecho, el mismo Alan Kay ha sido muy crítico al respecto14.

Generalmente, la forma de trabajar con objetos es a través de lo que conocemos como ADT (abstract data type en inglés; se puede traducir como «tipo de dato abstracto»), o sea, para crear un objeto, decimos «instanciamos un ADT» del objeto X. Y la implementación de un ADT se realiza a través de lo que se llama «clase». Entonces un ADT es la forma en que encapsulamos y ocultamos información.

A continuación, presentamos un ejemplo del paradigma orientado a objetos usando Python como medio para expresar algunos de sus conceptos:

1. class Alphabet(object): 2. def __init__(self): 3. self.letter = 'a' 4. 5. def current(self): 6. return self.letter 7. 8. def next(self): 9. if self.letter < 'z': 10. self.letter = chr(ord(self.letter) + 1) 11. return self.letter 12. 13. def back(self): 14. if self.letter > 'a': 15. self.letter = chr(ord(self.letter) - 1) 16. return self.letter 17. 18. obj = Alphabet() 19. print(obj.current()) # a 20. print(obj.next()) # b 21. print(obj.back()) # a 22. print(obj.back()) # a

Entonces, aquí el ADT es la clase Alphabet y su instancia sería la variable obj (línea 18). Esta clase tiene como objetivo devolver cada letra del alfabeto, a saber: de «a» a «z». Debemos, para eso, tener tres operaciones: saber la letra actual, devolver la siguiente y devolver la anterior.

Así, decimos que la clase Alphabet encapsula tres operaciones, en las que cuestiones como mantenerse en el intervalo («a» a «z») deben estar controladas. Esta validación se realiza en las líneas 9 y 14, por consiguiente, queda oculto para el usuario qué instancia la clase. Por tanto, vemos cómo invocamos dos veces la operación back() cuando la letra está en «a» y sigue devolviendo «a» (líneas 21 y 22). Esto significa que sí está funcionando como requerimos. (En Python la función chr transforma un valor numérico a un carácter, y ord hace lo contrario).

 

Ahora bien, existen otros conceptos que son particulares del paradigma, tales como: herencia (inheritance en inglés).

Para mostrar esto moveremos la validación de los intervalos a una clase «padre»:

1. class RangeManager(object): 2. def __init__(self, lower, upper): 3. self.lower = lower 4. self.upper = upper 5. 6. def validation_lower(self, current): 7. return True if current > self.lower else False 8. 9. def validation_upper(self, current): 10. return True if current < self.upper else False 11. 12. class Alphabet(RangeManager): 13. def __init__(self, lower, upper): 14. self.letter = 'a' 15. super().__init__(lower, upper) 16. 17. def current(self): 18. return self.letter 19. 20. def next(self): 21. if super().validation_upper(self.letter): 22. self.letter = chr(ord(self.letter) + 1) 23. return self.letter 24. 25. def back(self): 26. if super().validation_lower(self.letter): 27. self.letter = chr(ord(self.letter) - 1) 28. return self.letter 29. 30. obj = Alphabet('a', 'z') 31. print(obj.current()) # a 32. print(obj.next()) # b 33. print(obj.back()) # a 34. print(obj.back()) # a

Agregamos la clase RangeManager que se encargará de manejar los intervalos, así, le quitamos esa responsabilidad a la clase Alphabet. Entonces, en la definición de Alphabet heredamos de RangeManager (línea 12). Con esto, podemos: (1) asignar otro tipo de intervalos, no solo «a» a «z»; (2) podríamos agregar una nueva clase para intervalos numéricos que herede de RangeManager y obtener el mismo comportamiento que con Alphabet.

Nota:

Existen dos conceptos que suelen confundirse: herencia y subtipos. La primera permite reutilizar código que tiene un objeto; la segunda permite usar un objeto en otro contexto. Así, la herencia se suele realizar a través de jerarquías de clases, en cambio, los subtipos tienen su implementación a través de las interfaces.

Esto nos ayuda a reducir código y a separar la lógica en diversas ADT donde cada una tiene su propia responsabilidad dentro del programa. Este último ejemplo de código se encuentra de manera visual en la figura 3.1.

Figura 3.1 Representación visual de las dos clases: Alphabet y RangeManager, donde la flecha que las une hace referencia a la herencia, es decir, Alphabet hereda de RangeManager. Los cuadros con líneas punteadas son los argumentos que se envían a un objeto. Y las flechas punteadas (que salen de next y back) indican que dichas operaciones de Alphabet utilizan, a su vez, las operaciones de la clase padre: RangeManager.

Con este ejemplo, manipulamos la visibilidad de las operaciones para poder reutilizarlas en otra parte de nuestro programa.

En consecuencia, la programación orientada a objetos ha tenido un impacto —y lo sigue teniendo— en todos los aspectos de la industria del software. Los conceptos de objeto y envío de mensajes (comunicación entre operaciones de distintas ADT) son el núcleo para entender cómo trabaja. Y dado que muchas de las construcciones de código pueden repetirse en el tiempo, nace consigo, en este paradigma, la idea de «diseño de patrones», que es la idea de intentar estandarizar ciertas estructuras de objetos en un programa. Es decir, un diseño de patrones es una búsqueda de generalización, de clasificar técnicas que suelen repetirse en softwares que aplican este paradigma.

El paradigma orientado a objetos está en todas partes y es prácticamente difícil encontrar un software empresarial que no lo aplique en alguno de sus componentes. ¿Por qué? Fue pensado para construir software que evoluciona, escala y se desarrolla a medida que crecen sus líneas de código en el tiempo, y que sufre de constantes cambios en la rotación de personal que trabaja en este. Aunque claro, eso no significa que su popularidad implique que sea infalible o la mejor opción para todo tipo de sistemas.

3.2.3 Programación lógica

El paradigma lógico lleva el formalismo a su plenitud. La idea es acercar los algoritmos que construimos (código) a un conjunto de aserciones lógicas que puedan ser interpretadas por un compilador o intérprete. En otras palabras, la computación se construye a través de deducciones lógicas.

Dicho esto, no es de extrañar que muchos sistemas de comprobación de teoremas estén escritos en lenguajes que aplican este paradigma, como es el caso del más popular: Prolog. Al igual que la programación funcional, se centra más en el «cómo» que en el «qué» computar.

El paradigma lógico mantiene un equilibrio entre teoría, técnicas, sistemas y aplicaciones. Y es que este, a pesar de no ser utilizado masivamente en la industria, sigue siendo bastante popular en algunos sectores de la investigación científica en computación. Por ejemplo: la demostración automática de teoremas (automated theorem proving en inglés) y la lógica difusa (fuzzy logic en inglés).

Prolog

Ahora daremos una introducción a este paradigma usando Prolog. Este es el lenguaje estándar de este paradigma y sobre el que más literatura podemos encontrar. Comencemos conociendo cómo se construye un programa.

Un hecho es un predicado o relación entre símbolos. Algunas restricciones que se deben tener en cuenta:

• El predicado comienza con una letra minúscula.

• Los objetos se encuentran separados por el signo «,» (coma) entre paréntesis.

• Termina con el signo «.» (punto).

Algunos ejemplos de hechos, sintácticamente válidos, son los siguientes:

lenguaje(c++, imperativo, oop, funcional). lenguaje(racket, funcional, imperativo). lenguaje(haskell, funcional).

donde «lenguaje» es el nombre del hecho, y los objetos se encuentran entre paréntesis y separados por coma, o sea: «c++», «imperativo», «oop», etc.

Por otro lado, tenemos las reglas. Una regla es la fórmula o ecuación donde estarían los hechos. Está compuesta de cabecera y cuerpo, y este último se compone de varios hechos (que en este contexto se llaman «objetivos») separados por «,», que equivale al operador lógico «∧» (operador de conjunción). Para el operador de disyunción «∨» se deberían separar con «;».

cabecera : – objetivo_1, objetivo_2, objetivo_n.

Un ejemplo de programa en Prolog es el siguiente:

1. div(10, 5) . 2. div(20, 5) . 3. 4. div(30, X) :- div(10, X), div(20, X) . 5. 6. ?- div(30, 5) . % True

En Prolog las variables deben comenzar con mayúscula, así, en este ejemplo la variable es «X».

Las líneas 1 y 2 son los predicados o hechos; la línea 4 es la regla, y en la línea 6 el símbolo «?-» significa que estamos evaluando una expresión, la cual puede ser verdadera o falsa.

Entonces, este ejemplo trata de inferir si un número es divisible por otro, dados los hechos previamente definidos. Entonces la regla es:

X div(10, X) ∧ div(20, X) → div(30, X)

Asume que cualquier número que sea divisible por 10 y 20 también lo sería para 30, en el caso de que la variable incógnita «X» esté en los hechos. Por tanto, en la línea 6, cuando se prueba con (30,5), es verdadera porque el número 5 es divisible por 10 y 20 (según los hechos definidos previamente). (El signo «%» es para escribir un comentario; en este caso el resultado obtenido es True.)

El ejemplo anterior es bastante sencillo, pero muestra cómo se pueden construir reglas basadas en hechos para, posteriormente, crear inferencias.

El paradigma lógico es utilizado en áreas de inteligencia artificial para crear sistemas de reglas de inferencias. Un ejemplo son los conocidos «sistemas expertos», un software que pretende comportarse como un experto en un área en particular, en el aspecto del razonamiento y posterior toma de decisiones. Pero a diferencia de otros sistemas como los de deep learning, que encuentran patrones en los datos de manera automática, un sistema experto necesita tener asistencia de personas que conozcan el negocio para construir las reglas.

Una alternativa para aprender Prolog es usar alguna versión libre que lo implemente, por ejemplo, SWI-Prolog15 .

Este paradigma le ayudará a mejorar su lógica, y todo en computación es lógica, por tanto, no debería omitirlo en su aprendizaje.

3.2.4 Programación funcional

El paradigma funcional es el actor principal de este libro y, por lo mismo, no lo detallaremos en este apartado porque estará presente en los capítulos posteriores.

No obstante, podemos decir algunas de sus cualidades que lo hacen diferente a los demás. Primero, las funciones matemáticas son lo fundamental y están presentes en todas partes, así como las variables en el paradigma imperativo, los objetos en la orientación a objeto o las aserciones lógicas en el paradigma lógico.

Así, estos son algunos de sus conceptos principales:

• Funciones puras. Toda la computación se puede expresar a través de funciones, donde dados los mismos argumentos, dicha función debe devolver los mismos valores.

• Transparencia referencial. Cada expresión puede ser vista como un valor que, entonces, puede ser reemplazado sin perder el comportamiento del programa.

• Inmutabilidad. Una vez creada una entidad16, esta no puede ser modificada. Esto difiere bastante del paradigma imperativo, donde una misma entidad puede variar de estados (modificación o mutación).

Al igual que acontece con otros paradigmas, no todos los lenguajes lo implementan en su totalidad y depende, en cierta medida, de los designios de sus creadores. Es por eso y otras cuestiones que se dice que algunos lenguajes son más «funcionales» que otros o más «puros» que otros.

Por ejemplo, Haskell es un lenguaje funcional puro, en cambio, otros, como los de la familia LISP (no puros), tienden a incorporar características imperativas o de otro paradigma en su diseño. En sí, estos últimos se pueden considerar híbridos. Aunque eso no implica la superioridad de unos sobre otros. Simplemente se verá afectada la forma en que se diseña un programa y las limitaciones que presente el propio lenguaje.

Un ejemplo básico para ver este paradigma es manipular una lista en la que se aplica un operador para que devuelva una nueva lista. Para este ejemplo usaremos Racket, que es uno de los actores principales de este libro. No se preocupe por entender la sintaxis, ya que la veremos en detalle en la parte III del libro.

1. (define (mul x) (* x 2000)) 2. (map mul '(1 2 3)) 3. # '(2000 4000 6000)

Se define una función mul en la primera línea, que recibe un argumento «X» que se multiplica por 2000 y lo devulve. En la segunda línea se aplica el operador map que recibe dos argumentos: la función mul y una lista; y transforma dicha lista en una nueva donde se aplica a cada elemento la función del primer argumento (es decir, mul).

Con lo que devuelve:

‘(2000 4000 6000)

La lista que se entrega como argumento ‘(1 2 3) no es modificada, en cambio, se construye una nueva, así, se mantiene la inmutabilidad. Igualmente, la función mul es pura porque siempre devolverá el mismo valor si se le entrega el mismo argumento.

En este libro, veremos este y otros tantos ejemplos del paradigma funcional, el cual tiene una forma elegante de construir la computación que obliga a pensar de una manera diferente; y eso, sin duda, se transforma en una nueva herramienta para ser un mejor profesional.

Ahora damos paso a la segunda parte del libro, donde conoceremos el fundamento teórico de este paradigma. ¡Vamos!

_________________

12 Un excelente ejemplo de esto es el libro The AWK programming language de Alfred V. Aho, Brian W. Kernighan y Peter J. Weinberger, donde se presenta la especificación del lenguaje de programación AWK.

 

13 https://julialang.org/benchmarks/ [revisado en julio del 2020].

14 A Conversation with Alan Kay («Una conversación con Alan Kay»): https://queue.acm.org/detail.cfm?id=1039523 publicado en la revista ACM Queue. [Revisado en septiembre del 2020].

15 https://www.swi-prolog.org/ [revisado en julio del 2020].

16 En este contexto usamos el término «entidad» para señalar cualquier estructura que pueda almacenar algún dato.

To koniec darmowego fragmentu. Czy chcesz czytać dalej?