Tabla de Contenidos
Estructuras de datos
Sección perteneciente al curso Clean Code aplicado para desarrollos limpios y rentables
Abstracciones de información
Cohesiona variables y reduce la complejidad.
“Los malos programadores se preocupan por el código. Los buenos se preocupan por las estructuras de datos y sus relaciones.” – Linus Torvalds.
Pero, ¿a qué se refiere al pedirnos que nos preocupemos por las estructuras de datos? ¿No es algo que ya hacemos todos? Vamos a puntualizar. Lo que hacemos todos es usar estructuras de datos para mostrar, almacena y transmitir información relevante para el problema de negocio tratado.
Esto es imprescindible y se ha estandarizado en leyes, buenas prácticas, patrones y anti-patrones según cada cual; pues hay para elegir: documentos, normalización relacional, DTOs, ActiveRecord, POJOs… Efectivamente, creo que todos nos preocupamos por este tipo de estructuras.
En código limpio nos preocupamos además por dos usos de los datos con impacto en la legibilidad y mantenimiento del código.
Por un lado está la cohesión de tipos primitivos en estructuras que aporten orden y significado. El infame code smell “Primitive obsession”.
Por otra parte tenemos el uso de estructuras para simplificar condiciones lógicas que de otro modo están hard coded dificultando el mantenimiento.
En cualquier caso se resuelve creando unas estructuras muy simples. Según el lenguaje (idioma) en el que programes puede que tengan nombre propio.
Por ejemplo struct en C# o un object literal de JavaScript. A veces requerirán una clase para darle cuerpo; pero nunca expondrán métodos con lógica de negocio. Esos son otros objetos que aún no tocan en este tutorial.
Nos lo resume Uncle Bob en dos máximas; aquí va la primera:
“La estructura de datos expone sus propiedades y no tiene funciones significativas” – Robert C. Martin
Las estructuras de datos no las podemos confundir con lógica de negocio. Lo único que tenemos son datos y alguna función sencilla de validación o presentación.
Cohesión de primitivos
Agrupación de variables con sentido de negocio.
Los tipos primitivos son los strings, fechas, números enteros…
La idea de la cohesión es agrupar los primitivos en alguna estructura de datos.
Cómo hacerlo:
- Sin comportamiento de negocio: poca o ninguna función
- Cohesionan variables relacionadas
- Suelen tener nombres de entidades (un sustantivo)
- Composición mejor que herencia.
Límites a la hora de crear estas estructuras:
- 1 - 2 variables con tipos primitivos
- 2 - 8 propiedades primitivas por estructura
- 2 - 8 propiedades compuestas por estructura
- 1 - 4 niveles de jerarquía
- 0 - 1 niveles de herencia
“Crea muchas estructuras pequeñas, y agrúpalas en jerarquías cuando sea necesario.”
Ejemplo práctico
Partimos del siguiente código:
/* eslint-disable max-params */ // ❌ primitive obsession export function calculateInterest(): number { const principal = 1000; const rate = 3.5; const years = 1; const interest = getSimpleInterest(principal, rate, years); return interest; } // ❌ multiple parameters function getSimpleInterest(principal: number, rate: number, years: number): number { const PER_CENT = 100; const interest = (principal * rate * years) / PER_CENT; return interest; }
Arreglando:
// ✔️ Define types (or interfaces, os structs... or even classes) type Currency = 'EUR' | 'USD'; type Money = { amount: number; currency: Currency }; type Condition = { principal: Money; rate: number; years: number }; export function calculateInterest(): Money { // ✔️ object literal const mySavingsConditions: Condition = conditionsFactory(100, 3.5); // ✔️ single parameter const interest = getSimpleInterest(mySavingsConditions); return interest; } function moneyFactory(amount: number, currency: Currency = 'EUR') { const MIN_AMOUNT = 0; if (amount < MIN_AMOUNT) throw new Error(`No negatives allowed`); return { amount, currency, }; } function conditionsFactory( amount: number, rate: number, years: number = 1, currency: Currency = 'EUR' ) { const MAX_AMOUNT = 10000; if (amount > MAX_AMOUNT) throw new Error(`Maximum amount for capital is ${MAX_AMOUNT}`); const MIN_RATE = 1; const MAX_RATE = 10; if (rate < MIN_RATE || rate > MAX_RATE) throw new Error(`Rate must be between ${MIN_RATE} and ${MAX_RATE}`); const MIN_YEARS = 0; const MAX_YEARS = 100; if (years < MIN_YEARS || years > MAX_YEARS) throw new Error(`Year must be between ${MIN_YEARS} and ${MAX_YEARS}`); const conditions: Condition = { principal: moneyFactory(amount, currency), rate: rate, years: years, }; return conditions; } function getSimpleInterest(conditions: Condition): Money { const PER_CENT = 100; const interestAmount = (conditions.principal.amount * conditions.rate * conditions.years) / PER_CENT; return moneyFactory(interestAmount, conditions.principal.currency); }
Condiciones y algoritmos
Simplificación de algoritmos.
“Algoritmos + Estructuras de datos = Programas” – Niklaus Wirth
Usa estructuras de datos que eviten el uso de estructuras condicionales
El if y sobre todo el switch huelen mal:
- Reduce los
ifevitando flags en las funciones - Sustituye un
switchpor un objeto, un array o un mapa. - Incluso valora cambiar un
switchpor un sistema de clases con herencia (último recurso, recordemos que debemos seguir el principio KISS)
Si la lógica cambia y no queremos cambiar el código; tenemos un problema.
La solución pasa por reducir el uso de las estructuras condicionales sustituyéndolas por estructuras de datos.
Ejemplo práctico
Partimos de:
/* eslint-disable max-lines-per-function */ type Shape = { name: string; base?: number; height?: number; width?: number; radius?: number }; // ❌ high cyclomatic complexity export function getArea(shape: Shape): number { const PI = 3.14; const HALVE = 0.5; let area: number; // ❌ switch cases don´t scale well switch (shape.name) { case 'TRIANGLE': area = shape.base * shape.height * HALVE; break; case 'SQUARE': area = shape.height * shape.height; break; case 'RECTANGLE': area = shape.height * shape.width; break; case 'CIRCLE': area = PI * shape.radius * shape.radius; break; // ❌ more cases implies change the code default: throw new Error('shape not recognized'); } return area; } // 🚨 it seems a naive if condition, but... export function getUnitName(measureSystem: string): string { if (measureSystem === 'US') { return 'square yards'; } else { return 'square metres'; } } // ❌ there are duplicated logic export function getUnitSymbol(measureSystem: string): string { if (measureSystem === 'US') { return 'yd2'; } else { return 'm2'; } } // 🔥 and can need another else or a switch if there is another case const myMeasureSystem = 'US'; const myCircle: Shape = { name: 'CIRCLE', radius: 5 }; const myArea = getArea(myCircle); const myUnits = getUnitName(myMeasureSystem); const areaDescription = `My ${myCircle.name} occupies an area of ${myArea} ${myUnits}`; console.log(areaDescription);
Arreglado:
/* eslint-disable @typescript-eslint/no-use-before-define */ type Shape = { name: string; base?: number; height?: number; width?: number; radius?: number }; type SystemNames = 'US' | 'SI' | '?'; type MeasureSystem = { systemName: SystemNames; unitName: string; unitSymbol: string }; const PI = 3.14; const HALVE = 0.5; // ✔️ all the business knowledge is on the same place const MEASURE_SYSTEMS: MeasureSystem[] = [ { systemName: 'US', unitName: 'square yards', unitSymbol: 'yd2' }, { systemName: 'SI', unitName: 'square metres', unitSymbol: 'm2' }, ]; // ✔️ a data object with methods on each value // could be loaded or modified at run time export const AREA_CALCULATORS = { TRIANGLE: (shape: Shape): number => HALVE * shape.base * shape.height, SQUARE: (shape: Shape): number => shape.height * shape.height, RECTANGLE: (shape: Shape): number => shape.height * shape.width, CIRCLE: (shape: Shape): number => PI * shape.radius * shape.radius, }; // ✔️ this function never gets modified export function getArea(shape: Shape): number { const calculateArea = AREA_CALCULATORS[shape.name]; return calculateArea(shape); } // 💉 inject another shape without modifying AREA_CALCULATORS['SPHERE'] = (shape: { radius: number }) => 4 * PI * shape.radius ** 2; function getMeasureSystem(systemName: SystemNames): MeasureSystem { const defaultNotFound: MeasureSystem = { systemName: '?', unitName: '', unitSymbol: '', }; const found = MEASURE_SYSTEMS.find(ms => ms.systemName === systemName); return found || defaultNotFound; } // ✔️ with no need for conditions we can use very simple pure functions ina fat arrow notation export const getUnitName = (systemName: SystemNames): string => getMeasureSystem(systemName).unitName; export const getUnitSymbol = (systemName: SystemNames): string => getMeasureSystem(systemName).unitSymbol; const myMeasureSystem: SystemNames = 'SI'; const myCircle: Shape = { name: 'CIRCLE', radius: 5 }; const myArea = getArea(myCircle); const myUnits = getUnitName(myMeasureSystem); const areaDescription = `My ${myCircle.name} occupies an area of ${myArea} ${myUnits}`; console.log(areaDescription);
Verás que poco a poco tu código será más genérico y admitirá más cambios funcionales sin necesidad de recompilar. Verás entonces que el mundo está llenos de estructuras de datos por todas partes.
