Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Programación orientada a objetos y manejo de ex...

Programación orientada a objetos y manejo de excepciones

Avatar for Abraham Zamudio

Abraham Zamudio

September 17, 2026

More Decks by Abraham Zamudio

Other Decks in Education

Transcript

  1. Índice 1. Introducción 1.1. Del problema al modelo: por qué

    importan los paradigmas . . . . . . . . . . . . . . 1.2. Definición formal de los constructos fundamentales . . . . . . . . . . . . . . . . . . 1.3. La POO como estrategia de gestión de la complejidad . . . . . . . . . . . . . . . . . 1.4. Por qué POO en computación científica e ingeniería . . . . . . . . . . . . . . . . . . 1.5. Python como vehículo: el modelo de datos . . . . . . . . . . . . . . . . . . . . . . . 1.6. Principios de diseño orientado a objetos . . . . . . . . . . . . . . . . . . . . . . . . 1.7. Ejemplo integrador motivacional . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.8. Alcance y organización del documento . . . . . . . . . . . . . . . . . . . . . . . . . 6 6 6 7 7 9 9 10 11 2. Fundamentos Conceptuales 2.1. El objeto como unidad ontológica . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.2. Identidad, igualdad y equivalencia . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.3. La clase como especificación y fábrica . . . . . . . . . . . . . . . . . . . . . . . . . 2.4. Atributos de clase y de instancia . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.5. El proceso de instanciación . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.6. Casos de uso diferenciados de __new__ y __init__ . . . . . . . . . . . . . . . . . . 2.7. El ciclo de vida del objeto . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.8. Relaciones entre los constructos . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.9. Síntesis operativa . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12 12 13 13 14 15 16 16 17 17 3. Clases y Objetos en Python 3.1. Sintaxis declarativa y sus componentes . . . . . . . . . . . . . . . . . . . . . . . . . 3.2. Ejemplo canónico . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.3. El parámetro self y el protocolo de enlace . . . . . . . . . . . . . . . . . . . . . . 3.4. Tipos de métodos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.5. Resolución de atributos: el protocolo completo . . . . . . . . . . . . . . . . . . . . 3.6. Atributos de clase vs. atributos de instancia . . . . . . . . . . . . . . . . . . . . . . 3.6.1. El error clásico: mutables compartidos . . . . . . . . . . . . . . . . . . . . . 3.6.2. Diagnóstico formal del error . . . . . . . . . . . . . . . . . . . . . . . . . . 3.7. Casos legítimos de atributos de clase . . . . . . . . . . . . . . . . . . . . . . . . . . 3.8. Síntesis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17 18 18 19 20 21 21 22 22 23 23 4. Los Cuatro Pilares de la POO 4.1. Abstracción . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.1.1. Mecanismos de abstracción en Python . . . . . . . . . . . . . . . . . . . . . 4.1.2. Clases base abstractas: verificación en tiempo de instanciación . . . . . . . . 4.1.3. Protocolos: abstracción estructural . . . . . . . . . . . . . . . . . . . . . . . 4.2. Encapsulamiento . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.2.1. Convenciones de visibilidad . . . . . . . . . . . . . . . . . . . . . . . . . . 4.2.2. Propiedades: acceso controlado . . . . . . . . . . . . . . . . . . . . . . . . 4.2.3. Alternativas declarativas . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.3. Herencia . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.3.1. La relación es-un y sus límites . . . . . . . . . . . . . . . . . . . . . . . . . 4.3.2. Herencia simple: ejemplo canónico . . . . . . . . . . . . . . . . . . . . . . 4.3.3. Herencia cooperativa . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.4. Polimorfismo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.4.1. Duck typing y despacho dinámico . . . . . . . . . . . . . . . . . . . . . . . 24 24 24 24 26 27 27 28 29 29 29 30 31 31 32
  2. Programación en Python para Ingeniería ÍNDICE 4.4.2. Verificación estructural explícita

    . . . . . . . . . . . . . . . . . . . . . . . . 4.4.3. Polimorfismo paramétrico con genéricos . . . . . . . . . . . . . . . . . . . 4.5. Interacción entre los cuatro pilares . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.6. Síntesis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32 33 33 34 5. Herencia Múltiple y MRO 5.1. El problema de la ambigüedad . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.2. Definición formal del MRO . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.3. El algoritmo C3 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.3.1. Traza manual sobre una jerarquía simple . . . . . . . . . . . . . . . . . . . . 5.4. El problema del diamante . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.5. Herencia cooperativa con super() . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.6. Errores de linealización . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.7. Uso avanzado: mixins . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.8. super() en métodos de clase y estáticos . . . . . . . . . . . . . . . . . . . . . . . . 5.9. Anti-patrones frecuentes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.9.1. Llamada directa a la clase base . . . . . . . . . . . . . . . . . . . . . . . . . 5.9.2. Herencia múltiple sin propósito . . . . . . . . . . . . . . . . . . . . . . . . 5.10. Síntesis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34 34 35 35 36 36 37 38 38 40 40 40 41 41 6. Métodos Especiales (Dunder Methods) 6.1. El modelo de datos como protocolo . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2. Taxonomía de métodos especiales . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.3. Ejemplo canónico: vector matemático . . . . . . . . . . . . . . . . . . . . . . . . . 6.4. Representación: __repr__ vs. __str__ . . . . . . . . . . . . . . . . . . . . . . . . 6.5. Comparación y hashing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.6. Aritmética: métodos directos, reflejados e in-place . . . . . . . . . . . . . . . . . . . 6.7. Protocolo de contenedor e iteración . . . . . . . . . . . . . . . . . . . . . . . . . . 6.8. Gestores de contexto . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.9. Métodos especiales para acceso a atributos . . . . . . . . . . . . . . . . . . . . . . . 6.10. Errores frecuentes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.10.1. Confundir __repr__ con __str__ . . . . . . . . . . . . . . . . . . . . . . . 6.10.2. Violar el contrato de hashing . . . . . . . . . . . . . . . . . . . . . . . . . . 6.10.3. Devolver self en métodos no in-place . . . . . . . . . . . . . . . . . . . . . 6.10.4. Definir __eq__ sin NotImplemented . . . . . . . . . . . . . . . . . . . . . 6.11. Síntesis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41 42 42 43 44 45 45 46 47 48 49 49 49 49 49 49 7. Manejo de Excepciones 7.1. Modelo formal . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.2. Jerarquía de excepciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.3. Excepciones personalizadas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.4. La construcción try completa . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.5. Orden de los manejadores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.6. Captura múltiple y captura agrupada . . . . . . . . . . . . . . . . . . . . . . . . . . 7.7. Encadenamiento de excepciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.8. Re-lanzamiento y supresión . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.9. Excepciones y generadores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.10. Gestores de contexto como alternativa . . . . . . . . . . . . . . . . . . . . . . . . . 7.11. Anti-patrones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.11.1. Capturar Exception indiscriminadamente . . . . . . . . . . . . . . . . . . . 7.11.2. Usar excepciones para control de flujo normal . . . . . . . . . . . . . . . . . 49 50 50 51 52 53 54 54 55 55 56 57 57 57 Abraham Zamudio 4
  3. Programación en Python para Ingeniería ÍNDICE 7.11.3. Perder la causa

    original . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.11.4. Anidar try innecesariamente . . . . . . . . . . . . . . . . . . . . . . . . . . 7.12. Excepciones en contexto científico . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.13. Síntesis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57 58 58 59 8. Aplicación Integradora: Simulador Modular 8.1. Diseño de la arquitectura . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.2. Jerarquía de excepciones del dominio . . . . . . . . . . . . . . . . . . . . . . . . . 8.3. El estado como dataclass inmutable . . . . . . . . . . . . . . . . . . . . . . . . . . 8.4. El problema físico como clase abstracta . . . . . . . . . . . . . . . . . . . . . . . . 8.5. El integrador como clase abstracta . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.6. El resultado como estructura inmutable . . . . . . . . . . . . . . . . . . . . . . . . 8.7. El simulador como orquestador . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.8. Ejemplo de uso . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.9. Extensiones naturales . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.10. Análisis retrospectivo del diseño . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.11. Síntesis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59 60 60 61 62 64 65 66 68 69 70 70 9. Conclusion : 70 10. Conclusión 70 Abraham Zamudio 5
  4. Programación en Python para Ingeniería 1 Introducción 1.1 Del problema

    al modelo: por qué importan los paradigmas Todo programa es, en última instancia, una representación computacional de un fenómeno del mundo real. La pregunta fundamental de la ingeniería de software no es qué calcular, sino cómo organizar el conocimiento necesario para calcularlo. Los paradigmas de programación son precisamente eso: ontologías operativas que determinan qué entidades existen en un programa, cómo se relacionan y qué transformaciones son legítimas sobre ellas. El paradigma procedural, dominante desde los años sesenta, organiza el software como una secuencia de transformaciones sobre datos pasivos. El paradigma funcional lo organiza como composición de funciones puras sin estado mutable. El paradigma de Programación Orientada a Objetos (POO), formulado por primera vez en Simula 67 por Ole-Johan Dahl y Kristen Nygaard, y madurado en Smalltalk por Alan Kay, propone una tercera vía: organizar el software en torno a agentes computacionales autónomos que encapsulan su propio estado y se comunican mediante mensajes. La POO no es, por tanto, una característica sintáctica que Python agregue al lenguaje, sino un paradigma de modelado que se impone sobre la sintaxis y la trasciende. Un mismo programa en Python puede escribirse con o sin clases; la diferencia entre ambas versiones no reside en el número de palabras reservadas utilizadas, sino en la topología conceptual del sistema resultante. La POO sostiene que el software bien diseñado debe reflejar la estructura del dominio que modela, y que esa estructura debe ser explícita, inspeccionable y modificable. 1.2 Definición formal de los constructos fundamentales Antes de abordar la dimensión práctica de la POO, conviene precisar con rigor los objetos matemáticos sobre los que opera. A diferencia de otras disciplinas ingenieriles, la POO carece de una formalización universalmente aceptada, pero la comunidad ha convergido en un conjunto mínimo de conceptos que toda implementación debe respetar. Objeto (formal) Un objeto es una tupla ordenada O = ⟨𝜎, 𝜇, 𝜄⟩ donde: 𝜎 es el estado, un conjunto finito de variables (atributos) con valores mutables a lo largo del tiempo de ejecución; 𝜇 es el conjunto de operaciones (métodos) que el objeto expone al exterior, cada una con su firma y semántica; 𝜄 es la identidad, un testigo unívoco que distingue al objeto de cualquier otro con el mismo estado, incluso si 𝜎 coincide valor a valor con el de otro objeto. Formalmente, 𝜄 se materializa en Python como la dirección de memoria devuelta por id(obj), aunque su valor concreto es un detalle de implementación de CPython y no debe ser usado como semántica portable. La distinción entre valor y identidad es crítica. Dos objetos con 𝜎1 = 𝜎2 pueden ser iguales (relación de equivalencia ≈, definida por __eq__) o idénticos (𝜄1 = 𝜄2 , verificado con is). En numpy, por ejemplo, dos ndarray que contienen los mismos valores son iguales elemento a elemento, pero raramente idénticos en el sentido de is. Abraham Zamudio 6
  5. Programación en Python para Ingeniería1.3 La POO como estrategia de

    gestión de la complejidad Clase Una clase es una especificación que define simultáneamente la estructura del estado (qué atributos tendrán sus instancias), el contrato comportamental (qué métodos estarán disponibles) y las invariantes que se preservarán durante la vida del objeto. Formalmente, una clase es una fábrica y un contrato: una función de Σ∗ → O que produce objetos, y un predicado Φ : O → B que verifica que el objeto es válido. En Python, las clases no son meras construcciones sintácticas: son objetos de primera clase (instancias de la metaclase type) que pueden pasarse como argumentos, inspeccionarse en tiempo de ejecución y modificarse dinámicamente. Esta homogeneidad ontológica, en la que todo es un objeto incluyendo las clases mismas, es una de las características más distintivas del modelo de datos de Python. 1.3 La POO como estrategia de gestión de la complejidad El argumento central a favor de la POO no es estético sino económico. Los sistemas de software industriales alcanzan típicamente entre 105 y 107 líneas de código, con cientos de módulos interdependientes desarrollados por equipos distribuidos a lo largo de años. En tales condiciones, la complejidad accidental del código — aquella derivada de la organización interna del programa, no del problema que resuelve — supera con creces la complejidad esencial del dominio. La POO ofrece cuatro mecanismos concretos para contener esta complejidad: 1. Localidad: toda la lógica relacionada con un concepto reside en un único lugar (la clase). Los cambios se propagan localmente en lugar de difundirse transversalmente. 2. Contratos explícitos: la interfaz pública de una clase es un compromiso estable que aísla a sus clientes de los detalles internos. Cambiar la implementación no rompe el sistema si el contrato se preserva. 3. Extensibilidad: la herencia y la composición permiten añadir funcionalidad sin modificar el código existente, siguiendo el Principio Abierto/Cerrado de Bertrand Meyer. 4. Modelado del dominio: los objetos del programa corresponden a entidades nombrables del problema (un Fluido, un Integrador, una Red Neuronal), lo que reduce la distancia semántica entre especificación y código. Estas ventajas no son gratuitas. La POO introduce indirecciones (llamadas virtuales, dispatch dinámico, resolución de métodos) que pueden degradar el rendimiento en rutas críticas. Su uso indiscriminado conduce a jerarquías profundas, acoplamiento oculto y abstracciones prematuras. Las secciones posteriores de estas notas abordan explícitamente estos riesgos y las técnicas para mitigarlos. 1.4 Por qué POO en computación científica e ingeniería La comunidad de computación científica ha sido históricamente reticente a la POO. La razón es sencilla: en sus orígenes, el trabajo numérico se concentraba en rutinas de álgebra lineal donde los datos son homogéneos (float64 sobre arreglos densos) y las operaciones son transformaciones bien definidas. En ese dominio, la vectorización de numpy y el paradigma funcional asociado son claramente superiores a cualquier jerarquía de clases. Sin embargo, el perfil de la computación científica contemporánea ha cambiado radicalmente. Los sistemas actuales combinan: Múltiples escalas físicas: desde la mecánica cuántica hasta la dinámica de fluidos, cada una con su propio vocabulario y sus propias invariantes. Abraham Zamudio 7
  6. Programación en Python para Ingeniería 1.4 Por qué POO en

    computación científica e ingeniería Múltiples backends numéricos: CPU, GPU, TPU, clusters distribuidos, hardware especializado (JAX, CuPy, PyTorch). Múltiples fuentes de datos: experimentos instrumentados, simulaciones previas, bases de datos externas, APIs remotas. Múltiples esquemas de validación: unidades físicas, condiciones de frontera, restricciones de conservación, tolerancias numéricas. Múltiples consumidores: scripts de análisis, notebooks interactivos, pipelines automatizados, servicios web. Esta heterogeneidad no se gestiona bien con funciones sueltas. Se gestiona con abstracciones explícitas que capturen las invariantes del dominio y las hagan cumplir mecánicamente. Un objeto CampoElectromagnetico que conoce sus unidades, su malla de discretización y sus condiciones de frontera es categóricamente distinto de un numpy.ndarray anónimo al que el usuario debe recordar aplicarle la transformación correcta. Considere el siguiente contraste. En un estilo puramente procedural, un solver de elementos finitos podría verse así: 1 2 3 4 5 6 def resolver ( malla , material , fuente , bc_izq , bc_der , tol , max_iter ) : K = ensamblar_rigidez ( malla , material ) F = ensamblar_fuerza ( malla , fuente ) K , F = aplicar_dirichlet (K , F , malla , bc_izq , bc_der ) u = gradiente_conjugado (K , F , tol , max_iter ) return u Listing 1: Solver procedural: toda la información dispersa en argumentos. El código funciona, pero cada parámetro es un contrato implícito. Agregar una nueva condición de frontera requiere modificar la firma de resolver, lo que rompe la compatibilidad con todos sus llamadores. Cambiar el material de isótropo a anisótropo requiere modificar ensamblar_rigidez, que a su vez depende del formato exacto del argumento material. La complejidad accidental crece linealmente con cada nueva capacidad. En estilo orientado a objetos, la misma lógica se distribuye entre entidades autónomas: 1 2 3 4 5 6 class Problema : def __init__ ( self , malla , material , fuente , condiciones ) : self . malla = malla self . material = material self . fuente = fuente self . condiciones = condiciones # lista de CondicionFrontera 7 8 9 10 11 class Solver : def __init__ ( self , tol : float = 1e -8 , max_iter : int = 1000) : self . tol = tol self . max_iter = max_iter 12 13 14 15 16 17 18 def resolver ( self , problema : Problema ) -> " ndarray " : K = problema . malla . rigidez ( problema . material ) F = problema . malla . fuerza ( problema . fuente ) for bc in problema . condiciones : bc . aplicar (K , F ) return self . _gradiente_conjugado (K , F ) Listing 2: Solver orientado a objetos: cada entidad conoce su contrato. Ahora añadir una condición de frontera periódica es extensión (una nueva subclase de CondicionFrontera), no modificación. Cambiar el material es cambiar el objeto Material pasado al constructor, sin tocar el Abraham Zamudio 8
  7. Programación en Python para Ingeniería 1.5 Python como vehículo: el

    modelo de datos Solver. La complejidad accidental permanece constante mientras la complejidad esencial del problema crece. 1.5 Python como vehículo: el modelo de datos Python no impone la POO como Java o C++. Ofrece, en cambio, un modelo de datos rico y coherente que permite que los objetos definidos por el usuario participen de la sintaxis nativa del lenguaje. Esta propiedad, conocida como duck typing en su dimensión dinámica y como protocolo de dunder methods en su dimensión formal, es lo que hace a Python especialmente adecuado para construir abstracciones científicas sin sacrificar expresividad. Concretamente, el modelo de datos de Python establece que: Toda entidad es un objeto, incluidas funciones, módulos, clases y tipos primitivos. No existe una distinción ontológica entre “valores” y “referencias”. Todo comportamiento se delega a métodos especiales. La operación a + b no es primitiva: es azúcar sintáctico para type(a).__add__(a, b). La iteración for x in c invoca iter(c) y luego next() repetidamente. Los protocolos son implícitos. Un objeto es iterable si define __iter__, es un gestor de contexto si define __enter__ y __exit__, es un número si define __add__ y __mul__. No se requiere herencia de interfaces nominales. La introspección es universal. type(), dir(), getattr() y inspect permiten examinar y manipular objetos en tiempo de ejecución, lo que habilita técnicas de metaprogramación imposibles en lenguajes compilados estáticamente. Esta arquitectura tiene consecuencias profundas. Un ndarray de NumPy no hereda de ninguna clase “numérica”: simplemente implementa __add__, __mul__, __getitem__, __matmul__ y una veintena de operaciones especializadas. Un pandas.DataFrame no es una “lista”: implementa el protocolo de iteración y el protocolo de acceso por índice. Una nn.Module de PyTorch no es un “modelo”: implementa __call__ y expone parámetros como atributos. Comprender este modelo es la condición previa para diseñar abstracciones efectivas. No basta con escribir class Foo: pass: es necesario entender qué protocolos debe implementar Foo para que se comporte como un número, una colección, una función o un gestor de contexto. 1.6 Principios de diseño orientado a objetos La sintaxis de la POO es trivial; el diseño orientado a objetos es difícil. La comunidad ha destilado la experiencia de décadas en un conjunto de principios que, aunque heurísticos, orientan las decisiones de diseño hacia estructuras mantenibles. Abraham Zamudio 9
  8. Programación en Python para Ingeniería 1.7 Ejemplo integrador motivacional Principios

    SOLID SRP Single Responsibility Principle: una clase debe tener una única razón para cambiar. Si dos actores distintos (el físico y el ingeniero de infraestructura) pueden requerir modificaciones sobre la misma clase, la clase tiene demasiadas responsabilidades. OCP Open/Closed Principle: las entidades deben estar abiertas a extensión pero cerradas a modificación. Añadir funcionalidad debe requerir nuevo código, no cambios en el existente. LSP Liskov Substitution Principle: los objetos de una subclase deben poder sustituir a los de su superclase sin alterar la corrección del programa. Una función que acepta Fluido debe funcionar con cualquier subclase sin conocer sus detalles. ISP Interface Segregation Principle: los clientes no deben depender de interfaces que no usan. Es preferible muchas interfaces pequeñas que una grande. DIP Dependency Inversion Principle: los módulos de alto nivel no deben depender de módulos de bajo nivel; ambos deben depender de abstracciones. A estos principios clásicos se añaden otros específicos del trabajo numérico: Preferir composición sobre herencia. La herencia es la relación más fuerte que puede establecerse entre dos clases y debe reservarse para jerarquías genuinas de es-un. La composición (tiene-un) es más flexible y menos acoplada. Separar política de mecanismo. El código que decide qué hacer (orquestación, control de flujo) debe aislarse del código que sabe cómo hacerlo (álgebra lineal, integración numérica, E/S). Hacer explícitas las invariantes. Las precondiciones, postcondiciones e invariantes de una clase deben documentarse y, cuando sea posible, verificarse mecánicamente. Evitar la herencia múltiple no cooperativa. En Python, el MRO de C3 es determinista pero puede producir resultados contraintuitivos si las clases base no cooperan mediante super(). 1.7 Ejemplo integrador motivacional Para fijar las ideas, considere el siguiente fragmento que reúne los conceptos discutidos: encapsulamiento, polimorfismo, manejo de excepciones y uso del modelo de datos. 1 from abc import ABC , abstractmethod 2 3 4 5 6 class Fluido ( ABC ) : """ Fluido homogeneo con propiedades termodinamicas conocidas . """ 7 8 9 def __init__ ( self , nombre : str ) -> None : self . nombre = nombre 10 11 12 13 14 15 @property @abstractmethod def rho ( self ) -> float : " " " Densidad en kg / m ^3. " " " ... 16 17 18 19 @property @abstractmethod def mu ( self ) -> float : Abraham Zamudio 10
  9. Programación en Python para Ingeniería 1.8 Alcance y organización del

    documento " " " Viscosidad dinamica en Pa * s . " " " ... 20 21 22 def reynolds ( self , v : float , L : float ) -> float : " " " Numero de Reynolds . Re = rho * v * L / mu . " " " if self . mu <= 0: raise ValueError ( f " Viscosidad no positiva : { self . mu } " ) return self . rho * v * L / self . mu 23 24 25 26 27 28 def __repr__ ( self ) -> str : return f " { type ( self ) . __name__ }( nombre ={ self . nombre ! r }) " 29 30 31 32 33 34 35 36 class Agua ( Fluido ) : def __init__ ( self , T_celsius : float = 20.0) -> None : super () . __init__ ( " agua " ) self . T = T_celsius 37 @property def rho ( self ) -> float : return 998.2 * (1 - 3e -4 * ( self . T - 20) ) 38 39 40 41 @property def mu ( self ) -> float : return 1.002 e -3 * (1 - 0.024 * ( self . T - 20) ) 42 43 44 45 46 47 48 49 50 class Aire ( Fluido ) : def __init__ ( self , T_celsius : float = 20.0 , p_atm : float = 101325.0) -> None : super () . __init__ ( " aire " ) self . T = T_celsius self . p = p_atm 51 @property def rho ( self ) -> float : return self . p / (287.05 * ( self . T + 273.15) ) 52 53 54 55 @property def mu ( self ) -> float : return 1.825 e -5 * (( self . T + 273.15) / 293.15) ** 0.7 56 57 58 Listing 3: Ejemplo integrador: un fluido con invariantes verificadas. Este ejemplo exhibe varias propiedades deseables. La clase Fluido es abstracta (no puede instanciarse directamente) y define un contrato explícito: cualquier subclase debe proveer rho y mu. El método reynolds se implementa una sola vez, en la clase base, y funciona correctamente para cualquier fluido presente o futuro. Las propiedades se calculan dinámicamente, de modo que un cambio en T se refleja inmediatamente en rho y mu sin intervención del usuario. La validación de la viscosidad previene condiciones no físicas. Finalmente, __repr__ proporciona una representación legible que se usa automáticamente en notebooks y depuradores. 1.8 Alcance y organización del documento Las secciones que siguen desarrollan progresivamente los conceptos esbozados en esta introducción: La Sección 2 formaliza los constructos fundamentales: objetos, clases e instancias. La Sección 3 introduce la sintaxis de Python para definir clases, distinguiendo atributos de clase e instancia. Abraham Zamudio 11
  10. Programación en Python para Ingeniería La Sección 4 aborda los

    cuatro pilares clásicos: abstracción, encapsulamiento, herencia y polimorfismo. La Sección 5 discute herencia múltiple y el algoritmo MRO de C3. La Sección 6 introduce los métodos especiales y su papel en el modelo de datos. La Sección 7 trata el manejo de excepciones como mecanismo de control de flujo y de traducción entre capas. La Sección 8 sintetiza todo lo anterior en un simulador modular. El lector objetivo es un estudiante de ingeniería o ciencias con experiencia previa en programación procedural y conocimientos básicos de Python. No se asume familiaridad con la POO; sí se asume disposición a cuestionar hábitos procedurales profundamente arraigados. 2 Fundamentos Conceptuales El modelo de objetos de Python reposa sobre tres constructos mutuamente definidos: objeto, clase e instancia. Aunque su formulación intuitiva es sencilla, su caracterización rigurosa exige precisar las relaciones de identidad, pertenencia y creación que los vinculan. Esta sección establece dicha caracterización y la ilustra con el comportamiento concreto del intérprete CPython. 2.1 El objeto como unidad ontológica Objeto Un objeto es una unidad computacional autocontenida que agrupa tres componentes distinguibles: 1. un estado 𝜎, constituido por el conjunto de atributos (pares nombre–valor) que el objeto mantiene durante su ciclo de vida; 2. un comportamiento 𝜇, constituido por el conjunto de métodos (operaciones invocables) que el objeto expone o implementa; 3. una identidad 𝜄, un testigo inmutable y unívoco que distingue al objeto de cualquier otro, aun cuando sus estados coincidan valor a valor. Formalmente, un objeto puede representarse como la tupla ordenada O = ⟨𝜄, 𝜎, 𝜇⟩ donde 𝜄 es invariante bajo toda transformación de 𝜎. La tripartición anterior no es arbitraria. Cada componente cumple una función epistémica distinta en el razonamiento sobre programas: La identidad responde a la pregunta ¿es este el mismo objeto que aquel? y fundamenta las relaciones de aliasing (dos nombres que refieren al mismo objeto) y de copia (dos objetos distintos con estados equivalentes). El estado responde a la pregunta ¿en qué situación se encuentra el objeto? y determina su comportamiento observable en un momento dado. El comportamiento responde a la pregunta ¿qué puede hacerse con el objeto? y constituye su interfaz con el resto del sistema. En CPython, la identidad 𝜄 se materializa como la dirección de memoria del bloque PyObject subyacente, expuesta al usuario a través de la función incorporada id(). Sin embargo, esta correspondencia es un detalle de implementación y no debe explotarse como semántica: el lenguaje garantiza únicamente que id() es único y constante durante la vida del objeto, no que corresponda a una dirección física ni que sea estable entre ejecuciones. Abraham Zamudio 12
  11. Programación en Python para Ingeniería 2.2 2.2 Identidad, igualdad y

    equivalencia Identidad, igualdad y equivalencia La distinción entre identidad e igualdad es una fuente recurrente de errores conceptuales. Python ofrece dos operadores para expresarlas: a is b evalúa la identidad referencial: devuelve True si y solo si 𝜄𝑎 = 𝜄𝑏 . a == b evalúa la igualdad semántica: delega en a.__eq__(b), cuya implementación por defecto (heredada de object) coincide con is, pero que las clases pueden sobrescribir para definir equivalencia estructural. El siguiente fragmento ilustra la divergencia: 1 2 3 a = [1 , 2 , 3] b = [1 , 2 , 3] c = a 4 5 6 7 print ( a == b ) print ( a is b ) print ( a is c ) # True -- misma estructura # False -- distintos objetos # True -- mismo objeto ( aliasing ) c. append (4) print ( a ) print ( b ) # [1 , 2 , 3 , 4] # [1 , 2 , 3] 8 9 10 11 -- mutacion visible via alias -- b permanece intacto 12 13 14 15 16 # Los tipos inmutables pueden compartir instancias ( interning ) : x = 256 y = 256 print ( x is y ) # True -- CPython reutiliza enteros pequenos 17 18 19 20 x = 1000 y = 1000 print ( x is y ) # False -- fuera del rango de interning [ -5 , 256] Listing 4: Identidad vs. igualdad en estructuras mutables. El interning de enteros pequeños y de cadenas literales es una optimización de CPython que no forma parte de la especificación del lenguaje. Confiar en él produce código frágil y no portable. La regla operativa es inequívoca: usar is únicamente para comparar con None, True y False, y == para todo lo demás. 2.3 La clase como especificación y fábrica Clase Una clase es una entidad computacional que desempeña simultáneamente tres roles: 1. Especificación estructural: define el conjunto de atributos que tendrán sus instancias y, opcionalmente, sus valores por defecto. 2. Contrato comportamental: declara los métodos que estarán disponibles sobre sus instancias, con sus firmas y semánticas asociadas. 3. Fábrica: es invocable y, al serlo, produce nuevas instancias que satisfacen la especificación anterior. Adicionalmente, una clase puede imponer invariantes (predicados que deben preservarse durante toda la vida de la instancia) y protocolos (conjuntos de métodos especiales que habilitan sintaxis nativa). Abraham Zamudio 13
  12. Programación en Python para Ingeniería 2.4 Atributos de clase y

    de instancia En Python, las clases son a su vez objetos: instancias de la metaclase type. Esta propiedad, denominada homogeneidad ontológica, implica que las clases pueden: asignarse a variables y pasarse como argumentos; inspeccionarse en tiempo de ejecución con dir(), getattr() e inspect; modificarse dinámicamente (agregar o eliminar atributos y métodos después de su definición); crearse mediante llamadas explícitas a type(name, bases, dict), sin usar la sintaxis class. La siguiente traza ilustra la relación entre una clase, su metaclase y sus instancias: 1 2 3 4 class Particula : dimension = 3 def __init__ ( self , x , y , z ) : self .x , self .y , self . z = x , y , z 5 6 p = Particula (1.0 , 2.0 , 3.0) 7 8 9 # Nivel 1: la instancia print ( type ( p ) ) # < class ' __main__ . Particula '> 10 11 12 # Nivel 2: la clase ( que es un objeto ) print ( type ( Particula ) ) # < class ' type '> 13 14 15 # Nivel 3: la metaclase ( type es su propia metaclase ) print ( type ( type ) ) # < class ' type '> 16 17 18 19 20 # Relaciones print ( isinstance (p , Particula ) ) # True -- p es instancia de Particula print ( isinstance ( Particula , type ) ) # True -- la clase es instancia de type print ( issubclass ( Particula , object ) ) # True -- toda clase hereda de object Listing 5: Tres niveles ontológicos: instancia, clase, metaclase. type type type La jerarquía completa puede describirse como una cadena p −−−→ Particula −−−→ type −−−→ type, donde type es el único objeto autorreferente del modelo. 2.4 Atributos de clase y de instancia La definición anterior introduce una distinción crítica: los atributos pueden residir en la clase (compartidos por todas sus instancias) o en cada instancia individual. Formalmente, sea 𝐶 una clase y 𝑜 una instancia suya. La resolución de un acceso o.atributo sigue un protocolo preciso: 1. Se busca atributo en el diccionario de la instancia o.__dict__. Si existe, se devuelve. 2. Si no existe, se busca en la clase y, recursivamente, en sus clases base siguiendo el MRO. 3. Si tampoco existe allí, se invoca __getattr__ si está definido, o se lanza AttributeError. La escritura o.atributo = valor, en cambio, siempre crea o modifica el atributo en la instancia, nunca en la clase. Esta asimetría entre lectura y escritura explica el comportamiento a menudo sorprendente de los atributos mutables a nivel de clase: 1 2 class Config : opciones = [] # atributo de clase ( mutable ) 3 4 c1 , c2 = Config () , Config () 5 6 7 # Lectura : ambos acceden al MISMO objeto lista c1 . opciones . append ( " a " ) Abraham Zamudio 14
  13. Programación en Python para Ingeniería 8 2.5 El proceso de

    instanciación print ( c2 . opciones ) # [ ' a '] -- mutacion compartida # Escritura : crea atributo c1 . opciones = [ " b " ] c1 . opciones . append ( " c " ) print ( c1 . opciones ) print ( c2 . opciones ) print ( Config . opciones ) de instancia , sombrea al de clase # ahora c1 tiene su propia lista 9 10 11 12 13 14 15 # [ ' b ', 'c '] # [ ' a '] -- c2 sigue viendo el atributo de clase # [ ' a '] -- la clase original no cambio Listing 6: Asimetría lectura/escritura en atributos. La regla de diseño es categórica: los atributos mutables nunca deben declararse a nivel de clase si se pretende que cada instancia tenga los suyos. El patrón correcto es inicializarlos en __init__: 1 2 3 class Config : def __init__ ( self ) -> None : self . opciones : list [ str ] = [] # cada instancia tiene la suya Listing 7: Patrón correcto para atributos mutables por instancia. 2.5 El proceso de instanciación Instanciación La instanciación es el proceso por el cual una clase produce un nuevo objeto que satisface su especificación. En Python, el operador de llamada Clase(args...) desencadena un protocolo de dos fases: 1. Creación: se invoca Clase.__new__(Clase, args...) para asignar y estructurar el objeto. 2. Inicialización: se invoca obj.__init__(args...) sobre el objeto previamente creado para poblarlo. La distinción entre ambas fases no es meramente pedagógica: refleja dos responsabilidades ortogonales que pueden sobrescribirse independientemente. __new__ es un método estático (recibe la clase como primer argumento, no una instancia) y es responsable de asignar el objeto. Su implementación por defecto, heredada de object, asigna el bloque de memoria y devuelve el objeto vacío. __init__ es un método de instancia (recibe self) y es responsable de poblar el objeto. Su valor de retorno debe ser None; devolver cualquier otra cosa produce un TypeError. La firma efectiva del protocolo puede expresarse como: 1 2 3 4 5 def __call__ ( cls , * args , ** kwargs ) : obj = cls . __new__ ( cls , * args , ** kwargs ) if isinstance ( obj , cls ) : obj . __init__ (* args , ** kwargs ) return obj Listing 8: Protocolo de instanciación en pseudocódigo. La verificación isinstance(obj, cls) es importante: si __new__ devuelve un objeto de otra clase (patrón usado por fábricas y por singletons), Python omite la llamada a __init__ para no inicializar un objeto ajeno. Abraham Zamudio 15
  14. Programación en Python para Ingeniería 2.6 2.6 Casos de uso

    diferenciados de __new__ y __init__ Casos de uso diferenciados de __new__ y __init__ La sobrescritura de __new__ se justifica en escenarios específicos: 1. Subclases de tipos inmutables. Al heredar de int, str o tuple, la modificación del valor debe ocurrir en __new__, antes de que el objeto exista como instancia. 2. Patrón Singleton. Controlar que solo se cree una instancia por clase requiere interceptar la creación, no la inicialización. 3. Metaprogramación. Registrar automáticamente las instancias creadas o alterar su tipo dinámicamente. 4. Objetos con recursos externos. Deserialización desde formatos binarios o desde bases de datos. El siguiente fragmento ilustra el patrón Singleton implementado mediante __new__: 1 2 class RegistroGlobal : _instancia = None 3 def __new__ ( cls , * args , ** kwargs ) : if cls . _instancia is None : cls . _instancia = super () . __new__ ( cls ) return cls . _instancia 4 5 6 7 8 def __init__ ( self , ruta : str = " / tmp / log . txt " ) -> None : # Se invoca multiples veces ; debe ser idempotente if not hasattr ( self , " _inicializado " ) : self . ruta = ruta self . _inicializado = True 9 10 11 12 13 14 15 16 17 18 a = RegistroGlobal ( " / var / log / a . txt " ) b = RegistroGlobal ( " / var / log / b . txt " ) print ( a is b ) # True print ( a . ruta ) # / var / log / a . txt vez -- __init__ solo se aplico la primera Listing 9: Singleton mediante control de creación. Nótese que __init__ se ejecuta en cada llamada a RegistroGlobal(...), aunque __new__ devuelva siempre la misma instancia. La guarda _inicializado es responsabilidad del programador. 2.7 El ciclo de vida del objeto Todo objeto Python atraviesa cuatro fases distinguibles: 1. Asignación: __new__ reserva memoria y fija el tipo del objeto. En este punto el objeto existe pero su estado está indefinido. 2. Inicialización: __init__ establece los valores iniciales de los atributos y verifica invariantes. Al finalizar, el objeto está listo para su uso. 3. Uso: el objeto participa en operaciones, recibe mensajes, modifica su estado. Esta fase puede durar arbitrariamente. 4. Destrucción: cuando el contador de referencias alcanza cero (o el recolector de basura determina su inaccesibilidad), se invoca __del__ y se libera la memoria. La fase de destrucción merece una advertencia. __del__ no debe confundirse con del obj: el primero es un finalizador invocado por el intérprete cuando el objeto está por desaparecer; el segundo es una instrucción que elimina una referencia, lo que puede llevar a la destrucción si era la última. Para Abraham Zamudio 16
  15. Programación en Python para Ingeniería 2.8 Relaciones entre los constructos

    liberación determinista de recursos (archivos, conexiones, locks), la práctica recomendada es usar el protocolo de gestores de contexto (with) en lugar de depender de __del__. 2.8 Relaciones entre los constructos Los tres conceptos introducidos se relacionan mediante dos predicados fundamentales: isinstance(obj, Clase) verifica la pertenencia: ¿es obj una instancia de Clase o de alguna de sus subclases? issubclass(Sub, Base) verifica la subtipificación: ¿es Sub una subclase de Base? Ambos predicados respetan el MRO y la noción de subtipificación estructural cuando se usan Protocol de typing. La propiedad transitiva se preserva: si A es subclase de B y B lo es de C, entonces A es subclase de C, y toda instancia de A es también instancia de C. 1 2 3 class A : pass class B ( A ) : pass class C ( B ) : pass 4 5 6 7 8 9 c = C () print ( isinstance (c , print ( isinstance (c , print ( isinstance (c , print ( isinstance (c , A)) # True -- transitividad B)) # True C)) # True object ) ) # True -- toda clase hereda de object Listing 10: Transitividad de la subsumción. Esta transitividad es la base de la substituibilidad de Liskov: un algoritmo que opera sobre A debe funcionar correctamente con cualquier instancia de B, C o cualquier otra subclase, sin conocer los detalles de su implementación. 2.9 Síntesis operativa Los conceptos de esta sección se resumen en la siguiente tabla de correspondencias: Concepto Realización en Python Predicado Identidad Estado Comportamiento Pertenencia Subtipificación Creación Inicialización Destrucción id(obj) obj.__dict__ métodos de la clase type(obj) Clase.__mro__ __new__ __init__ __del__ a is b — hasattr(obj, m) isinstance(obj, C) issubclass(A, B) Clase(...) Clase(...) del obj Estos fundamentos son la base sobre la que se construyen los cuatro pilares clásicos de la POO, desarrollados en la Sección 4. Sin una comprensión precisa del modelo de identidad, del protocolo de instanciación y de la distinción entre atributos de clase e instancia, las abstracciones avanzadas (propiedades, descriptores, metaclases) resultan ininteligibles. 3 Clases y Objetos en Python Establecidos los fundamentos conceptuales, esta sección aborda su materialización en Python. El lenguaje ofrece una sintaxis declarativa para definir clases, pero su semántica subyacente es más rica Abraham Zamudio 17
  16. Programación en Python para Ingeniería 3.1 Sintaxis declarativa y sus

    componentes de lo que la superficie sugiere: involucra espacios de nombres jerárquicos, protocolos de resolución de atributos, descriptores y una asimetría fundamental entre lectura y escritura. Dominar estos mecanismos es indispensable para diseñar jerarquías predecibles. 3.1 Sintaxis declarativa y sus componentes La instrucción class introduce un nuevo espacio de nombres y produce un objeto de tipo type. Su forma canónica es: 1 2 3 4 5 6 class NombreClase ( Base1 , Base2 , ... , metaclass = Meta ) : """ Documentacion de la clase ( docstring ) . """ 7 8 atributo_de_clase : tipo = valor_inicial # nivel de clase def __init__ ( self , param1 , param2 ) : self . atributo_instancia = param1 self . _protegido = param2 # nivel de instancia 9 10 11 12 13 def metodo_instancia ( self ) : ... 14 15 16 @classmethod def metodo_de_clase ( cls ) : ... 17 18 19 20 @staticmethod def metodo_estatico () : ... 21 22 23 24 @property def propiedad_calculada ( self ) : ... 25 26 27 Listing 11: Anatomía de una definición de clase. Los elementos sintácticos tienen correspondencias semánticas precisas: La cláusula de herencia (Base1, Base2, ...) establece las clases base cuyo MRO se consultará para resolución de atributos. El docstring se almacena en NombreClase.__doc__ y es consumido por herramientas como help() y Sphinx. Los atributos de clase se depositan en el diccionario NombreClase.__dict__. Los métodos son, en realidad, funciones almacenadas en el diccionario de la clase que, al accederse vía instancia, se convierten en objetos método enlazados. El argumento metaclass= permite controlar la construcción misma de la clase; por defecto es type. 3.2 Ejemplo canónico El siguiente fragmento define una partícula puntual con atributos de clase e instancia, métodos de consulta y cálculo: Abraham Zamudio 18
  17. Programación en Python para Ingeniería 1 2 3 4 3.3

    El parámetro self y el protocolo de enlace class Particula : """ Representa una particula puntual en el espacio tridimensional . """ 5 # Atributo de clase : un unico valor compartido por todas las instancias dimension : int = 3 6 7 8 def __init__ ( self , x : float , y : float , z : float , masa : float ) -> None : # Atributos de instancia : cada objeto mantiene los suyos self . x = x self . y = y self . z = z self . masa = masa 9 10 11 12 13 14 15 def posicion ( self ) -> tuple [ float , float , float ]: """ Devuelve la terna de coordenadas cartesianas . """ return ( self .x , self .y , self . z ) 16 17 18 19 20 21 def energia_cinetica ( self , vx : float , vy : float , vz : float ) -> float : """ Energia cinetica clasica : E = (1/2) * m * | v |^2. """ return 0.5 * self . masa * ( vx **2 + vy **2 + vz **2) 22 23 24 25 26 27 28 29 30 31 32 33 p = Particula (1.0 , 2.0 , 0.0 , 9.11 e -31) print ( p . posicion () ) print ( p . energia_cinetica (0 , 0 , 1 e6 ) ) print ( p . dimension ) print ( Particula . dimension ) # (1.0 , 2.0 , 0.0) # ~4.55 e -19 # 3 -- accedido via instancia # 3 -- accedido via clase Listing 12: Definición de una clase con atributos de clase e instancia. La inspección del espacio de nombres revela la separación entre los dos niveles: 1 2 print ( p . __dict__ ) # { ' x ': 1.0 , 'y ': 2.0 , 'z ': 0.0 , ' masa ': 9.11 e -31} -- solo instancia 3 4 5 6 print ( Particula . __dict__ . keys () ) # dict_keys ([ ' __module__ ', ' __qualname__ ', ' dimension ', ' posicion ', # ' energia_cinetica ', ' __init__ ', ' __doc__ ', ...]) 7 8 9 print ( Particula . __dict__ [ ' dimension ' ]) print ( ' dimension ' in p . __dict__ ) # 3 # False -- vive en la clase Listing 13: Inspección de los espacios de nombres. 3.3 El parámetro self y el protocolo de enlace Método enlazado Un método enlazado (bound method) es un objeto callable que resulta de acceder a una función almacenada en el diccionario de una clase a través de una instancia. El método enlazado captura la instancia y la pasa automáticamente como primer argumento al invocarse. El mecanismo subyacente es un caso particular del protocolo descriptor. Formalmente: Abraham Zamudio 19
  18. Programación en Python para Ingeniería 3.4 Tipos de métodos 1.

    Las funciones son objetos que implementan __get__. 2. Cuando una función 𝑓 reside en Clase.__dict__ y se accede vía instancia.f, Python invoca Clase.__dict__[’f’].__get__(instancia, Clase). 3. El resultado es un objeto method que encapsula la pareja ( 𝑓 , instancia). 4. Al invocar instancia.f(args...), Python ejecuta internamente f(instancia, args...). Esta mecánica explica por qué self es un parámetro ordinario: no es una palabra reservada sino una convención de nomenclatura. El lenguaje no impone su nombre, pero la guía de estilo PEP 8 lo prescribe para toda la comunidad, y su violación produce código que resulta extraño a cualquier lector familiarizado con Python. Nota de Implementación La distinción entre función y método se puede verificar empíricamente. Particula.posicion es una función ordinaria (accedida vía clase), mientras que p.posicion es un método enlazado (accedido vía instancia). Ambos son invocables, pero solo el segundo inyecta automáticamente self. 1 2 print ( Particula . posicion ) print ( p . posicion ) # < function Particula . posicion at 0 x ... > # < bound method Particula . posicion of ... > 3 4 5 # Equivalencia explicita : assert p . posicion () == Particula . posicion ( p ) 6 7 8 9 # El metodo enlazado captura la instancia : print ( p . posicion . __self__ is p ) # True print ( p . posicion . __func__ is Particula . posicion ) # True Listing 14: Función vs. método enlazado. 3.4 Tipos de métodos Python reconoce tres tipos de métodos según cómo se enlazan: def m(self, ...) Método de instancia. Recibe la instancia como primer argumento. Accede tanto al estado de la instancia como al de la clase. @classmethod def m(cls, ...) Método de clase. Recibe la clase como primer argumento. Útil para constructores alternativos (factory methods) y para operaciones que conciernen a la clase entera. @staticmethod def m(...) Método estático. No recibe ni instancia ni clase. Es una función ordinaria colocada dentro del espacio de nombres de la clase por razones organizativas. 1 2 3 class Vector3D : def __init__ ( self , x : float , y : float , z : float ) -> None : self .x , self .y , self . z = x , y , z 4 5 6 7 # Metodo de instancia : opera sobre un vector concreto def norma ( self ) -> float : return ( self . x **2 + self . y **2 + self . z **2) ** 0.5 8 9 10 # Metodo de clase : constructor alternativo desde coordenadas esfericas @classmethod Abraham Zamudio 20
  19. Programación en Python para Ingeniería 3.5 Resolución de atributos: el

    protocolo completo def desde_esfericas ( cls , r : float , theta : float , phi : float ) -> " Vector3D " : import math return cls ( r * math . sin ( theta ) * math . cos ( phi ) , r * math . sin ( theta ) * math . sin ( phi ) , r * math . cos ( theta ) , ) 11 12 13 14 15 16 17 18 # Metodo estatico : utilidad sin dependencia de estado @staticmethod def producto_punto ( a : " Vector3D " , b : " Vector3D " ) -> float : return a . x * b . x + a . y * b . y + a . z * b . z 19 20 21 22 23 24 25 26 27 v = Vector3D . desde_esfericas (1.0 , 3.14159/2 , 0.0) print ( v . norma () ) # ~1.0 print ( Vector3D . producto_punto (v , v ) ) # ~1.0 Listing 15: Los tres tipos de métodos en acción. 3.5 Resolución de atributos: el protocolo completo El acceso a un atributo obj.nombre en Python no es una operación primitiva, sino la invocación de un protocolo en varias etapas. Comprenderlo es indispensable para diagnosticar comportamientos aparentemente anómalos. Resolución de atributos Dados una instancia o de clase C y un nombre n, la evaluación de o.n sigue este orden: 1. Se busca n en el tipo de o (es decir, en type(o).__mro__), no en la instancia. Si se encuentra un descriptor de datos (con __get__ y __set__), se invoca y se devuelve el resultado. 2. Se busca n en o.__dict__. Si existe, se devuelve directamente. 3. Se busca n nuevamente en el MRO. Si se encuentra un descriptor no de datos (con __get__ pero sin __set__) o un atributo ordinario, se devuelve. 4. Si nada coincide, se invoca C.__getattr__(o, n) si está definido. 5. En su defecto, se lanza AttributeError. La prioridad de los descriptores de datos sobre el diccionario de instancia explica por qué @property puede interceptar escrituras aunque la instancia tenga un atributo homónimo. Este comportamiento se estudia en detalle en la sección de descriptores; aquí basta retener que el modelo de atributos no es un simple lookup en un diccionario. Nota de Implementación La instrucción setattr(o, n, v) sigue un protocolo simétrico, pero no idéntico: consulta primero el MRO por un descriptor de datos con __set__; si no lo encuentra, escribe directamente en o.__dict__[n]. Esta asimetría es la razón por la que la mutación de atributos puede comportarse de forma distinta a su lectura. 3.6 Atributos de clase vs. atributos de instancia La distinción entre ambos niveles es la fuente más frecuente de errores sutiles en código Python, particularmente cuando están involucrados valores mutables. Abraham Zamudio 21
  20. Programación en Python para Ingeniería 3.6 Atributos de clase vs.

    atributos de instancia Atributo de clase Un atributo de clase es una entrada en el diccionario de la clase. Es compartido por todas las instancias que no lo sombreen con una entrada propia en su diccionario. Se declara directamente en el cuerpo de la clase. Atributo de instancia Un atributo de instancia es una entrada en el diccionario de una instancia particular. Es único de esa instancia y no se comparte con otras. Se crea típicamente en __init__ mediante asignación a self.nombre. 3.6.1. El error clásico: mutables compartidos Considere el siguiente par de clases. La primera comete un error sutil cuya sintomatología es silenciosa: la lista compartida parece funcionar en pruebas unitarias aisladas y solo se manifiesta cuando coexisten múltiples instancias. 1 2 class Incorrecto : items : list [ int ] = [] # COMPARTIDO por todas las instancias 3 4 5 def agregar ( self , x : int ) -> None : self . items . append ( x ) 6 7 8 9 10 class Correcto : def __init__ ( self ) -> None : self . items : list [ int ] = [] # Cada instancia tiene el suyo 11 12 13 def agregar ( self , x : int ) -> None : self . items . append ( x ) 14 15 16 17 18 19 20 # Comportamiento divergente : a , b = Incorrecto () , Incorrecto () a. agregar (1) print ( b . items ) # [1] <-- efecto colateral inesperado print ( a . items is b . items ) # True -- misma lista 21 22 23 24 25 c , d = Correcto () , Correcto () c. agregar (1) print ( d . items ) # [] <-- estado aislado , comportamiento correcto print ( c . items is d . items ) # False -- listas distintas Listing 16: El error clásico: estado mutable a nivel de clase. 3.6.2. Diagnóstico formal del error La explicación del comportamiento anterior reside en la asimetría entre lectura y escritura de atributos: Lectura de self.items: Python busca primero en self.__dict__ (falla), luego en type(self).__mro__ (éxito), y devuelve la lista de la clase. Mutación vía self.items.append(x): Python repite el mismo protocolo de lectura, obtiene la lista de la clase, y muta esa lista. No se crea un atributo de instancia porque la asignación no involucra a self.items = .... Abraham Zamudio 22
  21. Programación en Python para Ingeniería 3.7 Casos legítimos de atributos

    de clase Asignación vía self.items = [...]: Python crea una entrada en self.__dict__ que sombrea a la de la clase para esa instancia en particular. La moraleja es categórica: los atributos mutables (list, dict, set) jamás deben declararse a nivel de clase si se pretende que cada instancia mantenga el suyo. El patrón correcto es inicializarlos en __init__, o bien usar field(default_factory=...) de dataclasses para una expresión más declarativa. 3.7 Casos legítimos de atributos de clase El uso de atributos de clase no es un antipatrón per se. Es apropiado en varios escenarios: 1. Constantes de dominio. Valores inmutables que describen el dominio: dimension = 3, c = 299792458, PI = 3.14159.... 2. Contadores y registros. Cuando el objetivo es precisamente compartir estado entre instancias: contar cuántos objetos se han creado. 3. Configuración por defecto. Valores que las instancias pueden sobrescribir individualmente sin afectar a las demás. 4. Cachés de clase. Resultados de cómputos costosos compartidos entre todas las instancias. 1 2 3 class Particula : dimension : int = 3 _contador : int = 0 # constante de dominio # estado compartido intencional 4 def __init__ ( self , masa : float ) -> None : self . masa = masa type ( self ) . _contador += 1 # nota : type ( self ) , no __class__ 5 6 7 8 @classmethod def total_creadas ( cls ) -> int : return cls . _contador 9 10 11 12 13 14 15 16 a = Particula (1.0) b = Particula (2.0) print ( Particula . total_creadas () ) # 2 Listing 17: Uso legítimo: contador de instancias. Nota de Implementación Observe el uso de type(self)._contador += 1 en lugar de self._contador += 1. La segunda forma crearía un atributo de instancia que sombrearía al de clase, y el contador compartido nunca se incrementaría. Esta es otra manifestación de la asimetría lectura/escritura. 3.8 Síntesis Los mecanismos presentados en esta sección se organizan en la siguiente jerarquía de dependencias conceptuales: La declaración class introduce un espacio de nombres (__dict__) que puede contener tanto datos como funciones. El acceso a atributos es un protocolo de varias fases que privilegia descriptores de datos y respeta el MRO. Abraham Zamudio 23
  22. Programación en Python para Ingeniería Los métodos son funciones que,

    al accederse vía instancia, se enlazan automáticamente y reciben self. La distinción clase/instancia es una cuestión de ubicación del atributo en los espacios de nombres, no de declaración sintáctica. La elección entre atributo de clase o de instancia depende de si el estado debe compartirse o aislarse. Estos fundamentos habilitan las abstracciones más elaboradas que se estudian a continuación: encapsulamiento mediante propiedades, herencia, polimorfismo y protocolos especiales del modelo de datos. 4 Los Cuatro Pilares de la POO La literatura clásica de ingeniería de software atribuye a la POO cuatro propiedades fundamentales — abstracción, encapsulamiento, herencia y polimorfismo— que, articuladas, permiten construir sistemas extensibles, mantenibles y verificables. Estas propiedades no son independientes: la abstracción define contratos que el encapsulamiento protege, la herencia los especializa y el polimorfismo los consume. Esta sección las desarrolla en el orden lógico de dependencia, desde la especificación hasta el consumo. 4.1 Abstracción Abstracción La abstracción es la operación cognitiva y computacional de seleccionar un subconjunto de propiedades de un fenómeno para construir un modelo que preserve la información relevante y descarte la incidental. En POO, la abstracción se materializa en interfaces: especificaciones que declaran qué hace una entidad sin comprometerse con cómo lo hace. Formalmente, una abstracción puede caracterizarse como una tupla A = ⟨Σ, Φ⟩ donde Σ es el conjunto de operaciones expuestas (la signatura) y Φ es un predicado que establece las precondiciones, postcondiciones e invariantes que las implementaciones deben respetar (el contrato). La abstracción es efectiva cuando Φ es lo suficientemente fuerte para permitir razonamiento sobre clientes sin conocer implementaciones, y lo suficientemente débil para admitir múltiples implementaciones correctas. 4.1.1. Mecanismos de abstracción en Python Python ofrece tres mecanismos complementarios, ordenados de mayor a menor rigurosidad: 1. Clases base abstractas (abc.ABC): imponen verificación en tiempo de instanciación. Una subclase que no implemente todos los métodos abstractos no puede instanciarse. 2. Protocolos (typing.Protocol): definen subsumción estructural (duck typing verificado estáticamente). No requieren herencia nominal. 3. Convención: documentar la interfaz esperada sin verificación mecánica. Es la forma más débil, pero también la más flexible. 4.1.2. Clases base abstractas: verificación en tiempo de instanciación El módulo abc proporciona la infraestructura para definir clases abstractas. La clase ABC introduce la metaclase ABCMeta, que intercepta la creación de instancias y verifica que todos los métodos marcados con @abstractmethod hayan sido sobrescritos. Abraham Zamudio 24
  23. Programación en Python para Ingeniería 1 2 4.1 Abstracción from

    abc import ABC , abstractmethod from typing import TypeVar 3 4 5 import numpy as np from numpy . typing import NDArray 6 7 Array = NDArray [ np . float64 ] 8 9 10 11 class Integrador ( ABC ) : " " " Contrato comun para esquemas de integracion temporal . 12 13 14 15 16 Las subclases deben implementar `paso `. El metodo ` integrar ` es un template method que coordina la iteracion sin conocer los detalles del esquema concreto . """ 17 18 19 20 @abstractmethod def paso ( self , y : Array , t : float , dt : float ) -> Array : " " " Avanza el estado `y ` desde `t ` hasta `t + dt `. 21 22 23 24 25 26 27 28 Precondiciones : - `y ` es un array de forma (n ,) con valores finitos . - `dt > 0 `. Postcondiciones : - Devuelve un array de la misma forma que `y `. """ ... 29 30 31 32 33 34 35 36 37 def integrar ( self , y0 : Array , t0 : float , tf : float , dt : float ) -> tuple [ Array , float ]: " " " Integra desde t0 hasta tf con paso fijo dt . " " " if dt <= 0: raise ValueError ( f " dt debe ser positivo , recibido { dt } " ) if tf < t0 : raise ValueError ( f " tf ({ tf }) debe ser >= t0 ({ t0 }) " ) 38 39 40 41 42 43 t , y = t0 , y0 . copy () while t < tf : y = self . paso (y , t , dt ) t += dt return y , t 44 45 46 47 48 49 50 51 # Verificacion de la restriccion de instanciacion : try : Integrador () except TypeError as e : print ( e ) # Can 't instantiate abstract class Integrador # with abstract method paso Listing 18: Interfaz abstracta para integradores numéricos. El patrón exhibido por Integrador se denomina Template Method: la clase base define el esqueleto del algoritmo (integrar) y delega los pasos variables (paso) a las subclases. La ventaja es triple: elimina duplicación, garantiza consistencia entre implementaciones y permite que los clientes invoquen integrar sin conocer el esquema subyacente. 1 class EulerExplicito ( Integrador ) : Abraham Zamudio 25
  24. Programación en Python para Ingeniería 2 3 4.1 Abstracción def

    __init__ ( self , f ) -> None : self . f = f # lado derecho dy / dt = f (y , t ) 4 5 6 def paso ( self , y : Array , t : float , dt : float ) -> Array : return y + dt * self . f (y , t ) 7 8 9 10 11 class RungeKutta4 ( Integrador ) : def __init__ ( self , f ) -> None : self . f = f 12 13 14 15 16 17 18 def paso ( self , y : Array , t : float , dt : k1 = self . f (y , t ) k2 = self . f ( y + 0.5 * dt * k1 , t + k3 = self . f ( y + 0.5 * dt * k2 , t + k4 = self . f ( y + dt * k3 , t + dt ) return y + ( dt / 6.0) * ( k1 + 2* k2 float ) -> Array : 0.5 * dt ) 0.5 * dt ) + 2* k3 + k4 ) Listing 19: Implementaciones concretas del contrato. 4.1.3. Protocolos: abstracción estructural Los Protocol introducidos en PEP 544 permiten definir interfaces sin exigir herencia. Un objeto satisface un protocolo si tiene los atributos y métodos requeridos, independientemente de su linaje nominal. 1 from typing import Protocol , runtime_checkable 2 3 4 5 @runtime_checkable class PasoIntegrable ( Protocol ) : def paso ( self , y : Array , t : float , dt : float ) -> Array : ... 6 7 8 9 10 11 12 13 14 def integrar_generico ( esquema : PasoIntegrable , y0 : Array , t0 : float , tf : float , dt : float ) -> Array : " " " Funciona con cualquier objeto que implemente `paso `. " " " t , y = t0 , y0 . copy () while t < tf : y = esquema . paso (y , t , dt ) t += dt return y 15 16 17 18 19 20 # No hay herencia : basta con que exista el metodo class MiEsquema : def paso ( self , y , t , dt ) : return y + dt * ( - y ) # decaimiento exponencial 21 22 print ( isinstance ( MiEsquema () , PasoIntegrable ) ) # True Listing 20: Protocolo para objetos integrables. La diferencia entre ABC y Protocol refleja dos tradiciones de tipificación: Nominal: un objeto pertenece a un tipo si su clase declara explícitamente esa pertenencia (herencia). Es el modelo de ABC y de la mayoría de lenguajes compilados. Estructural: un objeto pertenece a un tipo si su estructura (los métodos y atributos que expone) satisface la especificación. Es el modelo de Protocol y del duck typing. Abraham Zamudio 26
  25. Programación en Python para Ingeniería 4.2 Encapsulamiento Nota de Implementación

    La elección entre ABC y Protocol no es dogmática. Use ABC cuando necesite compartir implementación parcial o imponer construcción mediante super(). Use Protocol cuando defina interfaces que tipos externos (bibliotecas de terceros) puedan satisfacer sin conocer su existencia. 4.2 Encapsulamiento Encapsulamiento El encapsulamiento es la propiedad por la cual el estado interno de un objeto es accesible únicamente a través de su interfaz pública. Su objetivo es preservar las invariantes de representación: los predicados que relacionan el estado interno con la semántica observable del objeto. En lenguajes como Java o C++, el encapsulamiento se impone mediante modificadores de visibilidad (private, protected) verificados por el compilador. Python adopta una filosofía distinta, sintetizada por la máxima “we are all consenting adults here”: el lenguaje ofrece mecanismos de marcado pero no de imposición, delegando la disciplina al programador. 4.2.1. Convenciones de visibilidad Python reconoce tres niveles de visibilidad, todos ellos convencionales: nombre Público. Forma parte del contrato y puede usarse sin restricción. _nombre Protegido por convención. Se considera interno de la clase o jerarquía. Los clientes externos pueden accederlo, pero la comunidad lo interpreta como violación de contrato. No se exporta con from module import *. __nombre Privado por name mangling. El intérprete reescribe el nombre a _Clase__nombre, lo que dificulta su acceso desde fuera de la clase y previene colisiones en jerarquías de herencia. El name mangling merece precisión. No es un mecanismo de seguridad —el atributo sigue siendo accesible invocando el nombre transformado— sino un mecanismo de prevención de colisiones. Su función es permitir que una subclase defina un atributo __x sin sobrescribir accidentalmente el __x de la clase base. 1 2 3 4 class Base : def __init__ ( self ) -> None : self . __secreto = " base " self . _protegido = " base " 5 6 7 8 9 10 class Derivada ( Base ) : def __init__ ( self ) -> None : super () . __init__ () self . __secreto = " derivada " self . _protegido = " derivada " # NO sobrescribe al de Base # SI sobrescribe al de Base 11 12 13 14 15 d = Derivada () print ( d . _protegido ) print ( d . _Base__secreto ) print ( d . _Derivada__secreto ) # " derivada " # " base " # " derivada " <-- el de la clase base <-- el de la clase derivada Listing 21: Name mangling en acción. Abraham Zamudio 27
  26. Programación en Python para Ingeniería 4.2.2. 4.2 Encapsulamiento Propiedades: acceso

    controlado El decorador @property es el mecanismo idiomático para exponer atributos calculados o validados sin romper la sintaxis de acceso (obj.atributo), preservando la posibilidad de evolucionar la implementación sin afectar a los clientes. Property Un descriptor es un objeto que implementa __get__, __set__ o __delete__, y que al residir en el diccionario de una clase controla el acceso al atributo homónimo desde sus instancias. Una @property es un descriptor de datos que envuelve métodos de acceso y mutación. 1 2 3 class Circulo : def __init__ ( self , radio : float ) -> None : self . _radio = radio # atributo protegido 4 @property def radio ( self ) -> float : " " " Radio del circulo ( unidades del SI ) . " " " return self . _radio 5 6 7 8 9 @radio . setter def radio ( self , valor : float ) -> None : if valor < 0: raise ValueError ( f " Radio no puede ser negativo : { valor } " ) self . _radio = valor 10 11 12 13 14 15 @property def area ( self ) -> float : " " " Area calculada : pi * r ^2. Solo lectura . " " " return 3.141592653589793 * self . _radio ** 2 16 17 18 19 20 @property def diametro ( self ) -> float : return 2.0 * self . _radio 21 22 23 24 @diametro . setter def diametro ( self , valor : float ) -> None : self . radio = valor / 2.0 # delega en la validacion de radio 25 26 27 28 29 30 31 32 33 34 c = Circulo (1.0) print ( c . area ) print ( c . diametro ) c. diametro = 10.0 print ( c . radio ) # # # # 3.14159... -- sin parentesis , es atributo 2.0 invoca el setter , valida y actualiza radio 5.0 35 36 37 38 39 try : c . radio = -1.0 except ValueError as e : print ( e ) # Radio no puede ser negativo : -1.0 Listing 22: Encapsulamiento con propiedades calculadas y validadas. La propiedad area es solo lectura porque no se define su setter. Intentar c.area = 10 produce un AttributeError. Este es un mecanismo declarativo para expresar inmutabilidad derivada: el valor se recalcula cada vez que se accede, reflejando cambios en el radio sin costo de sincronización. Abraham Zamudio 28
  27. Programación en Python para Ingeniería 4.3 Herencia Nota de Implementación

    Las propiedades no son gratuitas: cada acceso invoca una función, lo que introduce overhead respecto al acceso directo a un atributo en el diccionario de instancia. En rutas críticas de rendimiento (bucles internos de simulación), el acceso directo puede ser órdenes de magnitud más rápido. La decisión entre uno y otro es un compromiso entre seguridad e rendimiento. 4.2.3. Alternativas declarativas Para clases cuyo propósito es principalmente almacenar datos, dataclasses ofrece una sintaxis más compacta con validación y propiedades derivadas mediante __post_init__: 1 from dataclasses import dataclass , field 2 3 4 5 @dataclass class Circulo : radio : float 6 def __post_init__ ( self ) -> None : if self . radio < 0: raise ValueError ( f " Radio no puede ser negativo : { self . radio } " ) 7 8 9 10 11 12 13 @property def area ( self ) -> float : return 3.141592653589793 * self . radio ** 2 Listing 23: Encapsulamiento declarativo con dataclasses. 4.3 Herencia Herencia La herencia es la relación entre dos clases por la cual una clase derivada (o subclase) adquiere automáticamente los atributos y métodos de una clase base (o superclase), y puede además añadir comportamiento propio o especializar el heredado. Formalmente, la relación de herencia 𝐷 ≺ 𝐵 debe satisfacer el principio de substitución de Liskov: para todo predicado 𝜑 definido sobre instancias de 𝐵, toda instancia de 𝐷 debe satisfacer 𝜑. Es decir, 𝐷 debe poder usarse donde se espera 𝐵 sin alterar la corrección del programa. 4.3.1. La relación es-un y sus límites La herencia es apropiada cuando existe una relación ontológica genuina de subsunción: un Perro es un Animal, un Cuadrado no es un Rectángulo (porque mutar sus lados viola invariantes), un Estudiante es una Persona. La herencia no debe usarse para reutilizar código sin relación semántica: para eso existe la composición. El principio de sustitución de Liskov impone restricciones severas: Las precondiciones de un método en la subclase no pueden ser más fuertes que las de la clase base: una subclase no puede rechazar entradas que la base aceptaba. Las postcondiciones no pueden ser más débiles: una subclase no puede ofrecer menos garantías que la base. Las invariantes de la clase base deben preservarse en la subclase. Abraham Zamudio 29
  28. Programación en Python para Ingeniería 4.3.2. 1 2 4.3 Herencia

    Herencia simple: ejemplo canónico from abc import ABC , abstractmethod import math 3 4 5 6 class Fluido ( ABC ) : " " " Fluido homogeneo con propiedades termodinamicas conocidas . " " " 7 8 9 def __init__ ( self , nombre : str ) -> None : self . nombre = nombre 10 11 12 13 14 15 @property @abstractmethod def rho ( self ) -> float : " " " Densidad en kg / m ^3. " " " ... 16 17 18 19 20 21 @property @abstractmethod def mu ( self ) -> float : " " " Viscosidad dinamica en Pa * s . " " " ... 22 23 24 25 26 27 def reynolds ( self , v : float , L : float ) -> float : " " " Numero de Reynolds : Re = rho * v * L / mu . " " " if self . mu <= 0: raise ValueError ( f " Viscosidad no positiva : { self . mu } " ) return self . rho * v * L / self . mu 28 29 30 def __repr__ ( self ) -> str : return f " { type ( self ) . __name__ }({ self . nombre ! r }) " 31 32 33 34 35 36 class Agua ( Fluido ) : def __init__ ( self , T_celsius : float = 20.0) -> None : super () . __init__ ( " agua " ) self . T = T_celsius 37 38 39 40 @property def rho ( self ) -> float : return 998.2 * (1 - 3e -4 * ( self . T - 20) ) 41 42 43 44 @property def mu ( self ) -> float : return 1.002 e -3 * (1 - 0.024 * ( self . T - 20) ) 45 46 47 48 49 50 51 class Aire ( Fluido ) : def __init__ ( self , T_celsius : float = 20.0 , p_atm : float = 101325.0) -> None : super () . __init__ ( " aire " ) self . T = T_celsius self . p = p_atm 52 53 54 55 56 @property def rho ( self ) -> float : # Ley de los gases ideales : rho = p / ( R_esp * T ) return self . p / (287.05 * ( self . T + 273.15) ) 57 Abraham Zamudio 30
  29. Programación en Python para Ingeniería 4.4 Polimorfismo @property def mu

    ( self ) -> float : # Ley de Sutherland simplificada return 1.825 e -5 * (( self . T + 273.15) / 293.15) ** 0.7 58 59 60 61 Listing 24: Herencia simple con especialización. La clase Fluido es abstracta: define un contrato (rho, mu) e implementa un algoritmo común (reynolds) que depende exclusivamente de ese contrato. Las subclases Agua y Aire implementan las propiedades específicas sin tocar el algoritmo. Añadir un nuevo fluido es extensión, no modificación: se satisface el Principio Abierto/Cerrado. 4.3.3. Herencia cooperativa En jerarquías profundas o con herencia múltiple, la inicialización de las clases base debe realizarse mediante super() sin argumentos. Esto preserva la linealización del MRO y garantiza que cada clase base invoque a la siguiente en la cadena. 1 2 3 4 class A : def __init__ ( self , ** kwargs ) -> None : print ( " A . __init__ " ) super () . __init__ (** kwargs ) 5 6 7 8 9 10 class B ( A ) : def __init__ ( self , b_param : int , ** kwargs ) -> None : print ( " B . __init__ " ) self . b_param = b_param super () . __init__ (** kwargs ) 11 12 13 14 15 16 class C ( A ) : def __init__ ( self , c_param : str , ** kwargs ) -> None : print ( " C . __init__ " ) self . c_param = c_param super () . __init__ (** kwargs ) 17 18 19 20 21 22 class D (B , C) : def __init__ ( self , d_param : float , ** kwargs ) -> None : print ( " D . __init__ " ) self . d_param = d_param super () . __init__ (** kwargs ) 23 24 25 26 # Orden de ejecucion segun el MRO : D -> B -> C -> A -> object d = D ( d_param =1.0 , b_param =2 , c_param = " hola " ) # Imprime : D . __init__ , B . __init__ , C . __init__ , A . __init__ Listing 25: Herencia cooperativa con super(). El patrón cooperativo exige que todas las clases de la jerarquía acepten **kwargs y lo propaguen. A cambio, la cadena de inicialización respeta el MRO y no requiere conocimiento explícito de las clases base concretas. 4.4 Polimorfismo Polimorfismo El polimorfismo es la propiedad por la cual una misma operación puede comportarse de manera distinta según el tipo del objeto sobre el que se aplica. En su forma más general, permite escribir algoritmos genéricos que operan sobre abstracciones sin conocer las implementaciones concretas. Abraham Zamudio 31
  30. Programación en Python para Ingeniería 4.4 Polimorfismo La literatura distingue

    varios tipos de polimorfismo, de los cuales Python materializa cuatro: 1. Ad-hoc (sobrecarga): el operador + se comporta distinto para int, str y ndarray. Python lo implementa vía dunder methods y despacho dinámico. 2. Paramétrico (genéricos): funciones que operan sobre cualquier tipo que satisfaga un protocolo. En Python se expresa con typing.Generic o simplemente con duck typing. 3. De subtipificación (herencia): un método definido en la clase base se especializa en subclases. Es el polimorfismo clásico de la POO. 4. Estructural (duck typing): un objeto es aceptable si su estructura satisface la interfaz requerida, independientemente de su tipo nominal. 4.4.1. Duck typing y despacho dinámico En Python, el polimorfismo se materializa principalmente mediante el despacho dinámico de métodos. Cuando se evalúa obj.metodo(), el intérprete busca metodo en el MRO de type(obj) y ejecuta la primera implementación encontrada. No hay verificación de tipos en tiempo de compilación; la corrección depende de que el objeto tenga el método esperado. 1 2 def numero_reynolds ( fluido , v : float , L : float ) -> float : " " " Calcula Re para cualquier objeto con los metodos `rho ` y `mu `. 3 4 5 6 7 No importa la clase concreta : importa que la estructura del objeto satisfaga el protocolo implicito . """ return fluido . rho * v * L / fluido . mu 8 9 10 11 12 for f in ( Agua (20.0) , Aire (20.0) ) : Re = numero_reynolds (f , v =1.0 , L =0.1) print ( f "{ type ( f ) . __name__ :6 s } Re = { Re :10.2 f } " ) Listing 26: Polimorfismo mediante duck typing. La función numero_reynolds no declara el tipo de fluido porque no lo necesita: cualquier objeto con rho y mu funciona. Esta es la esencia del duck typing atribuido a James Whitelaw: “if it walks like a duck and quacks like a duck, it’s a duck”. 4.4.2. Verificación estructural explícita El duck typing puro puede producir errores tardíos y difíciles de diagnosticar. Los Protocol de typing permiten verificar la conformidad estructural en herramientas como mypy o pyright sin imponer herencia nominal: 1 from typing import Protocol , runtime_checkable 2 3 4 5 6 7 8 @runtime_checkable class FluidoProtocol ( Protocol ) : @property def rho ( self ) -> float : ... @property def mu ( self ) -> float : ... 9 10 11 12 13 def numero_reynolds ( fluido : FluidoProtocol , v : float , L : float ) -> float : " " " Version tipada : mypy verifica conformidad estructural . " " " return fluido . rho * v * L / fluido . mu 14 Abraham Zamudio 32
  31. Programación en Python para Ingeniería 4.5 Interacción entre los cuatro

    pilares 15 16 17 18 # Verificacion en runtime : print ( isinstance ( Agua () , FluidoProtocol ) ) print ( isinstance ( Aire () , FluidoProtocol ) ) # True # True Listing 27: Polimorfismo verificado con Protocol. 4.4.3. Polimorfismo paramétrico con genéricos Para algoritmos que operan sobre cualquier tipo sin requerir métodos específicos, Python ofrece type variables: 1 from typing import TypeVar , Sequence 2 3 T = TypeVar (" T " ) 4 5 6 7 8 9 def primer_elemento ( xs : Sequence [ T ]) -> T : " " " Devuelve el primer elemento de una secuencia no vacia . " " " if not xs : raise ValueError ( " Secuencia vacia " ) return xs [0] 10 11 12 13 14 15 # La misma funcion opera sobre cualquier tipo : print ( primer_elemento ([1 , 2 , 3]) ) # int print ( primer_elemento ( " hola " ) ) # str print ( primer_elemento ([1.0 , 2.0]) ) # float Listing 28: Polimorfismo paramétrico. A diferencia del polimorfismo de subtipificación, el polimorfismo paramétrico no requiere que los tipos estén relacionados jerárquicamente. La función primer_elemento opera sobre cualquier secuencia, sea de enteros, cadenas o cualquier otro tipo. Los verificadores estáticos infieren el tipo de retorno a partir del tipo del argumento. 4.5 Interacción entre los cuatro pilares Los cuatro pilares no son ortogonales; se articulan en patrones de diseño recurrentes: Patrón Pilares articulados Template Method Abstracción (contrato) + Herencia (esqueleto) + Polimorfismo (paso variable) Abstracción (interfaz) + Polimorfismo (algoritmo intercambiable) + Composición Abstracción + Herencia (subclase decide el producto) Abstracción (sujeto/observador) + Polimorfismo (notificación uniforme) Encapsulamiento (envoltura) + Polimorfismo (misma interfaz) + Composición Strategy Factory Method Observer Decorator El ejemplo integrador de la Sección 8 articula los cuatro pilares en un simulador modular, mostrando cómo se combinan en la práctica para construir sistemas extensibles. Abraham Zamudio 33
  32. Programación en Python para Ingeniería 4.6 4.6 Síntesis Síntesis Los

    cuatro pilares pueden resumirse en cuatro preguntas de diseño: 1. Abstracción: ¿cuál es el contrato mínimo que los clientes necesitan conocer? 2. Encapsulamiento: ¿qué invariantes deben preservarse y cómo se protegen de violaciones externas? 3. Herencia: ¿qué relaciones de subsunción genuinas existen en el dominio y cómo se especializan sin violar la substitución de Liskov? 4. Polimorfismo: ¿qué operaciones deben aplicarse uniformemente sobre familias de tipos, y cómo se expresa esa uniformidad? Las respuestas a estas preguntas guían el diseño de jerarquías sostenibles. El uso mecánico o dogmático de los pilares —herencia por reutilización, propiedades por costumbre, polimorfismo por moda— conduce a abstracciones prematuras, acoplamiento oculto y complejidad accidental. La disciplina consiste en aplicar cada pilar solo cuando el dominio lo justifica. 5 Herencia Múltiple y MRO La herencia múltiple permite que una clase combine el comportamiento de varias clases base. Es un mecanismo expresivo pero propenso a ambigüedades: cuando dos o más clases base definen el mismo método, ¿cuál debe ejecutarse? La respuesta no puede quedar al azar ni depender del orden de declaración, pues ello introduciría fragilidad ante refactorizaciones. Python resuelve este problema mediante una linealización determinista de la jerarquía, calculada por el algoritmo C3, que garantiza tres propiedades formales: consistencia local, monotonicidad y preservación del orden de las bases. 5.1 El problema de la ambigüedad Considere una jerarquía en la que dos clases base definen un método homónimo: 1 2 3 class A : def saludar ( self ) -> str : return " A " 4 5 6 7 class B ( A ) : def saludar ( self ) -> str : return " B " 8 9 10 11 class C ( A ) : def saludar ( self ) -> str : return " C " 12 13 14 class D (B , C) : pass 15 16 17 # ¿ Qu é debe devolver D () . saludar () : " B " , " C " o " A "? print ( D () . saludar () ) # "B" Listing 29: Ambigüedad potencial en herencia múltiple. La respuesta “B” no es arbitraria. Python construye una secuencia lineal de clases, el MRO, que determina unívocamente el orden de búsqueda. Comprender cómo se calcula esta secuencia es indispensable para diseñar jerarquías predecibles. Abraham Zamudio 34
  33. Programación en Python para Ingeniería 5.2 5.2 Definición formal del

    MRO Definición formal del MRO MRO (Method Resolution Order) Dada una clase 𝐶, su MRO es una lista ordenada de clases 𝐿 [𝐶] = [𝐶0 , 𝐶1 , . . . , 𝐶𝑛 ] tal que: 1. 𝐶0 = 𝐶 (la clase misma es el primer elemento). 2. 𝐶𝑛 = object (la raíz de toda jerarquía es el último). 3. Si 𝐶𝑖 aparece antes que 𝐶 𝑗 en 𝐿 [𝐶], entonces ninguna implementación de 𝐶 𝑗 puede sombrear a una de 𝐶𝑖 . 4. El orden respeta el orden de declaración de las bases: si class C(B1, B2), entonces 𝐵1 precede a 𝐵2 en 𝐿 [𝐶]. 5. Se preserva la linealización de cada base: 𝐿 [𝐵𝑖 ] aparece como subsecuencia de 𝐿 [𝐶]. La búsqueda de un método m sobre una instancia de 𝐶 recorre 𝐿 [𝐶] en orden y ejecuta la primera clase que defina m. El MRO se expone a través del atributo __mro__ (una tupla) y del método mro() (una lista). Ambos son consultables en tiempo de ejecución: 1 2 print ([ c . __name__ for c in D . __mro__ ]) # [ ' D ', 'B ', 'C ', 'A ', ' object '] 3 4 5 6 print ( D . mro () ) # [ < class ' __main__ . D '>, < class ' __main__ . B '>, < class ' __main__ . C '>, # < class ' __main__ . A '>, < class ' object ' >] 7 8 9 print ( D . __bases__ ) print ( D . __mro__ [1]) # ( < class 'B '>, < class 'C ' >) -- bases directas # < class 'B '> -- primera base en el orden lineal Listing 30: Inspección del MRO. 5.3 El algoritmo C3 El MRO no se calcula mediante una simple búsqueda en profundidad ni en anchura; ambos enfoques fallan en jerarquías complejas. Python utiliza el algoritmo C3, propuesto por Kim Barrett et al. en 1996, que computa la linealización de una clase 𝐶 con bases 𝐵1 , . . . , 𝐵𝑛 mediante la siguiente recurrencia: 𝐿 [𝐶] = [𝐶] + merge 𝐿 [𝐵1 ], 𝐿 [𝐵2 ], . . . , 𝐿 [𝐵𝑛 ], [𝐵1 , . . . , 𝐵𝑛 ]  donde la operación merge combina varias listas en una sola, seleccionando iterativamente la primera cabeza de lista que no aparezca en la cola de ninguna otra. Formalmente, para listas 𝐿 1 , . . . , 𝐿 𝑘 : 1. Sea 𝐻 la primera cabeza de lista (el primer elemento de la primera lista no vacía). 2. Si 𝐻 no aparece en la cola de ninguna lista, se extrae y se añade al resultado. 3. En caso contrario, se pasa a la siguiente lista. 4. El proceso se repite hasta agotar todas las listas. 5. Si en algún paso ninguna cabeza es seleccionable, el algoritmo falla y Python lanza TypeError. Abraham Zamudio 35
  34. Programación en Python para Ingeniería 5.4 El problema del diamante

    Propiedades garantizadas por C3 Consistencia local El orden de las bases directas se preserva. Monotonicidad Si 𝐴 precede a 𝐵 en 𝐿 [𝐶], entonces 𝐴 precede a 𝐵 en 𝐿 [𝐷] para toda subclase 𝐷 de 𝐶. Preservación La linealización de cada base aparece como subsecuencia de la linealización de la clase derivada. Estas propiedades son las que hacen al MRO determinista y predecible. 5.3.1. Traza manual sobre una jerarquía simple Considere la jerarquía del ejemplo anterior: 1 2 3 4 class class class class A : pass B ( A ) : pass C ( A ) : pass D (B , C) : pass Listing 31: Jerarquía para traza manual del algoritmo C3. La aplicación paso a paso del algoritmo procede así: 1. 𝐿 [ 𝐴] = [ 𝐴, object]. 2. 𝐿 [𝐵] = [𝐵] + merge([ 𝐴, object], [ 𝐴]) = [𝐵, 𝐴, object]. 3. 𝐿 [𝐶] = [𝐶] + merge([ 𝐴, object], [ 𝐴]) = [𝐶, 𝐴, object]. 4. 𝐿 [𝐷] = [𝐷] + merge([𝐵, 𝐴, object], [𝐶, 𝐴, object], [𝐵, 𝐶]). El proceso de merge para 𝐷 es el siguiente: Cabeza 𝐵: ¿aparece en la cola de alguna lista? No (solo en la cabeza de [𝐵, 𝐶]). Se selecciona. Resultado parcial: [𝐷, 𝐵]. Listas restantes: [ 𝐴, object], [𝐶, 𝐴, object], [𝐶]. Cabeza 𝐴: ¿aparece en la cola de alguna lista? Sí, en [𝐶, 𝐴, object]. Se pasa a la siguiente lista. Cabeza 𝐶: ¿aparece en la cola de alguna lista? No. Se selecciona. Resultado parcial: [𝐷, 𝐵, 𝐶]. Listas restantes: [ 𝐴, object], [ 𝐴, object], []. Cabeza 𝐴: ¿aparece en la cola? No. Se selecciona. Resultado parcial: [𝐷, 𝐵, 𝐶, 𝐴]. Cabeza object: se selecciona. Resultado final: [𝐷, 𝐵, 𝐶, 𝐴, object]. Por tanto, 𝐿 [𝐷] = [𝐷, 𝐵, 𝐶, 𝐴, object], y D().saludar() ejecuta la implementación de B. 5.4 El problema del diamante La motivación histórica del algoritmo C3 es resolver el problema del diamante: una jerarquía en la que una clase hereda de dos clases que a su vez heredan de una base común. 1 2 3 class Base : def __init__ ( self ) -> None : print ( " Base . __init__ " ) 4 5 class Izquierda ( Base ) : Abraham Zamudio 36
  35. Programación en Python para Ingeniería def __init__ ( self )

    -> None : print ( " Izquierda . __init__ " ) Base . __init__ ( self ) 6 7 8 5.5 Herencia cooperativa con super() # <-- llamada directa : ANTIPATRON 9 10 11 12 13 class Derecha ( Base ) : def __init__ ( self ) -> None : print ( " Derecha . __init__ " ) Base . __init__ ( self ) # <-- llamada directa : ANTIPATRON 14 15 16 17 18 19 class Hoja ( Izquierda , Derecha ) : def __init__ ( self ) -> None : print ( " Hoja . __init__ " ) Izquierda . __init__ ( self ) Derecha . __init__ ( self ) 20 21 22 23 24 25 26 27 # Resultado : # Hoja . __init__ # Izquierda . __init__ # Base . __init__ # Derecha . __init__ # Base . __init__ Hoja () <-- Base se inicializa DOS veces <-- indeseable Listing 32: Jerarquía en diamante. El problema es evidente: Base.__init__ se ejecuta dos veces, una por cada rama del diamante. En clases con inicialización costosa o con efectos secundarios (abrir archivos, reservar recursos), este comportamiento es incorrecto. La solución consiste en delegación cooperativa mediante super(). 5.5 Herencia cooperativa con super() super() La función incorporada super() devuelve un objeto proxy que delega la resolución de atributos a la siguiente clase en el MRO, no a la clase base nominal. La forma super() sin argumentos, válida dentro de métodos de instancia y de clase, infiere automáticamente la clase actual y la instancia desde el frame de ejecución. La forma sin argumentos es equivalente a super(ClaseActual, self) en métodos de instancia, o a super(ClaseActual, cls) en métodos de clase. La forma explícita es útil en casos avanzados (por ejemplo, en metaclases o cuando se delega desde un método externo), pero en código ordinario la forma sin argumentos es preferible por su robustez ante refactorizaciones. 1 2 3 4 class Base : def __init__ ( self , ** kwargs ) -> None : print ( " Base . __init__ " ) super () . __init__ (** kwargs ) 5 6 7 8 9 class Izquierda ( Base ) : def __init__ ( self , ** kwargs ) -> None : print ( " Izquierda . __init__ " ) super () . __init__ (** kwargs ) 10 11 12 13 14 class Derecha ( Base ) : def __init__ ( self , ** kwargs ) -> None : print ( " Derecha . __init__ " ) super () . __init__ (** kwargs ) 15 16 class Hoja ( Izquierda , Derecha ) : Abraham Zamudio 37
  36. Programación en Python para Ingeniería 5.6 Errores de linealización def

    __init__ ( self , ** kwargs ) -> None : print ( " Hoja . __init__ " ) super () . __init__ (** kwargs ) 17 18 19 20 21 22 23 24 25 26 # Resultado ( cada clase se inicializa UNA sola vez ) : # Hoja . __init__ # Izquierda . __init__ # Derecha . __init__ # Base . __init__ Hoja () Listing 33: Versión cooperativa del diamante. La traza sigue el MRO [Hoja, Izquierda, Derecha, Base, object]. Cada super() avanza al siguiente elemento de la lista, garantizando que cada clase se inicialice exactamente una vez. Este patrón se denomina herencia cooperativa y es la base del diseño correcto de jerarquías múltiples. Nota de Implementación La cooperación exige que todas las clases de la jerarquía acepten y propaguen **kwargs, incluso si no usan argumentos adicionales. Romper esta convención —por ejemplo, definiendo def __init__(self) sin **kwargs— corta la cadena de inicialización y produce errores silenciosos que solo se manifiestan cuando se combinan clases en nuevas jerarquías. 5.6 Errores de linealización El algoritmo C3 no siempre produce una linealización válida. Cuando dos jerarquías imponen restricciones contradictorias sobre el orden de las clases, Python lanza TypeError en tiempo de definición de la clase. 1 2 3 4 class class class class X : pass Y : pass A (X , Y) : pass B (Y , X) : pass 5 6 7 8 9 10 11 12 # Intento de crear una clase con bases conflictivas : try : class C (A , B ) : pass except TypeError as e : print ( e ) # Cannot create a consistent method resolution order ( MRO ) # for bases X , Y Listing 34: Linealización inconsistente detectada por C3. El conflicto es el siguiente. 𝐿 [ 𝐴] = [ 𝐴, 𝑋, 𝑌 , object] exige que 𝑋 preceda a 𝑌 . 𝐿 [𝐵] = [𝐵, 𝑌 , 𝑋, object] exige lo contrario. Ambas condiciones no pueden satisfacerse simultáneamente, por lo que C3 rechaza la construcción. Este comportamiento es deseable: detecta errores de diseño en el momento de la definición en lugar de producir comportamientos impredecibles en tiempo de ejecución. 5.7 Uso avanzado: mixins Los mixins son clases diseñadas específicamente para ser combinadas con otras mediante herencia múltiple. No representan entidades del dominio, sino capacidades ortogonales que se añaden a una clase principal. Son el uso más idiomático de la herencia múltiple en Python. Abraham Zamudio 38
  37. Programación en Python para Ingeniería 1 2 5.7 Uso avanzado:

    mixins import json from typing import Any 3 4 5 class JsonSerializableMixin : " " " A ñ ade capacidad de serializacion JSON a cualquier clase . " " " 6 7 8 def to_json ( self ) -> str : return json . dumps ( self . to_dict () , indent =2 , ensure_ascii = False ) 9 10 11 12 13 14 15 def to_dict ( self ) -> dict [ str , Any ]: " " " Implementacion por defecto : usa __dict__ filtrando privados . " " " return { k : v for k , v in self . __dict__ . items () if not k . startswith ( " _ " ) } 16 17 18 19 20 @classmethod def from_json ( cls , texto : str ) -> " JsonSerializableMixin " : datos = json . loads ( texto ) return cls (** datos ) 21 22 23 24 class LoggingMixin : " " " A ñ ade logging automatico de creacion y destruccion . " " " 25 26 27 28 29 30 def __init__ ( self , ** kwargs ) -> None : super () . __init__ (** kwargs ) # coopera con la cadena del MRO import logging self . _logger = logging . getLogger ( type ( self ) . __name__ ) self . _logger . info ( " Instancia creada : %r " , self ) 31 32 33 34 def __del__ ( self ) -> None : if hasattr ( self , " _logger " ) : self . _logger . info ( " Instancia destruida : %r " , self ) 35 36 37 38 39 40 41 class Sensor ( JsonSerializableMixin , LoggingMixin ) : def __init__ ( self , id_sensor : str , unidad : str , ** kwargs ) -> None : super () . __init__ (** kwargs ) self . id_sensor = id_sensor self . unidad = unidad 42 43 44 45 46 47 # MRO : Sensor -> JsonSerializableMixin -> LoggingMixin -> object s = Sensor ( "T -001 " , " celsius " ) print ( s . to_json () ) # {" id_sensor ": "T -001" , " unidad ": " celsius "} Listing 35: Mixin para serialización JSON. Los mixins se caracterizan por tres propiedades: 1. No tienen estado propio significativo. Pueden declarar atributos internos (como _logger), pero no definen identidad ni estado del dominio. 2. No se instancian directamente. Son clases auxiliares que solo cobran sentido combinadas con una clase principal. 3. Cooperan mediante super(). No conocen la clase base nominal; delegan en el MRO. Abraham Zamudio 39
  38. Programación en Python para Ingeniería 5.8 super() en métodos de

    clase y estáticos Nota de Implementación El orden de las bases en class Sensor(JsonSerializableMixin, LoggingMixin) importa. JsonSerializableMixin aparece primero en el MRO, por lo que sus métodos tienen prioridad. Si el orden se invirtiera, LoggingMixin.__init__ se ejecutaría primero y propagaría **kwargs a JsonSerializableMixin, que no define __init__ y por tanto delega en object. El efecto final es el mismo en este caso, pero en mixins con métodos homónimos el orden determina cuál prevalece. super() en métodos de clase y estáticos 5.8 super() funciona en los tres tipos de métodos, pero su semántica varía: 1 2 3 4 class Base : @classmethod def crear ( cls ) : return cls () 5 @staticmethod def utilidad () : return " base " 6 7 8 9 10 11 12 13 14 class Derivada ( Base ) : @classmethod def crear ( cls ) : # super () sin argumentos infiere ( Derivada , cls ) return super () . crear () 15 @staticmethod def utilidad () : # En metodos estaticos super () REQUIERE argumentos explicitos return super ( Derivada , Derivada ) . utilidad () + " + derivada " 16 17 18 19 Listing 36: super() en distintos tipos de métodos. En un método de instancia, super() infiere (ClaseActual, self). En un método de clase, infiere (ClaseActual, cls). En un método estático, no hay ni self ni cls en el frame, por lo que super() sin argumentos lanza RuntimeError. En ese caso debe usarse la forma explícita super(Clase, Clase). 5.9 Anti-patrones frecuentes 5.9.1. Llamada directa a la clase base Llamar Base.metodo(self, ...) en lugar de super().metodo(...) rompe la linealización: invoca una implementación específica y omite el resto del MRO. Es correcto en casos muy puntuales (por ejemplo, saltar deliberadamente parte de la cadena), pero es un antipatrón en código cooperativo. 1 2 3 4 # MAL : class Derivada ( Base ) : def __init__ ( self ) : Base . __init__ ( self ) # no coopera con otras bases 5 6 7 8 9 # BIEN : class Derivada ( Base ) : def __init__ ( self , ** kwargs ) : super () . __init__ (** kwargs ) Listing 37: Anti-patrón: llamada directa que rompe el MRO. Abraham Zamudio 40
  39. Programación en Python para Ingeniería 5.9.2. 5.10 Síntesis Herencia múltiple

    sin propósito Combinar clases sin una razón semántica clara produce jerarquías frágiles. Si el único objetivo es reutilizar código, la composición es preferible: 1 2 3 # MAL : herencia usada solo para reutilizar codigo class Servicio ( Logger , Validador , Cache , ConexionDB ) : pass 4 5 6 7 8 9 10 11 # BIEN : composicion explicita class Servicio : def __init__ ( self ) -> None : self . _logger = Logger () self . _validador = Validador () self . _cache = Cache () self . _db = ConexionDB () Listing 38: Composición como alternativa a herencia múltiple. La composición ofrece tres ventajas sobre la herencia múltiple en estos casos: expone explícitamente las dependencias, permite sustituir componentes en tiempo de ejecución y evita conflictos en el MRO. 5.10 Síntesis La herencia múltiple es un mecanismo poderoso pero exigente. Su uso correcto descansa sobre cuatro reglas: 1. Toda jerarquía múltiple debe ser cooperativa: las clases propagan **kwargs y delegan mediante super(). 2. El MRO es determinista y calculable: C3 garantiza consistencia y monotonicidad, pero rechaza jerarquías contradictorias. 3. Los mixins son el uso idiomático: encapsulan capacidades ortogonales y no representan entidades del dominio. 4. La composición es preferible cuando no hay subsumción: la herencia es la relación más fuerte y debe reservarse para casos donde es-un sea genuino. La siguiente sección aborda los dunder methods, que junto con el MRO constituyen los dos mecanismos sobre los que se articula el modelo de datos de Python. 6 Métodos Especiales (Dunder Methods) Los métodos especiales —denominados coloquialmente dunder methods por su doble guion bajo (double underscore)— constituyen la interfaz entre los objetos definidos por el usuario y la sintaxis del lenguaje. Cada operador, cada construcción sintáctica y cada protocolo incorporado de Python se traduce internamente en la invocación de un método especial sobre el tipo involucrado. Comprender esta correspondencia es indispensable para diseñar abstracciones que participen idiomáticamente de la sintaxis nativa, sin recurrir a nombres de método ad hoc que el intérprete desconoce. Abraham Zamudio 41
  40. Programación en Python para Ingeniería 6.1 6.1 El modelo de

    datos como protocolo El modelo de datos como protocolo Protocolo de datos Un protocolo es un conjunto de métodos especiales que, al ser implementados por una clase, habilitan un comportamiento sintáctico nativo sobre sus instancias. Los protocolos no requieren herencia nominal ni declaración explícita: son estructurales, y su presencia se verifica por la mera existencia de los métodos. Formalmente, sea Π un protocolo definido por un conjunto de métodos {𝑚 1 , . . . , 𝑚 𝑘 }. Una clase 𝐶 satisface Π si y solo si para cada 𝑚𝑖 existe una implementación accesible en el MRO de 𝐶. El intérprete no verifica esta condición en tiempo de definición; cuando una operación requiere un protocolo ausente, lanza TypeError con un mensaje que identifica la operación fallida. La siguiente tabla establece las correspondencias entre sintaxis y método especial: 6.2 Sintaxis Invocación efectiva obj + other obj * n abs(obj) len(obj) obj[i] obj[i] = x x in obj for x in obj with obj as x obj(args) repr(obj) bool(obj) type(obj).__add__(obj, other) type(obj).__mul__(obj, n) type(obj).__abs__(obj) type(obj).__len__(obj) type(obj).__getitem__(obj, i) type(obj).__setitem__(obj, i, x) type(obj).__contains__(obj, x) iter(obj) → obj.__iter__() obj.__enter__() / obj.__exit__() type(obj).__call__(obj, args) type(obj).__repr__(obj) type(obj).__bool__(obj) Taxonomía de métodos especiales La documentación oficial de Python organiza los métodos especiales en categorías según el protocolo que implementan. Para propósitos pedagógicos, adoptamos la siguiente clasificación: 1. Ciclo de vida: __new__, __init__, __del__. 2. Representación: __repr__, __str__, __format__, __bytes__. 3. Comparación: __eq__, __ne__, __lt__, __le__, __gt__, __ge__, __hash__. 4. Aritmética: __add__, __sub__, __mul__, __truediv__, __pow__, __matmul__, y sus variantes reflected (__radd__) e in-place (__iadd__). 5. Contenedor: __len__, __getitem__, __setitem__, __delitem__, __contains__. 6. Iteración: __iter__, __next__, __reversed__. 7. Gestor de contexto: __enter__, __exit__. 8. Callable: __call__. 9. Acceso a atributos: __getattr__, __setattr__, __delattr__, __getattribute__. 10. Conversión: __int__, __float__, __bool__, __index__, __complex__. Abraham Zamudio 42
  41. Programación en Python para Ingeniería 6.3 6.3 Ejemplo canónico: vector

    matemático Ejemplo canónico: vector matemático El siguiente ejemplo ilustra la implementación de un conjunto coherente de métodos especiales para una clase algebraica: 1 2 from __future__ import annotations from typing import Iterator 3 4 5 6 class Vector : " " " Vector de dimension arbitraria con operaciones algebraicas . " " " 7 8 __slots__ = ( " _c " ,) # optimizacion de memoria : sin __dict__ 9 10 11 def __init__ ( self , * componentes : float ) -> None : self . _c : tuple [ float , ...] = tuple ( componentes ) 12 13 14 15 16 # --- Representacion --def __repr__ ( self ) -> str : " " " Representacion inequivoca , ideal para depuracion . " " " return f " Vector ({ ' , '. join ( repr ( x ) for x in self . _c ) }) " 17 18 19 20 def __str__ ( self ) -> str : " " " Representacion legible para el usuario final . " " " return f " <{ ' , '. join ( f '{ x : g } ' for x in self . _c ) } > " 21 22 23 24 # --- Contenedor --def __len__ ( self ) -> int : return len ( self . _c ) 25 26 27 def __getitem__ ( self , i : int ) -> float : return self . _c [ i ] 28 29 30 def __iter__ ( self ) -> Iterator [ float ]: return iter ( self . _c ) 31 32 33 34 35 36 37 38 # --- Aritmetica --def __add__ ( self , other : Vector ) -> Vector : if len ( self ) != len ( other ) : raise ValueError ( f " Dimensiones incompatibles : { len ( self ) } vs { len ( other ) } " ) return Vector (*( a + b for a , b in zip ( self . _c , other . _c ) ) ) 39 40 41 42 43 def __sub__ ( self , other : Vector ) -> Vector : if len ( self ) != len ( other ) : raise ValueError ( " Dimensiones incompatibles " ) return Vector (*( a - b for a , b in zip ( self . _c , other . _c ) ) ) 44 45 46 def __mul__ ( self , escalar : float ) -> Vector : return Vector (*( escalar * x for x in self . _c ) ) 47 48 __rmul__ = __mul__ # permite 2.0 * v , no solo v * 2.0 49 50 51 def __neg__ ( self ) -> Vector : return Vector (*( - x for x in self . _c ) ) 52 53 54 55 def __abs__ ( self ) -> float : " " " Norma euclidiana : | v | = sqrt ( sum ( x_i ^2) ) . " " " return sum ( x * x for x in self . _c ) ** 0.5 56 Abraham Zamudio 43
  42. Programación en Python para Ingeniería 6.4 Representación: __repr__ vs. __str__

    # --- Comparacion --def __eq__ ( self , other : object ) -> bool : if not isinstance ( other , Vector ) : return NotImplemented return self . _c == other . _c 57 58 59 60 61 62 def __hash__ ( self ) -> int : return hash ( self . _c ) 63 64 65 # --- Conversion --def __bool__ ( self ) -> bool : " " " Un vector es verdadero si tiene al menos una componente no nula . " " " return any ( x != 0.0 for x in self . _c ) 66 67 68 69 70 71 72 73 v = Vector (3.0 , 4.0) w = Vector (1.0 , 2.0) 74 75 76 77 78 79 80 81 82 print ( repr ( v ) ) # Vector (3.0 , 4.0) print ( str ( v ) ) # <3 , 4 > print ( abs ( v ) ) # 5.0 print ( v + w ) # Vector (4.0 , 6.0) print ( v * 2) # Vector (6.0 , 8.0) print (2 * v ) # Vector (6.0 , 8.0) -- via __rmul__ print ( v == Vector (3.0 , 4.0) ) # True print ( bool ( Vector (0.0 , 0.0) ) ) # False Listing 39: Vector matemático con operadores sobrecargados. 6.4 Representación: __repr__ vs. __str__ Ambos métodos producen cadenas, pero cumplen funciones distintas y deben respetar convenciones diferentes. __repr__ El método __repr__ debe devolver una cadena inequívoca que, idealmente, permita reconstruir el objeto si se evalúa como código Python. Su audiencia son los desarrolladores y las herramientas de depuración. __str__ El método __str__ debe devolver una cadena legible orientada al usuario final. No se exige que sea evaluable ni que identifique el tipo. Si no se define, Python recurre a __repr__ como fallback. La regla empírica es: __repr__ para depuradores, __str__ para interfaces de usuario. En objetos matemáticos, la representación repr suele incluir el nombre de la clase y las componentes con precisión completa, mientras que str puede usar notación compacta o simbólica. Adicionalmente, __format__ permite controlar la presentación en f-strings con especificadores: 1 2 3 class Temperatura : def __init__ ( self , celsius : float ) -> None : self . celsius = celsius 4 5 6 7 def __format__ ( self , spec : str ) -> str : if spec == " C " : return f " { self . celsius :.2 f } C " Abraham Zamudio 44
  43. Programación en Python para Ingeniería 6.5 Comparación y hashing if

    spec == " F " : fahrenheit = self . celsius * 9 / 5 + 32 return f " { fahrenheit :.2 f } F " if spec == " K " : kelvin = self . celsius + 273.15 return f " { kelvin :.2 f } K " return str ( self ) 8 9 10 11 12 13 14 15 16 17 18 19 20 t = Temperatura (25.0) print ( f " { t : C } " ) # 25.00 C print ( f " { t : F } " ) # 77.00 F print ( f " { t : K } " ) # 298.15 K Listing 40: Control de formato con __format__. 6.5 Comparación y hashing El protocolo de comparación comprende seis métodos: __eq__, __ne__, __lt__, __le__, __gt__, __ge__. Python 3 no infiere automáticamente las relaciones complementarias (como sí lo hacía Python 2); el decorador @functools.total_ordering puede generarlas a partir de __eq__ y __lt__. Contrato de hashing Si una clase define __eq__ pero no __hash__, Python establece __hash__ = None, lo que hace que sus instancias sean no hasheables: no pueden usarse como claves de diccionario ni como elementos de conjunto. La regla es: objetos iguales deben producir el mismo hash, pero objetos distintos pueden colisionar. La coherencia entre igualdad y hashing es indispensable para el correcto funcionamiento de las estructuras basadas en tablas hash (dict, set, frozenset). Violarla produce comportamientos silenciosamente incorrectos: un objeto podría insertarse dos veces en un conjunto, o no recuperarse de un diccionario donde fue almacenado. 1 from dataclasses import dataclass 2 3 4 5 6 @dataclass ( frozen = True ) class Punto : x : float y : float # genera __eq__ y __hash__ coherentes 7 8 9 p1 = Punto (1.0 , 2.0) p2 = Punto (1.0 , 2.0) 10 11 12 13 14 15 print ( p1 == p2 ) print ( hash ( p1 ) == hash ( p2 ) ) print ( len ({ p1 , p2 }) ) d = { p1 : " origen " } print ( d [ p2 ]) # True # True # 1 -- p1 y p2 son el mismo elemento # " origen " -- recuperable via p2 Listing 41: Coherencia entre igualdad y hashing. 6.6 Aritmética: métodos directos, reflejados e in-place El protocolo aritmético es el más elaborado del modelo de datos. Para cada operador binario, Python define hasta tres métodos: Abraham Zamudio 45
  44. Programación en Python para Ingeniería 6.7 Protocolo de contenedor e

    iteración __op__ Método directo: se invoca cuando el operando izquierdo es de la clase que lo define. __rop__ Método reflejado (right): se invoca cuando el operando izquierdo no implementa la operación pero el derecho sí. Ejemplo: 2 * v invoca v.__rmul__(2). __iop__ Método in-place: se invoca para operadores aumentados (+=, *=). Si no está definido, Python recurre al método directo y reasigna el resultado. El orden de resolución para a + b es: 1. Si type(b) es subclase de type(a) y define __radd__, se invoca primero (regla de prioridad de subclase). 2. Se intenta a.__add__(b). Si no lanza NotImplemented, se devuelve el resultado. 3. Se intenta b.__radd__(a). Si no lanza NotImplemented, se devuelve el resultado. 4. Se lanza TypeError. La distinción entre devolver NotImplemented y lanzar una excepción es crucial: NotImplemented indica al intérprete que pruebe con el método reflejado; lanzar NotImplementedError aborta la operación inmediatamente. 1 2 3 class Matriz : def __init__ ( self , filas : list [ list [ float ]]) -> None : self . filas = filas 4 def __matmul__ ( self , otra : " Matriz " ) -> " Matriz " : " " " Producto matricial : A @ B . " " " n , m , p = len ( self . filas ) , len ( self . filas [0]) , len ( otra . filas [0]) resultado = [[0.0] * p for _ in range ( n ) ] for i in range ( n ) : for k in range ( m ) : for j in range ( p ) : resultado [ i ][ j ] += self . filas [ i ][ k ] * otra . filas [ k ][ j ] return Matriz ( resultado ) 5 6 7 8 9 10 11 12 13 14 def __iadd__ ( self , otra : " Matriz " ) -> " Matriz " : " " " Suma in - place : A += B modifica A sin crear nueva instancia . " " " for i , fila in enumerate ( otra . filas ) : for j , valor in enumerate ( fila ) : self . filas [ i ][ j ] += valor return self # __iadd__ debe devolver self 15 16 17 18 19 20 21 def __repr__ ( self ) -> str : return f " Matriz ({ self . filas }) " 22 23 Listing 42: Métodos reflejados e in-place. 6.7 Protocolo de contenedor e iteración Los contenedores personalizados implementan un subconjunto de los métodos __len__, __getitem__, __setitem__, __delitem__, __contains__, __iter__. Python ofrece dos protocolos de iteración mutuamente excluyentes: Protocolo de secuencia: define __getitem__ con índices enteros desde cero. Python proporciona una iteración por defecto que consulta los índices hasta recibir IndexError. Protocolo de iterador: define __iter__ devolviendo un iterador, y __next__ sobre el iterador devolviendo elementos hasta lanzar StopIteration. Abraham Zamudio 46
  45. Programación en Python para Ingeniería 6.8 Gestores de contexto El

    segundo es preferible por su eficiencia: no requiere consultas repetidas por índice y permite iterar sobre estructuras sin acceso aleatorio. 1 2 class ProgresionAritmetica : " " " Genera a , a +d , a +2 d , ... hasta n terminos . " " " 3 def __init__ ( self , inicio : float , razon : float , n : int ) -> None : self . inicio = inicio self . razon = razon self . n = n 4 5 6 7 8 def __len__ ( self ) -> int : return self . n 9 10 11 def __getitem__ ( self , i : int ) -> float : if not 0 <= i < self . n : raise IndexError ( f " Indice fuera de rango : { i } " ) return self . inicio + i * self . razon 12 13 14 15 16 def __iter__ ( self ) -> Iterator [ float ]: for i in range ( self . n ) : yield self . inicio + i * self . razon 17 18 19 20 def __contains__ ( self , valor : float ) -> bool : # Verifica si `valor ` pertenece a la progresion if self . razon == 0: return valor == self . inicio k = ( valor - self . inicio ) / self . razon return k . is_integer () and 0 <= k < self . n 21 22 23 24 25 26 Listing 43: Iterador sobre progresión aritmética. 6.8 Gestores de contexto El protocolo de gestor de contexto permite que un objeto controle la entrada y salida de un bloque with, garantizando la liberación determinista de recursos incluso ante excepciones. Protocolo de gestor de contexto Un objeto es un gestor de contexto si implementa __enter__(self) y __exit__(self, exc_type, exc_val, tb). La instrucción with obj as x: cuerpo se traduce en: 1. x = obj.__enter__() 2. Ejecución del cuerpo. 3. obj.__exit__(None, None, None) si no hubo excepción. 4. obj.__exit__(tipo, valor, traceback) si hubo excepción. Devolver True suprime la excepción; False o None la propagan. 1 2 3 4 5 class ArchivoCSV : def __init__ ( self , ruta : str , modo : str = " r " ) -> None : self . ruta = ruta self . modo = modo self . _handle = None 6 7 8 9 def __enter__ ( self ) -> " ArchivoCSV " : self . _handle = open ( self . ruta , self . modo , encoding = " utf -8 " ) return self Abraham Zamudio 47
  46. Programación en Python para Ingeniería 6.9 Métodos especiales para acceso

    a atributos 10 def __exit__ ( self , exc_type , exc_val , tb ) -> bool : if self . _handle is not None : self . _handle . close () # No suprimimos excepciones : devolvemos False implicitamente return False 11 12 13 14 15 16 def __iter__ ( self ) : for linea in self . _handle : yield linea . rstrip ( " \ n " ) . split ( " ," ) 17 18 19 20 21 22 23 24 with ArchivoCSV ( " datos . csv " ) as f : for fila in f : print ( fila ) Listing 44: Gestor de contexto para archivos con formato específico. 6.9 Métodos especiales para acceso a atributos Python ofrece cuatro métodos especiales que interceptan el acceso a atributos en distintos niveles: __getattribute__ Se invoca en toda lectura de atributo, incluidos los que existen. Es peligroso sobrescribirlo: puede producir recursión infinita si no se delega cuidadosamente en object.__getattribute__. __getattr__ Se invoca solo cuando el atributo no se encuentra por las vías normales. Es el mecanismo habitual para atributos calculados o proxy. __setattr__ Se invoca en toda asignación de atributo. __delattr__ Se invoca en toda eliminación de atributo con del. 1 2 class SoloPositivos : " " " Objeto cuyos atributos publicos deben ser positivos . " " " 3 4 5 6 7 8 9 10 11 12 13 def __setattr__ ( self , nombre : str , valor ) -> None : if nombre . startswith ( " _ " ) : # Atributos internos : sin validacion object . __setattr__ ( self , nombre , valor ) else : if not isinstance ( valor , ( int , float ) ) or valor <= 0: raise ValueError ( f " { nombre } debe ser positivo , recibido { valor ! r } " ) object . __setattr__ ( self , nombre , valor ) 14 15 16 17 18 19 20 21 22 obj = SoloPositivos () obj . x = 5.0 # OK obj . _cache = -1 # OK ( privado ) try : obj . y = -1.0 # Falla except ValueError as e : print ( e ) # y debe ser positivo , recibido -1.0 Listing 45: Intercepción de atributos para validación. Abraham Zamudio 48
  47. Programación en Python para Ingeniería 6.10 Errores frecuentes 6.10.1. Confundir

    __repr__ con __str__ 6.10 Errores frecuentes Definir solo __str__ sin __repr__ produce representaciones ambiguas en depuradores y en la consola interactiva. La convención inversa es correcta: definir __repr__ y dejar que __str__ lo herede. 6.10.2. Violar el contrato de hashing Definir __eq__ sin __hash__ hace las instancias no hasheables, lo que rompe su uso en diccionarios y conjuntos. La solución es usar @dataclass(frozen=True) o definir ambos métodos coherentemente. 6.10.3. Devolver self en métodos no in-place Los métodos __add__, __mul__, etc., deben devolver nuevas instancias. Devolver self produce aliasing inesperado: c = a + b podría modificar a. Solo los métodos __iadd__, __imul__, etc., devuelven self. 6.10.4. Definir __eq__ sin NotImplemented Cuando __eq__ recibe un objeto de tipo incompatible, debe devolver NotImplemented (no False) para que Python intente la comparación reflejada. Devolver False aborta prematuramente y puede producir resultados incorrectos en jerarquías polimórficas. 6.11 Síntesis Los métodos especiales son la interfaz entre el código del usuario y la sintaxis del lenguaje. Su implementación disciplinada requiere: 1. Coherencia interna: __eq__ y __hash__ deben ser consistentes; __add__ y __radd__ deben conmutar cuando corresponda. 2. Respeto de contratos: __repr__ debe ser inequívoco, __bool__ debe devolver bool, __len__ debe devolver un entero no negativo. 3. Uso de NotImplemented: nunca lanzar excepción desde un método aritmético cuando el tipo del otro operando es incompatible; devolver NotImplemented permite la resolución reflejada. 4. Minimalismo: implementar solo los métodos que aportan semántica genuina. Un objeto no necesita soportar toda la aritmética; basta con aquello que el dominio justifique. La siguiente sección aborda el manejo de excepciones, que junto con los métodos especiales completa el modelo de comportamiento de los objetos Python. 7 Manejo de Excepciones El manejo de excepciones es el mecanismo mediante el cual un programa separa la lógica normal de la lógica de recuperación ante fallos. A diferencia de los códigos de retorno, que el llamador puede ignorar silenciosamente, las excepciones interrumpen el flujo de control y obligan al programador a decidir explícitamente cómo proceder. En Python, esta separación se materializa mediante un conjunto de construcciones (try, except, else, finally) y una jerarquía de clases que da a cada condición anómala una identidad tipada. Abraham Zamudio 49
  48. Programación en Python para Ingeniería 7.1 7.1 Modelo formal Modelo

    formal Excepción Una excepción es un objeto que representa una condición anómala detectada durante la ejecución. Su levantamiento (raising) interrumpe el flujo secuencial del programa y desencadena un proceso de propagación por la pila de llamadas. Formalmente, una excepción es una tupla E = ⟨𝜏, 𝜇, 𝜃⟩ donde 𝜏 es el tipo (una clase derivada de BaseException), 𝜇 es el mensaje descriptivo y 𝜃 es el traceback (la secuencia de frames activos en el momento del levantamiento). La propagación sigue una semántica precisa: 1. Se evalúa una expresión que falla (por ejemplo, 1/0) o se ejecuta la instrucción raise exc. 2. El intérprete construye un objeto excepción y captura el estado actual del call stack. 3. Se busca un manejador (except) en el frame actual. Si existe uno que capture el tipo del objeto, la propagación termina y el control pasa al cuerpo del manejador. 4. Si no existe, el frame actual se desenrolla (unwind) y la búsqueda continúa en el frame llamador. 5. El proceso continúa hasta encontrar un manejador o hasta agotar la pila. En el último caso, el intérprete imprime el traceback y termina el programa con código de salida distinto de cero. Este modelo es no local: el punto donde se lanza la excepción y el punto donde se captura pueden estar separados por decenas de frames. Esta separación es precisamente la que permite escribir código intermedio que ignora las condiciones anómalas y las deja propagar hasta quien puede manejarlas. 7.2 Jerarquía de excepciones Todas las excepciones en Python derivan de BaseException, pero la jerarquía distingue dos grandes ramas: las excepciones que no deben capturarse (porque representan señales de terminación legítima) y las que sí deben capturarse (porque representan errores recuperables). Clase Propósito BaseException SystemExit KeyboardInterrupt GeneratorExit Exception ArithmeticError LookupError OSError Raíz de toda la jerarquía. No capturar. Terminación solicitada por sys.exit(). Interrupción por Ctrl+C. Cierre de un generador (gen.close()). Raíz de los errores recuperables. Errores aritméticos generales. Errores de indexación o búsqueda. Errores del sistema operativo. Las excepciones más comunes en código científico son: ValueError: un argumento tiene el tipo correcto pero un valor inadecuado (float(.abc")). TypeError: una operación se aplica a un tipo incompatible (“a” + 1). KeyError: se accede a una clave inexistente en un diccionario. IndexError: se accede a un índice fuera de rango en una secuencia. ZeroDivisionError: división por cero entera o módulo cero. Nótese que 1.0 / 0.0 no lanza esta excepción: produce inf o nan según IEEE 754. Abraham Zamudio 50
  49. Programación en Python para Ingeniería 7.3 Excepciones personalizadas FileNotFoundError: subclase

    de OSError lanzada al abrir un archivo inexistente. OverflowError: operación aritmética que excede los límites representables en float (los enteros de Python tienen precisión arbitraria y no la producen). Nota de Implementación Nunca capture BaseException. Si lo hace, capturará también KeyboardInterrupt, lo que impide al usuario interrumpir el programa, y SystemExit, lo que impide terminar limpiamente. La única excepción a esta regla es el cleanup de nivel superior (como el __exit__ de un gestor de contexto), donde puede justificarse capturar todo para garantizar la liberación de recursos. 7.3 Excepciones personalizadas La jerarquía incorporada cubre los errores genéricos del lenguaje y de la biblioteca estándar. Para errores específicos de un dominio (numérico, gráfico, financiero), conviene definir una jerarquía propia que: 1. Derive de Exception (nunca de BaseException). 2. Defina una base por módulo o paquete (ErrorNumerico). 3. Especialice por condición concreta (NoConvergencia, MatrizSingular). 4. Almacene información contextual útil para el diagnóstico (iteraciones, residuos, valores de entrada). 1 2 from __future__ import annotations from typing import Callable 3 4 5 6 class ErrorNumerico ( Exception ) : " " " Base de todos los errores del modulo numerico . 7 8 9 10 Permite a los clientes capturar cualquier error del dominio con un unico ` except ErrorNumerico `. """ 11 12 13 14 class NoConvergencia ( ErrorNumerico ) : " " " El metodo iterativo no alcanzo la tolerancia solicitada . " " " 15 16 17 18 19 20 21 22 23 def __init__ ( self , iteraciones : int , residuo : float , tol : float ) -> None : super () . __init__ ( f " No convergio en { iteraciones } iteraciones " f " ( residuo = { residuo :.3 e } , tol = { tol :.3 e }) " ) self . iteraciones = iteraciones self . residuo = residuo self . tol = tol 24 25 26 27 class MatrizSingular ( ErrorNumerico ) : " " " La matriz no es invertible o esta mal condicionada . " " " 28 29 30 31 32 33 def __init__ ( self , numero_condicion : float ) -> None : super () . __init__ ( f " Matriz singular o mal condicionada " f " ( cond = { numero_condicion :.3 e }) " ) Abraham Zamudio 51
  50. Programación en Python para Ingeniería 7.4 La construcción try completa

    self . numero_condicion = numero_condicion 34 35 36 37 38 39 40 41 42 43 44 def newton ( f : Callable [[ float ] , float ] , df : Callable [[ float ] , float ] , x0 : float , tol : float = 1e -10 , max_iter : int = 100 , ) -> float : " " " Metodo de Newton - Raphson para encontrar raices de f . 45 Raises : NoConvergencia : si no se alcanza `tol ` en ` max_iter ` iteraciones . ZeroDivisionError : si la derivada se anula durante la iteracion . """ x = x0 for k in range ( max_iter ) : fx = f ( x ) if abs ( fx ) < tol : return x dfx = df ( x ) if dfx == 0: raise ZeroDivisionError ( f " Derivada nula en x = { x } durante iteracion { k } " ) x = x - fx / dfx raise NoConvergencia ( max_iter , abs ( fx ) , tol ) 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 Listing 46: Jerarquía de excepciones para un dominio numérico. La convención de nomenclatura de la comunidad es clara: los nombres de las excepciones terminan en Error (ValueError, FileNotFoundError) o describen una condición excepcional concreta (NoConvergencia, KeyboardInterrupt). Evite sufijos como Exception (NoConvergenciaException) o Fault: son redundantes o pertenecen a otros ecosistemas. 7.4 La construcción try completa La instrucción try admite cuatro cláusulas, cada una con una función precisa: try Contiene el código cuya ejecución puede fallar. except Captura excepciones específicas y define la lógica de recuperación. else Se ejecuta solo si el bloque try terminó sin excepciones. Es el lugar correcto para el código que debe ejecutarse tras un éxito, pero que no debe quedar protegido por el mismo try. finally Se ejecuta siempre, haya habido excepción o no, y haya sido capturada o no. Es el lugar para liberar recursos. La semántica precisa puede expresarse así: sea 𝐵 el cuerpo del try, 𝐸 el conjunto de manejadores, 𝑆 el cuerpo del else y 𝐹 el cuerpo del finally. La ejecución procede de la siguiente manera: 1. Se ejecuta 𝐵. 2. Si 𝐵 termina sin excepción: se ejecuta 𝑆. 3. Si 𝐵 lanza una excepción 𝑒: se busca el primer manejador de 𝐸 que capture type(𝑒). Si existe, se ejecuta; en caso contrario, 𝑒 se propaga al llamador. Abraham Zamudio 52
  51. Programación en Python para Ingeniería 7.5 Orden de los manejadores

    4. Independientemente de lo anterior, se ejecuta 𝐹. Si 𝐹 lanza una excepción, esta reemplaza a cualquier excepción pendiente. 5. Si 𝐹 ejecuta return, break o continue, esos cambios de control sobrescriben cualquier retorno o excepción previa. Esto es una fuente clásica de errores sutiles. 1 2 def leer_datos ( ruta : str ) -> list [ float ]: " " " Lee un archivo de numeros , uno por linea . 3 Returns : Lista de floats leidos del archivo . 4 5 6 Raises : FileNotFoundError : si el archivo no existe . ValueError : si alguna linea no es convertible a float . """ try : with open ( ruta , encoding = " utf -8 " ) as f : datos = [ float ( linea ) for linea in f if linea . strip () ] except FileNotFoundError : # Enriquecer la excepcion con contexto y re - lanzar raise FileNotFoundError ( f " Archivo de datos no encontrado : { ruta ! r } " ) from None except ValueError as e : # Envolver la excepcion original preservando la causa raise ValueError ( f " Dato no numerico en { ruta ! r }: { e } " ) from e else : # Solo si el try tuvo exito return datos finally : # Siempre se ejecuta , con o sin excepcion print ( f " Intento de lectura de { ruta ! r } finalizado . " ) 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 Listing 47: Estructura completa con las cuatro cláusulas. Nota de Implementación La cláusula else no es un adorno sintáctico. Colocar código de post-procesamiento exitoso dentro del try es un error común que puede producir comportamientos incorrectos: si ese código lanza una excepción, será capturada por los except del mismo try, aunque no tenga relación con la operación protegida. 7.5 Orden de los manejadores Los manejadores except se evalúan en orden de aparición. El primer manejador cuyo tipo sea compatible con la excepción (mediante isinstance) se ejecuta; los demás se ignoran. Esto impone dos reglas: 1. Los manejadores más específicos deben preceder a los más generales. 2. Un manejador except Exception al final captura todo lo que no haya sido capturado antes. 1 2 3 # INCORRECTO : el manejador general captura todo try : operacion_riesgosa () Abraham Zamudio 53
  52. Programación en Python para Ingeniería 4 5 6 7 7.6

    Captura múltiple y captura agrupada except Exception : print ( " Error generico " ) except FileNotFoundError : # inalcanzable print ( " Archivo no encontrado " ) 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 # CORRECTO : especifico primero , general al final try : operacion_riesgosa () except FileNotFoundError : print ( " Archivo no encontrado " ) except PermissionError : print ( " Permiso denegado " ) except OSError as e : print ( f " Error de E / S generico : { e } " ) except Exception as e : # Ultimo recurso : re - lanzar para no ocultar bugs print ( f " Error inesperado : { type ( e ) . __name__ }: { e } " ) raise Listing 48: Orden incorrecto vs. correcto. 7.6 Captura múltiple y captura agrupada Python permite capturar varias excepciones en un mismo manejador mediante una tupla: 1 2 3 4 5 6 try : resultado = calcular () except ( ValueError , TypeError , ZeroDivisionError ) as e : # Los tres tipos se manejan igual print ( f " Error de calculo : { e } " ) resultado = None Listing 49: Captura múltiple. La captura agrupada es apropiada cuando las distintas condiciones requieren la misma recuperación. Si la recuperación difiere, se prefieren manejadores separados incluso si comparten alguna lógica, porque el tipo de la excepción suele contener información útil para el diagnóstico. 7.7 Encadenamiento de excepciones Un patrón recurrente consiste en capturar una excepción de bajo nivel y transformarla en una de alto nivel más significativa para el dominio. Python preserva automáticamente la traza original y ofrece control explícito sobre la relación entre ambas excepciones. Encadenamiento implícito y explícito Cuando una excepción 𝑒 2 se lanza mientras otra 𝑒 1 está siendo manejada, Python establece automáticamente e_2.__context__ = e_1 (encadenamiento implícito). La instrucción raise e_2 from e_1 establece además e_2.__cause__ = e_1 (encadenamiento explícito). La instrucción raise e_2 from None suprime el encadenamiento implícito. 1 2 3 4 5 6 # 1. Encadenamiento impl í cito ( contexto automatico ) def procesar ( datos ) : try : return int ( datos ) except ValueError : raise TypeError ( " Datos no procesables " ) Abraham Zamudio 54
  53. Programación en Python para Ingeniería 7.8 Re-lanzamiento y supresión #

    __context__ = ValueError original 7 8 9 10 11 12 13 14 15 # 2. Encadenamiento explicito ( causa declarada ) def procesar ( datos ) : try : return int ( datos ) except ValueError as e : raise TypeError ( " Datos no procesables " ) from e # __cause__ = ValueError , __context__ = ValueError 16 17 18 19 20 21 22 23 # 3. Supresion del encadenamiento def procesar ( datos ) : try : return int ( datos ) except ValueError : raise TypeError ( " Datos no procesables " ) from None # __context__ suprimido en la presentacion Listing 50: Los tres modos de encadenamiento. La buena práctica es usar from e cuando la excepción original contiene información de diagnóstico relevante, y from None cuando solo añade ruido (por ejemplo, al traducir entre abstracciones que no deben filtrarse al usuario). 7.8 Re-lanzamiento y supresión Dentro de un manejador, raise sin argumentos re-lanza la excepción actual preservando su traceback original. Es el mecanismo correcto para añadir efectos secundarios (logging, cleanup) sin alterar la excepción: 1 import logging 2 3 logger = logging . getLogger ( __name__ ) 4 5 6 7 8 9 10 11 def operacion_critica () : try : resultado = calcular_algo () except Exception : logger . exception ( " Fallo en operacion critica " ) raise # re - lanza la MISMA excepcion con el traceback original return resultado Listing 51: Re-lanzamiento con efectos secundarios. Nota de Implementación Dentro de un manejador, escriba raise y no raise e. La segunda forma re-lanza la excepción pero reinicia el traceback al punto del raise, perdiendo el origen real del fallo. La primera preserva la traza completa. 7.9 Excepciones y generadores Los generadores introducen una complicación adicional: cuando un generador es cerrado prematuramente (por ejemplo, porque el consumidor abandona la iteración o porque se ejecuta gen.close()), Python inyecta una excepción GeneratorExit en el punto de suspensión. El código del generador debe manejar esta situación mediante try/finally: Abraham Zamudio 55
  54. Programación en Python para Ingeniería 1 2 3 4 5

    6 7 8 9 10 11 12 13 7.10 Gestores de contexto como alternativa def leer_bloques ( ruta : str , tamano : int = 1024) : " " " Genera bloques de un archivo , garantizando cierre . " " " f = open ( ruta , " rb " ) try : while True : bloque = f . read ( tamano ) if not bloque : return yield bloque finally : # Se ejecuta incluso si el consumidor abandona la iteracion f . close () print ( f " Cerrado : { ruta } " ) Listing 52: Cleanup en generadores. 7.10 Gestores de contexto como alternativa Para liberación determinista de recursos, los gestores de contexto (with) son preferibles a try/finally explícito. No solo reducen el ruido sintáctico, sino que garantizan la ejecución del cleanup incluso si el cuerpo lanza una excepción o ejecuta return, break o continue. 1 2 3 4 5 6 # Version explicita con try / finally : f = open ( " datos . txt " ) try : contenido = f . read () finally : f . close () 7 8 9 10 # Version idiomatica con gestor de contexto : with open ( " datos . txt " ) as f : contenido = f . read () Listing 53: Equivalencia entre try/finally y with. La segunda versión es equivalente semánticamente pero más concisa y resistente a errores. Para recursos personalizados (conexiones a base de datos, locks, transacciones), la implementación de un gestor de contexto mediante contextlib.contextmanager encapsula el patrón: 1 2 from contextlib import contextmanager import time 3 4 5 6 7 8 9 10 11 12 @contextmanager def cronometro ( nombre : str ) : " " " Mide y reporta el tiempo de ejecucion de un bloque . " " " inicio = time . perf_counter () try : yield finally : duracion = time . perf_counter () - inicio print ( f " { nombre }: { duracion :.4 f } s " ) 13 14 15 16 with cronometro ( " integracion " ) : resultado = integrar ( y0 , t0 , tf , dt ) Listing 54: Gestor de contexto con contextlib. Abraham Zamudio 56
  55. Programación en Python para Ingeniería 1 2 3 4 5

    7.11 Anti-patrones 7.11.1. Capturar Exception indiscriminadamente 7.11 Anti-patrones # MAL : oculta bugs , dificulta el diagnostico try : resultado = calcular () except Exception : pass # silencio total 6 7 8 9 10 11 12 13 # BIEN : capture lo especifico , re - lance lo inesperado try : resultado = calcular () except ( ValueError , ZeroDivisionError ) as e : logger . warning ( " Calculo fallido : %s " , e ) resultado = valor_por_defecto # Cualquier otra excepcion se propaga Listing 55: Anti-patrón: captura indiscriminada. 7.11.2. Usar excepciones para control de flujo normal Las excepciones son costosas en términos de rendimiento (construcción del traceback, desenrollado de la pila). Usarlas para control de flujo ordinario degrada el rendimiento y dificulta la lectura: 1 2 3 4 5 6 # MAL : excepcion como control de flujo def buscar ( diccionario , clave ) : try : return diccionario [ clave ] except KeyError : return None 7 8 9 10 # BIEN : usar el metodo disenado para ello def buscar ( diccionario , clave ) : return diccionario . get ( clave ) 11 12 13 14 15 16 # BIEN : usar LBYL ( Look Before You Leap ) si la verificacion es barata def buscar ( diccionario , clave ) : if clave in diccionario : return diccionario [ clave ] return None Listing 56: Anti-patrón: excepciones para control de flujo. 7.11.3. Perder la causa original Transformar una excepción sin preservar su causa dificulta el diagnóstico: 1 2 3 4 5 # MAL : la causa original se pierde try : config = json . load ( open ( ruta ) ) except Exception : raise ConfigError ( " Configuracion invalida " ) 6 7 8 9 10 11 # BIEN : preservar la causa try : config = json . load ( open ( ruta ) ) except ( OSError , json . JSONDecodeError ) as e : raise ConfigError ( f " Configuracion invalida en { ruta ! r } " ) from e Abraham Zamudio 57
  56. Programación en Python para Ingeniería 7.12 Excepciones en contexto científico

    Listing 57: Anti-patrón: perder la causa. 7.11.4. Anidar try innecesariamente Cada try introduce un costo cognitivo y sintáctico. Si dos operaciones pueden fallar de la misma forma y comparten la recuperación, un solo try es suficiente: 1 2 3 4 5 # MAL : dos try para el mismo manejador try : a = leer_archivo ( " a . txt " ) except FileNotFoundError : a = None 6 7 8 9 10 try : b = leer_archivo ( " b . txt " ) except FileNotFoundError : b = None 11 12 13 14 15 16 17 18 # BIEN : un solo try si la recuperacion es la misma try : a = leer_archivo ( " a . txt " ) b = leer_archivo ( " b . txt " ) except FileNotFoundError as e : print ( f " Falta archivo : { e . filename } " ) a = b = None Listing 58: Anti-patrón: anidamiento innecesario. 7.12 Excepciones en contexto científico En computación científica, las excepciones cumplen tres funciones adicionales específicas del dominio: 1. Verificación de precondiciones numéricas: división por cero, matrices singulares, valores fuera de rango físico. 2. Diagnóstico de convergencia: los métodos iterativos (Newton, gradiente conjugado, punto fijo) deben señalar explícitamente la no convergencia, no devolver silenciosamente un resultado incorrecto. 3. Validación de unidades: cuando se usan bibliotecas como pint, la incompatibilidad de unidades produce excepciones específicas. Un patrón común es definir una jerarquía específica del dominio numérico y envolver las excepciones de numpy o scipy en ella: 1 import numpy as np 2 3 4 class ErrorSimulacion ( Exception ) : " " " Base de errores del simulador . " " " 5 6 7 class EstadoInvalido ( ErrorSimulacion ) : " " " El estado contiene valores no finitos . " " " 8 9 10 class PasoInestable ( ErrorSimulacion ) : " " " El integrador detecto inestabilidad numerica . " " " 11 12 Abraham Zamudio 58
  57. Programación en Python para Ingeniería 13 14 15 16 17

    7.13 Síntesis def validar_estado ( y : np . ndarray ) -> None : if not np . all ( np . isfinite ( y ) ) : raise EstadoInvalido ( f " Estado contiene NaN o Inf : { y [:5]}... " ) 18 19 20 21 22 23 24 25 26 27 28 29 30 31 def paso_seguro ( integrador , y , t , dt ) : try : y_nuevo = integrador . paso (y , t , dt ) validar_estado ( y_nuevo ) return y_nuevo except EstadoInvalido : # Reducir dt y reintentar , o propagar si ya es minimo if dt < 1e -12: raise PasoInestable ( f " dt minimo alcanzado en t = { t } " ) from None return paso_seguro ( integrador , y , t , dt / 2) Listing 59: Envoltura de excepciones numéricas. 7.13 Síntesis El manejo disciplinado de excepciones descansa sobre seis principios: 1. Capture lo específico, propague lo demás. Un manejador debe corresponder a una condición que el código sabe recuperar. 2. No silencie. Un except con pass oculta bugs. Si no hay recuperación posible, re-lance. 3. Preserve la causa. Use raise ... from e al transformar excepciones entre capas. 4. Distinga else de try. El código de éxito no debe estar bajo la protección del try original. 5. Use with para recursos. Los gestores de contexto son más seguros y concisos que try/finally explícito. 6. Defina jerarquías del dominio. Una base por módulo y subclases por condición concreta permite a los clientes capturar al nivel de abstracción adecuado. Con esta sección se completa el recorrido por los mecanismos fundamentales de la POO en Python. La sección final integra todos los conceptos en un simulador modular que ilustra cómo se combinan clases, herencia, métodos especiales y manejo de excepciones en el diseño de software científico. 8 Aplicación Integradora: Simulador Modular Las secciones precedentes introdujeron de forma aislada los mecanismos fundamentales de la POO en Python: clases, encapsulamiento, herencia, polimorfismo, MRO, métodos especiales y manejo de excepciones. Esta sección final articula todos ellos en un simulador modular de ecuaciones diferenciales ordinarias (EDOs), un caso de uso representativo de la computación científica donde la combinación de estos mecanismos produce software extensible, verificable y numéricamente robusto. El diseño sigue tres principios arquitectónicos: 1. Separación de responsabilidades: el estado, el esquema de integración, el problema físico y el orquestador residen en abstracciones independientes. 2. Inversión de dependencias: el simulador depende de protocolos (contratos abstractos), no de implementaciones concretas. Abraham Zamudio 59
  58. Programación en Python para Ingeniería 8.1 Diseño de la arquitectura

    3. Fallos explícitos: las condiciones anómalas (no convergencia, inestabilidad, estados inválidos) se señalan mediante excepciones tipadas del dominio, no mediante códigos de retorno. 8.1 Diseño de la arquitectura La arquitectura consta de cinco abstracciones cooperantes: Componente Responsabilidad Estado Representa el vector de estado 𝑦(𝑡) junto con el tiempo 𝑡. Inmutable por convención. Define el lado derecho 𝑦¤ = 𝑓 (𝑦, 𝑡). Contrato abstracto. Define el esquema numérico de avance temporal. Contrato abstracto. Contiene la traza completa de la simulación. Estructura inmutable. Orquesta la integración, gestiona el paso adaptativo y valida invariantes. ProblemaEDO Integrador Resultado Simulador Las relaciones entre componentes siguen el patrón Strategy: el simulador acepta cualquier integrador que satisfaga el contrato, y cualquier problema que defina 𝑓 . Esto permite cambiar el esquema numérico o el modelo físico sin modificar el orquestador. 8.2 Jerarquía de excepciones del dominio La primera decisión de diseño consiste en definir una jerarquía de errores específica del simulador. Esto permite que los clientes capturen al nivel de abstracción adecuado: errores de simulación en general, o condiciones específicas como la falta de convergencia. 1 from __future__ import annotations 2 3 4 5 6 import math from abc import ABC , abstractmethod from dataclasses import dataclass , field , replace from typing import Iterator , Sequence , Protocol , runtime_checkable 7 8 9 10 11 # ============================================================================ # 1. JERARQUIA DE EXCEPCIONES # ============================================================================ 12 13 14 class ErrorSimulacion ( Exception ) : " " " Base de todos los errores del simulador . " " " 15 16 17 18 class EstadoInvalido ( ErrorSimulacion ) : " " " El vector de estado contiene valores no finitos o incompatibles . " " " 19 20 21 22 23 24 25 26 def __init__ ( self , t : float , componentes : Sequence [ float ]) -> None : super () . __init__ ( f " Estado invalido en t = { t :.6 e }: " f " componentes = { list ( componentes ) [:5]} " ) self . t = t self . componentes = tuple ( componentes ) 27 Abraham Zamudio 60
  59. Programación en Python para Ingeniería 8.3 El estado como dataclass

    inmutable 28 29 30 class PasoInestable ( ErrorSimulacion ) : "" " El paso de integracion produce un error relativo excesivo . " " " 31 def __init__ ( self , t : float , dt : float , error_relativo : float ) -> None : super () . __init__ ( f " Paso inestable en t = { t :.6 e } con dt = { dt :.3 e } " f " ( error relativo = { error_relativo :.3 e }) " ) self . t = t self . dt = dt self . error_relativo = error_relativo 32 33 34 35 36 37 38 39 40 41 42 43 class DimensionIncompatible ( ErrorSimulacion ) : " " " Dos estados tienen dimensiones distintas . " " " 44 def __init__ ( self , n1 : int , n2 : int ) -> None : super () . __init__ ( f " Dimensiones incompatibles : { n1 } vs { n2 } " ) self . n1 = n1 self . n2 = n2 45 46 47 48 Listing 60: Jerarquía de excepciones del simulador. La jerarquía respeta las convenciones establecidas en la sección anterior: base única por dominio (ErrorSimulacion), subclases por condición concreta, información contextual almacenada como atributos para diagnóstico programático. 8.3 El estado como dataclass inmutable El vector de estado se modela como una dataclass congelada (frozen=True). Esta decisión tiene tres consecuencias: Inmutabilidad: cada paso de integración produce un nuevo objeto, eliminando la posibilidad de aliasing accidental. Hashing automático: las instancias pueden usarse como claves de diccionario o elementos de conjunto. Generación de dunders coherentes: __eq__, __hash__ y __repr__ se derivan automáticamente de los campos declarados. 1 2 3 # ============================================================================ # 2. ESTADO DEL SISTEMA # ============================================================================ 4 5 6 7 8 @dataclass ( frozen = True , slots = True ) class Estado : """ Vector de estado y ( t ) junto con el tiempo t . 9 10 11 Inmutable por convencion : cada operacion produce una nueva instancia . """ 12 13 14 t : float = 0.0 y : tuple [ float , ...] = (0.0 , 1.0) 15 16 17 18 def __post_init__ ( self ) -> None : # Validacion de invariantes al construir if not math . isfinite ( self . t ) : Abraham Zamudio 61
  60. Programación en Python para Ingeniería 8.4 El problema físico como

    clase abstracta raise EstadoInvalido ( self .t , self . y ) for componente in self . y : if not math . isfinite ( componente ) : raise EstadoInvalido ( self .t , self . y ) 19 20 21 22 23 # --- Protocolo de contenedor --def __len__ ( self ) -> int : return len ( self . y ) 24 25 26 27 def __getitem__ ( self , i : int ) -> float : return self . y [ i ] 28 29 30 def __iter__ ( self ) -> Iterator [ float ]: return iter ( self . y ) 31 32 33 # --- Aritmetica de estados --def __add__ ( self , otro : Estado ) -> Estado : " " " Suma componente a componente . Los tiempos no se suman . " " " if len ( self ) != len ( otro ) : raise DimensionIncompatible ( len ( self ) , len ( otro ) ) return replace ( self , y = tuple ( a + b for a , b in zip ( self .y , otro . y ) ) , ) 34 35 36 37 38 39 40 41 42 43 def __sub__ ( self , otro : Estado ) -> Estado : if len ( self ) != len ( otro ) : raise DimensionIncompatible ( len ( self ) , len ( otro ) ) return replace ( self , y = tuple ( a - b for a , b in zip ( self .y , otro . y ) ) , ) 44 45 46 47 48 49 50 51 def __mul__ ( self , escalar : float ) -> Estado : return replace ( self , y = tuple ( escalar * x for x in self . y ) ) 52 53 54 __rmul__ = __mul__ 55 56 def __abs__ ( self ) -> float : " " " Norma euclidiana del vector de estado . " " " return math . sqrt ( sum ( x * x for x in self . y ) ) 57 58 59 60 # --- Conversion --def como_lista ( self ) -> list [ float ]: " " " Devuelve una copia mutable del vector . " " " return list ( self . y ) 61 62 63 64 Listing 61: Estado como dataclass inmutable con operaciones algebraicas. Nótese el uso de replace (proporcionado por dataclasses) en lugar de crear instancias directamente. Esta función respeta la semántica de la dataclass y evita duplicar la lógica de construcción. También se observa que __add__ no suma los tiempos: la suma de estados es una operación algebraica sobre vectores, no sobre el eje temporal. 8.4 El problema físico como clase abstracta El lado derecho de la EDO, 𝑦¤ = 𝑓 (𝑦, 𝑡), se modela mediante una clase base abstracta. Esta abstracción define un contrato mínimo: cualquier problema físico (oscilador armónico, sistema de Lotka-Volterra, ecuaciones de Navier-Stokes discretizadas) se reduce a implementar derivada. Abraham Zamudio 62
  61. Programación en Python para Ingeniería 1 2 3 8.4 El

    problema físico como clase abstracta # ============================================================================ # 3. PROBLEMA FISICO ( LADO DERECHO DE LA EDO ) # ============================================================================ 4 5 6 class ProblemaEDO ( ABC ) : " " " Contrato para el lado derecho de dy / dt = f (y , t ) . " " " 7 8 9 10 11 12 @property @abstractmethod def dimension ( self ) -> int : " " " Dimension del espacio de estados . " " " ... 13 14 15 16 @abstractmethod def derivada ( self , t : float , y : tuple [ float , ...]) -> tuple [ float , ...]: " " " Evalua f (y , t ) . 17 18 19 20 21 22 23 24 Precondiciones : - ` len ( y ) == self . dimension `. - `t ` y `y ` son finitos . Postcondiciones : - Devuelve una tupla de longitud ` self . dimension `. """ ... 25 26 27 28 def __call__ ( self , t : float , y : tuple [ float , ...]) -> tuple [ float , ...]: " " " Azucar sintactico : permite invocar el problema como f (t , y ) . " " " return self . derivada (t , y ) 29 30 31 32 33 class OsciladorArmotonico ( ProblemaEDO ) : """ Oscilador armonico 1 D : y '' + omega ^2 * y = 0. 34 35 36 Estado : y = ( posicion , velocidad ) . """ 37 38 39 40 41 42 43 44 def __init__ ( self , omega : float , amortiguamiento : float = 0.0) -> None : if omega <= 0: raise ValueError ( f " omega debe ser positivo : { omega } " ) if amortiguamiento < 0: raise ValueError ( f " amortiguamiento no puede ser negativo " ) self . omega = omega self . gamma = amortiguamiento 45 46 47 48 @property def dimension ( self ) -> int : return 2 49 50 51 52 53 54 def derivada ( self , t : float , y : tuple [ float , ...]) -> tuple [ float , ...]: x, v = y dxdt = v dvdt = - self . omega ** 2 * x - 2 * self . gamma * v return ( dxdt , dvdt ) 55 56 57 58 59 def energia ( self , y : tuple [ float , ...]) -> float : " " " Energia mecanica total : E = (1/2) v ^2 + (1/2) omega ^2 x ^2. " " " x, v = y return 0.5 * v * v + 0.5 * self . omega ** 2 * x * x Abraham Zamudio 63
  62. Programación en Python para Ingeniería 8.5 El integrador como clase

    abstracta Listing 62: Contrato abstracto para el lado derecho de la EDO. La clase OsciladorArmotonico demuestra el uso combinado de propiedades (para dimension, calculada a partir del estado interno), validación en el constructor, y dunder __call__ que permite sintaxis funcional sobre un objeto. 8.5 El integrador como clase abstracta El esquema de integración numérica se modela mediante otra jerarquía abstracta. El contrato exige únicamente el método paso, que avanza el estado desde 𝑡 hasta 𝑡 + Δ𝑡. Los esquemas concretos (Euler explícito, Runge-Kutta 4, Dormand-Prince) implementan este contrato sin conocer al simulador que los utiliza. 1 2 3 # ============================================================================ # 4. INTEGRADORES NUMERICOS # ============================================================================ 4 5 6 class Integrador ( ABC ) : " " " Contrato para esquemas de integracion temporal de paso fijo . " " " 7 8 9 10 11 12 @property @abstractmethod def orden ( self ) -> int : " " " Orden de convergencia del esquema : error ~ dt ^ orden . " " " ... 13 14 15 16 17 18 19 @abstractmethod def paso ( self , problema : ProblemaEDO , t : float , y : tuple [ float , ...] , dt : float ) -> tuple [ float , ...]: " " " Avanza el estado y desde t hasta t + dt . " " " ... 20 21 22 def __repr__ ( self ) -> str : return f " { type ( self ) . __name__ }( orden ={ self . orden }) " 23 24 25 26 class EulerExplicito ( Integrador ) : " " " Esquema de Euler explicito . Orden 1 , condicionalmente estable . " " " 27 28 29 30 @property def orden ( self ) -> int : return 1 31 32 33 34 35 36 def paso ( self , problema , t , y , dt ) : return tuple ( yi + dt * fi for yi , fi in zip (y , problema . derivada (t , y ) ) ) 37 38 39 40 class RungeKutta4 ( Integrador ) : " " " Esquema clasico de Runge - Kutta de cuarto orden . " " " 41 42 43 @property def orden ( self ) -> int : Abraham Zamudio 64
  63. Programación en Python para Ingeniería 8.6 El resultado como estructura

    inmutable return 4 44 45 def paso ( self , problema , t , y , dt ) : k1 = problema . derivada (t , y ) y2 = tuple ( yi + 0.5 * dt * ki for yi , ki in zip (y , k1 ) ) k2 = problema . derivada ( t + 0.5 * dt , y2 ) y3 = tuple ( yi + 0.5 * dt * ki for yi , ki in zip (y , k2 ) ) k3 = problema . derivada ( t + 0.5 * dt , y3 ) y4 = tuple ( yi + dt * ki for yi , ki in zip (y , k3 ) ) k4 = problema . derivada ( t + dt , y4 ) return tuple ( yi + ( dt / 6.0) * ( k1i + 2* k2i + 2* k3i + k4i ) for yi , k1i , k2i , k3i , k4i in zip (y , k1 , k2 , k3 , k4 ) ) 46 47 48 49 50 51 52 53 54 55 56 57 Listing 63: Contrato abstracto y esquemas concretos de integración. La propiedad orden es un ejemplo de metadato expuesto como parte del contrato. Un cliente puede usarla para seleccionar adaptativamente el paso, o para verificar que la tolerancia solicitada es alcanzable con el esquema disponible. 8.6 El resultado como estructura inmutable La traza completa de una simulación se representa como una estructura inmutable con acceso indexado. Esto permite post-procesamiento sin riesgo de mutaciones accidentales. 1 2 3 # ============================================================================ # 5. RESULTADO DE LA SIMULACION # ============================================================================ 4 5 6 7 @dataclass ( frozen = True , slots = True ) class Resultado : " " " Traza completa de una simulacion . " " " 8 9 10 tiempos : tuple [ float , ...] estados : tuple [ tuple [ float , ...] , ...] 11 12 13 14 15 16 17 def __post_init__ ( self ) -> None : if len ( self . tiempos ) != len ( self . estados ) : raise ValueError ( f " Longitudes inconsistentes : " f " { len ( self . tiempos ) } tiempos vs { len ( self . estados ) } estados " ) 18 19 20 def __len__ ( self ) -> int : return len ( self . tiempos ) 21 22 23 def __getitem__ ( self , i : int ) -> tuple [ float , tuple [ float , ...]]: return ( self . tiempos [ i ] , self . estados [ i ]) 24 25 26 def __iter__ ( self ) : return iter ( zip ( self . tiempos , self . estados ) ) 27 28 29 30 @property def estado_final ( self ) -> Estado : return Estado ( t = self . tiempos [ -1] , y = self . estados [ -1]) 31 32 33 34 def trayectoria ( self , componente : int ) -> list [ float ]: " " " Extrae la trayectoria temporal de una componente del estado . " " " return [ estado [ componente ] for estado in self . estados ] Abraham Zamudio 65
  64. Programación en Python para Ingeniería 8.7 El simulador como orquestador

    Listing 64: Resultado inmutable con protocolo de secuencia. 8.7 El simulador como orquestador El simulador coordina los componentes anteriores. Su responsabilidad es: 1. Validar las precondiciones de configuración (dt, tolerancias). 2. Iterar el integrador, acumulando los estados en una lista temporal. 3. Validar el estado en cada paso, detectando valores no finitos. 4. Detectar y reportar inestabilidad numérica cuando el error relativo entre pasos excede un umbral. 5. Empaquetar el resultado en una estructura inmutable. 1 2 3 # ============================================================================ # 6. SIMULADOR ( ORQUESTADOR ) # ============================================================================ 4 5 6 class Simulador : " " " Orquesta la integracion temporal de una EDO . 7 8 9 10 11 12 13 Args : problema : Lado derecho de la EDO . integrador : Esquema numerico de avance temporal . dt : Paso de integracion ( debe ser positivo ) . umbral_inestabilidad : Error relativo maximo admisible por paso . """ 14 15 16 17 18 19 20 21 22 23 24 25 def __init__ ( self , problema : ProblemaEDO , integrador : Integrador , dt : float = 1e -3 , umbral_inestabilidad : float = 1 e3 , ) -> None : if dt <= 0: raise ValueError ( f " dt debe ser positivo , recibido { dt } " ) if umbral_inestabilidad <= 0: raise ValueError ( " umbral_inestabilidad debe ser positivo " ) 26 27 28 29 30 self . problema = problema self . integrador = integrador self . dt = dt self . umbral_inestabilidad = umbral_inestabilidad 31 32 33 34 35 36 37 38 39 40 41 42 43 def ejecutar ( self , estado_inicial : Estado , t_final : float , ) -> Resultado : " " " Ejecuta la simulacion desde estado_inicial hasta t_final . " " " if len ( estado_inicial ) != self . problema . dimension : raise DimensionIncompatible ( len ( estado_inicial ) , self . problema . dimension ) if t_final <= estado_inicial . t : raise ValueError ( Abraham Zamudio 66
  65. Programación en Python para Ingeniería 44 45 46 8.7 El

    simulador como orquestador f " t_final ({ t_final }) debe ser > t_inicial " f " ({ estado_inicial . t }) " ) 47 48 49 tiempos : list [ float ] = [ estado_inicial . t ] estados : list [ tuple [ float , ...]] = [ estado_inicial . y ] 50 51 52 t = estado_inicial . t y = estado_inicial . y 53 54 55 56 while t < t_final : # Ajustar el ultimo paso para no pasarnos de t_final dt_efectivo = min ( self . dt , t_final - t ) 57 58 59 60 61 y_nuevo = self . integrador . paso ( self . problema , t , y , dt_efectivo ) t_nuevo = t + dt_efectivo 62 63 64 65 # Validar que el estado permanezca finito if not all ( math . isfinite ( c ) for c in y_nuevo ) : raise EstadoInvalido ( t_nuevo , y_nuevo ) 66 67 68 # Detectar inestabilidad numerica self . _verificar_estabilidad (t , y , y_nuevo , dt_efectivo ) 69 70 71 tiempos . append ( t_nuevo ) estados . append ( y_nuevo ) 72 73 t , y = t_nuevo , y_nuevo 74 75 return Resultado ( tuple ( tiempos ) , tuple ( estados ) ) 76 77 78 79 80 81 82 83 84 85 86 def _verificar_estabilidad ( self , t : float , y_anterior : tuple [ float , ...] , y_nuevo : tuple [ float , ...] , dt : float , ) -> None : " " " Detecta crecimiento anomalo entre pasos consecutivos . " " " norma_anterior = math . sqrt ( sum ( x * x for x in y_anterior ) ) norma_nueva = math . sqrt ( sum ( x * x for x in y_nuevo ) ) 87 88 89 90 # Si el estado anterior era cero , no hay base de comparacion if norma_anterior < 1e -14: return 91 92 error_relativo = abs ( norma_nueva - norma_anterior ) / norma_anterior 93 94 95 if error_relativo > self . umbral_inestabilidad : raise PasoInestable (t , dt , error_relativo ) Listing 65: Simulador como orquestador con validación de invariantes. El método _verificar_estabilidad implementa una heurística: detecta cuando la norma del estado cambia abruptamente entre pasos consecutivos, lo que sugiere que el paso es demasiado grande para la rigidez del problema o que la solución ha divergido. La heurística es conservadora: no reemplaza un análisis formal de estabilidad, pero captura los casos más comunes. Abraham Zamudio 67
  66. Programación en Python para Ingeniería 8.8 8.8 Ejemplo de uso

    Ejemplo de uso El siguiente fragmento ilustra el uso completo del simulador para un oscilador armónico amortiguado, con análisis posterior de conservación de energía: 1 2 3 # ============================================================================ # 7. EJEMPLO DE USO # ============================================================================ 4 5 6 7 def main () -> None : # Configuracion del problema fisico problema = OsciladorArmotonico ( omega =2.0 , amortiguamiento =0.1) 8 9 10 11 # Seleccion del integrador y paso integrador = RungeKutta4 () simulador = Simulador ( problema , integrador , dt =1 e -3) 12 13 14 # Estado inicial : x =1.0 , v =0.0 estado_inicial = Estado ( t =0.0 , y =(1.0 , 0.0) ) 15 16 17 18 19 20 21 22 23 try : resultado = simulador . ejecutar ( estado_inicial , t_final =10.0) except PasoInestable as e : print ( f " Simulacion abortada : { e } " ) return except EstadoInvalido as e : print ( f " Estado invalido detectado : { e } " ) return 24 25 26 27 # Analisis : energia inicial y final E_inicial = problema . energia ( resultado . estados [0]) E_final = problema . energia ( resultado . estados [ -1]) 28 29 30 31 32 33 print ( f " Pasos ejecutados : { len ( resultado ) } " ) print ( f "t final : { resultado . tiempos [ -1]:.4 f } " ) print ( f " Energia inicial : { E_inicial :.6 e } " ) print ( f " Energia final : { E_final :.6 e } " ) print ( f " Deriva relativa : { abs ( E_final - E_inicial ) / E_inicial :.3 e } " ) 34 35 36 37 # Trayectoria de la posicion posiciones = resultado . trayectoria ( componente =0) print ( f " Posicion final : { posiciones [ -1]:.6 e } " ) 38 39 40 41 if __name__ == " __main__ " : main () Listing 66: Uso completo del simulador. La ejecución produce una salida similar a: 1 2 3 4 5 6 Pasos ejecutados : 10001 t final : 10.0000 Energia inicial : 2.000000 e +00 Energia final : 7.347709 e -01 Deriva relativa : 6.326 e -01 Posicion final : -1.218738 e -01 Listing 67: Salida esperada del ejemplo. La energía disminuye porque el amortiguamiento disipa energía mecánica. El integrador de orden 4 conserva la energía con error de orden O (𝑑𝑡 4 ) en ausencia de amortiguamiento; con 𝑑𝑡 = 10−3 y Abraham Zamudio 68
  67. Programación en Python para Ingeniería 8.9 Extensiones naturales 10000 pasos,

    la acumulación de errores es apreciable pero el comportamiento cualitativo es correcto. 8.9 Extensiones naturales La arquitectura admite extensiones sin modificar el código existente, ilustrando el Principio Abierto/Cerrado: 1. Nuevos integradores: implementar Integrador y pasar al simulador. No se modifica nada más. 2. Nuevos problemas: implementar ProblemaEDO y combinarlo con cualquier integrador. 3. Observadores: introducir el patrón Observer agregando un método notificar_paso invocado dentro del bucle. 4. Paso adaptativo: modificar el simulador para ajustar dt según la dinámica local, usando dos integradores de distinto orden (patrón embedded Runge-Kutta). 5. Paralelización: ejecutar múltiples simulaciones con parámetros distintos usando concurrent.futures, aprovechando que Estado y Resultado son inmutables y por tanto thread-safe. El siguiente fragmento ilustra un integrador adaptativo simple que selecciona entre Euler y RK4 según el error estimado: 1 2 class IntegradorAdaptativo ( Integrador ) : " " " Selecciona entre Euler y RK4 segun la dinamica local . " " " 3 4 5 6 7 def __init__ ( self , tolerancia : float = 1e -6) -> None : self . _rapido = EulerExplicito () self . _preciso = RungeKutta4 () self . tolerancia = tolerancia 8 9 10 11 12 @property def orden ( self ) -> int : # Orden efectivo cuando domina el esquema de alto orden return 4 13 14 15 16 17 18 19 20 def paso ( self , problema , t , y , dt ) : # Un paso de RK4 y dos de Euler y_rk4 = self . _preciso . paso ( problema , t , y , dt ) y_euler_medio = self . _rapido . paso ( problema , t , y , dt / 2) y_euler = self . _rapido . paso ( problema , t + dt / 2 , y_euler_medio , dt / 2 ) 21 22 23 24 25 26 # Estimar error error = math . sqrt ( sum (( a - b ) ** 2 for a , b in zip ( y_rk4 , y_euler ) ) ) norma = math . sqrt ( sum ( a * a for a in y_rk4 ) ) 27 28 29 30 if norma > 0 and error / norma < self . tolerancia : return y_rk4 return y_euler # fallback : menos preciso pero mas robusto Listing 68: Integrador adaptativo usando composición. Este integrador ilustra el patrón Strategy anidado: contiene dos integradores concretos y selecciona entre ellos en tiempo de ejecución. La composición preferida sobre la herencia evita las complicaciones del MRO en este caso. Abraham Zamudio 69
  68. Programación en Python para Ingeniería 8.10 8.10 Análisis retrospectivo del

    diseño Análisis retrospectivo del diseño La siguiente tabla mapea cada componente del simulador con los conceptos introducidos en secciones previas: Componente Conceptos aplicados Estado dataclass(frozen=True), slots, validación en __post_init__, dunder de contenedor, operadores aritméticos Clase abstracta (ABC), @abstractmethod, @property, dunder __call__ Clase abstracta, polimorfismo de subtipificación, __repr__ personalizado Dataclass inmutable, protocolo de secuencia, propiedad calculada (estado_final) Composición, inyección de dependencias, validación de invariantes, manejo de excepciones Jerarquía del dominio, información contextual, encadenamiento ProblemaEDO Integrador Resultado Simulador Excepciones 8.11 Síntesis El simulador modular demuestra que los mecanismos de la POO no son adornos sintácticos, sino herramientas para gestionar la complejidad de sistemas científicos reales. Los tres principios arquitectónicos que guiaron el diseño se materializaron así: 1. Separación de responsabilidades: cada clase tiene una única razón para cambiar. Cambiar el esquema numérico no afecta al problema físico; cambiar el modelo no afecta al integrador. 2. Inversión de dependencias: el simulador depende de ProblemaEDO e Integrador (abstracciones), no de OsciladorArmotonico ni RungeKutta4 (implementaciones). 3. Fallos explícitos: las condiciones anómalas se señalan mediante excepciones tipadas que transportan información de diagnóstico. Los clientes pueden capturar al nivel adecuado sin depender de códigos de retorno frágiles. La lección final es que la POO en Python no consiste en escribir clases por escribirlas, sino en modelar el dominio con abstracciones que capturen invariantes. Un simulador con diez funciones sueltas puede funcionar; un simulador con clases bien diseñadas puede extenderse, verificarse y mantenerse durante años. La diferencia no está en la sintaxis, sino en la disciplina de diseño. Con esta sección concluye el recorrido del documento. El lector que haya seguido el desarrollo completo posee ahora las herramientas conceptuales y las técnicas concretas para aplicar el paradigma de la programación orientada a objetos a problemas de computación científica e ingeniería, con criterio para decidir cuándo y cómo aplicar cada mecanismo. 9 Conclusión A lo largo de este documento se ha demostrado que la Programación Orientada a Objetos (POO) en Python no es un mero artificio sintáctico ni un reemplazo antagónico de los paradigmas procedural o funcional, sino una disciplina de modelado y gestión de la complejidad accidental. En disciplinas de ingeniería y computación científica, donde los modelos físicos abarcan múltiples escalas, restricciones cinemáticas y backends heterogéneos, la POO provee la topología conceptual idónea para mapear fielmente el dominio del problema en arquitecturas de software mantenibles y extensibles. Abraham Zamudio 70
  69. Programación en Python para Ingeniería La articulación efectiva de este

    paradigma descansa sobre la síntesis de sus dimensiones conceptuales y mecánicas: 1. Fundamentos y ciclo de vida: La distinción rigurosa entre identidad (𝜄), estado (𝜎) y comportamiento (𝜇) previene fallos sutiles derivados de la mutabilidad compartida. El protocolo de instanciación en dos fases (__new__ y __init__) y la asimetría entre lectura y escritura en los espacios de nombres clarifican la frontera entre atributos de clase e instancia. 2. Los cuatro pilares como garantes del diseño: La abstracción (mediante contratos formales con ABC o estructurales con Protocol) y el encapsulamiento (mediante propiedades que resguardan invariantes sin romper la interfaz) establecen límites claros entre componentes. Paralelamente, la herencia especializada y el polimorfismo dinámico habilitan el cumplimiento sistemático del Principio Abierto/Cerrado (OCP) y la substitución de Liskov (LSP). 3. Resolución lineal y herencia cooperativa: Ante arquitecturas con herencia múltiple o mixins ortogonales, el algoritmo C3 garantiza un MRO monótono y determinista. La correcta delegación a través de super() desactiva el problema del diamante y asegura una inicialización cooperativa robusta. 4. El modelo de datos y la sintaxis idiomática: Los métodos especiales (dunder methods) actúan como el puente entre el código del usuario y el motor de ejecución de Python. Al implementar coherentemente protocolos de representación, contenedores, gestores de contexto y operadores algebraicos, los objetos de ingeniería se comportan de manera nativa e intuitiva sin sacrificar expresividad matemática. 5. Robustez mediante excepciones tipadas: La separación formal entre el flujo nominal y las anomalías computacionales elimina los códigos de retorno ambiguos. El diseño de jerarquías de excepciones del dominio acopladas al ciclo de vida de los recursos (try/except/else/finally y bloques with) asegura diagnósticos precisos ante divergencias o inestabilidades numéricas. El simulador modular presentado en la Sección 8 constituye la evidencia empírica de estas conclusiones: desacoplar el estado inmutable, el esquema numérico y el problema físico permite extender algoritmos (como esquemas adaptativos o nuevos problemas dinámicos) con cero modificaciones sobre el núcleo de cómputo. En definitiva, la excelencia en el desarrollo de software científico no radica en sobrecargar jerarquías innecesarias, sino en aplicar cada abstracción con rigor, garantizando que el código sea tan demostrable, verificable y elegante como los modelos matemáticos que representa. Abraham Zamudio 71