1.13. Programación orientada a objetos en Python: Definición de clases

Hemos dicho anteriormente que Python es un lenguaje de programación orientado a objetos. Hasta ahora, hemos utilizado una serie de clases incorporadas para mostrar ejemplos de datos y estructuras de control. Una de las características más poderosas en un lenguaje de programación orientado a objetos es la capacidad de permitir a un programador (solucionador de problemas) crear nuevas clases que modelen los datos necesarios para resolver el problema.

Recuerde que usamos tipos abstractos de datos para proporcionar la descripción lógica de cómo se ve un objeto de datos (su estado) y qué puede hacer (sus métodos). Mediante la construcción de una clase que implementa un tipo abstracto de datos, un programador puede aprovechar el proceso de abstracción y al mismo tiempo proporcionar los detalles necesarios para utilizar realmente la abstracción en un programa. Siempre que deseemos implementar un tipo abstracto de datos, lo haremos con una nueva clase.

1.13.1. Una clase Fraccion

Un ejemplo muy común para mostrar los detalles de la implementación de una clase definida por el usuario es construir una clase para implementar el tipo abstracto de datos Fraccion. Ya hemos visto que Python proporciona una serie de clases numéricas para nuestro uso. Hay ocasiones en las que, sin embargo, sería más apropiado ser capaz de crear objetos de datos que “luzcan” como fracciones.

Una fracción como \(\frac {3}{5}\) consta de dos partes. El valor de arriba, conocido como el numerador, puede ser cualquier entero. El valor de abajo, llamado el denominador, puede ser cualquier entero mayor que 0 (las fracciones negativas tienen un numerador negativo). Aunque es posible crear una aproximación de punto flotante para cualquier fracción, en este caso nos gustaría representar la fracción como un valor exacto.

Las operaciones para el tipo Fraccion permitirán que un objeto de datos Fraccion se comporte como cualquier otro valor numérico. Necesitamos ser capaces de sumar, restar, multiplicar y dividir fracciones. También queremos ser capaces de mostrar fracciones usando la forma estándar de “barra”, por ejemplo 3/5. Además, todos los métodos de fracciones deben devolver resultados en sus términos menores de modo que, sin importar el cálculo que se realice, siempre terminemos con la forma más simplificada.

En Python, definimos una nueva clase proporcionando un nombre y un conjunto de definiciones de métodos que son sintácticamente similares a las definiciones de funciones. Para este ejemplo,

class Fraccion:

   #los métodos van aquí

proporciona el esqueleto para definir los métodos. El primer método que todas las clases deben proporcionar es el constructor. El constructor define la forma en que se crean los objetos de datos. Para crear un objeto Fraccion, tendremos que proporcionar dos piezas de datos, el numerador y el denominador. En Python, el método constructor siempre se llama __init__ (dos subrayados antes y después de init) y se muestra en el Programa 2.

Programa 2

class Fraccion:

    def __init__(self,arriba,abajo):

        self.num = arriba
        self.den = abajo

Observe que la lista de parámetros formales contiene tres elementos (self, arriba, abajo). self es un parámetro especial que siempre se utilizará como una referencia al objeto mismo. Debe ser siempre el primer parámetro formal; no obstante, nunca se le dará un valor de parámetro real en la invocación. Como se describió anteriormente, las fracciones requieren dos piezas de datos de estado, el numerador y el denominador. La notación self.num en el constructor define que el objeto fraccion tenga un objeto de datos interno llamado num como parte de su estado. Del mismo modo, self.den crea el denominador. Los valores de los dos parámetros formales se asignan inicialmente al estado, permitiendo que el nuevo objeto fraccion conozca su valor inicial.

Para crear una instancia de la clase Fraccion, debemos invocar al constructor. Esto ocurre usando el nombre de la clase y pasando los valores reales para el estado necesario (note que nunca invocamos directamente a __init__). Por ejemplo,

miFraccion = Fraccion(3,5)

Crea un objeto llamado miFraccion que representa la fracción \(\frac {3}{5}\) (tres quintos). La Figura 5 muestra este objeto tal como está implementado ahora.

../_images/fraction1.png

Figura 5: Una instancia de la clase Fraccion

Figura 5: Una instancia de la clase Fraccion

Lo siguiente que debemos hacer es implementar el comportamiento que requiere el tipo abstracto de datos. Para comenzar, considere lo que sucede cuando tratamos de imprimir un objeto Fraccion.

>>> miF = Fraccion(3,5)
>>> print(miF)
<__main__.Fraction instance at 0x409b1acc>

El objeto fraccion, miF, no sabe cómo responder a esta solicitud de impresión. La función print requiere que el objeto sea convertido en una cadena para que se pueda escribir en la salida. La única opción que miF tiene es mostrar la referencia real que se almacena en la variable (la dirección en sí misma). Esto no es lo que queremos.

Hay dos maneras de resolver este problema. Una de ellas es definir un método llamado mostrar que permitirá que el objeto Fraccion se imprima como una cadena. Podemos implementar este método como se muestra en el Programa 3. Si como antes creamos un objeto Fraccion, podemos pedirle que se muestre, en otras palabras, que se imprima en el formato apropiado. Desafortunadamente, esto no funciona en general. Para que la impresión funcione correctamente, necesitamos decirle a la clase Fraccion cómo puede convertirse en una cadena. Esto es lo que necesita la función print para hacer su trabajo.

Programa 3

def mostrar(self):
     print(self.num,"/",self.den)
>>> miF = Fraccion(3,5)
>>> miF.mostrar()
3 / 5
>>> print(miF)
<__main__.Fraction instance at 0x40bce9ac>
>>>

En Python, todas las clases tienen un conjunto de métodos estándar que se proporcionan pero que podrían no funcionar correctamente. Uno de ellos, __str__, es el método para convertir un objeto en una cadena. La implementación predeterminada para este método es devolver la cadena de la dirección de la instancia como ya hemos visto. Lo que necesitamos hacer es proporcionar una “mejor” implementación para este método. Diremos que esta implementación reescribe a la anterior, o que redefine el comportamiento del método.

Para ello, simplemente definimos un método con el nombre __str__ y le damos una nueva implementación como se muestra en el Programa 4. Esta definición no necesita ninguna otra información excepto el parámetro especial self. A su vez, el método construirá una representación de cadena convirtiendo cada pieza de datos de estado internos en una cadena y luego colocando un caracter / entre las cadenas usando la concatenación de cadenas. La cadena resultante se devolverá cada vez que se solicite a un objeto Fraccion que se convierta en una cadena. Observe las diversas formas en que se utiliza esta función.

Programa 4

def __str__(self):
    return str(self.num)+"/"+str(self.den)
>>> miF = Fraccion(3,5)
>>> print(miF)
3/5
>>> print("Comí", miF, "de la pizza")
Comí 3/5 de la pizza
>>> miF.__str__()
'3/5'
>>> str(miF)
'3/5'
>>>

Podemos redefinir muchos otros métodos para nuestra nueva clase Fraccion. Algunas de los más importantes son las operaciones aritméticas básicas. Nos gustaría poder crear dos objetos Fraccion y luego sumarlos usando la notación estándar “+”. En este punto, si intentamos sumar dos fracciones, obtendremos lo siguiente:

>>> f1 = Fraccion(1,4)
>>> f2 = Fraccion(1,2)
>>> f1+f2

Traceback (most recent call last):
  File "<pyshell#173>", line 1, in -toplevel-
    f1+f2
TypeError: unsupported operand type(s) for +:
          'instance' and 'instance'
>>>

Si nos fijamos atentamente en el error, veremos que el problema es que el operador “+” no entiende los operandos para Fraccion.

Podemos corregir este error agregándole a la clase Fraccion un método que redefina el método asociado a la adición. En Python, este método se llama __add__ y requiere dos parámetros. El primero, self, siempre es necesario, y el segundo representa el otro operando en la expresión. Por ejemplo,

f1.__add__(f2)

pedirá al objeto Fraccion f1 que sume el objeto Fraccion f2 a sí mismo. Esto se puede escribir en la notación estándar, f1 + f2.

Dos fracciones deben tener el mismo denominador para poder ser sumadas. La forma más fácil de asegurarse de que tienen el mismo denominador es simplemente utilizar el producto de los dos denominadores como un denominador común de modo que \(\frac {a}{b} + \frac {c}{d} = \frac {ad}{bd} + \frac {cb}{bd} = \frac {ad + cb}{bd}\). La implementación se muestra en el Programa 5. La función de adición devuelve un nuevo objeto Fraccion con el numerador y el denominador de la suma. Podemos usar este método escribiendo una expresión aritmética estándar que involucre fracciones, asignando el resultado de la adición e imprimiendo nuestro resultado.

Programa 5

def __add__(self,otraFraccion):

     nuevoNum = self.num*otraFraccion.den + self.den*otraFraccion.num
     nuevoDen = self.den * otraFraccion.den

     return Fraccion(nuevoNum,nuevoDen)
>>> f1=Fraccion(1,4)
>>> f2=Fraccion(1,2)
>>> f3=f1+f2
>>> print(f3)
6/8
>>>

El método de adición ya funciona como queremos, pero una cosa podría ser mejor. Note que \(6/8\) es el resultado correcto (\(\frac {1}{4} + \frac{1}{2}\)) pero no está en la representación de “términos menores”. La mejor representación sería \(3/4\). Con el fin de estar seguros de que nuestros resultados estén siempre en los términos menores, necesitamos una función auxiliar que sepa cómo simplificar las fracciones. Esta función tendrá que buscar el máximo común divisor, o MCD. Podemos entonces dividir el numerador y el denominador por el MCD y el resultado se simplificará a los términos menores.

El algoritmo más conocido para encontrar un máximo común divisor es el Algoritmo de Euclides, el cual será discutido en detalle en el Capítulo 8. El Algoritmo de Euclides establece que el máximo común divisor de dos enteros \(m\) y \(n\) es \(n\) si \(n\) divide de forma exacta a \(m\). No obstante, si \(n\) no divide exactamente a \(m\), entonces la respuesta es el máximo común divisor de \(n\) y el residuo de \(m\) dividido entre \(n\). Aquí simplemente proporcionaremos una implementación iterativa (ver ActiveCode 1). Tenga en cuenta que esta implementación del algoritmo del MCD sólo funciona cuando el denominador es positivo. Esto es aceptable para nuestra clase Fraccion porque hemos dicho que una fracción negativa estará representada por un numerador negativo.

Ahora podemos utilizar esta función para ayudar a simplificar cualquier fracción. Para poner una fracción en los términos menores, dividiremos el numerador y el denominador por su máximo común divisor. Por lo tanto, para la fracción \(6/8\), el máximo común divisor es 2. Dividiendo arriba y abajo por 2 se crea una nueva fracción, \(3/4\) (ver el Programa 6).

Programa 6

def __add__(self,otraFraccion):
    nuevoNum = self.num*otraFraccion.den + self.den*otraFraccion.num
    nuevoDen = self.den * otraFraccion.den
    comun = mcd(nuevoNum,nuevoDen)
    return Fraccion(nuevoNum//comun,nuevoDen//comun)
>>> f1=Fraccion(1,4)
>>> f2=Fraccion(1,2)
>>> f3=f1+f2
>>> print(f3)
3/4
>>>
../_images/fraction2.png

Figura 6: Una instancia de la clase Fraccion con dos métodos

Figura 6: Una instancia de la clase Fraccion con dos métodos

Nuestro objeto Fraccion ahora tiene dos métodos muy útiles y se parece a la Figura 6. Un grupo adicional de métodos que necesitamos incluir en nuestra clase de ejemplo Fraccion permitirá que dos fracciones se comparen entre sí. Supongamos que tenemos dos objetos Fraccion, f1 y f2. f1==f2 solo será True si son referencias al mismo objeto. Dos objetos diferentes con los mismos numeradores y denominadores no serían iguales en esta implementación. Esto se denomina igualdad superficial (ver la Figura 7).

../_images/fraction3.png

Figura 7: Igualdad superficial versus igualdad profunda

Figura 7: Igualdad superficial versus igualdad profunda

Podemos crear igualdad profunda (véase la Figura 7) –igualdad por el mismo valor, no por la misma referencia– redefiniendo el método __eq__. El método __eq__ es otro método estándar disponible en cualquier clase. El método __eq__ compara dos objetos y devuelve True si sus valores son iguales, False de lo contrario.

En la clase Fraccion podemos implementar el método __eq__ poniendo de nuevo las dos fracciones en sus términos menores y luego comparando los numeradores (ver el Programa 7). Es importante tener en cuenta que hay otros operadores relacionales que pueden redefinirse. Por ejemplo, el método __le__ proporciona la funcionalidad de “menor que o igual”.

Programa 7

def __eq__(self, otro):
    primerNum = self.num * otro.den
    segundoNum = otro.num * self.den

    return primerNum == segundoNum

La clase Fraccion completa, hasta este punto, se muestra en el ActiveCode 2. Dejamos los métodos aritméticos y relacionales restantes como ejercicios.

Autoevaluación

Para asegurarnos de que usted entiende cómo se implementan los operadores en las clases de Python y cómo se escriben correctamente los métodos, escriba algunos métodos para implementar *, /, y -. También implemente los operadores de comparación > y <

1.13.2. Herencia: Compuertas lógicas y circuitos

Nuestra sección final presentará otro aspecto importante de la programación orientada a objetos. La herencia es la habilidad para que una clase esté relacionada con otra clase de la misma manera que las personas pueden estar relacionadas entre sí. Los hijos heredan características de sus padres. Del mismo modo, las clases hija en Python pueden heredar datos y comportamientos característicos de una clase madre. Estas clases se denominan a menudo subclases y superclases, respectivamente.

La Figura 8 muestra las colecciones incorporadas de Python y sus relaciones entre sí. Llamamos a una estructura de relación como ésta una jerarquía de herencias. Por ejemplo, la lista es un hija de la colección secuencial. En este caso, llamamos hija a la lista y madre a la secuencia (o la subclase lista y la superclase secuencia). Esto a menudo se denomina Relación ES-UNA (la lista ES-UNA colección secuencial). Esto implica que las listas heredan características importantes de las secuencias, a saber, el ordenamiento de los datos y operaciones, tales como la concatenación, la repetición y la indización.

../_images/inheritance1.png

Figura 8: Una jerarquía de herencias para las colecciones de Python

Figura 8: Una jerarquía de herencias para las colecciones de Python

Las listas, las tuplas y las cadenas son todas tipos de colecciones secuenciales. Todas heredan organización de datos y operaciones comunes. Sin embargo, cada una de ellas es distinta según los datos sean o no homogéneos y si la colección es inmutable. Los hijos se parecen a sus padres pero se distinguen agregando características adicionales.

Al organizar las clases de esta manera jerárquica, los lenguajes de programación orientados a objetos permiten que el código previamente escrito se extienda para satisfacer las necesidades de una nueva situación. Además, al organizar los datos de esta manera jerárquica, podemos comprender mejor las relaciones que existen entre ellos. Podemos ser más eficientes en la construcción de nuestras representaciones abstractas.

Para explorar esta idea más a fondo, construiremos una simulación, una aplicación para simular circuitos digitales. El bloque constructivo básico para esta simulación será la compuerta lógica. Estos conmutadores electrónicos representan relaciones de álgebra booleana entre su entrada y su salida. En general, las compuertas tienen una sola línea de salida. El valor de la salida depende de los valores dados en las líneas de entrada.

Las compuertas AND tienen dos líneas de entrada, cada una de las cuales puede ser 0 ó 1 (representando False o True, repectivamente). Si ambas líneas de entrada tienen valor 1, la salida resultante es 1. Sin embargo, si una o ambas líneas de entrada son 0, el resultado es 0. Las compuertas OR también tienen dos líneas de entrada y producen un 1 si uno o ambos valores de entrada son 1. En el caso en que ambas líneas de entrada sean 0, el resultado es 0.

Las compuertas NOT se diferencian de las otras dos compuertas porque sólo tienen una única línea de entrada. El valor de salida es simplemente el opuesto al valor de entrada. Si aparece 0 en la entrada, se produce 1 en la salida. Similarmente, un 1 produce un 0. La Figura 9 muestra cómo se representa típicamente cada una de estas compuertas. Cada compuerta tiene también una tabla de verdad de valores que muestran el mapeo de entrada a salida que es llevado a cabo por la compuerta.

../_images/truthtable.png

Figura 9: Tres tipos de compuertas lógicas

Figura 9: Tres tipos de compuertas lógicas

Podemos construir circuitos que tengan funciones lógicas al combinar estas compuertas en varios patrones y luego aplicarles un conjunto de valores de entrada. La Figura 10 muestra un circuito que consta de dos compuertas AND, una compuerta OR y una única compuerta NOT. Las líneas de salida de las dos compuertas AND se conectan directamente en la compuerta OR y la salida resultante de la compuerta OR es suministrada a la compuerta NOT. Si aplicamos un conjunto de valores de entrada a las cuatro líneas de entrada (dos por cada puerta AND), los valores se procesan y aparece un resultado en la salida de la compuerta NOT. La Figura 10 también muestra un ejemplo con valores.

../_images/circuit1.png

Figura 10: Circuito

Figura 10: Circuito

Para implementar un circuito, primero construiremos una representación para compuertas lógicas. Las compuertas lógicas se organizan fácilmente en una jerarquía de herencias de clase como se muestra en la Figura 11. En la parte superior de la jerarquía, la clase CompuertaLogica representa las características más generales de las compuertas lógicas: a saber, una etiqueta para la compuerta y una línea de salida. El siguiente nivel de subclases divide las compuertas lógicas en dos familias, las que tienen una línea de entrada y las que tienen dos. Debajo de ellas, aparecen las funciones lógicas específicas de cada una.

../_images/gates.png

Figura 11: Una jerarquía de herencias para las compuertas lógicas

Figura 11: Una jerarquía de herencias para las compuertas lógicas

Ahora podemos comenzar a implementar las clases empezando con la más general, CompuertaLogica. Como se ha indicado anteriormente, cada compuerta tiene una etiqueta para la identificación y una sola línea de salida. Además, necesitamos métodos para permitir que un usuario de una compuerta le pida la etiqueta a la compuerta.

El otro comportamiento que necesita toda compuerta lógica es la capacidad de conocer su valor de salida. Esto requerirá que la compuerta lleve a cabo la lógica apropiada con base en la entrada actual. Con el fin de producir la salida, la compuerta tiene que saber específicamente cuál es esa lógica. Esto implica invocar a un método para realizar el cálculo lógico. La clase completa se muestra en el Programa 8.

Programa 8

class CompuertaLogica:

    def __init__(self,n):
        self.etiqueta = n
        self.salida = None

    def obtenerEtiqueta(self):
        return self.etiqueta

    def obtenerSalida(self):
        self.salida = self.ejecutarLogicaDeCompuerta()
        return self.salida

En este punto, no implementaremos la función ejecutarLogicaDeCompuerta. La razón de esto es que no sabemos cómo llevará a cabo cada compuerta su propia operación lógica. Estos detalles serán incluidos por cada compuerta individual que se añada a la jerarquía. Esta es una idea muy poderosa en la programación orientada a objetos. Estamos escribiendo un método que usará código que aún no existe. El parámetro self es una referencia al verdadero objeto compuerta que invoca el método. Cualquier compuerta lógica nueva que se agregue a la jerarquía simplemente tendrá que implementar la función ejecutarLogicaDeCompuerta y se utilizará en el momento apropiado. Una vez se haya usado, la compuerta puede proporcionar su valor de salida. Esta capacidad de extender una jerarquía que existe actualmente y proporcionar las funciones específicas que la jerarquía necesita para usar la nueva clase es extremadamente importante para reutilizar el código ya existente.

Categorizamos las compuertas lógicas en función del número de líneas de entrada. La compuerta AND tiene dos líneas de entrada. La compuerta OR también tiene dos líneas de entrada. Las compuertas NOT tienen una línea de entrada. La clase CompuertaBinaria será una subclase de CompuertaLogica y agregará dos líneas de entrada. La clase CompuertaUnaria también será subclase de CompuertaLogica pero sólo contará con una única línea de entrada. En el diseño de circuitos asistido por computador, estas líneas a veces se llaman “pines” por lo que vamos a utilizar esa terminología en nuestra implementación.

Programa 9

class CompuertaBinaria(CompuertaLogica):

    def __init__(self,n):
        CompuertaLogica.__init__(self,n)

        self.pinA = None
        self.pinB = None

    def obtenerPinA(self):
        return int(input("Ingrese la entrada del Pin A para la compuerta "+ self.obtenerEtiqueta()+"-->"))

    def obtenerPinB(self):
        return int(input("Ingrese la entrada del Pin B para la compuerta "+ self.obtenerEtiqueta()+"-->"))

Programa 10

class CompuertaUnaria(CompuertaLogica):

    def __init__(self,n):
        CompuertaLogica.__init__(self,n)

        self.pin = None

    def obtenerPin(self):
        return int(input("Ingrese la entrada del Pin para la compuerta "+ self.obtenerEtiqueta()+"-->"))

El Programa 9 y el Programa 10 implementan estas dos clases. Los constructores en ambas clases comienzan con una llamada explícita al constructor de la clase madre utilizando el método __init__ de la madre. Al crear una instancia de la clase CompuertaBinaria, primero queremos inicializar cualesquiera ítems de datos heredados de CompuertaLogica. En este caso, eso significa la etiqueta para la compuerta. A continuación, el constructor agrega las dos líneas de entrada (pinA y pinB). Éste es un patrón muy común que debe usarse siempre al crear jerarquías de clases. Los constructores de las clases hija deben llamar a los constructores de las clases madre y luego ocuparse de sus propios datos distintivos.

Python también tiene una función llamada super que se puede usar en lugar de nombrar explícitamente la clase madre. Éste es un mecanismo más general, y es ampliamente utilizado especialmente cuando una clase tiene más de una clase madre. Sin embargo, esa opción no se discutirá en esta introducción. Por ejemplo, en nuestro ejemplo anterior, CompuertaLogica.__init__(self,n) podría reemplazarse por super(CompuertaUnaria,self).__init__(n).

El único comportamiento que añade la clase CompuertaBinaria es la capacidad de obtener los valores de las dos líneas de entrada. Dado que estos valores vienen de algún lugar externo, simplemente le pediremos al usuario a través de una instrucción input que los proporcione. La misma implementación se usa para la clase CompuertaUnaria excepto que sólo hay una línea de entrada.

Ahora que tenemos una clase general para las compuertas dependiendo del número de líneas de entrada, podemos construir compuertas específicas que tengan un comportamiento único. Por ejemplo, la clase CompuertaAND será una subclase de CompuertaBinaria, ya que las compuertas AND tienen dos líneas de entrada. Como antes, la primera línea del constructor invoca al constructor de la clase madre (CompuertaBinaria), que a su vez llama al constructor de su clase madre (CompuertaLogica). Note que la clase CompuertaAND no proporciona ningún dato nuevo, ya que hereda dos líneas de entrada, una línea de salida y una etiqueta.

Programa 11

class CompuertaAND(CompuertaBinaria):

    def __init__(self,n):
        CompuertaBinaria.__init__(self,n)

    def ejecutarLogicaDeCompuerta(self):

        a = self.obtenerPinA()
        b = self.obtenerPinB()
        if a==1 and b==1:
            return 1
        else:
            return 0

Lo único que CompuertaAND necesita agregar es el comportamiento específico que realiza la operación booleana que se describió anteriormente. Éste es el lugar donde podemos proporcionar el método ejecutarLogicaDeCompuerta. Para una compuerta AND, este método debe obtener primero los dos valores de entrada y luego devuelve 1 sólo si ambos valores de entrada son 1. La clase completa se muestra en el Programa 11.

Podemos mostrar la clase CompuertaAND en acción creando una instancia y pidiéndole que calcule su salida. La sesión siguiente muestra un objeto CompuertaAND, c1, que tiene una etiqueta interna "C1". Cuando invocamos el método obtenerSalida, el objeto debe llamar primero a su método ejecutarLogicaDeCompuerta que a su vez consulta las dos líneas de entrada. Una vez que se proporcionan los valores, se muestra la salida correcta.

>>> c1 = CompuertaAND("C1")
>>> c1.obtenerSalida()
Ingrese la entrada del Pin A para la compuerta C1-->1
Ingrese la entrada del Pin B para la compuerta C1-->0
0

El mismo desarrollo se puede hacer para las compuertas OR y las compuertas NOT. La clase CompuertaOR también será una subclase de CompuertaBinaria y la clase CompuertaNOT extenderá la clase CompuertaUnaria. Ambas clases tendrán que proporcionar sus propias funciones ejecutarLogicaDeCompuerta, ya que ése será su comportamiento específico.

Podemos utilizar una sola compuerta construyendo primero una instancia de una de las clases de compuerta y, luego, pidiendo a la compuerta su salida (que a su vez necesitará que se proporcionen las entradas). Por ejemplo:

>>> c2 = CompuertaOR("C2")
>>> c2.obtenerSalida()
Ingrese la entrada del Pin A para la compuerta C2-->1
Ingrese la entrada del Pin B para la compuerta C2-->1
1
>>> c2.obtenerSalida()
Ingrese la entrada del Pin A para la compuerta C2-->0
Ingrese la entrada del Pin B para la compuerta C2-->0
0
>>> c3 = CompuertaNOT("C3")
>>> c3.obtenerSalida()
Ingrese la entrada del Pin para la compuerta C3-->0
1

Ahora que tenemos las compuertas básicas funcionando, podemos centrar nuestra atención en la construcción de circuitos. Para crear un circuito, necesitamos conectar las compuertas juntas, la salida de una fluirá hacia la entrada de otra. Para ello, implementaremos una nueva clase llamada Conector.

La clase Conector no residirá en la jerarquía de las compuertas. Sin embargo, sí usará la jerarquía de ellas por el hecho que cada conector tendrá dos compuertas, una en cada extremo (ver la Figura 12). Esta relación es muy importante en la programación orientada a objetos. Se llama la Relación TIENE-UN(A). Recuerde que antes usamos la frase “Relación ES-UN(A)” para decir que una clase hija está relacionada con una clase madre, por ejemplo CompuertaUnaria ES-UNA CompuertaLogica.

../_images/connector.png

Figura 12: Un conector conecta la salida de una compuerta a la entrada de otra

Figura 12: Un conector conecta la salida de una compuerta a la entrada de otra

Ahora, con la clase Conector, decimos que un Conector TIENE-UNA CompuertaLogica lo cual significa que los conectores tendrán instancias de la clase CompuertaLogica dentro de ellos, pero no forman parte de la jerarquía. Al diseñar clases, es muy importante distinguir entre aquéllas que tienen la relación ES-UN(A) (lo cual requiere herencia) y aquéllas que tienen relaciones TIENE-UN(A) (sin herencia).

El Programa 12 muestra la clase Conector. Las dos instancias de compuertas dentro de cada objeto conector se referirán como deCompuerta y aCompuerta, reconociendo que los valores de los datos “fluirán” desde la salida de una compuerta a una línea de entrada de la siguiente. El llamado a asignarProximoPin es muy importante para realizar conexiones (ver el Programa 13). Necesitamos agregar este método a nuestras clases de compuertas para que cada aCompuerta pueda elegir la línea de entrada adecuada para la conexión.

Programa 12

class Conector:

    def __init__(self, deComp, aComp):
        self.deCompuerta = deComp
        self.aCompuerta = aComp

        aComp.asignarProximoPin(self)

    def obtenerFuente(self):
        return self.deCompuerta

    def obtenerDestino(self):
        return self.aCompuerta

En la clase CompuertaBinaria, para compuertas con dos posibles líneas de entrada, el conector debe conectarse a una sola línea. Si ambas están disponibles, elegiremos pinA de forma predeterminada. Si pinA ya está conectado, entonces elegiremos pinB. No es posible conectarse a una compuerta sin líneas de entrada disponibles.

Programa 13

def asignarProximoPin(self,fuente):
    if self.pinA == None:
        self.pinA = fuente
    else:
        if self.pinB == None:
            self.pinB = fuente
        else:
           raise RuntimeError("Error: NO HAY PINES DISPONIBLES")

Ahora es posible obtener entradas desde dos lugares: externamente, como antes, y desde la salida de una compuerta que está conectada a esa línea de entrada. Esto requiere un cambio en los métodos obtenerPinA y obtenerPinB (ver el Programa 14). Si la línea de entrada no está conectada a nada (None), entonces se pide al usuario que ingrese el valor externamente como antes. Sin embargo, si hay una conexión, se accede a ella y se consulta el valor de salida de deCompuerta. Esto, a su vez, hace que esa compuerta procese su lógica. Se continúa este proceso hasta que todas las entradas estén disponibles y el valor de salida final se convierta en la entrada requerida para la compuerta en cuestión. En cierto sentido, el circuito opera hacia atrás para encontrar la entrada necesaria para finalmente producir la salida.

Programa 14

def obtenerPinA(self):
    if self.pinA == None:
        return input("Ingrese la entrada del Pin A para la compuerta " + self.obtenerNombre()+"-->")
    else:
        return self.pinA.obtenerFuente().obtenerSalida()

El siguiente fragmento construye el circuito mostrado anteriormente en esta sección:

>>> c1 = CompuertaAND("C1")
>>> c2 = CompuertaAND("C2")
>>> c3 = CompuertaOR("C3")
>>> c4 = CompuertaNOT("C4")
>>> c1 = Conector(c1,c3)
>>> c2 = Conector(c2,c3)
>>> c3 = Conector(c3,c4)

Las salidas de las dos compuertas AND (c1 y c2) están conectadas a la compuerta OR (c3) y la salida de esta última está conectada a la compuerta NOT (c4). La salida de la compuerta NOT es la salida de todo el circuito. Por ejemplo:

>>> c4.obtenerSalida()
Ingrese la entrada del Pin A para la compuerta C1-->0
Ingrese la entrada del Pin B para la compuerta C1-->1
Ingrese la entrada del Pin A para la compuerta C2-->1
Ingrese la entrada del Pin B para la compuerta C2-->1
0

Inténtelo usted mismo usando el ActiveCode 4.

Autoevaluación

Cree dos nuevas clases de compuertas, una llamada CompuertaNOR y otra llamada CompuertaNAND. Las compuertas NAND funcionan como compuertas AND que tienen una NOT conectada a la salida. Las compuertas NOR funcionan como compuertas OR que tienen una NOT conectada a la salida.

Cree una serie de compuertas que demuestren que la siguiente ecuación NOT((A and B) or (C and D)) es equivalente a NOT(A and B) and NOT (C and D). Asegúrese de usar algunas de sus nuevas compuertas en la simulación.

Next Section - 1.14. Resumen