Programación orientada a objetos (POO) en Python
# Los codigos presentados en este documento pertenecen al lenguaje de programación
# Python, hasta que descubra como ejecutarlos correctamente en la consola de R los
# presentare como comentariosEs uno de los elementos más importantes de los lenguajes de programación se utiliza para organizar programas en módulos y abstracciones de datos.
La POO buscar pensar en los objetos como agrupaciones de datos y los métodos que operan en dichos datos. Por ejemplo, un árbol se puede representar con propiedades inherentes como la altura, el diámetro del tronco, la densidad de la madera, su edad y etc…
Las clases en Python nos permiten crear nuevos tipos de organización que contienen la información arbitraria sobre un objeto. No obstante, estas clases solo proveen la estructura. Es decir, son un molde con el cual podemos construir nuevos objetos específicos. La clases señalan las propiedades de mi objeto de estudio (clasificación superior) (tipo de objeto), pero no representan un objeto en específico (perteneciente a la clasificación superior)(instancias).En consecuencia, las instancias son los objetos reales u específicos que creamos en nuestro programa.
Por ejemplo, dentro de la clase arboles puedo generar un objeto u instancia para la especie Tectona grandis
En Python todo es un objeto y todo tiene un tipo.
Objeto: Representación en memoria de mis datos. Los objetos se pueden crear, manipular y destruir.
Tipo: Se refiere a encapsular tanto los datos como el comportamiento dentro de un solo objeto.
Definición y uso de la clase
Una vez que sabemos que todo es un objeto y todo es un tipo. Podemos generar nuestros tipos de datos abstractos.
### Ejemplo de Platzi
# class <nombre_de_la_clase>(<super_clase>):
# def __init__(self, <params>):
# <expresion>
# def <nombre_del_metodo>(self, <params>):
# <expresion>Primero utilizamos el Key-word class este nos permite empezar a definir nuestro tipo de dato abstracto, luego le damos el nombre de la clase, en algunas ocasiones podemos referir a una superclase. Posteriormente, utilizamos el método init que es especifico de Python y se conoce como constructor, este comienza con el primer parámetro que lo llamamos self (por convención) luego introducimos los parámetros de inicialización
Ahora el Key-word def normalmente se utiliza para funciones. Sin embargo, dentro de una clase lo llamamos método, en consecuencia, agregamos el nombre del método y los parámetros self y de inicialización
### Ejemplo de Platzi
### Definición
# class Persona:
# def __init__(self, nombre, edad):
# self.nombre = nombre
# self.edad = edad
# def saludad(self, otra_persona)
# return f’Hola {otra_persona.nombre, me llamo {self.nombre}.’
#jhon = Persona(‘Jhon’, 24)
#amanda = Persona(‘Amanda’, 38)
#jhon.saluda(amanda)Tenemos la clase persona y en su constructor recibe los parámetros nombre y edad, luego inicializamos las variables de instancia con self.nombrevariable. Ahora tenemos un metodo muy sencillo que saluda y recibe otra_persona como parametro y regresa un saludo con la concatenacion. El uso simplemente inicializamos las instancias de la clase y por ultimo con el doc_notation ejecutamos los metodos de la clase
Instancias
Mientras la clase es un molde, a los objetos se les conoce como instancias. Cuando esta se crea se ejecuta el método init
Todos los métodos de una clase reciben como primer parámetro self.
Los atributos de clase permiten hacer varias cosas:
- Representar datos.
- Procedimientos para interactuar con los mismos (métodos)
- Mecanismos para esconder la representación interna
A estos atributos se acceda con la notación de punto. Ademas, se puede tener atributos privados, por convención comienzan con _nombredelmetodo.
Funciones y decoradores
Funciones
Como ya hemos mencionado las funciones se determinan a partir de la Key-worddef
Un ejemplo de una función muy sencilla es:
#def elevar_cubo(numero):
# return numero * numero * numeroEn consecuencia, al ejecutar con un argumento obtenemos:
#elevar_cubo(3)
#27Veamos un ejemplo donde definimos tres diferentes funciones que utilizaremos de manera conjunta, con la tercera siendo un poco mas compleja:
### Ejemplo de Platzi
#def presentarse(nombre):
# return f"Me llamo {nombre}"
#def estudiemos_juntos(nombre):
# return f"¡Hey {nombre}, aprendamos Python!"
#def consume_funciones(funcion_entrante):
# return funcion_entrante("David")
#consume_funciones(estudiemos_juntos)Por otra parte, al igual que los condicionales y bucles también puedes colocar funciones dentro de otra función.
### Ejemplo de Platzi
#def funcion_mayor():
# print("Esta es una función mayor y su mensaje de salida.")
# def librerias():
# print("Algunas librerías de Python son: Scikit-learn, NumPy y TensorFlow.")
# def frameworks():
# print("Algunos frameworks de Python son: Django, Dash y Flask.")
# frameworks()
# librerias()
#funcion_mayor()getters y setters
Tienen el objetivo de asegurar el encapsulamiento de datos. Cómo habrás visto, si declaramos una variable privada en Python al colocar un guión bajo al inicio de esta (_) y normalmente son utilizados para: añadir lógica de validación al momento de obtener y definir un valor y, para evitar el acceso directo al campo de una clase.
Función property()
Esta función está incluida en Python, en particular crea y retorna la propiedad de un objeto. La propiedad de un objeto posee los métodos getter(), setter() y del().
En tanto la función tiene cuatro atributos: property(fget, fset, fdel, fdoc) :
- fget : trae el valor de un atributo.
- fset : define el valor de un atributo.
- fdel : elimina el valor de un atributo.
- fdoc : crea un docstring por atributo.
Veamos un ejemplo del mismo caso implementando la función property() :
### Ejemplo de Platzi
#class Millas:
# def __init__(self):
# self._distancia = 0
### Función para obtener el valor de _distancia
# def obtener_distancia(self):
# print("Llamada al método getter")
# return self._distancia
### Función para definir el valor de _distancia
# def definir_distancia(self, recorrido):
# print("Llamada al método setter")
# self._distancia = recorrido
### Función para eliminar el atributo _distancia
# def eliminar_distancia(self):
# del self._distancia
# distancia = property(obtener_distancia, definir_distancia, eliminar_distancia)
### Creamos un nuevo objeto
#avion = Millas()
### Indicamos la distancia
#avion.distancia = 200
### Obtenemos su atributo distancia
#print(avion.distancia)Decoradores
Los decoradores son una forma sencilla de llamar funciones de orden mayor, es decir, funciones que toman otra función cómo parámetro y/o retornan otra función como resultado. De esta forma un decorador añade capacidades a una función sin modificarla. Los decoradores se identifican con el simbolo @
Decorador @property
Este decorador es uno de varios con los que ya cuenta Python, el cual nos permite utilizar getters y setters para hacer más fácil la implementación de la programación orientada a objetos en Python cambiando los métodos o atributos de las clases de forma que no modifiquemos el código.
### Ejemplo de Platzi
#class Millas:
# def __init__(self):
# self._distancia = 0
# Función para obtener el valor de _distancia
# Usando el decorador property
# @property
# def obtener_distancia(self):
# print("Llamada al método getter")
# return self._distancia
# Función para definir el valor de _distancia
# @obtener_distancia.setter
# def definir_distancia(self, valor):
# if valor < 0:
# raise ValueError("No es posible convertir distancias menores a 0.")
# print("Llamada al método setter")
# self._distancia = valor
## Creamos un nuevo objeto
#avion = Millas()
## Indicamos la distancia
#avion.distancia = 200
# Obtenemos su atributo distancia
#print(avion.definir..distancia)De esta manera usamos el decorador @property para utilizar getters y setters de una forma más prolija e incluimos una nueva funcionalidad a nuestro método definir_distancia() , al mismo tiempo protegemos el acceso a nuestras variables privadas y cumplimos con el principio de encapsulación.
### Ejemplo de Platzi
# class Coordenada:
# def __init__(self, x, y):
# self.x = x
# self.y = y
# def distancia(self, otra_coordenada):
# x_diff = (self.x - otra_coordenada)**2
# y.diff = (self.y - otra_coordenada.y)**2
# return (x_diff + y_diff)**0.5
# if __name__=='__main__':
# coord_1 = Coordenada(3,30)
# coord_2 = Coordenada(4,8)
# print(coord_1.distancia(coord_2))
# print(isintance(coord_2, Coordenada))Tipos de datos abstractos
Una vez se posee un objeto se tiene varias ventajas como:
Decomposición (estructurar datos mas pequeños). Ejemplo, un árbol se compone de las raices, el tronco, las ramas, las hojas y etc.
Abstracción (Sin preocupaciones por la forma en que se genera). Ejemplo, para modelar el crecimiento forestal de una especie no es necesario incluir dentro de mi código el mecanismo por el cual los árboles capturan carbono en el xilema.
Encapsular (“Esconder” los datos que no son relevantes en el momento). Ejemplo, poseeo datos de un aprovechamiento forestal detallado y deseo modelar la biomasa area, por ende, no es necesario tener en mi conjunto de datos los asociados a las raices.
Decomposición
Consiste decomponer datos en datos mas pequeños (partes). Esto os ayuda a mantener un nuestro código o software de manera mas sencilla
Para ellos, las clases nos permite generar estas partes de un objeto y estas partes en su conjunto nos ayuda a resolver los problemas existentes.
### Ejemplo propio
#class Arbol:
# def __init__(self, especie, dap, altura, densidad):
# self.especie = especie
# self.dap = Dap
# self.altura = altura
# self.densidad = densidad
# self._estado = 'en tratamiento fitosanitario'
# self.aprovechar = 'No'
#class Dap:
# def __init__(self, perimetro = 'cm')
# if perimetro == 'cm':
# self.dap.funcion(perimetro/pi)
# else:
# sel.dap.funcion1(perimetro/(100*pi))Abstraccón
Cosiste en enfocarno en la informacion relevante, para ello, separamos la informacion irrelevante de nuesto conjunto de datos. Para eso, podemos utilizar variables y métodos (privados o públicos).
Las funciones matematicas y fisicas son abstracciones que nos permiten entender el universo.
### Ejemplo de Platzi
#class Lavadora:
# def __initi__(self):
# def lavar(self, temperatura = 'caliente')
# self._llenar_tanque_de_agua(temperatura)
# self._anadir_jabon()
# self._lavar()
# self._centrifugar()
# def _llenar_tanque_de_agua(self, temperatura):
# print(f'Llenando el tanque con agua {temperatura}')
# def _anadir_jabon(self):
# print('Anadiendo jabon')
# def _lavar(self):
# print('Lavando la ropa')
# def _centrifugar(self):
# print('Centrifugando la ropa')
#if __name__ == '__main__':
# lavadora = Lavadora()
# lavadora.lavar()Encapsulacion
Permite agrupas datos y su comportamiento. Controla el acceso a dichos datos y previene modificaciones no autorizadas.
Lo importante de encapsulacion es la programación defensiva que nos permite controlar el acceso a los datos, asi como, sus modificaciones.
A veces cuando se modifica una de nuestras clases esta se romper. Por tanto, podemos utilizar la programacion defensiva para determinar cuando y como se modifica una clase u propiedad, ademas, de cuando y como podemos extraer información de esta clase.
### Ejemplo de Platzi
#class CasillaDeVotacion:
# def __init__(self, identificador, pais):
# self._identificador = identificador
# self._pais = pais
# self._region = None
# @property
# def region(self):
# return self._region
# @region.setter
# def region(self, region):
# if region in self._pais:
# self._region = region
# else:
# raise ValueError(f'La region {region} no es valida en {self._pais}')
#casilla = CasillaDeVotacion(123, ['Ciudad de Mexico', 'Morelos'])
#print(casilla.region)
#casilla.region = 'Ciudad de Mexico'
#print(casilla.region)Nota: Para poder definir que una función es una propiedad (acceder a la función atraves de doc_notation) simplemente escribimos @property (implementación getters). Posteriormente, cuando tenemos otra función que nos permite modificar el valor anterior, normalmente se usa @NombrePropiedad.setter (notación de decoradores)(implementación setters). Dentro de estas funciones se puede definir cuando y como se puede acceder a las variables, asi como, cuando y como podemos modificarlas.Por tanto, nos da control de nuestra clase y previene que cambios no autorizados cambien la logica de nuestras clases.
Herencia
La herencia nos permite modelar una jerarquia de objetos, asi como, compartir cierto comportamiento entre cada uno de los objetos. Esto nos ayuda a tener:
- Un código más organizado.
- Una jerarquia con sentido.
En consecuencia, la herencia nos permite reutilizar código, dado que, si tenemos un comportamiento que es común entre una serie de objetos de la misma categoría, este comportamiento debe enviarse a un superclase que permita compartirlo con las subclases (heredan el comportamiento). Las subclases tiene una mayor especialización.
### Ejemplo de Platzi con variaciones realizadas por mi
#menu = """
#1 - Rectangulo
#2 - Cuadrado
#Elige una opcion: """
#opcion= int(input(menu))
#class Rectangulo:
# def __init__(self, base, altura):
# self.base = base
# self.altura = altura
# def area(self):
# return self.base*self.altura
#class Cuadrado(Rectangulo):
# def __init__(self, lado):
# super().__init__(lado, lado)
#if __name__=='__main__':
# if opcion == 1:
# b = float(input("Ingresa el tamaño de la base: "))
# a = float(input("Ingresa el #tamaño de la altura: "))
# rectagulo = Rectangulo(base= b, altura= a)
# print(rectagulo.area())
# else :
# l = float(input("Ingresa el tamaño del lado: "))
# cuadrado =Cuadrado(lado=l)
# print(f' El área de la figura geometrica es de: {cuadrado.area()} unidades cuadradas')Polimorfismo
El polimorfismo es la habilidad de tomar varias formas. Ademas, es un forma de herencia en la que la implementación base se altera. Entonces, el polimorfismo seria una herencia con edit.
Python, nos permite cambiar el comportamiento de una superclase para adaptarlo a la subclase. Es otras palabras, tomamos el nombre del método de la superclase y lo implementamos de manera distinta en la subclase.
# Ejemplo de Platzi
#class Persona:
# def __init__(self, nombre):
# self.nombre = nombre
# def avanza(self):
# print('Ando caminando')
#class Ciclista(Persona):
# def __init__(self, nombre):
# super().__init__(nombre)
# def avanza(self):
# print('Ando moviendome en mi bicicleta')
#def main():
# persona = Persona('Jhon')
# persona.avanza()
# ciclista = Ciclista('Carlos')
# ciclista.avanza()
#if __name__=='__main__':
# main()La linea de codigo class Cicilista(Persona):. Se lee, la ciclista extiende a persona y la funcion super() nos permite obtener una referencia a la superclase, asi inicializandola.
Introducción a la complejidad algorítmica
La complejidad algorítmica nos permite comparar la eficiencia entre difenetes algoritmos y esto a su vez nos va a permitir predecir el tiempo que tardaremos en resolver un problema (compejidad temporal) y cuanto espacio ocupara en memoria (comlejidad espacial).
La complejidad algorítmica en terminos temporales la podemos definir como T(n), es decir, la funcion T que recibe el input n. Esto determinara el tiempo que se va a tardar nuestro algoritmo.
La forma estandar en la que se mide la complejidad algorítmica es contando los pasos con una medida asintotica, es decir, conforme se acerca al infinito.
#Ejemplo de Platzi
#import time
#def factorial(n):
# respuesta = 1
# while n > 1:
# respuesta *= n
# n -= 1
# return respuesta
#def factorial_r(n):
# if n == 1:
# return 1
# return n * factorial(n - 1)
#if __name__=='__main__':
# n = 10000
# comienzo = time.time()
# factorial(n)
# final = time.time()
# print(final - comienzo)
# comienzo = time.time()
# factorial_r(n)
# final = time.time()
# print(final - comienzo)Conteo abstracto de operación
Notación asintótica
El enfoque se centra en lo que pasa conforme el tamaño del problema se acerca al infinito. Por tanto, no importan las variaciones pequeñas.
Big O notation: Mide el peor de los casos. Esto es lo que mejor nos permite comparar nuestro algoritmos y por ende entender cual es la complejidad real algorítmica. Aqui lo que importa es el termino de mayor tamaño.
###Ejemplo de Platzi
#-----------------------
###Ley de la suma
###1
#def f(n):
# for i in range (n):
# print(i)
# for i in range(n):
# print(i)
### O(n) + O(n) = O(n+n) = O(2n) = O(n)
### Esta función tiene un crecimiento lineal
### con respecto a n (crece en O(n))
#-------------------------
###2
#def f(n):
# for i in range (n):
# print(i)
# for i in range(n * n):
# print(i)
# O(n) + O(n * n) = O(n + n^2) = O(n^2)
### Por tanto, crece en 0(n^2)
### Funcion cuadratica
#--------------------
###Ley multiplicacion
###3
#def f(n):
# for i in range(n):
# for j in range(n):
# print(i, j)
### loop dentro de otro loop
### O(n) * O(n) = O(n * n) = O(n^2)
### Funcion cuadratipa
#--------------------
### Recursividad multiple
### 4
#def fibonacci(n):
# if n == 0 or n ==1:
# return 1
# return fibonacci(n - 1) + #fibonacci(n - 2)
### O(2**n)
### Crecimiento exponencialTipos
O(1) Constante
O(n) Lineal
O(log n) Logarítmica
O(n log n) Log lineal
O(n**2) Polinomial
O(2**n) Exponencial
Complejidad algorítmica
Algoritmos de busquedad y ordenacion
Busquedad Lineal (implementación iterativa)
Busca en todos los elementos de manera secuencial.
###Ejemplo de Platzi
#import random
#def busqueda_lineal(lista, objetivo):
# match = False
# for elemento in lista:
# if elemento == objetivo:
# match = True
# break
# return match
#if __name__ == '__main__':
# tamano_de_lista = int(input('De que tamaño sera la lista?:'))
# objetivo = int(input('Que número quieres encontrar?:'))
# lista = [random.randint(0,100) for i in range(tamano_de_lista)]
# encontrado = busqueda_lineal(lista, objetivo)
# print(lista)
# print(f'El elemento {objetivo}{" esta" if encontrado else " no esta"} en la lista')
### Este código tiene una complejidad algorítmica O(n)-->LinealBúsqueda binaria (implementación recursiva)
Divide y conquista. El problema se divide en dos en cada iteración. Por tanto, el problema se hace cada vez más pequeño hasta que nos aproximamos a la solución.
Esta búsqueda asume que tenemos una lista ordenada.
###Ejemplo de Platzi
#import random
#def busqueda_binaria(lista, comienzo, final, objetivo):
# print(f'buscando el {objetivo} entre {lista[comienzo]} y {lista[final - 1]}')
# if comienzo > final:
# return False
# medio = (comienzo + final)//2
# if lista[medio] == objetivo:
# return True
# elif lista[medio] < objetivo:
# return busqueda_binaria(lista, medio + 1, final, objetivo)
# else:
# return busqueda_binaria(lista, comienzo, medio - 1, objetivo)
#if __name__ == '__main__':
# tamano_de_lista = int(input('De que tamaño sera la lista?:'))
# objetivo = int(input('Que número quieres encontrar?:'))
# lista = sorted([random.randint(0,100) for i in range(tamano_de_lista)])
# encontrado = busqueda_binaria(lista, 0, len(lista), objetivo)
# print(lista)
# print(f'El elemento {objetivo}{" esta" if encontrado else " no esta"} en la lista')Ordenamiento de burbuja
Es un algotimo que recorre repetidamente una lista que necesita ordenarze. Compara elementos adyacentes y los intercambias si están en el orden incorrecto. Este procedimiento se repite hasta que no se requieran mas intercambios, lo que indica que la lista se encuentra ordenada.
###Ejemplo de Platzi
#import random
#def ordenamiento_burbuja(lista):
# n = len(lista)
# for i in range(n):
# for j in range(0, n - i - 1):
# if lista[j] > lista[j + 1]:
# lista[j], lista[j + 1] = lista[j + 1], lista[j]
# return lista
#if __name__=='__main__':
# tamano_lista = int(input('De que tamaño sera la lista?:'))
# lista = [random.randint(0,100) for i in range(tamano_lista)]
# print(lista)
# lista_ordenada = ordenamiento_burbuja(lista)
# print(lista_ordenada)
### Complejidad algoritmica: funcion cuadraticaFuncionamiento script: Obtenemos la longitud de nuestra lista para poder iterar al largo de toda la lista. Cada vez que iteramos en cada uno de los elementos de la lista, vamos a iterar internamente hasta el final, posteriormente, se itera cada vez un poco menos, dado que garantizamos que nuestros datos estan ordenados. Al recorrer el segundo loop estamos comparando los elementos adyacentes, por tanto, si el elemnto adyacente es menor, intercambiamos los elementos.
Ordenamiento por inserción
Este algoritmo ordena “in situ”. Es decir, que no se crea una nueva lista con los elementos ordenados sino que modifica los valores en memoria.
Funcionamiento: El primer elemento se encuetra ordenado por definicion (base) y el resto de elementos se organiza a partir de este. Esto a partir de comparar el resto de los elementos con las base y si el elemento coparado es mayor que la base, el comparado se mueve a la derecha, posteriormente, se realiza lo mismo a partir de los elementos ya organizados.