Código limpio: Datos / Sudo Null IT News

El código limpio no es un conjunto de características externas, como los nombres de las variables y la presencia o ausencia de comentarios, aunque también son importantes. El código limpio es una arquitectura de producto de software que hace que el código del programa sea fácil de leer y modificar. La escritura de dicho código se basa en muchas plantillas estándar (SÓLIDAS, patrones de diseño, etc.) desarrolladas durante la práctica de programación. En este artículo se proporciona una descripción de otra plantilla de este tipo.

Un objeto inmutable es un objeto cuyo estado no se puede cambiar después de su creación.(1). Este concepto no se usa tan ampliamente en diversas publicaciones, por lo que comenzaré con un análisis más detallado de este concepto y la justificación de por qué vale la pena usar este patrón.

La definición clásica es Programación orientada a objetos (POO), un paradigma de programación en el que un programa se representa como una colección de objetos y su ejecución consiste en la interacción entre objetos. Un objeto es un conjunto de datos y operaciones que se pueden realizar con estos datos.(2).

La práctica de programación muestra que no todas las operaciones que se pueden realizar con datos deben ubicarse en un solo objeto. Por ejemplo, la fórmula para calcular el ángulo en un triángulo rectángulo se puede representar como una expresión constante. La probabilidad de que cambie es cercana a cero, por lo que se puede utilizar de forma segura como parte de un objeto de datos. Otro ejemplo, fijar el precio de un producto en una tienda, también es una fórmula, pero puede cambiar de acuerdo con las necesidades de marketing. Dicha fórmula no debe colocarse en un método que pertenezca a un objeto de datos.

Es fácil imaginar una situación en la que un programador inserta dicho método en un objeto y, después de un período de tiempo, es necesario cambiarlo. Al mismo tiempo, cambiar la fórmula simplemente no funcionará; es necesario garantizar la compatibilidad con versiones anteriores. Antes de una fecha determinada, los cálculos deben realizarse utilizando la fórmula anterior, después utilizando la nueva. La tarea no parece trivial y resolverla requerirá una importante inversión de tiempo de trabajo.

Los ejemplos dados son polares en su contenido y es bastante sencillo determinar dónde se puede incluir funcionalidad en un objeto y dónde es innecesaria. En aplicaciones reales, esto puede resultar bastante difícil de lograr. Como regla general, los desarrolladores comprenden los procesos comerciales del cliente de manera superficial y no pueden predecir dónde cambiarán los algoritmos y dónde no. El cliente, a su vez, muchas veces no comprende del todo las necesidades de los programadores.

Al resolver tales situaciones, ha demostrado su eficacia el método de separación adoptado en las lenguas naturales, dividiendo clases según características morfológicas. En pocas palabras, divida las clases en sustantivos y verbos.

Palabras que sirven como nombre de un objeto en un sentido amplio, es decir. tienen el significado de objetividad, se llaman sustantivos(3).

Verbo: una categoría de palabras que denotan una acción o estado de un objeto como un proceso.(4).

Veamos esta técnica utilizando un área pequeña como ejemplo de procesos comerciales.

Área del gráfico

Área del gráfico

El significado de objetividad en este ámbito es:

  • Orden (orden de clase),

  • Cuenta (Cuenta de clase),

  • Cliente (cliente de clase),

  • Efectivo (clase Dinero).

Tenga en cuenta que ninguno de los objetos que describen los datos requiere cambios; algunos datos pasan a otros. Por tanto son objetos inmutables.

Indicar acciones:

  • Creando una cuenta (interfaz CreandoCuenta),

  • Referencia al cliente (interfaz ReferralToTheClient),

  • Esperando recibo (interfaz WaitingForReceipt).

C++ no tiene una palabra clave de interfaz, pero este concepto define con precisión las clases descritas anteriormente y es probable que se cambie en el futuro. Al mismo tiempo, los cambios se producirán en el marco de interfaces ya definidas y no afectarán a los principales procesos comerciales.

Las acciones con interfaces están fuera del alcance de este artículo, por lo que nos centraremos en trabajar con datos. Para no cometer errores al diseñar, puedes utilizar la técnica implementada en el lenguaje C# con la palabra clave readonly. Describe las clases de datos de tal manera que los campos no se puedan cambiar después de que salga el constructor.

class Product {
private:
    std::string name;          // Наименование товара
    double price;              // Цена
public:
    Product(std::string name, double price):
        name(name), price(price) {}
    std::string name() const { return name; }
    double price() const { return price; }
};

Pregunta cómo funciona si nos hemos limitado. Echa un vistazo al diagrama.

En primer lugar, separamos claramente la acción y los datos, logrando así flexibilidad en el código. Ahora, si la tarea es cambiar el método de cálculo del interés, simplemente cambiaremos el comportamiento creando una nueva clase y no afectará otras partes del código. Además de esto, siempre podemos volver a utilizar la clase anterior.

En segundo lugar, si es necesario, podemos almacenar los valores de precios antiguos y nuevos, o recalcularlos dinámicamente cuando cambien las condiciones.

Compliquemos el ejemplo dejando aún más clara la ventaja de este enfoque.

Automatizamos un servicio de taxi imaginario, o más bien un módulo de cálculo del coste de un viaje.

Como puede ver, el cálculo de costos se puede dividir en acciones individuales e integrarse en una secuencia de operaciones pequeñas y comprensibles. Además, podemos generar datos después de cada operación por separado, guardarlos, y cuando el departamento de marketing solicita generar informes sobre los ingresos por viajes nocturnos, simplemente los recuperamos. En un artículo aparte se mostrará cómo se implementa esto directamente en el código del programa.

Al separar datos y acciones, agregamos la posibilidad de cambios posteriores usando interfaces. Ahora veamos cómo agregar nuevas funciones a una clase de datos existente.

A menudo surgen situaciones en las que, en la etapa de desarrollo e incluso al redactar especificaciones técnicas, queda claro que los datos presentados se pueden ampliar. Al crear objetos inmutables, parece que nos estamos limitando aún más. De hecho, existen técnicas que te permiten evitar estas restricciones.

El primer paso es abandonar la idea de simplemente agregar un nuevo campo a una clase existente. Por dos razones. En primer lugar, el programa ya está en ejecución y cuanto más complejo es el producto de software, más interconectado está entre los datos y los algoritmos para procesarlos. Seguirlos puede ser muy difícil y, cuando varias personas trabajaron en el código, casi imposible. En segundo lugar, necesitaba expandir los datos por una razón; lo más probable es que necesite agregar nuevos algoritmos, y es más fácil agregarlos sobre una funcionalidad que ya funciona que realizar cambios en una existente.

class Product {
private:
    std::string m_name;          // Наименование товара
    double m_price;              // Цена
public:
    Product(std::string name, double price) :
        m_name(name), m_price(price) {}
    std::string name() const { return m_name; }
    double price() const { return m_price; }
};

class Manufacturer {};

class ExpandProduct : public Product {
private:
    std::shared_ptr<Manufacturer> manufacturer;
public:
    ExpandProduct(std::string name, std::shared_ptr<Manufacturer> manufacturer, double price):
        Product(name, price) {
        this->manufacturer = manufacturer;
    }
    std::shared_ptr<Manufacturer> getManufacturer() const { return manufacturer; }
};

Si utilizamos la herencia para ampliar las capacidades de las clases, entonces la única forma de utilizar algoritmos antiguos y nuevos es el polimorfismo basado en punteros.

int main() {

    std::list<std::shared_ptr<Product>> products{
        std::make_shared<Product>("Product1", CEREALS, 500),
        std::make_shared<Product>("Product2", DRINKS, 400),
        std::make_shared<Product>("Product3", PACKS, 300)
    };

    std::list<std::shared_ptr<Product>> newproducts;
    std::ranges::for_each(products, (&newproducts)(auto& elem) {
        auto temp = std::make_shared<ExpandProduct>(
            elem->getName(),
            elem->getClassifier(),
            std::make_shared<Manufacturer>(),
            elem->getPrice()
        );
        newproducts.push_back(temp);
    });

    for (auto& elem : newproducts) {
        std::cout << elem->getName() << std::endl;
    }

    return 0;
}

En este punto, surge la pregunta de cuándo es necesario utilizar punteros durante el desarrollo, para que en el futuro sea posible ampliar la funcionalidad sin problemas.

Veamos el uso de punteros desde el punto de vista de la separación entre datos y acciones, que se describen en este artículo.

Para aquellos que no quieran sumergirse en la tecnología de gestión de memoria, les daré un ejemplo claro de la vida real. Imaginemos un almacén de productos de hormigón armado. Que haya bordillos o losas de construcción. Los productos fabricados se almacenan en un almacén. Se extrae a medida que se vende; naturalmente, primero se venden los restos y luego los productos recién elaborados. Pero en realidad se envía lo que está más cerca de la salida; nadie hurgará en todo el almacén para extraer esa losa. Son todos iguales y no tienen vida útil. Entonces resulta que toda la contabilidad de losas se basa en enlaces.

Con este enfoque, dejamos que los datos se encuentren en el mismo lugar y accedemos a ellos a través de enlaces, como las losas del ejemplo.

Con la separación correcta de datos y algoritmos, los objetos con datos rara vez cambian en función de algunos datos, formamos secuencias de otros datos, y así sucesivamente, hasta la reflexión visual. Hay una secuencia clara. Traducimos grandes conjuntos de datos a otros más pequeños y así sucesivamente a interfaces de usuario. Por regla general, entre el conjunto de datos utilizado y el conjunto de datos realmente necesario sólo pasan dos o tres operaciones.

Por tanto, la pirámide de datos tiene aproximadamente tres niveles. (5)(6). Así es exactamente como se diseñan los recolectores de basura en lenguajes como C# y Java. En C++, la gestión de la memoria la manejan el compilador y el sistema operativo, y el programador puede influir en estos procesos sólo a través de herramientas dinámicas de gestión de la memoria.(7).

El C++ moderno ha desarrollado herramientas para gestionar la memoria dinámica en forma de punteros inteligentes. Sin embargo, la semántica de copiar y mover queda a discreción de los programadores. El nivel de formación de los diferentes programadores varía significativamente, por lo que es difícil seguir todos los movimientos de copia al escribir código. Tenemos que redefinir los constructores y métodos correspondientes, pero también utilizamos bibliotecas de complementos.

La salida obvia a esta situación es crear todos los objetos que almacenen datos a través de un enlace. Lo mismo debería hacerse con objetos de clases que implementan acciones, aunque por un motivo diferente. Esto se debe a la inversión de dependencia, que se analiza en un artículo aparte.

Podemos concluir que es mejor utilizar todos los objetos por referencia. En realidad, así es como se hace todo en los lenguajes de nivel superior C# y Java. Tienen sólo dos tipos de datos, base y referencia. Sí, y en el lenguaje C++ hay ejemplos del uso de este enfoque, por ejemplo en Qt hay una clase QObject de la que derivan todas las demás.(8).

Por supuesto, no es necesario crear absolutamente todos los objetos según el modelo, pero si sigue este estilo, será mucho más fácil modificar el producto de software terminado.

Ahora que nos hemos ocupado de las posibilidades de ampliar clases con datos, quiero ofrecer un método más eficiente para agregar campos a un conjunto de datos ya preparado.

Como regla general, al ampliar la funcionalidad, necesitamos agregar no un campo, sino varios. En este caso, los campos pueden contradecirse. Por ejemplo, si tomamos como base nuestro ejemplo, un producto puede tener características completamente diferentes. Tome un cartón de leche y un televisor. En tales casos, la mano se extiende para crear dos clases separadas, pero luego se viola el principio de herencia.

El patrón decorador ayuda a sortear esta limitación. A continuación se muestra un ejemplo basado en ello.

enum Classifier { NONE, CEREALS, DRINKS, PACKS };

/* Декоратор — это структурный паттерн проектирования,
 * который позволяет динамически добавлять объектам новую
 * функциональность, оборачивая их в полезные «обёртки». */
class Properties {};

class PropertiesCereals : public Properties {};
class PropertiesDrinks : public Properties {};
class PropertiesPacks : public Properties {};

class Product {
private:
    std::string name;          // Наименование товара
    double price;              // Цена
    Classifier category;       // Классификатор товара
    std::shared_ptr<Properties> properties;
public:
    Product(std::string name, double price, Classifier classifier, std::shared_ptr<Properties> properties):
        name(name), price(price), category(classifier), properties(properties) {}
    std::string getName() const { return name; }
    double getPrice() const { return price; }
    Classifier getClassifier() const { return category; }
    const std::shared_ptr<Properties> getProperties() const { return properties; }
};

Ahora nuestra clase de producto se convierte en un decorador de las propiedades restantes. Tenemos la oportunidad no solo de ampliar la clase de producto en sí, sino también de ampliar las posibilidades para describir sus propiedades.

Pero eso no es todo, puedes crear una clase abstracta para todos los productos y desarrollar algoritmos generales que no dependan de un tipo específico de producto. Por ejemplo, cree un formulario de informe universal para todos los productos al mismo tiempo.

Aunque todo lo descrito en este artículo fue ilustrado trabajando con productos, estas técnicas se aplican a cualquier tipo de datos. Basta con realizar una planificación preliminar del sistema en desarrollo.


  1. Wikipedia- Objeto inmutable

  2. Gran enciclopedia rusa – Programación orientada a objetos

  3. Universidad Federal de Kazán (región del Volga) – conferencias

  4. Universidad Federal de Kazán (región del Volga) – conferencias

  5. Conceptos básicos de la recolección de basura de C#: Conceptos básicos de recolección de basura

  6. Implementación del recolector de basura de Java – Implementación de un recolector de basura

  7. Gestión de memoria dinámica C++ – Gestión de memoria dinámica

  8. Modelo de objetos Qt Framework Qt 6 – Modelo de objetos

Publicaciones Similares

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *