Clean C++ - Es
Clean C++ - Es
Limpiar C++
Desarrollo de software sostenible
Patrones y Mejores Prácticas con C++ 17
—
Stephan Roth
www.allitebooks.com
Machine Translated by Google
Limpiar C++
Patrones de desarrollo de software sostenible
y mejores prácticas con C++ 17
Stephan Roth
www.allitebooks.com
Machine Translated by Google
C++ limpio: patrones de desarrollo de software sostenible y mejores prácticas con C++ 17
Stephan Roth
Bad Schwartau, SchleswigHolstein, Alemania
ISBN13 (pbk): 9781484227923 DOI ISBN13 (electrónico): 9781484227930
10.1007/9781484227930
Número de control de la Biblioteca del Congreso: 2017955209
Copyright © 2017 por Stephan Roth
Esta obra está sujeta a derechos de autor. Todos los derechos están reservados por el Editor, ya sea total o parcialmente el
material, específicamente los derechos de traducción, reimpresión, reutilización de ilustraciones, recitación, radiodifusión,
reproducción en microfilmes o de cualquier otra forma física, y transmisión o almacenamiento de información. y recuperación,
adaptación electrónica, software de computadora, o por metodología similar o diferente ahora conocida o desarrollada en el futuro.
En este libro pueden aparecer nombres, logotipos e imágenes de marcas registradas. En lugar de utilizar un símbolo de marca
comercial con cada aparición de un nombre, logotipo o imagen de marca registrada, utilizamos los nombres, logotipos e imágenes
solo de manera editorial y en beneficio del propietario de la marca comercial, sin intención de infringir la marca comercial.
El uso en esta publicación de nombres comerciales, marcas registradas, marcas de servicio y términos similares, incluso si no
están identificados como tales, no debe tomarse como una expresión de opinión sobre si están o no sujetos a derechos de propiedad.
Si bien se cree que los consejos y la información de este libro son verdaderos y precisos en la fecha de publicación, ni los autores ni los
editores ni el editor pueden aceptar ninguna responsabilidad legal por los errores u omisiones que puedan cometerse. El editor no
ofrece ninguna garantía, expresa o implícita, con respecto al material contenido en este documento.
Imagen de portada de Freepik (www.freepik.com)
Director general: Welmoed Spahr
Director editorial: Todd Green
Editor de adquisiciones: Steve Anglin
Editor de desarrollo: Matthew Moodie
Revisor técnico: Marc Gregoire
Editor coordinador: Mark Powers
Editora de estilo: Karen Jameson
Distribuido al comercio de libros en todo el mundo por Springer Science+Business Media New York, 233
Spring Street, 6th Floor, New York, NY 10013. Teléfono 1800SPRINGER, fax (201) 3484505, correo electrónico
ordersny @ springersbm.com, o visite www.springeronline.com. Apress Media, LLC es una LLC de California y el único miembro
(propietario) es Springer Science + Business Media Finance Inc (SSBM Finance Inc). SSBM Finance Inc
es una corporación de Delaware .
Para obtener información sobre las traducciones, envíe un correo electrónico a [email protected], o visite http://www.apress.com/
rightspermissions .
Los títulos de Apress se pueden comprar al por mayor para uso académico, corporativo o promocional. Las versiones y licencias de libros
electrónicos también están disponibles para la mayoría de los títulos. Para obtener más información, consulte nuestra página web de ventas
masivas de libros electrónicos e impresos en http://www.apress.com/bulksales.
Cualquier código fuente u otro material complementario al que haga referencia el autor en este libro está disponible para los lectores
en GitHub a través de la página del producto del libro, ubicada en www.apress.com/9781484227923. Para obtener información más
detallada, visite http://www.apress.com/sourcecode.
Impreso en papel libre de ácido
www.allitebooks.com
Machine Translated by Google
A Caroline y Maximilian: mi querida y maravillosa familia.
www.allitebooks.com
Machine Translated by Google
Contenido de un vistazo
Sobre el autor xiii
Acerca del revisor técnico xiv
Agradecimientos xvi
■Capítulo 1: Introducción 1
■Capítulo 2: Construir una red de seguridad 9
■Capítulo 3: Tenga principios 27
■Capítulo 4: Conceptos básicos de Clean C++ 41
■Capítulo 5: Conceptos avanzados de C++ moderno 85
■Capítulo 6: Orientación a objetos 133
■Capítulo 7: Programación funcional 167
■Capítulo 8: Desarrollo dirigido por pruebas 191
■Capítulo 9: Patrones de diseño y expresiones idiomáticas 217
■Apéndice A: Guía UML pequeña 273
■Bibliografía 285
Índice 287
www.allitebooks.com
Machine Translated by Google
Contenido
Sobre el autor xiii
Acerca del revisor técnico xiv
Agradecimientos xvi
■Capítulo 1: Introducción 1
Entropía del software 2
Código limpio 3
¿Por qué C++? 4
C++11: el comienzo de una nueva era 4
¿Para quién es este libro? 5
Convenciones utilizadas en este libro 5
Barras laterales 5
Notas, consejos y advertencias 6
Ejemplos de código 6
Sitio web complementario y repositorio de código fuente 7
diagramas UML 7
■Capítulo 2: Construir una red de seguridad 9
La necesidad de probar 9
Introducción a las pruebas 11
Pruebas unitarias………………………………………………………………………… 13
¿Qué pasa con el control de calidad? 14
Reglas para buenas pruebas unitarias 15
Calidad del código de prueba 15
Denominación de la prueba unitaria 15
viii
www.allitebooks.com
Machine Translated by Google
■ Contenido
Prueba unitaria Independencia 16
Una aserción por prueba 17
Inicialización independiente de entornos de prueba unitaria 18
Excluir getters y setters 18
Excluir código de terceros 18
Excluir sistemas externos 19
¿Y qué hacemos con la base de datos? 19
No mezcle el código de prueba con el código de producción 19
Las pruebas deben ejecutarse rápido 22
Dobles de prueba (objetos falsos) 22
■Capítulo 3: Tenga principios 27
¿Qué es un principio?.............................................................................................................. 27
BESO 28
YAGNI 28
SECO 29
Ocultación de información 29
Cohesión fuerte 32
Acoplamiento flojo 35
Tenga cuidado con las optimizaciones 39
Principio de menor asombro (PLA) 39
La Regla de Boy Scout 39
■Capítulo 4: Conceptos básicos de Clean C++ 41
Buenos nombres 41
Los nombres deben explicarse por sí mismos 43
Usar nombres del dominio 44
Elija nombres en un nivel apropiado de abstracción 45
Evite la redundancia al elegir un nombre 46
Evite las abreviaturas crípticas 46
Evite la notación y los prefijos húngaros 47
Evite usar el mismo nombre para diferentes propósitos 48
viii
www.allitebooks.com
Machine Translated by Google
■ Contenido
Comentarios 48
Deje que el código cuente una historia 49
No Comentar Cosas Obvias 49
No deshabilite el código con comentarios 50
No escribir comentarios en bloque 50
Los raros casos en los que los comentarios son útiles 53
Funciones 56
¡Una cosa, no más! 58
Que sean pequeños 59
Denominación de funciones 60
Usar nombres que revelen la intención 61
Argumentos y valores de retorno 61
Acerca del antiguo estilo C en proyectos C++ 72
Preferir C++ Strings and Streams sobre el antiguo estilo C char* 72
Evite el uso de printf(), sprintf(), gets(), etc. 74
Preferir contenedores de biblioteca estándar en lugar de arreglos de estilo C simples. 77
Usar moldes de C++ en lugar de moldes antiguos de estilo C 80
Evite las macros 82
■Capítulo 5: Conceptos avanzados de C++ moderno 85
Gestión de recursos 85
La adquisición de recursos es inicialización (RAII) 87
Punteros inteligentes. 87
Evitar nuevos y borrados explícitos 93
Gestión de recursos propios 93
Nos gusta moverlo 94
¿Qué son las semánticas de movimiento? 95
El asunto con esos valores l y valores r 96
rvalue Referencias 97
No haga cumplir la mudanza en todas partes 98
La regla del cero 99
ix
www.allitebooks.com
Machine Translated by Google
■ Contenido
El compilador es su colega 102
Tipo Deducción Automática 103
Cálculos durante el tiempo de compilación 106
Plantillas variables 108
No permitir un comportamiento indefinido 109
Programación rica en tipos 110
Conozca sus bibliotecas 116
Aproveche el <algoritmo> 117
Aprovecha Boost 122
Más bibliotecas que debe conocer 123
Manejo adecuado de excepciones y errores 123
La prevención es mejor que el cuidado posterior 124
Una excepción es una excepción, ¡literalmente! 128
Si no puede recuperarse, salga rápidamente 129
Definir tipos de excepción específicos del usuario 129
Lanzamiento por valor, captura por const Referencia 131
Preste Atención al Orden Correcto de las Cláusulas Captura 131
■Capítulo 6: Orientación a objetos 133
Pensamiento Orientado a Objetos 133
Abstracción: la clave para dominar la complejidad 135
Principios para un buen diseño de clase 135
Mantenga las clases pequeñas 136
Principio de responsabilidad única (SRP) 137
Principio AbiertoCerrado (OCP) 137
Principio de sustitución de Liskov (LSP) 138
Principio de segregación de interfaz (ISP) 149
Principio de dependencia acíclica 150
Principio de inversión de dependencia (DIP) 153
No hables con extraños (Ley de Deméter)…………………………………………………………………… 158
Evite las clases anémicas 163
www.allitebooks.com
Machine Translated by Google
■ Contenido
¡Di, no preguntes! 163
Evitar miembros de clase estáticos 165
■Capítulo 7: Programación funcional 167
¿Qué es la programación funcional? 168
¿Qué es una función? 169
Funciones puras vs. impuras 170
Programación funcional en C++ moderno 171
Programación Funcional con Plantillas C++ 171
Objetos similares a funciones (funtores) 173
Carpetas y envoltorios de funciones 179
Expresiones lambda 181
Expresiones lambda genéricas (C++14) 183
Funciones de orden superior 184
Mapear, filtrar y reducir 185
Código Limpio en Programación Funcional 189
■Capítulo 8: Desarrollo dirigido por pruebas 191
Los inconvenientes de las pruebas unitarias simples (POUT) 192
El desarrollo basado en pruebas como elemento de cambio 193
El flujo de trabajo de TDD 193
TDD por ejemplo: el código de números romanos Kata 196
Las ventajas de TDD 213
Cuándo no debemos usar TDD 215
■Capítulo 9: Patrones de diseño y expresiones idiomáticas 217
Principios de diseño frente a patrones de diseño 217
Algunos patrones y cuándo usarlos 218
Inyección de dependencia (DI) 218
Adaptador 230
Estrategia 231
Comando 235
Procesador de comandos 239
xi
www.allitebooks.com
Machine Translated by Google
■ Contenido
Compuesto 242
Observador 245
Fábricas 250
Fachada 253
Clase de dinero 254
Objeto de caso especial (Objeto nulo) 257
¿Qué es un modismo? 260
Algunas expresiones idiomáticas útiles de C++ 261
■Apéndice A: Guía UML pequeña 273
Diagramas de clases 273
Clase 273
Interfaz 275
Asociación 278
Generalización 280
Dependencia 281
Componentes 282
Estereotipos 283
■Bibliografía 285
Índice 287
xi
Machine Translated by Google
Sobre el Autor
Stephan Roth, nacido el 15 de mayo de 1968, es un apasionado entrenador, consultor y
formador de Ingeniería de Sistemas y Software en la consultora alemana oose
Innovative Informatik eG ubicada en Hamburgo.
Antes de unirse a oose, Stephan trabajó durante muchos años como desarrollador de
software, arquitecto de software e ingeniero de sistemas en el campo de los sistemas de
inteligencia de comunicación y reconocimiento de radio. Ha desarrollado aplicaciones
sofisticadas, especialmente para sistemas distribuidos con requisitos de rendimiento
ambiciosos, e interfaces gráficas de usuario utilizando C++ y otros lenguajes de programación.
Stephan también es ponente en congresos profesionales y autor de varias publicaciones.
Como miembro de la Gesellschaft für Systems Engineering eV, la alemana
capítulo de la organización internacional de Ingeniería de Sistemas INCOSE, también participa en la comunidad de Ingeniería de
Sistemas. Además, es un partidario activo del movimiento Software Craftsmanship y se preocupa por los principios y prácticas de Clean
Code Development (CCD).
Stephan Roth vive con su esposa Caroline y su hijo Maximilian en Bad Schwartau, un balneario en el
Estado federal alemán de SchleswigHolstein, cerca del Mar Báltico.
Puede visitar el sitio web y el blog de Stephan sobre Ingeniería de Sistemas, Ingeniería de Software y Software
Artesanía a través de la URL rothsoft.de. Tenga en cuenta que los artículos están escritos principalmente en alemán.
Además de eso, puede contactarlo por correo electrónico o seguirlo en las redes que se enumeran a continuación.
Correo electrónico: stephan@clean
cpp.com Twitter: @_StephanRoth (https://twitter.com/_StephanRoth)
Página de perfil de Google+: http://gplus.to/sro LinkedIn:
http://www.linkedin.com/pub/stephanroth/79/3a1/514
XIII
Machine Translated by Google
Acerca del revisor técnico
Marc Gregoire es un ingeniero de software de Bélgica. Se graduó de la Universidad de Lovaina, Bélgica, con un título en
"Burgerlijk ingenieur in de computer wetenschappen" (equivalente a Master of Science en ingeniería en informática).
Al año siguiente, obtuvo una maestría, cum laude, en inteligencia artificial en la misma universidad. Después de
completar sus estudios, Marc comenzó a trabajar para una empresa de consultoría de software llamada Ordina Bélgica.
Como consultor, trabajó para Siemens y Nokia Siemens Networks en software crítico 2G y 3G que se ejecuta en Solaris
para operadores de telecomunicaciones. Esto requería trabajar en equipos internacionales que se extendían desde
América del Sur y los Estados Unidos hasta EMEA y Asia. Marc ahora trabaja para Nikon Metrology en software
industrial de escaneo láser 3D.
Su principal experiencia es C/C++, y específicamente Microsoft VC++ y el framework MFC. Tiene
experiencia en el desarrollo de programas C++ que se ejecutan 24x7 en plataformas Windows y Linux: por ejemplo, el
software de automatización del hogar KNX/EIB. Además de C/C++, a Marc también le gusta C# y usa PHP para crear páginas web.
Desde abril de 2007, ha recibido el premio anual Microsoft MVP (Most Valuable Professional) por su experiencia en
Visual C++.
Marc es el fundador del grupo belga de usuarios de C++ (www.becpp.org), autor de Professional C++ y un
miembro del foro CodeGuru (como Marc G). Mantiene un blog en www.nuonsoft.com/blog/.
XV
Machine Translated by Google
Expresiones de gratitud
Escribir un libro como este nunca es solo el trabajo de una persona individual, el autor. Siempre hay numerosas y fabulosas personas
que contribuyen significativamente a un proyecto tan grande.
En primer lugar, me gustaría agradecer a Steve Anglin de Apress. Steve se puso en contacto conmigo en marzo de 2016 y me
convenció para que continuara con mi proyecto de libro con Apress Media LLC, que hasta entonces había sido autoeditado en
Leanpub. Fue una gran suerte y te lo agradezco, querido Steve. En julio de 2016 se firmaron los contratos. No obstante, también me
gustaría agradecer a la excelente plataforma de autoedición Leanpub que sirvió algunos años como una especie de "incubadora" para
este libro.
A continuación, me gustaría agradecer a Mark Powers, Gerente de Operaciones Editoriales de Apress, por su gran apoyo durante
la redacción del manuscrito. Mark no solo estuvo siempre disponible para responder preguntas: su incesante seguimiento del progreso del
manuscrito fue un incentivo positivo para mí. Te estoy muy agradecida, querido Mark. Además, muchas gracias también a Matthew
Moodie, editor principal de desarrollo de Apress, quien brindó la ayuda adecuada durante todo el proceso de desarrollo del libro.
Un agradecimiento especial para mi revisor técnico Marc Gregoire. Marc, gracias por examinar críticamente cada capítulo. Has
encontrado muchos problemas que probablemente yo nunca hubiera encontrado. Me presionaste mucho para mejorar varias
secciones, y eso fue muy valioso para mí.
Por supuesto, también me gustaría dar las gracias a todo el equipo de producción de Apress. han hecho
un excelente trabajo con respecto a la finalización (edición de copia, indexación, composición/diseño, etc.) de todo el libro hasta la
distribución de los archivos finales de impresión (y libro electrónico).
Por supuesto, también doy las gracias a todos mis compañeros de oose. Gracias por las muchas discusiones inspiradoras.
Por último, pero no menos importante, me gustaría agradecer a mi querida y única familia, especialmente por
comprender que el proyecto de un libro requiere mucho tiempo. Maximilian y Caroline, sois maravillosos.
xvii
Machine Translated by Google
CAPÍTULO 1
Introducción
Cómo se hace es tan importante como que se haga.
—Eduardo Namur
Todavía es una triste realidad que muchos proyectos de desarrollo de software se encuentran en malas condiciones, y algunos
incluso podrían estar en una grave crisis. Las razones para esto son múltiples. Algunos proyectos, por ejemplo, se ven afectados
por una pésima gestión de proyectos. En otros proyectos, las condiciones y los requisitos cambian constantemente, pero el
proceso no es compatible con este entorno de alta dinámica.
En algunos proyectos hay razones puramente técnicas: su código es de mala calidad. Eso no significa
necesariamente que el código no esté funcionando correctamente. Su calidad externa, medida por el departamento de
control de calidad mediante pruebas de caja negra, de usuario o de aceptación, puede ser bastante alta. Puede pasar el
control de calidad sin quejas, y el informe de prueba dice que no encuentran nada malo. También los usuarios del software
pueden estar satisfechos y contentos, y su desarrollo se ha completado a tiempo y dentro del presupuesto (... lo cual es raro, lo sé).
Todo parece estar bien... ¿realmente todo?
Sin embargo, la calidad interna de este código, que podría funcionar correctamente, puede ser muy pobre. A menudo el
el código es difícil de entender y horrible de mantener y extender. Innumerables unidades de software, como clases o
funciones, son muy grandes, algunas de ellas con miles de líneas de código. Demasiadas dependencias entre unidades de
software provocan efectos secundarios no deseados si se cambia algo. El software no tiene una arquitectura perceptible.
Su estructura parece tener un origen aleatorio y algunos desarrolladores hablan de "software desarrollado históricamente" o
"arquitectura por accidente". Las clases, funciones, variables y constantes tienen nombres malos y misteriosos, y el código
está plagado de muchos comentarios: algunos de ellos están desactualizados, solo describen cosas obvias o simplemente están
equivocados. Los desarrolladores tienen miedo de cambiar algo o de ampliar el software porque saben que está podrido y
es frágil, y saben que la cobertura de las pruebas unitarias es deficiente, si es que las hay. "Nunca toque un sistema en
ejecución" es una afirmación que se escucha con frecuencia en este tipo de proyectos. La implementación de una nueva función
no necesita unos días hasta que esté lista para su implementación; tarda varias semanas o incluso meses.
Este tipo de software malo a menudo se conoce como Big Ball Of Mud. Este término fue utilizado por primera vez en 1997
por Brian Foote y Joseph W. Yoder en un documento para la Cuarta Conferencia sobre Patrones Lenguajes de Programas
(PLoP '97/EuroPLoP '97). Foote y Yoder describen la Gran Bola de Barro como "... una jungla de código de espagueti
estructurada al azar, en expansión, descuidada, con cinta adhesiva y alambre para embalar". Dichos sistemas de software son
pesadillas de mantenimiento costosas y que consumen mucho tiempo, ¡y pueden poner de rodillas a una organización de desarrollo!
Los fenómenos patológicos que acabamos de describir se pueden encontrar en proyectos de software en todos los
sectores y dominios industriales. El lenguaje de programación utilizado no importa. Encontrarás Big Ball Of Muds escrito en
Java, PHP, C, C#, C++, o cualquier otro lenguaje más o menos popular. Pero ¿por qué es así?
© Stephan Roth 2017 1
S. Roth, C++ limpio, DOI 10.1007/9781484227930_1
Machine Translated by Google
Capítulo 1 ■ Introducción
Entropía del software
En primer lugar, hay algo que parece ser como una ley natural. Al igual que cualquier otro sistema cerrado y complejo, el software
tiende a ensuciarse con el tiempo. Este fenómeno se llama entropía del software. El término se basa en la segunda ley de la
termodinámica. Afirma que el desorden de un sistema cerrado no se puede reducir; solo puede permanecer sin cambios o
aumentar. El software parece comportarse de esta manera. Cada vez que se agrega una nueva función o se cambia algo, el código
se vuelve un poco más desordenado. También existen numerosos factores influyentes que podrían reenviar la entropía del software,
por ejemplo, los siguientes:
•Calendarios de proyectos poco realistas que aumentarán la presión y, por lo tanto, obligarán a los
desarrolladores a estropear las cosas ya hacer su trabajo de una manera mala y poco profesional.
•Inmensa complejidad de los sistemas de software en la actualidad.
•Los desarrolladores tienen diferentes niveles de habilidad y experiencia.
•Equipos multiculturales distribuidos globalmente, lo que impone problemas de comunicación.
•El desarrollo presta atención principalmente a los aspectos funcionales (requisitos funcionales y casos
de uso del sistema) del software, por lo que los requisitos de calidad (también conocidos como
requisitos no funcionales), como la eficiencia del rendimiento, la mantenibilidad, la usabilidad, la
portabilidad, la seguridad, etc., son descuidados o, en el peor de los casos, están siendo
completamente olvidados.
•Entorno de desarrollo inapropiado y malas herramientas.
•La gerencia se enfoca en ganar dinero rápido y no comprende el valor del desarrollo de software sostenible.
•Hacks rápidos y sucios e implementaciones que no se ajustan al diseño (también conocido como Broken
ventanas).
LA TEORÍA DE LA VENTANA ROTA
La teoría de la ventana rota se desarrolló en la investigación criminal estadounidense. La teoría establece
que una sola ventana destruida en un edificio abandonado puede ser el desencadenante del deterioro de
todo un vecindario. La ventana rota envía una señal fatal al medio ambiente: “¡Mira, a nadie le
importa este edificio!” Esto atrae más deterioro, vandalismo y otros comportamientos antisociales. La
Teoría de la Ventana Rota se ha utilizado como base para varias reformas en la política criminal,
especialmente para el desarrollo de estrategias de Tolerancia Cero.
En el desarrollo de software, esta teoría fue retomada y aplicada a la calidad del código. Los hacks y las
malas implementaciones, que no cumplen con el diseño del software, se denominan "ventanas rotas".
Si estas malas implementaciones no se reparan, pueden aparecer más hacks para tratar con ellos en
su vecindario. Y así, la dilapidación del código se pone en marcha.
No tolere "ventanas rotas" en su código: ¡ arréglelas!
Sin embargo, parece ser que los proyectos particulares de C y C++ son propensos al desorden y tienden más que otros a
caer en mal estado. Incluso la World Wide Web está llena de ejemplos de código C++ malos, pero aparentemente muy rápidos y
altamente optimizados, con una sintaxis cruel e ignorando por completo los principios elementales para un buen diseño y un código
bien escrito.
2
Machine Translated by Google
Capítulo 1 ■ Introducción
Una razón para esto podría ser que C ++ es un lenguaje de programación de múltiples paradigmas en un nivel intermedio,
es decir, comprende características de lenguaje de alto y bajo nivel. Con C++, puede escribir sistemas de software empresarial
grandes y distribuidos con interfaces de usuario sofisticadas, así como software para pequeños sistemas integrados con
comportamiento en tiempo real, vinculado muy de cerca al hardware subyacente. El lenguaje multiparadigma significa que puede
escribir programas procedimentales, funcionales u orientados a objetos, o incluso una combinación de los tres paradigmas.
Además, C ++ permite la metaprogramación de plantillas (TMP), una técnica en la que un compilador utiliza las llamadas plantillas
para generar un código fuente temporal, que el compilador fusiona con el resto del código fuente y luego compila. Y desde el
lanzamiento del estándar ISO C ++ 11, se han agregado aún más formas, por ejemplo, la programación funcional con funciones
anónimas ahora es compatible de una manera muy elegante con expresiones lambda. Como consecuencia de estas capacidades
diversas, C++ tiene la reputación de ser muy complejo, complicado y engorroso.
Otra causa del mal software podría ser que muchos desarrolladores no tenían experiencia en TI.
Cualquiera puede comenzar a desarrollar software hoy en día, sin importar si tiene un título universitario o cualquier otro
aprendizaje en informática. La gran mayoría de los desarrolladores de C++ son (o eran) no expertos. Especialmente en los dominios
tecnológicos automotriz, transporte ferroviario, aeroespacial, eléctrico/electrónico o ingeniería mecánica, muchos ingenieros
se deslizaron hacia la programación durante las últimas décadas sin tener una educación en informática. A medida que la complejidad
crecía y los sistemas técnicos contenían más y más software, había una necesidad urgente de programadores. Esta demanda fue
cubierta por la mano de obra existente.
Ingenieros eléctricos, matemáticos, físicos y también mucha gente de disciplinas estrictamente no técnicas comenzaron
a desarrollar software y a aprenderlo principalmente de manera autodidacta y práctica simplemente haciéndolo. Y lo han hecho a
su leal saber y entender.
Básicamente, no hay absolutamente nada de malo en ello. ¡Pero a veces solo conocer las herramientas y el lenguaje
de programación no es suficiente! Desarrollo de software no es lo mismo que programación. El mundo está lleno de software que
fue manipulado por desarrolladores de software mal capacitados. Hay muchas cosas en niveles abstractos que un desarrollador debe
considerar para crear un sistema sostenible, por ejemplo, arquitectura y diseño.
¿Cómo debe estructurarse un sistema para lograr ciertos objetivos de calidad? ¿Para qué sirve esta cosa orientada a objetos y cómo
la uso de manera eficiente? ¿Cuáles son las ventajas y desventajas de un determinado marco o biblioteca? ¿Cuáles son las
diferencias entre varios algoritmos y por qué un algoritmo no se ajusta a todos los problemas similares? ¿Y qué diablos es un
autómata finito determinista, y por qué ayuda a hacer frente a la complejidad?
¡Pero no hay razón para desanimarse! Lo que realmente importa para la salud continua de un software es que alguien se
preocupe por él, ¡y el código limpio es la clave!
código limpio
Un gran malentendido es confundir el código limpio con algo que se puede llamar "código hermoso".
El código limpio no tiene razones de belleza. A los programadores profesionales no se les paga por escribir código hermoso o
bonito. Son contratados por empresas de desarrollo como expertos para crear valor para el cliente.
El código está limpio si cualquier miembro del equipo puede entenderlo y mantenerlo fácilmente.
El código limpio es la base para ser rápido. Si su código está limpio y la cobertura de prueba es buena, un cambio o una
nueva función solo tomará unas pocas horas o un par de días, y no semanas o meses, hasta que se implemente, pruebe e implemente.
El código limpio es la base para un software sostenible y mantiene un proyecto de desarrollo de software
funcionando durante mucho tiempo sin acumular una gran cantidad de deuda técnica. Los desarrolladores deben cuidar
activamente el software y asegurarse de que se mantenga en forma porque el código es crucial para la supervivencia de una
organización de desarrollo de software.
El código limpio también es clave para hacerte un desarrollador más feliz. Conduce a una vida libre de estrés. Si su
código está limpio y se siente cómodo con él, puede mantener la calma en todas las situaciones, incluso frente a un plazo de
entrega ajustado.
Todos los puntos mencionados anteriormente son ciertos, pero el punto clave es este: ¡ El código limpio ahorra dinero!
En esencia, se trata de eficiencia económica. Cada año, las organizaciones de desarrollo pierden mucho dinero porque su código
está en mal estado.
3
Machine Translated by Google
Capítulo 1 ■ Introducción
¿Por qué C++?
C hace que sea fácil pegarse un tiro en el pie. C++ lo hace más difícil, pero cuando lo haces, ¡te vuelas toda la
pierna!
—Bjarne Stroustrup, Preguntas frecuentes de Bjarne Stroustrup: ¿De verdad dijiste eso?
Cada lenguaje de programación es una herramienta, y cada uno tiene sus fortalezas y debilidades. Una parte importante del
trabajo de un arquitecto de software es elegir el lenguaje de programación, o actualmente el conjunto de lenguajes de
programación, que se adapte perfectamente al proyecto. Es una decisión arquitectónica importante que nunca debe tomarse
sobre la base de una intuición o preferencias personales. Del mismo modo, un principio como "En nuestra empresa hacemos todo con
<reemplace esto con el idioma de su elección>" podría no ser una buena guía.
Como lenguaje de programación multiparadigma, C++ es un crisol que combina diferentes ideas y conceptos. El lenguaje
siempre ha sido una excelente opción cuando se trata del desarrollo de sistemas operativos, controladores de dispositivos, sistemas
integrados, sistemas de administración de bases de datos, juegos de computadora ambiciosos, animaciones 3D y diseño
asistido por computadora, procesamiento de audio y video en tiempo real, administración de big data, y muchas otras aplicaciones
críticas para el rendimiento. Hay ciertos dominios en los que C++ es la lingua franca. Grandes bases de código C++ con miles de millones
de líneas de código todavía están disponibles y en uso.
Hace unos años, una opinión muy difundida era que C++ es difícil de aprender y usar. El lenguaje puede ser complejo y
desalentador para los programadores que a menudo tienen la tarea de escribir programas grandes y complejos. Debido a esto, los
lenguajes principalmente interpretados y administrados, como Java o C#, se estaban volviendo populares.
Un marketing desmedido por parte del fabricante de estos lenguajes hizo el resto. En consecuencia, los lenguajes administrados
han llegado a dominar en ciertos dominios, pero los lenguajes compilados de forma nativa aún dominan en otros. Un lenguaje de
programación no es una religión. Si no necesita el rendimiento de C++, pero Java, por ejemplo, facilita su trabajo, entonces utilícelo.
C++11 – El comienzo de una nueva era
Algunas personas dicen que C++ actualmente está experimentando un renacimiento. Algunos incluso hablan de una revolución.
Dicen que el C++ moderno de hoy ya no es comparable con el “C++ histórico” de principios de los 90. El catalizador de esta tendencia
fue principalmente la aparición del estándar C++ ISO/IEC 14882:2011 [ISO11], más conocido como C++11, en septiembre de 2011.
Sin duda, C++11 ha traído grandes innovaciones. Parecía que la publicación de esta norma
ha puesto algunas cosas en marcha. Y mientras este libro está en producción, el comité de estandarización de C++ ha completado
su trabajo en el nuevo estándar C++17, que ahora se encuentra en su proceso final de votación ISO.
Además, C++20 ya está comenzando.
Actualmente están sucediendo muchas cosas en el espacio de desarrollo nativo, especialmente en las empresas.
de la industria manufacturera, porque el software se ha convertido en el factor de valor agregado más importante para los sistemas
técnicos. Las herramientas de desarrollo para C++ son mucho más poderosas hoy en día y hay disponibles una multitud de bibliotecas
y marcos útiles. Pero no necesariamente llamaría a todo este desarrollo una revolución.
Creo que es la evolución habitual. Además, los lenguajes de programación deben mejorarse y adaptarse continuamente para
cumplir con los nuevos requisitos, y C ++ 98 respectivamente C ++ 03 (que era principalmente una versión de corrección de errores en C
++ 98) era un poco largo en el diente.
4
Machine Translated by Google
Capítulo 1 ■ Introducción
Para quien es este libro
Como formador y consultor, tengo la oportunidad de echar un vistazo a muchas empresas que están desarrollando software. Además, observo muy de
cerca lo que sucede en la escena de los desarrolladores. y he reconocido
un hueco.
Mi impresión es que los programadores de C++ han sido ignorados por aquellos que promueven la artesanía del software.
y desarrollo de código limpio. Muchos principios y prácticas, que son relativamente bien conocidos en el entorno de Java y en el moderno mundo del
desarrollo web o de juegos, parecen ser en gran parte desconocidos en el mundo de C++. Libros pioneros, como The Pragmatic Programmer [Hunt99]
de Andrew Hunt y David Thomas, o Clean Code [Martin09] de Robert C. Martin , a menudo ni siquiera son conocidos.
Este libro trata de cerrar un poco esa brecha, porque incluso con C++, ¡el código se puede escribir limpio! Si desea aprender a escribir en C++
limpio, este libro es para usted.
¡Este libro no es un manual básico de C++! Ya debe estar familiarizado con los conceptos básicos del idioma para usar el conocimiento de este
libro de manera eficiente. Si solo desea comenzar con el desarrollo de C ++ y aún no tiene conocimientos básicos del lenguaje, primero debe aprenderlos,
lo que se puede hacer con otros libros o con una buena capacitación de introducción a C ++.
Además, este libro no contiene ningún truco o truco esotérico. Sé que un montón de chiflados y mente
Es posible explotar cosas con C++, pero generalmente no tienen el espíritu del código limpio y solo rara vez se deben usar para un programa C++
limpio y moderno. Si estás realmente loco por la misteriosa calistenia con punteros de C++, este libro no es para ti.
Para ver algunos ejemplos de código en este libro, varias características de lenguaje de los estándares C++11 (ISO/IEC 14882:2011), C+
+14 (ISO/IEC 14882:2014) y también algunas de las últimas versiones de C++. Se utilizan 17. Si no está familiarizado con estas funciones, no
se preocupe. Proporcionaré breves introducciones sobre algunos de ellos con la ayuda de las barras laterales. Tenga en cuenta que, en realidad, no todos
los compiladores de C++ son compatibles con todas las características del lenguaje nuevo por completo.
Aparte de eso, este libro está escrito para ayudar a los desarrolladores de C++ de todos los niveles y muestra con ejemplos cómo escribir código
C++ comprensible, flexible, fácil de mantener y eficiente. Incluso si usted es un desarrollador de C++ experimentado, hay algunos puntos de información
y datos en este libro que creo que encontrará útiles en su trabajo. Los principios y prácticas presentados se pueden aplicar tanto a nuevos sistemas de
software, a veces llamados proyectos greenfield; así como sistemas heredados con una larga historia, que a menudo se denominan proyectos brownfield
de forma peyorativa.
Las convenciones usadas en este libro
En este libro se utilizan las siguientes convenciones tipográficas:
La fuente en cursiva se utiliza para introducir nuevos términos y nombres.
La fuente en negrita se usa dentro de los párrafos para enfatizar términos o información importante.
declaraciones.
La fuente monoespaciada se usa dentro de los párrafos para referirse a elementos del programa, como nombres
de clases, variables o funciones, declaraciones y palabras clave de C++. Esta fuente también se usa para mostrar
entradas de línea de comando, una dirección de un sitio web (URL), una secuencia de pulsaciones de teclas o
la salida producida por un programa.
Barras laterales
A veces, les paso pequeños fragmentos de información que están tangencialmente relacionados con el contenido que los rodea, que podrían considerarse
separados de ese contenido. Estas secciones se conocen como barras laterales. A veces uso una barra lateral para presentar una discusión adicional o
contrastante sobre el tema que la rodea. Ejemplo:
5
Machine Translated by Google
Capítulo 1 ■ Introducción
ESTE ENCABEZADO CONTIENE EL TÍTULO DE UNA BARRA LATERAL
Este es el texto en una barra lateral.
Notas, consejos y advertencias Otro tipo de barra
lateral para fines especiales se utiliza para notas, consejos y advertencias. Se utilizan para brindarle información especial, para
brindarle un consejo útil o para advertirle sobre cosas que pueden ser peligrosas y deben evitarse. Ejemplo:
■ Nota Este es el texto de la nota.
Ejemplos de código Los
ejemplos de código y los fragmentos de código aparecerán separados del texto, resaltados por la sintaxis (las palabras clave del
lenguaje C++ están en negrita) y en una fuente monoespaciada. Las secciones de código más largas suelen tener títulos. Para
hacer referencia a líneas específicas del ejemplo de código en el texto, los ejemplos de código a veces se decoran con números de línea.
Listado 11. Un ejemplo de código de línea numerada
01 clase Clazz { 02
público:
03 Clazz();
04 virtual ~Clazz(); void
hacerAlgo(); 05 06
07 privado: 08
int _atributo;
09
10 función vacía (); 11};
Para centrarse mejor en aspectos específicos del código, las partes irrelevantes a veces se oscurecen y representan
por un comentario con puntos suspensivos (…), como en este ejemplo:
void Clazz::función() { // ...
Estilo de codificación
Solo unas pocas palabras sobre el estilo de codificación que he usado en este libro.
Puede tener la impresión de que mi estilo de programación se parece mucho al código típico de Java, mezclado con
el estilo de Kernighan y Ritchie (K&R). En mis casi 20 años como desarrollador de software, e incluso más adelante en
mi carrera, todavía he aprendido otros lenguajes de programación además de C++, por ejemplo, ANSIC, Java, Delphi,
Scala y varios lenguajes de secuencias de comandos. Por lo tanto, adopté mi propio estilo de programación, que es
un crisol de diferentes influencias.
6
Machine Translated by Google
Capítulo 1 ■ Introducción
Tal vez no te guste mi estilo, y prefieras el estilo Kernel de Linus Torvald, el estilo Allman, o cualquier otro.
otro popular estándar de codificación C++. Por supuesto, esto está perfectamente bien. Me gusta mi estilo y a ti te gusta el tuyo.
Sitio web complementario y repositorio de código fuente
Este libro va acompañado de un sitio web complementario: www.cleancpp.com.
El sitio web incluye:
•Un foro de discusión donde los lectores pueden discutir temas específicos con otros lectores y,
por supuesto, con el autor.
•La discusión de temas adicionales que quizás aún no hayan sido cubiertos en este libro.
•Versión en alta resolución de todas las figuras de este libro.
La mayoría de los ejemplos de código fuente de este libro y otras adiciones útiles están disponibles en GitHub en:
https://github.com/cleancpp
Puede consultar el código usando Git con el siguiente comando:
$> git clonar https://github.com/cleancpp/booksamples.git
Puede obtener un archivo .zip del código en https://github.com/cleancpp/booksamples y haciendo clic en el botón “Descargar ZIP”.
Diagramas UML
Algunas ilustraciones de este libro son diagramas UML. El lenguaje de modelado unificado (UML) es un lenguaje gráfico estandarizado para
crear modelos de software y otros sistemas. En su versión actual 2.5, UML ofrece 14 tipos de diagramas para describir un sistema por
completo.
No se preocupe si no está familiarizado con todos los tipos de diagramas; Utilizo en este libro sólo algunos de ellos. estoy presente
Diagramas UML de vez en cuando para proporcionar una descripción general rápida de ciertos problemas que posiblemente no se
puedan detectar lo suficientemente rápido con solo leer el código. En el Apéndice A encontrará una breve descripción de las notaciones utilizadas.
7
Machine Translated by Google
CAPITULO 2
Construya una red de seguridad
Probar es una habilidad. Si bien esto puede sorprender a algunas personas, es un hecho simple.
—Mark Fewster y Dorothy Graham, Automatización de pruebas de software, 1999
Que comience la parte principal de este libro con un capítulo sobre pruebas puede sorprender a algunos lectores, pero esto
se debe a varias buenas razones. Durante los últimos años, las pruebas en ciertos niveles se han convertido en una piedra
angular esencial del desarrollo de software moderno. Los beneficios potenciales de una buena estrategia de prueba son enormes.
Todo tipo de pruebas, si están bien diseñadas, pueden ser útiles y útiles. En este capítulo describiré por qué pienso que las
Pruebas Unitarias, en especial, son indispensables para asegurar un nivel fundamental de excelente calidad en el software.
Tenga en cuenta que este capítulo trata sobre lo que a veces se denomina POUT ("Pruebas unitarias sencillas") y no
la herramienta de apoyo al diseño TestDriven Development (TDD), de la que hablaré más adelante en este libro.
La necesidad de las pruebas
1962: NASA MARINER 1
La nave espacial Mariner 1 se lanzó el 22 de julio de 1962 como una misión de sobrevuelo de Venus para la exploración
planetaria. Debido a un problema con su antena direccional, el cohete de lanzamiento AtlasAgena B funcionó de manera poco
confiable y perdió su señal de control desde el control de tierra poco después del lanzamiento.
Este caso excepcional se había considerado durante el diseño y la construcción del cohete. El vehículo de
lanzamiento AtlasAgena cambió a control automático por la computadora de guía a bordo.
Desafortunadamente, un error en el software de esta computadora condujo a comandos de control incorrectos que causaron
una desviación crítica del rumbo e imposibilitaron el gobierno. El cohete se dirigió hacia la tierra y apuntó a un área crítica.
A los T+293 segundos, el oficial de seguridad de campo envió el comando de destrucción para hacer estallar el cohete. Un
informe de examen de la NASA1 menciona un error tipográfico en el código fuente de la computadora, la falta de un guión
(''), como la causa del error. La pérdida total fue de $18,5 millones, que era una gran cantidad de dinero en esos días.
1
Centro Nacional de Datos de Ciencias Espaciales de la NASA (NSSDC): Mariner 1, http://nssdc.gsfc.nasa.gov/nmc/spacecraft
Display.do?id=MARIN1, consultado el 28 de abril de 2014.
© Stephan Roth 2017 9
S. Roth, C++ limpio, DOI 10.1007/9781484227930_2
Machine Translated by Google
Capítulo 2 ■ Construir una red de seguridad
Si se les pregunta a los desarrolladores de software por qué las pruebas son buenas y esenciales, supongo que lo más común
La respuesta será la reducción de bugs, errores o fallas. Sin duda, esto es básicamente correcto: las pruebas son una parte
elemental de la garantía de calidad.
Los errores de software generalmente se perciben como una molestia desagradable. Los usuarios están molestos por el mal
el comportamiento del programa, que produce una salida no válida, o están seriamente molestos por los bloqueos regulares. A veces,
incluso las cosas raras, como un texto truncado en un cuadro de diálogo de una interfaz de usuario, son suficientes para molestar
significativamente a los usuarios de software en su trabajo diario. La consecuencia puede ser una creciente insatisfacción con el software y,
en el peor de los casos, su sustitución por otro producto. Además de una pérdida financiera, la imagen del fabricante del software sufre errores.
En el peor de los casos, la empresa se mete en serios problemas y se pierden muchos puestos de trabajo.
Pero el escenario descrito anteriormente no se aplica a todas las piezas de software. Las implicaciones de un
error puede ser mucho más dramático.
1986: DESASTRE DEL ACELERADOR MÉDICO THERAC25
Este caso es probablemente el fracaso más importante en la historia del desarrollo de software. El
Therac25 era un dispositivo de radioterapia. Fue desarrollado y producido desde 1982 hasta 1985 por la empresa
estatal Atomic Energy of Canada Limited (AECL). Se produjeron e instalaron once dispositivos en clínicas de
EE. UU. y Canadá.
Debido a errores en el software de control, un proceso de garantía de calidad insuficiente y otras deficiencias,
tres pacientes perdieron la vida a causa de una sobredosis de radiación. Otros tres pacientes fueron irradiados
y se llevaron daños permanentes y graves para la salud.
Un análisis de este caso tiene como resultado que, entre otras cosas, el software fue escrito por una sola
persona que también era responsable de las pruebas.
Cuando las personas piensan en computadoras, generalmente tienen en mente una PC de escritorio, una computadora portátil, una
tableta o un teléfono inteligente. Y si piensan en software, generalmente piensan en tiendas web, suites ofimáticas o sistemas de TI comerciales.
Pero este tipo de software y computadoras representan solo un porcentaje muy pequeño de todos los sistemas con los que tenemos
contacto todos los días. La mayor parte del software que nos rodea controla máquinas que interactúan físicamente con el mundo. Toda
nuestra vida está gestionada por software. En pocas palabras: ¡ Hoy no hay vida sin software! El software está en todas partes y es una parte
esencial de nuestra infraestructura.
Si subimos a un ascensor, damos nuestras vidas en manos del software. Las aeronaves son controladas por
software, y todo el sistema mundial de control del tráfico aéreo depende del software. Nuestros automóviles modernos contienen una cantidad
significativa de pequeños sistemas informáticos con software que se comunican a través de una red, responsables de muchas funciones críticas
para la seguridad del vehículo. Climatización, puertas automáticas, dispositivos médicos, trenes, líneas de producción automatizadas en
fábricas… Hagamos lo que hagamos hoy en día, estamos permanentemente en contacto con el software. Y con la revolución digital y el
Internet de las cosas (IoT), la relevancia del software para nuestra vida volverá a aumentar significativamente. Casi ningún otro tema es más
evidente que con el automóvil autónomo (sin conductor).
Creo que es innecesario enfatizar que cualquier error en estos sistemas intensivos en software puede tener consecuencias
catastróficas. Una falla o mal funcionamiento de estos importantes sistemas puede ser una amenaza para la vida o la condición física.
En el peor de los casos, cientos de personas pueden perder la vida durante un accidente aéreo, posiblemente causado por una declaración
if incorrecta en una subrutina del subsistema FlybyWire. La calidad en ningún caso es negociable en este tipo de sistemas. ¡Nunca!
Pero incluso en sistemas sin requisitos de seguridad funcional, los errores pueden tener serias implicaciones,
especialmente si son más sutiles en su destructividad. Es fácil imaginar que los errores en el software financiero podrían ser un desencadenante
de una crisis bancaria mundial en la actualidad. Solo asuma que el software financiero de un gran banco arbitrario realiza cada publicación
dos veces debido a un error, y este problema no se notará durante un par de días.
10
Machine Translated by Google
Capítulo 2 ■ Construir una red de seguridad
1990: EL ACCIDENTE DE AT&T
El 15 de enero de 1990, la red telefónica de larga distancia de AT&T colapsó y 75 millones de llamadas telefónicas
fallaron durante 9 horas. El apagón fue causado por una sola línea de código (una declaración de interrupción incorrecta)
en una actualización de software que AT&T implementó en los 114 interruptores electrónicos operados por computadora
(4ESS) en diciembre de 1989. El problema comenzó la tarde del 15 de enero cuando un El mal funcionamiento en el
centro de control de AT&T en Manhattan provocó una reacción en cadena y desactivó los interruptores en la mitad de la red.
La pérdida estimada para AT&T fue de $60 millones, y probablemente una gran cantidad de pérdidas para las empresas
que dependían de la red telefónica.
Introducción a las pruebas
Hay diferentes niveles de medidas de aseguramiento de la calidad en los proyectos de desarrollo de software. Estos niveles a menudo se
visualizan en forma de pirámide, la llamada pirámide de prueba. El concepto fundamental fue desarrollado por el desarrollador de software
estadounidense Mike Cohn, uno de los fundadores de Scrum Alliance. Describió la pirámide de automatización de pruebas en su libro
Succeeding with Agile [Cohn09]. Con la ayuda de la pirámide, Cohn describe el grado de automatización requerido para una prueba de
software eficiente. En los años siguientes, la Pirámide de prueba ha sido desarrollada por diferentes personas. El que se muestra en la Figura
21 es mi versión.
Figura 21. La pirámide de prueba
La forma de pirámide, por supuesto, no es una coincidencia. El mensaje detrás de esto es que deberías tener muchos
más pruebas unitarias de bajo nivel (aproximadamente 100% de cobertura de código) que otro tipo de pruebas. Pero ¿por qué es eso?
11
Machine Translated by Google
Capítulo 2 ■ Construir una red de seguridad
La experiencia ha demostrado que los costos totales relacionados con la implementación y el mantenimiento de las pruebas están
aumentando hacia la parte superior de la pirámide. Las pruebas de sistemas grandes y las pruebas manuales de aceptación del usuario
suelen ser complejas, a menudo requieren una gran organización y no se pueden automatizar fácilmente. Por ejemplo, una prueba de IU
automatizada es difícil de escribir, a menudo frágil y relativamente lenta. Por lo tanto, estas pruebas a menudo se realizan manualmente, lo que
es adecuado para la aprobación del cliente (pruebas de aceptación) y las pruebas exploratorias periódicas por parte del control de calidad,
pero consumen demasiado tiempo y son demasiado costosas para el uso diario durante el desarrollo.
Además, las pruebas de sistemas grandes, o las pruebas basadas en UI, son totalmente inadecuadas para verificar todas las rutas
posibles de ejecución a través de todo el sistema. Hay mucho código en un sistema de software que se ocupa de rutas alternativas,
excepciones y manejo de errores, preocupaciones transversales (seguridad, manejo de transacciones, registro...) y otras funciones auxiliares
que se requieren, pero que a menudo no se pueden alcanzar a través del usuario normal. interfaz.
Sobre todo, si falla una prueba a nivel del sistema, la causa exacta del error puede ser difícil de localizar. Las pruebas del sistema
generalmente se basan en los casos de uso del sistema. Durante la ejecución de un caso de uso, muchos componentes están involucrados.
Esto significa que se ejecutan muchos cientos, o incluso miles, de líneas de código. ¿Cuál de estas líneas fue responsable de la prueba
fallida? Esta pregunta a menudo no se puede responder fácilmente y requiere un análisis costoso y que requiere mucho tiempo.
Desafortunadamente, en varios proyectos de desarrollo de software encontrará pirámides de prueba degeneradas, como se
muestra en la figura 22. En tales proyectos, se pone un enorme esfuerzo en las pruebas de nivel superior, mientras que se descuidan las
pruebas unitarias elementales (antipatrón de cono de helado). En el caso extremo faltan por completo (Cup Cake AntiPattern).
Figura 22. Pirámides de prueba degeneradas (antipatrones)
Por lo tanto, una base amplia de pruebas unitarias completamente automatizadas, bien diseñadas, muy rápidas, con
mantenimiento regular y económicas, respaldada por una selección de pruebas de componentes útiles, puede ser una base sólida para
garantizar una calidad bastante alta de un sistema de software.
12
Machine Translated by Google
Capítulo 2 ■ Construir una red de seguridad
Pruebas unitarias
“refactorizar” sin pruebas no es refactorizar, es solo mover cosas.
—Corey Haines (@coreyhaines), 20 de diciembre de 2013, en Twitter
Una prueba unitaria es una pieza de código que ejecuta una pequeña parte de su base de código de producción en un contexto particular.
La prueba le mostrará en una fracción de segundo que su código funciona como espera que funcione. Si la cobertura de la prueba unitaria
es bastante alta y puede verificar en menos de un minuto que todas las partes de su sistema en desarrollo funcionan correctamente, tendrá
numerosas ventajas:
•Numerosas investigaciones y estudios han demostrado que corregir errores después de que el software es
Se ha demostrado que el envío es mucho más costoso que tener pruebas unitarias.
•Las pruebas unitarias brindan una respuesta inmediata sobre toda su base de código. Siempre que la cobertura de la
prueba sea lo suficientemente alta (aprox. 100 %), los desarrolladores saben en tan solo unos segundos si el
código funciona correctamente.
•Las pruebas unitarias brindan a los desarrolladores la confianza para refactorizar su código sin temor a hacer algo
mal que rompa el código. De hecho, un cambio estructural en un código base sin una red de seguridad de
pruebas unitarias es peligroso y no debería llamarse Refactorización.
• Una alta cobertura con pruebas unitarias puede evitar sesiones de depuración frustrantes y que consumen
mucho tiempo. Las búsquedas, que a menudo duran horas, de la causa de un error utilizando un
depurador se pueden reducir drásticamente. Por supuesto, nunca podrá eliminar por completo el uso de un
Depurador. Esta herramienta todavía se puede usar para analizar problemas sutiles o para encontrar la
causa de una prueba unitaria fallida. Pero ya no será la herramienta de desarrollo fundamental para garantizar
la calidad del código.
•Las pruebas unitarias son un tipo de documentación ejecutable porque muestran exactamente cómo
el código está diseñado para ser utilizado. Son, por así decirlo, una especie de ejemplo de uso.
•Las pruebas unitarias pueden detectar regresiones fácilmente, es decir, pueden mostrar cosas de inmediato
que solía funcionar, pero inesperadamente dejó de funcionar después de que se realizó un cambio en el código.
•Las pruebas unitarias fomentan la creación de interfaces limpias y bien formadas. Eso puede ayudar
para evitar dependencias no deseadas entre unidades. Un diseño para la capacidad de prueba también es
un buen diseño para la usabilidad, es decir, si una pieza de código se puede montar fácilmente en un
dispositivo de prueba, entonces también se puede integrar con menos esfuerzo en el código de producción
del sistema.
•Las pruebas unitarias hacen que el desarrollo sea más rápido.
Especialmente el último elemento de esta lista parece ser paradójico y necesita un poco de explicación. Unidad
las pruebas ayudan a que el desarrollo avance más rápido, ¿cómo puede ser eso? Eso no parece lógico.
No hay duda al respecto: escribir pruebas unitarias significa esfuerzo. En primer lugar, los gerentes solo ven ese esfuerzo y no
entienden por qué los desarrolladores deberían invertir tiempo en las pruebas. Y especialmente en la fase inicial de un proyecto, el efecto
positivo de las pruebas unitarias en la velocidad de desarrollo puede no ser visible. En estas primeras etapas de un proyecto, cuando la
complejidad del sistema es relativamente baja y casi todo funciona bien, al principio parece que escribir pruebas unitarias solo requiere esfuerzo.
Pero los tiempos están cambiando…
13
Machine Translated by Google
Capítulo 2 ■ Construir una red de seguridad
Cuando el sistema se vuelve más y más grande (+ 100,000 LOC) y la complejidad aumenta, se vuelve más difícil entender y
verificar el sistema (recuerde la entropía del software que describí en el Capítulo 1 ).
Con frecuencia, cuando muchos desarrolladores en diferentes equipos están trabajando en un sistema enorme, se enfrentan todos los
días con el código escrito por otros desarrolladores. Sin pruebas unitarias, esto puede convertirse en un trabajo muy frustrante. Estoy
seguro de que todos conocen esas estúpidas e interminables sesiones de depuración, recorriendo el código en modo de un solo paso
mientras analizan los valores de las variables una y otra vez. … ¡Esto es una gran pérdida de tiempo!
Y ralentizará significativamente la velocidad de desarrollo.
Particularmente en las etapas medias y tardías de desarrollo, y en la fase de mantenimiento después de la entrega del producto,
las buenas pruebas unitarias despliegan sus efectos positivos. El mayor ahorro de tiempo de las pruebas unitarias se produce unos
meses o años después de escribir una prueba, cuando es necesario cambiar o ampliar una unidad o su API.
Si la cobertura de la prueba es alta, es casi irrelevante si un código editado por un desarrollador fue escrito por él mismo o por
otro desarrollador. Las buenas pruebas unitarias ayudan a los desarrolladores a comprender rápidamente un fragmento de código
escrito por otra persona, incluso si se escribió hace tres años. Si una prueba falla, muestra exactamente dónde se rompe algún
comportamiento. Los desarrolladores pueden confiar en que todo seguirá funcionando correctamente si se superan todas las pruebas.
Las sesiones de depuración largas y molestas se vuelven una rareza, y el Depurador sirve principalmente para encontrar
rápidamente la causa de una prueba fallida si esta causa no es obvia. Y eso es genial porque es divertido trabajar de esa manera. Es
motivador y conduce a mejores y más rápidos resultados. Los desarrolladores tendrán mayor confianza en el código base y se sentirán
cómodos con él. ¿Cambiar los requisitos o solicitar nuevas funciones? No hay problema, porque pueden enviar el nuevo producto rápido
y con frecuencia, y con una calidad excelente.
MARCOS DE PRUEBA DE UNIDAD
Hay varios marcos de prueba de unidad diferentes disponibles para el desarrollo de C++, por ejemplo, CppUnit,
Boost.Test, CUTE, Google Test y un par más.
En principio, todos estos marcos siguen el diseño básico de los llamados xUnit, que es un nombre colectivo para
varios marcos de pruebas unitarias que derivan su estructura y funcionalidad de SUnit de Smalltalk.
Aparte del hecho de que el contenido de este capítulo no se fija en un marco de pruebas unitarias específico, y
porque su contenido es aplicable a las pruebas unitarias en general, una comparación completa y detallada de todos
los marcos disponibles estaría más allá del alcance de este libro. Además, elegir un marco adecuado depende
de muchos factores. Por ejemplo, si es muy importante para usted que pueda agregar rápidamente nuevas pruebas
con una cantidad mínima de trabajo, entonces este podría ser un criterio de eliminación para ciertos marcos.
¿Qué pasa con el control de calidad?
Un desarrollador podría tener la siguiente actitud: “¿Por qué debo probar mi software? Tenemos probadores y un departamento de
control de calidad, es su trabajo”.
La pregunta esencial es esta: ¿Es la calidad del software una preocupación exclusiva del departamento de control de calidad?
La respuesta simple y clara: ¡ No!
He dicho esto antes, y lo diré de nuevo. A pesar de que su empresa puede tener un grupo de control de calidad
separado para probar el software, el objetivo del grupo de desarrollo debe ser que el control de calidad no
encuentre nada malo.
—Robert C. Martin, El codificador limpio [Martin11]
14
Machine Translated by Google
Capítulo 2 ■ Construir una red de seguridad
Sería extremadamente poco profesional entregar una pieza de software a control de calidad de la que se sabe que contiene errores. Los
desarrolladores profesionales nunca imponen la responsabilidad de la calidad de un sistema a otros departamentos. Por el contrario, los
artesanos de software profesionales construyen asociaciones productivas con la gente de control de calidad. Deben trabajar en estrecha
colaboración y complementarse entre sí.
Por supuesto, es un objetivo muy ambicioso entregar software 100% libre de defectos. De vez en cuando, QA encontrará
Ocurre algo. Y eso es bueno. QA es nuestra segunda red de seguridad. Comprueban si las medidas de garantía de calidad anteriores fueron
suficientes y efectivas.
De nuestros errores podemos aprender y mejorar. Los desarrolladores profesionales solucionan esos déficits de calidad de inmediato
corrigiendo los errores que encontró el control de calidad y escribiendo pruebas unitarias automatizadas para detectarlos en el futuro. Luego, deben
pensar cuidadosamente en esto: "¿Cómo, en el nombre de Dios, puede suceder que hayamos pasado por alto este problema?" El resultado de esta
retrospectiva debe servir como insumo para mejorar el proceso de desarrollo.
Reglas para buenas pruebas unitarias
He visto muchas pruebas unitarias que son bastante inútiles. Las pruebas unitarias deben agregar valor a su proyecto. Para lograr este objetivo,
se deben seguir algunas reglas esenciales, que describiré en esta sección.
Calidad del código de prueba Los
mismos requisitos de alta calidad para el código de producción tienen que ser válidos para el código de prueba unitario. Iré aún más lejos:
idealmente, no debería haber una distinción crítica entre el código de producción y el de prueba, ambos son iguales. Si decimos que hay código
de producción por un lado y código de prueba por el otro, separamos cosas que van juntas inseparablemente. ¡No hagas eso! Pensar en la
producción y el código de prueba en dos categorías sienta las bases para poder descuidar las pruebas más adelante en el proyecto.
Denominación de prueba unitaria
Si una prueba unitaria falla, el desarrollador quiere saber de inmediato:
•Cuál es el nombre de la unidad; ¿De quién fue la prueba que falló?
•¿Qué se probó y cuál fue el entorno de la prueba (el escenario de la prueba)?
• ¿Cuál era el resultado esperado de la prueba y cuál es el resultado real de la prueba fallida?
Por lo tanto, una denominación expresiva y descriptiva de las pruebas unitarias es muy importante. Mi consejo es establecer estándares
de nomenclatura para todas las pruebas.
En primer lugar, es una buena práctica nombrar el módulo de prueba de unidad (según el marco de prueba de unidad, se denominan
Arneses de prueba o Dispositivos de prueba) de tal manera que la unidad probada pueda derivarse fácilmente de él.
Deben tener un nombre como <Unidad_bajo_Prueba>Prueba, donde el marcador de posición <Unidad_bajo_Prueba> debe ser sustituido por el
nombre del sujeto de prueba, obviamente. Por ejemplo, si su sistema bajo prueba (SUT) es la unidad Money, el dispositivo de prueba
correspondiente que se adjunta a esa unidad y contiene todos los casos de prueba de la unidad, debe llamarse MoneyTest (vea la Figura 23).
Figura 23. El sistema bajo prueba (SUT) y su Contexto de Prueba
15
Machine Translated by Google
Capítulo 2 ■ Construir una red de seguridad
Más allá de eso, las pruebas unitarias deben tener nombres expresivos y descriptivos. No es útil si las pruebas unitarias
tienen nombres más o menos sin sentido como testConstructor(), test4391() o sumTest(). Aquí hay dos sugerencias para encontrar
un buen nombre para ellos.
Para clases generales multipropósito que se pueden usar en diferentes contextos, un nombre expresivo podría
contienen las siguientes partes:
•La condición previa del escenario de prueba, es decir, el estado del SUT antes de que se realizara la prueba.
ejecutado.
•La parte probada de la unidad bajo prueba, típicamente el nombre del procedimiento probado,
función o método (API).
•El resultado esperado de la prueba.
Eso lleva a una plantilla de nombre para procedimientos/métodos de prueba unitaria, como esta:
<Condición previa y estado de la unidad bajo prueba>_<Parte probada de la API>_<Comportamiento esperado>
Aquí están algunos ejemplos:
Listado 21. Algunos ejemplos de nombres de pruebas unitarias buenos y expresivos
void CustomerCacheTest::cacheIsEmpty_addElement_sizeIsOne(); void
CustomerCacheTest::cacheContainsOneElement_removeElement_sizeIsZero(); void
ComplexNumberCalculatorTest::givenTwoComplexNumbers_add_Works(); void PruebaDinero::
dadosDosObjetosDineroConDiferentesBalance_theInequalityComparason_Works(); void
MoneyTest::createMoneyObjectWithParameter_getBalanceAsString_returnsCorrectString(); void
InvoiceTest::invoiceIsReadyForAccounting_getInvoiceDate_returnsToday();
Otro enfoque posible para crear nombres expresivos de pruebas unitarias es manifestar un requisito específico en
el nombre. Estos nombres suelen reflejar los requisitos del dominio de la aplicación. Por ejemplo, se derivan de los requisitos de las
partes interesadas.
Listado 22. Algunos ejemplos más de nombres de pruebas unitarias que verifican los requisitos específicos del dominio
void UserAccountTest::creatingNewAccountWithExistingEmailAddressThrowsException(); void
ChessEngineTest::aPawnCanNotMoveBackwards(); void
ChessEngineTest::aEl enroque no está permitido si el rey involucrado ha sido movido antes (); void
ChessEngineTest::aNo se permite el enroque si la torre involucrada se ha movido antes (); void Prueba de control del
calentador:: si la temperatura del agua es mayor que 92 grados, apague el calentador (); void Prueba de inventario
de libro:: un libro que está en el inventario puede ser prestado por personas autorizadas (); void Prueba de inventario de libro::
un libro que ya está prestado no puede tomar prestado dos veces ();
A medida que lea los nombres de estos métodos de prueba, quedará claro que incluso si la implementación de las pruebas y los
métodos de prueba no se muestran aquí, se puede derivar fácilmente una gran cantidad de información útil. Y esto también es una gran
ventaja si tal prueba falla. Casi todos los marcos de pruebas unitarias escriben el nombre de la prueba fallida en la salida estándar
(stdout). Por lo tanto, la ubicación del error se facilita enormemente.
Independencia de las pruebas unitarias Cada
prueba unitaria debe ser independiente de todas las demás. Sería fatal si las pruebas deben ejecutarse en un orden específico porque
una prueba se basa en el resultado de la anterior. Nunca escriba una prueba unitaria cuyo resultado sea el requisito previo para una
prueba posterior. Nunca deje la unidad bajo prueba en un estado alterado, lo cual es una condición previa para las siguientes pruebas.
dieciséis
Machine Translated by Google
Capítulo 2 ■ Construir una red de seguridad
Los problemas principales pueden ser causados por estados globales, por ejemplo, el uso de Singletons o miembros estáticos
en su unidad bajo prueba. No solo es que los Singleton aumentan el acoplamiento entre las unidades de software. También suelen
tener un estado global que elude la independencia de las pruebas unitarias. Por ejemplo, si un determinado estado global es la
condición previa para una prueba exitosa, pero la prueba anterior ha mutado ese estado global, puede causar serios problemas.
Especialmente en los sistemas heredados, que a menudo están plagados de Singletons, esto plantea la pregunta: ¿cómo puedo
deshacerme de todas esas desagradables dependencias de esos Singletons y hacer que mi código sea mejor comprobable? Bueno, esa
es una pregunta importante que discuto en la sección Inyección de dependencia en el Capítulo 6.
TRATAR CON SISTEMAS LEGADOS
Si se enfrenta a los llamados sistemas heredados y enfrenta muchas dificultades al intentar agregar pruebas unitarias, le
recomiendo el libro Trabajar de manera efectiva con código heredado [Feathers07] de Michael C.
Plumas. El libro de Feathers contiene muchas estrategias para trabajar con grandes bases de código heredadas no probadas.
También incluye un catálogo de 24 técnicas de ruptura de dependencia. Estas estrategias y técnicas están más allá del alcance
de este libro.
Una afirmación por prueba Sé que este es un
tema controvertido, pero intentaré explicar por qué creo que es importante. Mi consejo es limitar una prueba unitaria para usar una sola
afirmación, como esta:
Listado 23. Una prueba unitaria que verifica el operador no igual de una clase de dinero
void MoneyTest::givenTwoMoneyObjectsWithDifferentBalance_theInequalityComparison_Works() {
constante Dinero m1(4000.0); const
Dinero m2(2000.0);
ASSERT_TRUE(m1 != m2); }
Ahora se podría argumentar que también podríamos verificar si otros operadores de comparación (por ejemplo,
Money::operator==()) están funcionando correctamente en esta prueba unitaria. Sería fácil hacerlo simplemente agregando más
afirmaciones, como esta:
Listado 24. Pregunta: ¿Es realmente una buena idea verificar todos los operadores de comparación en una prueba unitaria?
void MoneyTest::givenTwoMoneyObjectsWithDifferentBalance_testAllComparisonOperators() {
constante Dinero m1(4000.0); const
Dinero m2(2000.0);
ASSERT_TRUE(m1 != m2);
ASSERT_FALSE(m1 == m2);
ASSERT_TRUE(m1 < m2);
ASSERT_FALSE(m1 >
m2); // ...más afirmaciones aquí...
}
17
www.allitebooks.com
Machine Translated by Google
Capítulo 2 ■ Construir una red de seguridad
Creo que los problemas con este enfoque son obvios:
• Si una prueba puede fallar por varias razones, puede ser difícil para los desarrolladores encontrar
rápidamente la causa del error. Sobre todo, una aserción temprana que falla oscurece errores
adicionales, es decir, oculta aserciones posteriores, porque se detiene la ejecución de la prueba.
•Como ya se explicó en la sección Nombre de prueba unitaria, debemos nombrar una prueba en un
manera precisa y expresiva. Con múltiples aserciones, una prueba unitaria realmente prueba
muchas cosas (lo que, dicho sea de paso, es una violación del principio de responsabilidad
única; consulte el capítulo 6), y sería difícil encontrarle un buen nombre. Lo anterior...
testAllComparisonOperators() no es lo suficientemente preciso.
Inicialización independiente de entornos de pruebas unitarias Esta regla es algo similar a la
independencia de pruebas unitarias. Cuando se completa una prueba implementada limpia, todos los estados relacionados con
esa prueba deben desaparecer. En términos más específicos: cuando se ejecutan todas las pruebas unitarias, cada prueba debe
ser una instanciación parcial aislada de una aplicación. Cada prueba tiene que configurar e inicializar su entorno requerido
completamente por su cuenta. Lo mismo se aplica a la limpieza después de la ejecución de la prueba.
Excluir getters y setters
No escriba pruebas unitarias para getters y setters habituales de una clase, como esta:
Listado 25. Un simple setter y getter
void Cliente::setForename(const std::string& nombre) { this>nombre = nombre; }
std::string Cliente::getForename() const { return nombre; }
¿Realmente espera que algo pueda salir mal con métodos tan sencillos? Estas funciones miembro suelen ser tan
simples que sería una tontería escribir pruebas unitarias para ellas. Además, los getters y setters habituales se prueban
implícitamente mediante otras pruebas unitarias más importantes.
Atención, acabo de escribir que no es necesario probar getters y setters habituales y simples . A veces,
getters y setters no son tan simples. De acuerdo con el Principio de ocultación de información (consulte la sección
Ocultación de información en el Capítulo 3) que discutiremos más adelante, debe ocultarse para el cliente si un getter es
simple y estúpido, o si tiene que hacer cosas complejas para determinar su valor de retorno. . Por lo tanto, a veces puede
ser útil escribir una prueba explícita para un getter o setter.
Excluir código de terceros
¡No escriba pruebas para código de terceros! No tenemos que verificar que las bibliotecas o los marcos funcionen como
se esperaba. Por ejemplo, podemos asumir con la conciencia tranquila que la función miembro utilizada innumerables veces
std::vector::push_back() de la biblioteca estándar de C++ funciona correctamente. Por el contrario, podemos esperar que el
código de terceros venga con sus propias pruebas unitarias. Puede ser una sabia decisión arquitectónica no utilizar bibliotecas o
marcos en su proyecto que no tengan pruebas unitarias propias y cuya calidad sea dudosa.
18
Machine Translated by Google
Capítulo 2 ■ Construir una red de seguridad
Excluir sistemas externos
Lo mismo que para el código de terceros se aplica a los sistemas externos. No escriba pruebas para sistemas que están en el contexto de su
sistema a desarrollar y, por lo tanto, no están bajo su responsabilidad. Por ejemplo, si su software financiero utiliza un sistema de conversión de
moneda externo existente que está conectado a través de Internet, no debe probarlo. Además del hecho de que un sistema de este tipo no puede
proporcionar una respuesta definida (el factor de conversión entre monedas varía minuto a minuto) y que dicho sistema puede ser imposible de
alcanzar debido a problemas de red, no somos responsables del sistema externo.
Mi consejo es burlarse (consulte la sección Probar dobles (objetos falsos) más adelante en este capítulo) estas cosas y
para probar su código, no el de ellos.
¿Y qué hacemos con la base de datos?
Muchos sistemas de TI contienen bases de datos (relacionales) hoy en día. Se requieren para conservar grandes cantidades de objetos o datos
en un almacenamiento a largo plazo, de modo que estos objetos o datos puedan consultarse de manera cómoda y sobrevivan a un apagado del
sistema.
Una pregunta importante es esta: ¿qué haremos con la base de datos durante las pruebas unitarias?
Mi primer y principal consejo sobre este tema es: cuando haya alguna forma de probar sin una
base de datos, ¡pruebe sin la base de datos!
—Gerard Meszaros, xUnit Patterns
Las bases de datos pueden causar problemas diversos y, en ocasiones, sutiles durante las pruebas unitarias. Por ejemplo, si muchas pruebas
unitarias usan la misma base de datos, la base de datos tiende a convertirse en un gran almacenamiento central que esas pruebas deben compartir
para diferentes propósitos. Este intercambio puede afectar adversamente la independencia de las pruebas unitarias que he discutido anteriormente en
este capítulo. Podría ser difícil garantizar la condición previa requerida para cada prueba unitaria. La ejecución de una prueba unitaria puede causar
efectos secundarios no deseados para otras pruebas a través de la base de datos de uso común.
Otro problema es que las bases de datos son básicamente lentas. Son mucho más lentos que el acceso a la memoria de la computadora
local. Las pruebas unitarias que interactúan con la base de datos tienden a ejecutar magnitudes más lentas que las pruebas que pueden ejecutarse
completamente en la memoria. Imagine que tiene unos cientos de pruebas unitarias, y cada prueba necesita un lapso de tiempo adicional de
500 ms en promedio, causado por las consultas de la base de datos. En resumen, todas las pruebas tardan varios minutos más que sin una base de datos.
Mi consejo es simular la base de datos (consulte la sección sobre Probar objetos dobles/simulacros más adelante en este capítulo) y
ejecutar todas las pruebas unitarias únicamente en la memoria. No se preocupe: la base de datos, si existe, estará involucrada a nivel de integración
y prueba del sistema.
No mezcle el código de prueba con el código de producción
A veces, a los desarrolladores se les ocurre la idea de equipar su código de producción con un código de prueba. Por ejemplo, una clase puede
contener código para manejar una dependencia con una clase colaboradora durante una prueba de la siguiente manera:
Listado 26. Una posible solución para lidiar con una dependencia durante la prueba
#include <memoria>
#include "DataAccessObject.h" #include
"CustomerDAO.h" #include
"FakeDAOForTest.h"
usando DataAccessObjectPtr = std::unique_ptr<DataAccessObject>;
19
Machine Translated by Google
Capítulo 2 ■ Construir una red de seguridad
clase Cliente { público:
Cliente() {}
explícito Cliente(bool testMode) : inTestMode(testMode) {}
void save()
{ DataAccessObjectPtr dataAccessObject = getDataAccessObject(); // ...usar
dataAccessObject para guardar este cliente... };
// ...
privado:
DataAccessObjectPtr getDataAccessObject() const {
if (inTestMode) { return
std::make_unique<FakeDAOForTest>(); } else { return
std::make_unique<CustomerDAO>(); }
} // ...más operaciones aquí...
bool enModoPrueba{ falso }; // ...más
atributos aquí... };
DataAccessObject es la clase base abstracta de DAO específicos, en este caso, CustomerDAO y
FakeDAOForTest. El último es el llamado objeto falso, que no es más que un doble de prueba (consulte la
sección sobre Dobles de prueba (objetos falsos) más adelante en este capítulo). Está destinado a reemplazar el DAO
real, ya que no queremos probarlo y no queremos salvar al cliente durante la prueba (recuerde mi consejo sobre las
bases de datos). El miembro de datos booleano en Modo de prueba controla cuál de los dos DAO se usa.
Bueno, este código funcionaría, pero la solución tiene varias desventajas.
En primer lugar, nuestro código de producción está repleto de código de prueba. Aunque no parezca dramático a
primera vista, puede aumentar la complejidad y reducir la legibilidad. Necesitamos un miembro adicional para distinguir
entre el modo de prueba y el uso de producción de nuestro sistema. Este miembro booleano no tiene nada que ver
con un cliente, y mucho menos con el dominio de nuestro sistema. Y es fácil imaginar que ese tipo de miembro se
requiere en muchas clases de nuestro sistema.
Además, nuestra clase Customer tiene dependencias con CustomerDAO y FakeDAOForTest. Puede verlo en la
lista de inclusiones en la parte superior del código fuente. Esto significa que el dummy de prueba FakeDAOForTest
también forma parte del sistema en el entorno de producción. Es de esperar que el código del doble de prueba nunca se
llame en producción, sino que se compile, enlace e implemente.
Por supuesto, hay formas más elegantes de lidiar con estas dependencias y de mantener el código de producción
libre de código de prueba. Por ejemplo, podemos inyectar el DAO específico como parámetro de referencia en Customer::save().
Listado 27. Evitar dependencias para probar el código (1)
clase ObjetoAccesoDatos;
clase Cliente { public:
void
save(DataAccessObject& dataAccessObject) {
// ...usar dataAccessObject para guardar este cliente... }
20
Machine Translated by Google
Capítulo 2 ■ Construir una red de seguridad
// ... };
Alternativamente, esto se puede hacer durante la construcción de instancias de tipo Cliente. En este caso debemos
mantener una referencia a la DAO como un atributo de la clase. Además, tenemos que suprimir la generación
automática del constructor predeterminado a través del compilador porque no queremos que ningún usuario del
Cliente pueda crear una instancia incorrectamente inicializada del mismo.
Listado 28. Evitar dependencias para probar el código (2)
clase ObjetoAccesoDatos;
clase Cliente
{ public:
Cliente() = borrar;
Customer(DataAccessObject& dataAccessObject) : dataAccessObject(dataAccessObject) {} void
save() { // ...use
el miembro dataAccessObject para guardar este cliente... }
// ...
privado:
DataAccessObject& dataAccessObject; // ...
};
FUNCIONES ELIMINADAS [C++11]
En C++, el compilador genera automáticamente las denominadas funciones miembro especiales (constructor
predeterminado, constructor de copia, operador de asignación de copia y destructor) para un tipo si no declara el suyo
propio. Desde C++11, esta lista de funciones miembro especiales se amplía con el constructor de movimiento y el
operador de asignación de movimiento. C ++ 11 (y superior) proporciona una manera fácil y declarativa de suprimir la
creación automática de cualquier función de miembro especial, así como funciones de miembros normales y funciones
de no miembros: puede eliminarlas. Por ejemplo, puede evitar la creación de un constructor predeterminado de esta manera:
clase Clazz
{ público:
Clazz() = borrar; };
Y otro ejemplo: puede eliminar el operador nuevo para evitar que las clases se asignen dinámicamente en el montón:
class Clazz
{ public:
void* operator new(std::size_t) = delete; };
21
Machine Translated by Google
Capítulo 2 ■ Construir una red de seguridad
Una tercera alternativa podría ser que la DAO específica sea creada por una Fábrica (ver sección Fábrica en el Capítulo 9
sobre Patrones de Diseño) el Cliente conoce. Esta fábrica se puede configurar desde el exterior para crear el tipo de DAO que se
requiere si el sistema se ejecuta en un entorno de prueba. No importa cuál de estas posibles soluciones elija, el Cliente está libre de
código de prueba. No hay dependencias de DAO específicos en Customer.
Las pruebas deben ejecutarse rápido
En proyectos grandes, un día llegará al punto en que tendrá miles de pruebas unitarias. Esto es excelente en términos de calidad
del software. Pero un efecto secundario incómodo podría ser que las personas dejen de ejecutar estas pruebas antes de realizar
un registro en el repositorio del código fuente, porque lleva demasiado tiempo.
Es fácil imaginar que existe una fuerte correlación entre el tiempo que lleva realizar las pruebas y la productividad de un equipo.
Si la ejecución de todas las pruebas unitarias lleva 15 minutos, 1/2 hora o más, los desarrolladores no pueden hacer su trabajo y
pierden el tiempo esperando los resultados de la prueba. Si bien la ejecución de cada prueba unitaria toma “solo” medio segundo en
promedio, se necesitan más de 8 minutos para realizar 1000 pruebas. Eso significa que la ejecución de todo el conjunto de pruebas
10 veces al día resultará en casi 1,5 horas de tiempo de espera en total. Como resultado, los desarrolladores ejecutarán las pruebas
con menos frecuencia.
Mi consejo es: ¡ Las pruebas deben ejecutarse rápido! Las pruebas unitarias deben establecer un ciclo de retroalimentación rápido para los desarrolladores.
La ejecución de todas las pruebas unitarias para un proyecto grande no debería durar más de unos 3 minutos, y mucho menos que
eso. Para una ejecución de prueba local más rápida (<= unos segundos) durante el desarrollo, el marco de prueba debe proporcionar
una manera fácil de desactivar grupos de pruebas irrelevantes temporalmente.
No hace falta decir que en el sistema de compilación automatizado, todas las pruebas deben ejecutarse sin excepción.
continuamente cada vez antes de que se construya el producto final. El equipo de desarrollo debe recibir una notificación
inmediata si una o más pruebas fallan en el sistema de compilación. Por ejemplo, esto se puede hacer por correo electrónico o con la
ayuda de una visualización óptica (por ejemplo, debido a una pantalla plana en la pared o un "semáforo" controlado por el sistema de
construcción) en un lugar destacado. ¡Si falla una sola prueba, bajo ninguna circunstancia debe liberar y enviar el producto!
Dobles de prueba (objetos falsos)
Las pruebas unitarias solo deben llamarse "pruebas unitarias" si las unidades a probar son completamente independientes de
los colaboradores durante la ejecución de la prueba, es decir, la unidad bajo prueba no utiliza otras unidades o sistemas externos.
Por ejemplo, mientras que la participación de una base de datos durante una prueba de integración no es crítica y necesaria,
porque ese es el propósito de una prueba de integración, el acceso (por ejemplo, una consulta) a esta base de datos durante
una prueba de unidad real está prohibido (consulte la sección Y qué ¿Qué hacemos con la base de datos?, anteriormente en este
capítulo). Por lo tanto, las dependencias de la unidad a probar con otros módulos o sistemas externos deben reemplazarse por los
llamados Test Doubles, también conocidos como Fake Objects o MockUps.
Para trabajar de manera elegante con tales Test Doubles, se debe evitar el acoplamiento flojo de la unidad bajo prueba.
por lo que se ha esforzado (consulte la sección Acoplamiento flojo en el capítulo Tenga principios). Por ejemplo, se puede introducir
una abstracción (p. ej., una interfaz en forma de una clase puramente abstracta) en el punto donde se accede a un colaborador
que no es deseado para la prueba, como se muestra en la Figura 24.
22
Machine Translated by Google
Capítulo 2 ■ Construir una red de seguridad
Figura 24. Una interfaz facilita el reemplazo de X con un Test Double XMock
Supongamos que desea desarrollar una aplicación que utilice un servicio web externo para las conversiones de
divisas actuales. Durante una prueba unitaria, no puede utilizar este servicio externo de forma natural, ya que ofrece
diferentes factores de conversión cada segundo. Además, el servicio se consulta a través de Internet, que es básicamente
lento y puede fallar. Y es imposible simular casos límite. Por lo tanto, debe reemplazar la conversión de moneda real
por un doble de prueba durante la prueba unitaria.
Primero, tenemos que introducir un punto de variación en nuestro código, donde podemos reemplazar el
módulo que se comunica con el servicio de conversión de moneda por un doble de prueba. Esto se puede hacer con la
ayuda de una interfaz, que en C++ es una clase abstracta con funciones miembro puramente virtuales.
Listado 29. Una interfaz abstracta para convertidores de divisas
class Conversor de divisas
{ public:
virtual ~Conversor de divisas() { } virtual
long double getConversionFactor() const = 0; };
El acceso al servicio de conversión de moneda a través de Internet está encapsulado en una clase que implementa
la interfaz del convertidor de divisas.
Listado 210. La clase que accede al servicio de conversión de moneda en tiempo real
clase RealtimeCurrencyConversionService: public CurrencyConverter { public: virtual long
double
getConversionFactor() const override; // ...más miembros aquí que se
requieren para acceder al servicio... };
23
Machine Translated by Google
Capítulo 2 ■ Construir una red de seguridad
Para fines de prueba, existe una segunda implementación: Test Double CurrencyConversionServiceMock.
Los objetos de esta clase devolverán un factor de conversión definido y predecible, ya que se requiere para las pruebas unitarias.
Además, los objetos de esta clase proporcionan además la capacidad de establecer el factor de conversión desde el exterior,
por ejemplo, para simular casos límite.
Listado 211. El doble de prueba
clase CurrencyConversionServiceMock: public CurrencyConverter { public: virtual long
double
getConversionFactor() const override { return conversionFactor; }
void setConversionFactor(const long double value) {
factorconversion = valor; }
privado:
long double conversionFactor{0.5}; };
En el lugar del código de producción donde se usa el convertidor de divisas, ahora se usa la interfaz
para acceder al servicio. Debido a esta abstracción, es totalmente transparente para el código del cliente qué tipo de
implementación se usa durante el tiempo de ejecución, ya sea el convertidor de moneda real o su Test Double.
Listado 212. El encabezado de la clase que usa el servicio.
#include <memoria>
clase Conversor de Moneda;
clase UserOfConversionService { public:
UserOfConversionService() = eliminar;
UserOfConversionService(const std::shared_ptr<CurrencyConverter>& conversionService); void hacerAlgo(); // Más
de la interfaz de clase
pública sigue aquí...
privado:
std::shared_ptr<CurrencyConverter> conversionService; //...implementación
interna... };
Listado 213. Un extracto del archivo de implementación.
UserOfConversionService::UserOfConversionService (const std::shared_ ptr<CurrencyConverter>&
conversionService) : conversionService(conversionService)
{ }
void UserOfConversionService::hacerAlgo() {
long double conversionFactor = conversionService>getConversionFactor(); // ...
24
Machine Translated by Google
Capítulo 2 ■ Construir una red de seguridad
En una prueba unitaria para la clase UserOfConversionService, el caso de prueba ahora puede pasar
el objeto simulado a través del constructor de inicialización. Por otro lado, en el funcionamiento normal del
software, el servicio real se puede pasar a través del constructor. Esta técnica se conoce como un patrón de
diseño denominado Inyección de dependencia, que se analiza en detalle en la sección homónima del capítulo Patrón de diseño.
Listado 214. Un ejemplo de cómo UserOfConversionService obtiene su objeto CurrencyConverter requerido
std::shared_ptr<CurrencyConverter> serviceToUse = std::make_shared<nombre de la clase deseada aquí */>();
UserOfConversionService usuario(servicioParaUsar); //
La instancia de UserOfConversionService está lista para usarse...
user.doSomething();
25
Machine Translated by Google
CAPÍTULO 3
ser de principios
Aconsejaría a los estudiantes que presten más atención a las ideas fundamentales que a la
última tecnología. La tecnología estará obsoleta antes de que se gradúen. Las ideas
fundamentales nunca pasan de moda.
—David L. Parnás
En este capítulo, presento los principios más importantes y fundamentales del software bien diseñado y elaborado. Lo
especial de estos principios es que no están vinculados a ciertos paradigmas de programación o lenguajes de programación.
Algunos de ellos ni siquiera son específicos del desarrollo de software. Por ejemplo, el principio KISS discutido puede ser
relevante para muchas áreas de la vida: en términos generales, no es una mala idea hacer que todo sea lo más simple posible
en la vida, no solo el desarrollo de software.
Es decir, no debe aprender los siguientes principios una vez y luego olvidarlos. Estos consejos son
dado para que lo interiorices. Estos principios son tan importantes que, idealmente, deberían convertirse en una segunda
naturaleza para todos los desarrolladores. Y muchos de los principios más concretos que analizo más adelante en este libro
tienen sus raíces en los siguientes principios básicos.
¿Qué es un principio?
En este libro encontrará varios principios para mejorar el código C++ y el software bien diseñado. Pero, ¿qué es un principio
en general?
Muchas personas tienen principios que las guían a lo largo de su vida. Por ejemplo, si estás en contra de comer
carne por varias razones, eso sería un principio. Si quiere proteger a su hijo, bríndele principios a lo largo del camino,
guiándolo para que tome las decisiones correctas por su cuenta, por ejemplo, "¡Ten cuidado y no hables con extraños!" Con
este principio en mente, el niño puede deducir el comportamiento correcto en ciertas situaciones específicas.
Un principio es un tipo de regla, creencia o idea que te guía. Los principios a menudo se relacionan directamente
con los valores o un sistema de valores. Por ejemplo, no necesitamos que nos digan que el canibalismo está mal porque los
humanos tienen un valor innato con respecto a la vida humana. Y como otro ejemplo, el Manifiesto Agile [Beck01] contiene doce
principios que guían a los equipos de proyecto en la implementación de proyectos Agile.
Los principios no son leyes irrevocables. No están tallados en piedra. Las violaciones deliberadas de los principios a
veces son necesarias en la programación. Si tiene muy buenas razones para violar los principios, hágalo, ¡pero hágalo con
mucho cuidado! Debería ser una excepción.
Algunos de los siguientes principios básicos son, en varios puntos más adelante en el libro, revisados y profundizados.
© Stephan Roth 2017 27
S. Roth, C++ limpio, DOI 10.1007/9781484227930_3
Machine Translated by Google
Capítulo 3 ■ Tenga principios
BESO
Todo debe hacerse lo más simple posible, pero no más simple.
—Albert Einstein, físico teórico, 1879 1955
KISS es un acrónimo de "Mantenlo simple, estúpido" o "Mantenlo simple y estúpido" (OK, lo sé, hay otros significados para este
acrónimo, pero estos dos son los más comunes). En eXtreme Programming (XP), este principio está representado por una práctica
llamada "Haz lo más simple que pueda funcionar" (DTSTTCPW).
El principio KISS establece que la simplicidad debe ser un objetivo principal en el desarrollo de software y que se debe
evitar la complejidad innecesaria.
Creo que KISS es uno de esos principios que los desarrolladores suelen olvidar cuando están desarrollando software.
Los desarrolladores de software tienden a escribir el código de forma elaborada y hacen las cosas más complicadas de lo que
deberían ser. Lo sé, todos somos desarrolladores excelentemente capacitados y altamente motivados, y sabemos todo sobre
patrones de diseño y arquitectura, marcos, tecnologías, herramientas y otras cosas interesantes y sofisticadas. La creación de
software genial no es nuestro trabajo de 9 a 5: es nuestra misión y logramos el cumplimiento a través de nuestro trabajo.
Pero debe tener en cuenta que cualquier sistema de software tiene una complejidad intrínseca que ya es un desafío
en sí mismo. Sin duda, los problemas complejos a menudo requieren un código complejo. La complejidad intrínseca no se
puede reducir. Este tipo de complejidad simplemente está ahí, debido a los requisitos que debe cumplir el sistema.
Pero sería fatal agregar una complejidad casera e innecesaria a esta complejidad intrínseca. Por lo tanto, es aconsejable no usar
todas las características sofisticadas de su lenguaje o patrones de diseño geniales solo porque puede hacerlo. Por otro lado, no
exageres en la sencillez. Si son necesarias diez decisiones en un caso de cambio, así es como es.
¡Mantén tu código tan simple como puedas! Por supuesto, si hay requisitos de calidad de alta prioridad sobre
flexibilidad y extensibilidad, debe agregar complejidad para cumplir con estos requisitos. Por ejemplo, puede utilizar el conocido
Patrón de estrategia (consulte el Capítulo 9 sobre patrones de diseño) para introducir un punto de variación flexible en su código
cuando los requisitos lo exijan. Pero tenga cuidado y agregue solo esa cantidad de complejidad que facilita las cosas.
Centrarse en la simplicidad es probablemente una de las cosas más difíciles para un programador.
Y es una experiencia de aprendizaje de por vida.
—Adrian Bolboaca (@adibolb), 3 de abril de 2014, en Twitter
YAGNI
Siempre implemente las cosas cuando realmente las necesite, nunca cuando prevea que las necesitará.
—Ron Jeffries, ¡NO lo vas a necesitar! [Jeffries98]
Este principio está estrechamente relacionado con el principio KISS discutido anteriormente. YAGNI es un acrónimo de "¡No lo
vas a necesitar!" A veces se traduce como "¡No lo vas a necesitar!" YAGNI es la declaración de
guerra contra la generalización especulativa y el exceso de ingeniería. Establece que no debe escribir código que no sea necesario
en este momento, pero que podría serlo en el futuro.
28
Machine Translated by Google
Capítulo 3 ■ Tenga principios
Probablemente todo desarrollador conoce este tipo de impulsos tentadores en su trabajo diario: “Tal vez podríamos
úsalo más tarde…”, o “Vamos a necesitar…” ¡ No, no lo vas a necesitar! En cualquier caso, debe resistirse a producir algo para un
posible uso posterior. Puede que no lo necesites después de todo. Pero si ha implementado esa cosa innecesaria, ha perdido el tiempo y
el código se ha vuelto más complicado de lo que debería ser. Y por supuesto, también violas el principio KISS. ¡Las peores consecuencias
podrían ser que estas piezas de código para el futuro tengan errores y causen problemas graves!
Mi consejo es este: confíe en el poder de refactorizar y construya cosas no sin antes saber que son realmente necesarias.
SECO
Copiar y pegar es un error de diseño.
—David L. Parnás
Aunque este principio es uno de los más importantes, estoy bastante seguro de que a menudo se viola, sin querer o intencionalmente.
SECO es un acrónimo de "¡No te repitas!" y establece que debemos evitar la duplicación, porque la duplicación es mala. A veces, este
principio también se conoce como "Una vez y solo una vez" (OAOO).
La razón por la cual la duplicación es muy peligrosa es obvia: cuando se cambia una pieza, sus copias deben cambiarse
en consecuencia. Y no tengas grandes esperanzas. Es una apuesta segura que el cambio ocurrirá. Creo que es innecesario mencionar
que cualquier pieza copiada será olvidada tarde o temprano y podemos saludar a los errores.
OK, eso es todo, ¿nada más que decir? Espera, todavía hay algo y tenemos que ir más profundo.
En su brillante libro, The Pragmatic Programmer [Hunt99], Dave Thomas y Andy Hunt afirman que aplicar el principio DRY significa
que tenemos que asegurarnos de que “cada pieza de conocimiento debe tener una representación única, inequívoca y autorizada dentro
de un sistema”. Es notable que Dave y Andy no mencionaron explícitamente el código, pero hablan sobre el conocimiento. Y el
conocimiento de un sistema es mucho más amplio que solo su código. Por ejemplo, el principio DRY también es válido para la documentación,
el proyecto y los planes de prueba, o los datos de configuración del sistema. ¡EL SECO afecta a todo! Quizás puedas imaginar que el
cumplimiento estricto de este principio no es tan fácil como parece a primera vista.
Ocultación de información
La ocultación de información es un principio fundamental y conocido desde hace mucho tiempo en el desarrollo de software. Se
documentó por primera vez en el artículo seminal "Sobre los criterios que se utilizarán en la descomposición de sistemas en módulos".
[Parnas72] escrito por el destacado David L. Parnas en 1972.
El principio establece que una pieza de código que llama a otra pieza de código no debe conocer los aspectos internos de esa otra
pieza de código. Esto hace posible cambiar partes internas de la pieza de código llamada sin verse obligado a cambiar la pieza de
código de llamada en consecuencia.
David L. Parnas describe la ocultación de información como el principio básico para descomponer sistemas en módulos.
Parnas argumentó que la modularización del sistema debería involucrar la ocultación de decisiones de diseño difíciles o decisiones de
diseño que probablemente cambien. Cuantas menos partes internas exponga una unidad de software (por ejemplo, una clase o
componente) a su entorno, menor será el acoplamiento entre la implementación de la unidad y sus clientes.
Como resultado, los cambios en la implementación interna de una unidad de software no se propagarán a su entorno.
Las ventajas de ocultar información son numerosas:
•Limitación de las consecuencias de los cambios en los módulos
•Influencia mínima en otros módulos si es necesaria una corrección de errores
• Aumento significativo de la reutilización de los módulos.
• Mejor capacidad de prueba de los módulos.
29
Machine Translated by Google
Capítulo 3 ■ Tenga principios
La ocultación de información a menudo se confunde con la encapsulación, pero no es lo mismo. Sé que ambos términos
se han usado como sinónimos en muchos libros destacados, pero no estoy de acuerdo. La ocultación de información es un
principio de diseño para ayudar a los desarrolladores a encontrar buenos módulos. El principio funciona en múltiples niveles de
abstracción y despliega su efecto positivo, especialmente en grandes sistemas.
La encapsulación es a menudo una técnica dependiente del lenguaje de programación para restringir el acceso a las entrañas
de un módulo. Por ejemplo, en C++ puede preceder una lista de miembros de la clase con la palabra clave privada para garantizar que
no se pueda acceder a ellos desde fuera de la clase. Pero solo porque usamos este tipo de guardias para el control de acceso, todavía
estamos lejos de ocultar la información automáticamente. La encapsulación facilita, pero no garantiza, la ocultación de
información.
El siguiente ejemplo de código muestra una clase encapsulada con poca información oculta:
Listado 31. Una clase para la dirección automática de puertas (extracto)
class AutomaticDoor { público:
enum
class State { cerrado = 1,
abriendo,
abierto,
cerrando };
privado:
Estado
estatal; // ...más atributos aquí...
público:
Estado getState() const; // ...más
funciones miembro aquí... };
Esto no es ocultar información, porque partes de la implementación interna de la clase están expuestas al entorno, incluso si la
clase parece estar bien encapsulada. Tenga en cuenta el tipo del valor de retorno de getState.
Los clientes que usan esta clase requieren la clase de enumeración Estado, como lo demuestra el siguiente ejemplo:
Listado 32. Un ejemplo de cómo se debe usar AutomaticDoor para consultar el estado actual de la puerta
#include "PuertaAutomática.h"
int main()
{ PuertaAutomáticaPuertaAutomática;
PuertaAutomática::Estado estadoPuertas = PuertaAutomática.getEstado(); if (doorsState
== AutomaticDoor::State::closed) { // hacer algo... } return 0; }
30
Machine Translated by Google
Capítulo 3 ■ Tenga principios
CLASE DE ENUMERACIÓN (ESTRUCTURA) [C++11]
Con C++11 también ha habido una innovación en los tipos de enumeraciones. Para la compatibilidad hacia abajo con los estándares
anteriores de C++, todavía existe la conocida enumeración con su palabra clave enum. Desde C ++ 11, también existen las clases
de enumeración, respectivamente, las estructuras de enumeración.
Un problema con esas antiguas enumeraciones de C++ es que exportan sus literales de enumeración al espacio de nombres
circundante, lo que provoca conflictos de nombres, como en el siguiente ejemplo:
const std::oso de cuerda; // ...y
en otros lugares del mismo espacio de nombres... enum
Animal { perro, venado, gato, pájaro, oso }; // error: 'oso' redeclarado como otro tipo de símbolo
Además, las enumeraciones antiguas de C++ se convierten implícitamente a int, lo que provoca errores sutiles cuando no se espera
o no se desea dicha conversión:
enum Animal {perro, venado, gato, pájaro, oso};
animal animal = perro; int
unNúmero = animal; // Conversión implícita: funciona
Estos problemas ya no existen cuando se usan clases de enumeración, también llamadas "nuevas enumeraciones" o "enumeraciones
fuertes". Sus literales de enumeración son locales a la enumeración y sus valores no se convierten implícitamente a otros
tipos (como a otra enumeración o un int).
const std::oso de cuerda; // ...y
en otros lugares del mismo espacio de nombres... enum
class Animal { perro, venado, gato, pájaro, oso }; // No hay conflicto con la cadena nombrada
'oso'
Animal animal = Animal::perro; int
unNúmero = animal; // ¡Error del compilador!
Se recomienda enfáticamente usar clases de enumeración en lugar de enumeraciones simples y antiguas para un programa C++
moderno, porque hace que el código sea más seguro. Y debido a que las clases de enumeración también son clases, se pueden
declarar hacia adelante.
¿Qué sucederá si se debe cambiar la implementación interna de AutomaticDoor y la enumeración
¿El estado de la clase se elimina de la clase? Es fácil ver que tendrá un impacto significativo en el código del cliente.
Dará como resultado cambios en todas partes donde se use la función miembro AutomaticDoor::getState().
El siguiente es un AutomaticDoor encapsulado con buena ocultación de información:
Listado 33. Una clase mejor diseñada para la dirección automática de puertas
class PuertaAutomática
{ public:
bool isClosed() const; bool
esApertura() const;
31
Machine Translated by Google
Capítulo 3 ■ Tenga principios
bool isOpen() const; bool
se cierra() const; // ...más
operaciones aquí...
privado:
enum class Estado
{cerrado = 1,
apertura,
apertura,
cierre};
Estado
estado; // ...más atributos aquí... };
Listado 34. Un ejemplo de cómo se puede usar la clase elegante AutomaticDoor después de cambiarla
#include "PuertaAutomática.h"
int main()
{ PuertaAutomáticaPuertaAutomática;
if (automaticDoor.isClosed()) { // hacer
algo... } return 0; }
Ahora es mucho más fácil cambiar las entrañas de AutomaticDoor. El código del cliente ya no depende de las
partes internas de la clase. Ahora puede eliminar el estado de enumeración y reemplazarlo por otro tipo de
implementación sin que ningún usuario de la clase lo note.
Cohesión fuerte
Un consejo general en el desarrollo de software es que cualquier entidad de software (sinónimos: módulo,
componente, unidad, clase, función…) debe tener una fuerte (o alta) cohesión. En términos muy generales, la cohesión es
fuerte cuando el módulo hace un trabajo bien definido.
Para profundizar en este principio, echemos un vistazo a dos ejemplos donde la cohesión es débil, comenzando con
la Figura 31.
32
Machine Translated by Google
Capítulo 3 ■ Tenga principios
Figura 31. MyModule tiene demasiadas responsabilidades, y esto genera muchas dependencias desde y hacia otros
módulos.
En esta ilustración de la modularización de un sistema arbitrario, tres aspectos diferentes del negocio
dominio se colocan dentro de un solo módulo. Los aspectos A, B y C no tienen nada, o casi nada, en común,
pero los tres están ubicados dentro de MyModule. Una mirada al código del módulo podría revelar que las
funciones de A, B y C están operando en piezas de datos diferentes y completamente independientes.
Ahora eche un vistazo a todas las flechas discontinuas en esa imagen. Cada uno de ellos es una dependencia.
El elemento en la cola de dicha flecha requiere el elemento en la punta de la flecha para su implementación. En este
caso, cualquier otro módulo del sistema que quiera utilizar los servicios ofrecidos por A, B o C, se hará dependiente de
todo el módulo MyModule. El principal inconveniente de un diseño de este tipo es obvio: generará demasiadas
dependencias y la capacidad de mantenimiento se desvanecerá.
Para aumentar la cohesión, los aspectos de A, B y C deben separarse y trasladarse a sus propios módulos
(Figura 32).
33
Machine Translated by Google
Capítulo 3 ■ Tenga principios
Figura 32. Alta cohesión: los aspectos A, B y C previamente mezclados se han separado en módulos discretos
Ahora es fácil ver que cada uno de estos módulos tiene muchas menos dependencias que nuestro antiguo MyModule.
Está claro que A, B y C no tienen nada que ver entre sí directamente. El único módulo, que depende de los tres módulos A,
B y C, es el denominado Módulo 1.
Otra forma de cohesión débil se llama Shot Gun AntiPattern. Creo que es de conocimiento general que un
La escopeta es un arma de fuego que dispara una gran cantidad de pequeños perdigones esféricos. El arma tiene
típicamente una gran dispersión. En el desarrollo de software, esta metáfora se utiliza para expresar que cierto aspecto
del dominio, o idea lógica única, está muy fragmentado y distribuido en muchos módulos. La Figura 33 representa tal situación.
34
Machine Translated by Google
Capítulo 3 ■ Tenga principios
Figura 33. El Aspecto A estaba disperso en cinco módulos.
Incluso con esta forma de cohesión débil, surgieron muchas dependencias desfavorables. el distribuido
los fragmentos del Aspecto A deben trabajar en estrecha colaboración. Eso significa que cada módulo que implementa un subconjunto
del Aspecto A debe interactuar al menos con otro módulo que contenga otro subconjunto del Aspecto A. Esto genera una gran cantidad
de dependencias transversales a lo largo del diseño. En el peor de los casos, puede dar lugar a dependencias cíclicas, como entre los
módulos 1 y 3, o entre los módulos 6 y 7. Esto tiene, una vez más, un impacto negativo en la capacidad de mantenimiento y la
capacidad de ampliación. Y, por supuesto, la capacidad de prueba de este diseño es extremadamente mala.
Este tipo de diseño conducirá a algo que se llama Cirugía de escopeta. Cierto tipo de cambio con respecto al Aspecto A
lleva a realizar muchos cambios pequeños en muchos módulos. Eso es realmente malo y debe evitarse. Tenemos que arreglar esto
juntando todas las partes del código que son fragmentos del mismo aspecto lógico en un solo módulo cohesivo.
Existen otros principios, por ejemplo, el Principio de Responsabilidad Única (SRP) del diseño orientado a objetos (consulte el
Capítulo 6), que fomentan una alta cohesión. La alta cohesión a menudo se correlaciona con un acoplamiento flojo y viceversa.
Bajo acoplamiento
Considere el siguiente pequeño ejemplo:
Listado 35. Un interruptor que puede encender y apagar una lámpara
lámpara de clase
{ público:
vacío en () {
35
Machine Translated by Google
Capítulo 3 ■ Tenga principios
//...
}
anular apagado()
{ //...
} };
Interruptor de clase
{ privado:
Lámpara y
lámpara; estado booleano {falso};
público:
Interruptor (lámpara y lámpara): lámpara (lámpara) {}
void toggle() { if
(estado) { estado
= falso;
lampara.apagada(); }
más { estado
= verdadero; lámpara.encendido(); }
} };
Básicamente, este fragmento de código funcionará. Primero puede crear una instancia de la clase Lamp. Luego, esto se pasa por
referencia al instanciar la clase Switch. Visualizado con UML, este pequeño ejemplo se vería así en la Figura 34.
Figura 34. Un diagrama de clase de interruptor y lámpara
¿Cuál es el problema con este diseño?
El problema es que nuestro Switch contiene una referencia directa a la clase concreta Lamp. En otras palabras:
el interruptor sabe que hay una lámpara.
Tal vez usted argumentará: “Bueno, pero ese es el propósito del cambio. Tiene que encender y apagar las lámparas”. me gustaría
diga: Sí, si eso es lo único que debe hacer el interruptor, entonces este diseño podría ser adecuado. Pero vaya a una tienda de bricolaje
y eche un vistazo a los interruptores que puede comprar allí. ¿Saben que existen las lámparas?
¿Y qué piensas sobre la capacidad de prueba de este diseño? ¿Se puede probar el interruptor de forma independiente como se
requiere para la prueba unitaria? No, esto no es posible. ¿Y qué haremos cuando el interruptor tenga que encender no solo una lámpara,
sino un ventilador o una persiana eléctrica?
En nuestro ejemplo anterior, el interruptor y la lámpara están estrechamente acoplados.
En el desarrollo de software, se debe buscar un acoplamiento débil (también conocido como acoplamiento bajo o débil) entre
módulos. Eso significa que debe construir un sistema en el que cada uno de sus módulos tenga, o haga uso de, poco o ningún conocimiento
de las definiciones de otros módulos separados.
36
Machine Translated by Google
Capítulo 3 ■ Tenga principios
La clave para el acoplamiento flojo en el desarrollo de software son las interfaces. Una interfaz declara
características de comportamiento accesibles públicamente de una clase sin comprometerse con una implementación
particular de esa clase. Una interfaz es como un contrato. Las clases que implementan una interfaz se comprometen a
cumplir el contrato, es decir, estas clases deben proporcionar implementaciones para las firmas de métodos de la interfaz.
En C++, las interfaces se implementan mediante clases abstractas, como esta:
Listado 36. La interfaz conmutable
class Switchable { public:
virtual
void on() = 0; vacío virtual
apagado() = 0; };
La clase Switch ya no contiene una referencia a la Lámpara. En su lugar, contiene una referencia a nuestro
nueva clase de interfaz Conmutable.
Listado 37. La clase Switch modificada, donde Lamp se ha ido
Interruptor de clase
{ privado:
Conmutable y conmutable;
estado booleano {falso};
público:
Interruptor (conmutable y conmutable): conmutable (conmutable) {}
void toggle() { if
(estado) { estado
= falso;
conmutable.off(); } más
{ estado =
verdadero;
conmutable.on(); }
} };
La clase Lamp implementa nuestra nueva interfaz.
Listado 38. La clase 'Lámpara' implementa la interfaz 'Conmutable'
Lámpara de clase : conmutable pública
{ pública:
invalidar () anular { // ...
anular off() invalidar { // ...
} };
37
Machine Translated by Google
Capítulo 3 ■ Tenga principios
Expresado en UML, nuestro nuevo diseño se parece al de la figura 35.
Figura 35. Interruptor y lámpara acoplados libremente a través de una interfaz
Las ventajas de tal diseño son obvias. Switch es completamente independiente de las clases concretas que debe controlar.
Además, Switch se puede probar de forma independiente al proporcionar una prueba doble que implementa la interfaz Switchable.
¿Quieres controlar un ventilador en lugar de una lámpara? No hay problema: este diseño está abierto para la extensión.
Simplemente cree una clase Ventilador u otras clases que representen dispositivos eléctricos que implementen la interfaz
Conmutable, como se muestra en la Figura 36.
Figura 36. A través de una interfaz, un interruptor puede controlar diferentes clases de dispositivos eléctricos
La atención al acoplamiento flojo puede proporcionar un alto grado de autonomía para los módulos individuales de un sistema.
El principio puede ser efectivo en diferentes niveles: tanto en los módulos más pequeños como en el nivel de arquitectura del
sistema para componentes grandes. La alta cohesión fomenta el acoplamiento flexible, porque un módulo con una responsabilidad
claramente definida generalmente depende de menos colaboradores.
38
www.allitebooks.com
Machine Translated by Google
Capítulo 3 ■ Tenga principios
Tenga cuidado con las optimizaciones
La optimización prematura es la raíz de todos los males (o al menos de la mayor parte) en la programación.
—Donald E. Knuth, informático estadounidense [Knuth74]
He visto desarrolladores que inician optimizaciones que desperdician el tiempo solo con vagas ideas de gastos generales, pero
sin saber realmente dónde se pierde el rendimiento. A menudo manipulaban las instrucciones individuales; o trató de optimizar
pequeños bucles locales para exprimir hasta la última gota de rendimiento. Solo como nota al pie, uno de estos
programadores de los que estoy hablando era yo.
El éxito de estas actividades fue generalmente marginal. Las ventajas de rendimiento esperadas por lo general
no surgió. Al final fue sólo una pérdida de tiempo precioso. Por el contrario, a menudo la capacidad de comprensión y
mantenimiento del código supuestamente optimizado sufre drásticamente. Particularmente malo: a veces incluso sucede que
sutilmente se deslizan errores en el código durante tales medidas de optimización. Mi consejo es el siguiente: siempre que no haya
requisitos de rendimiento explícitos que satisfacer, manténgase alejado de las optimizaciones.
La comprensibilidad y mantenibilidad de nuestro código debe ser nuestro primer objetivo. Y como explico en la sección “¡Pero
el Call Time Overhead!” En el capítulo 4, los compiladores son muy buenos hoy en día para optimizar el código.
Siempre que sienta el deseo de optimizar algo, piense en YAGNI.
Solo cuando los requisitos de desempeño explícitos, que son solicitados expresamente por una parte interesada, no son
satisfecho, si entras en acción. Pero primero debe analizar cuidadosamente dónde se pierde el rendimiento. No haga ninguna
optimización solo sobre la base de una intuición. Por ejemplo, puede usar un generador de perfiles para averiguar dónde están los
cuellos de botella. Después del uso de una herramienta de este tipo, los desarrolladores suelen sorprenderse de que el rendimiento
se pierda en una ubicación completamente diferente a la que se suponía originalmente.
■ Nota Un Profiler es una herramienta para el análisis dinámico de programas. Mide, entre otras métricas, la frecuencia y duración
de las llamadas a funciones. La información de perfil recopilada se puede utilizar para ayudar a la optimización del programa.
Principio de menor asombro (PLA)
El Principio de menor asombro (POLA/PLA), también conocido como Principio de menor sorpresa (POLS), es bien conocido en el
diseño y la ergonomía de la interfaz de usuario. El principio establece que el usuario no debe sorprenderse por las respuestas
inesperadas de la interfaz de usuario. El usuario no debe confundirse con controles que aparecen o desaparecen, mensajes de error
confusos, reacciones inusuales en secuencias de pulsaciones de teclas establecidas (recuerde: Ctrl + C es el estándar de facto
para copiar aplicaciones en sistemas operativos Windows, y no para salir de un programa), o otro comportamiento inesperado.
Este principio también puede trasladarse bien al diseño de API en el desarrollo de software. Llamar a una función no debería
sorprender a la persona que llama con un comportamiento inesperado o efectos secundarios misteriosos. Una función debe hacer
exactamente lo que implica su nombre de función (consulte la sección sobre "Nombramiento de funciones" en el Capítulo 4). Por
ejemplo, llamar a un getter en una instancia de una clase no debería modificar el estado interno de ese objeto.
La regla de los boy scouts
Este principio es acerca de usted y su comportamiento. Dice lo siguiente: Siempre deje el campamento más limpio de lo que lo
encontró.
39
Machine Translated by Google
Capítulo 3 ■ Tenga principios
Los boy scouts tienen muchos principios. Uno de sus principios establece que deben limpiar un desorden o contaminación
en el medio ambiente de inmediato, una vez que han encontrado cosas tan malas. Como artesanos de software responsables,
debemos aplicar este principio a nuestro trabajo diario. Siempre que encontremos algo en un fragmento de código que deba
mejorarse, o que sea un mal olor de código, debemos corregirlo de inmediato. Y no importa quién fue el autor original de este código.
La ventaja de este comportamiento es que evitamos continuamente el deterioro de nuestro código. Si todos nos comportamos
de esta manera, el código simplemente no podría pudrirse. La tendencia de la creciente entropía del software tiene pocas posibilidades
de dominar nuestro sistema. Y la mejora no tiene por qué ser gran cosa. Puede ser una limpieza muy pequeña, por ejemplo:
•Renombrar una clase, variable, función o método con un nombre incorrecto (consulte la sección
“Buenos nombres y denominación de funciones” en el Capítulo 4).
•Descomponer las entrañas de una función grande en partes más pequeñas (ver la sección “Que sean
pequeños” en el Capítulo 4).
• Eliminar un comentario haciendo que el fragmento de código comentado se explique por sí mismo
(ver apartado “Evitar Comentarios” en el Capítulo 4).
• Limpiar un complejo y desconcertante complejo ifelse.
•Eliminar un poco de código duplicado (consulte la sección sobre el principio SECO en este
capítulo).
Dado que la mayoría de estas mejoras son refactorizaciones de código, es esencial contar con una sólida red de seguridad que
consista en buenas pruebas unitarias, como se describe en el Capítulo 2. Sin pruebas unitarias implementadas, no puede estar seguro
de no romper algo.
Además de una buena cobertura de pruebas unitarias, todavía necesitamos una cultura especial en nuestro equipo: propiedad colectiva del código.
La propiedad colectiva del código significa que realmente debemos trabajar como comunidad. Cada miembro del equipo, en
cualquier momento, puede realizar un cambio o una extensión en cualquier pieza de código. No debe haber una actitud como “Este
es el código de Peter, y ese es el módulo de Fred. ¡Yo no los toco!” Debe considerarse un valor alto que otras personas puedan
hacerse cargo del código que escribimos. Nadie en un equipo real debería tener miedo o tener que obtener permiso para limpiar el
código o agregar nuevas funciones. Con una cultura de propiedad colectiva del código, la Regla Boy Scout funcionará bien.
40
Machine Translated by Google
CAPÍTULO 4
Conceptos básicos de C++ limpio
Como ya expliqué en la Introducción de este libro (ver Capítulo 1), mucho código C++ no está limpio. En muchos proyectos,
la entropía del software ha tomado la delantera. Incluso si se trata de un proyecto de desarrollo en curso, por ejemplo, con una pieza
de software en mantenimiento, gran parte del código base suele ser muy antiguo. El código se ve como fue escrito en el siglo
pasado. ¡Esto no es sorprendente, porque la mayor parte de ese código fue escrito en el siglo pasado! Hay muchos proyectos
con un largo ciclo de vida, que tienen sus raíces en los años 90 o incluso en los 80. Además, muchos programadores simplemente
copian fragmentos de código de proyectos heredados y los modifican para hacer las cosas.
Algunos programadores tratan el lenguaje como una de muchas herramientas. No ven ninguna razón para mejorar
algo, porque lo que improvisan funciona de alguna manera. No debería ser así porque conducirá rápidamente a una mayor
entropía del software y el proyecto se convertirá en un gran desastre más rápido de lo que piensas.
En este capítulo describo los conceptos básicos generales de C++ limpio. Estas son a veces cosas universales que a menudo
son independientes del lenguaje de programación. Por ejemplo, dar un buen nombre es fundamental en todos los lenguajes de
programación. Varios otros aspectos, como la corrección de constantes, el uso de punteros inteligentes o las grandes ventajas de
la semántica de movimiento, son específicos de C++.
Pero antes de discutir temas específicos, quiero señalar un consejo general:
Si aún no lo está haciendo, ¡comience a usar C++ 11 (o superior) ahora!
Con el nuevo estándar que surgió en 2011, C++ se ha mejorado de muchas maneras y algunas características
de C++11, pero también de los siguientes estándares C++14 y C++17, son demasiado útiles para ignorarlos. Y no se trata sólo
de rendimiento. El lenguaje definitivamente se ha vuelto mucho más fácil de usar e incluso se ha vuelto más poderoso. C++11 no
solo puede hacer que su código sea más corto, más claro y más fácil de leer: puede aumentar su productividad. Además,
las características de este estándar de lenguaje y sus sucesores le permiten escribir un código más correcto y seguro para las
excepciones.
Pero ahora exploremos los elementos clave de C++ limpio y moderno paso a paso...
buenos nombres
Los programas deben estar escritos para que la gente los lea, y solo de manera incidental para que las máquinas los ejecuten.
—Hal Abelson y Gerald Jay Sussman, 1984
© Stephan Roth 2017 41
S. Roth, C++ limpio, DOI 10.1007/9781484227930_4
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
El siguiente fragmento de código fuente está tomado del conocido Apache OpenOffice versión 3.4.1, un paquete de software
de oficina de código abierto. Apache OpenOffice tiene una larga historia, que se remonta al año 1984.
Desciende de Oracles OpenOffice.org (OOo), que era una versión de código abierto del anterior StarOffice.
En 2011, Oracle detuvo el desarrollo de OpenOffice.org, despidió a todos los desarrolladores y aportó el código y las marcas
registradas a Apache Software Foundation. Por lo tanto, sea tolerante y tenga en cuenta que Apache Software Foundation ha
heredado una bestia antigua de casi 30 años y una gran deuda técnica.
Listado 41. Un extracto del código fuente de OpenOffice 3.4.1 de Apache
// Construyendo la estructura de información para elementos individuales
SbxInfo* ProcessWrapper::GetInfo( abreviado nIdx ) {
Métodos* p = &pMetodos[ nIdx ];
// Wenn mal eine Hilfedatei zur Verfuegung steht:
// SbxInfo* pResultInfo = new SbxInfo( Hilfedateiname, p>nHelpId );
SbxInfo* pResultInfo = nuevo SbxInfo; corto nPar
= p>nArgs & _ARGSMASK; for( corto i = 0; i <
nPar; i++ ) {
p++;
String aMethodName( p>pName, RTL_TEXTENCODING_ASCII_US ); sal_uInt16
nInfoFlags = (p>nArgs >> 8) & 0x03; if( p>nArgs & _OPT )
nInfoFlags |= SBX_OPCIONAL;
pResultInfo>AddParam( aMethodName,
p>eType, nInfoFlags );
} devuelve pResultInfo;
}
Tengo una pregunta simple para usted: ¿ Qué hace esta función?
Parece fácil dar una respuesta a primera vista, porque el fragmento de código es pequeño (menos de 20 LOC) y la
sangría está bien. Pero, de hecho, no es posible decir de un vistazo lo que realmente hace esta función, y la razón de esto
radica no solo en el dominio que puede ser desconocido para usted.
Este fragmento de código abreviado tiene muchos malos olores (p. ej., código comentado, comentarios en alemán,
literales mágicos como 0x03, etc.), pero un problema importante es la mala denominación. El nombre de la función GetInfo()
es muy abstracto y nos da una vaga idea de lo que realmente hace esta función. Además, el nombre del espacio de
nombres ProcessWrapper no es muy útil. ¿Quizás pueda usar esta función para recuperar información sobre un proceso en
ejecución? Bueno, ¿no sería RetrieveProcessInformation() un nombre mucho mejor?
Después de un análisis de la implementación de la función, también notará que el nombre es engañoso, porque
GetInfo() no es solo un captador simple como podría sospechar. También hay algo creado con el nuevo operador. Tal vez
también notó el comentario sobre la función que habla sobre construir, y no solo obtener algo. En otras palabras, el sitio de la
llamada recibirá un recurso que se asignó en el montón y debe cuidarlo. Para enfatizar este hecho, ¿no sería mucho mejor un
nombre como CreateProcessInformation()?
A continuación, observe el argumento y el valor de retorno de la función. ¿Qué es SbxInfo? ¿Qué es nIdx?
Tal vez el argumento nIdx tenga un valor que se usa para acceder a un elemento en una estructura de datos (es decir, un
índice), pero eso sería solo una suposición. De hecho, no lo sabemos exactamente.
42
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Los desarrolladores leen el código fuente mucho más a menudo que lo traduce un compilador. Por lo tanto, el código fuente debe ser legible y
los buenos nombres son un factor clave para aumentar su legibilidad. Si está trabajando en un proyecto con varias personas, una buena denominación es
esencial para que usted y sus compañeros de equipo puedan entender su código rápidamente. E incluso si tiene que editar o leer un fragmento de código
escrito por usted mismo después de algunas semanas o algunos meses, los buenos nombres de clases, métodos y variables lo ayudarán a recordar lo
que pretendía.
Entonces, aquí está mi consejo básico:
Los archivos de código fuente, los espacios de nombres, las clases, las plantillas, las funciones, los argumentos, las variables y las constantes deben
tener nombres significativos y expresivos.
Cuando diseño software o escribo código, paso mucho tiempo pensando en nombres. Creo que es tiempo bien invertido para pensar en buenos
nombres, incluso si a veces no es fácil y toma 5 minutos o más. Rara vez encuentro el nombre perfecto para una cosa inmediatamente. Por lo tanto,
cambio el nombre a menudo, lo cual es fácil con un buen editor o un entorno de desarrollo integrado (IDE) con capacidades de refactorización.
Si encontrar un nombre adecuado para una variable, función o clase parece ser difícil o casi imposible, potencialmente indica que algo más
podría estar mal. Tal vez exista un problema de diseño y deba encontrar y resolver la causa raíz de su problema de nombres.
Aquí hay algunos consejos para encontrar buenos nombres.
Los nombres deben ser autoexplicativos Me he comprometido con el concepto
de código autodocumentado. El código autodocumentado es un código en el que no se requieren comentarios para explicar su propósito (consulte
también la siguiente sección sobre comentarios y cómo evitarlos). Y el código autodocumentado requiere nombres que se expliquen por sí mismos para sus
espacios de nombres, clases, variables, constantes y funciones.
Use nombres simples pero descriptivos y que se expliquen por sí mismos.
Listado 42. Algunos ejemplos de malos nombres
número entero sin signo ;
bandera
booleana ; std::vector<Cliente> lista;
Datos del producto;
Las convenciones de nomenclatura de variables a menudo pueden convertirse en una guerra religiosa, pero estoy muy seguro de que existe un
amplio acuerdo en que num, flag, list y data son realmente malos nombres. ¿Qué son los datos? Todo son datos. Este nombre no tiene absolutamente
ninguna semántica. Es como si envolviera sus bienes y muebles en cajas de mudanza y en lugar de escribir en ellas lo que realmente contienen,
por ejemplo, " Utensilios de cocina", escribiría la palabra "Cosas" en cada caja. En la casa nueva cuando llegan las cajas, esta información es
completamente inútil.
43
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Aquí hay un ejemplo de cómo podríamos nombrar mejor las cuatro variables del ejemplo de código anterior:
Listado 43. Algunos ejemplos de buenos nombres
número int sin firmar de artículos; bool ha
cambiado;
std::vector<Cliente> clientes;
Producto pedidoProducto;
Ahora se puede argumentar que los nombres son mejores cuanto más largos son. Considere el siguiente ejemplo:
Listado 44. Un nombre de variable muy exhaustivo.
unsigned int totalNumberOfCustomerEntriesWithMangledAddressInformation;
Sin duda, este nombre es extremadamente expresivo. Incluso sin saber de dónde viene este código, el lector sabe muy bien para qué
se usa esta variable. Sin embargo, hay problemas con nombres como este. Por ejemplo, no puede recordar fácilmente nombres tan largos. Y
son difíciles de escribir. Si se utilizan nombres tan detallados en las expresiones, la legibilidad del código puede incluso verse afectada:
Listado 45. Un caos de nombres, causado por nombres demasiado detallados.
totalNumberOfCustomerEntriesWithMangledAddressInformation =
cantidadDeCustomerEntriesWithIncompleteOrMissingZipCode +
cantidadDeCustomerEntriesWithoutCityInformation +
cantidadDeCustomerEntriesWithoutStreetInformation;
Los nombres demasiado largos y detallados no son apropiados ni deseables cuando se trata de limpiar nuestro código. Si el
el contexto es claro en el que se usa una variable, son posibles nombres más cortos y menos descriptivos. Si la variable es un miembro
(atributo) de una clase, por ejemplo, el nombre de la clase suele proporcionar suficiente contexto para la variable:
Listado 46. El nombre de la clase proporciona suficiente información de contexto para el atributo.
class CustomerRepository { private:
unsigned
int numberOfMangledEntries; // ... };
Usar nombres del dominio
Es posible que ya haya oído hablar del diseño basado en dominios (DDD) antes de ahora. El término "Diseño impulsado por el dominio"
fue acuñado por Eric Evans en su libro homónimo de 2004 [Evans04]. DDD es un enfoque en el desarrollo de software complejo orientado a
objetos que se centra principalmente en el dominio central y la lógica del dominio.
En otras palabras, DDD trata de hacer que su software sea un modelo de un sistema de la vida real mediante la asignación de cosas y conceptos
del dominio comercial en el código. Por ejemplo, si el software que se desarrollará respaldará los procesos comerciales en un alquiler de
automóviles, entonces las cosas y los conceptos de alquiler de automóviles (por ejemplo, automóvil de alquiler, grupo de automóviles,
arrendatario, período de alquiler, confirmación de alquiler, contabilidad, etc.) deben ser detectable en el diseño de este software.
Si, por el contrario, el software se desarrolla en la industria aeroespacial, el dominio aeroespacial debería reflejarse en él.
44
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Las ventajas de tal enfoque son obvias: el uso de términos del dominio facilita, sobre todo,
la comunicación entre los desarrolladores y otras partes interesadas. DDD ayuda al equipo de desarrollo de software a crear un
modelo común entre el negocio y las partes interesadas de TI en la empresa que el equipo puede usar para comunicarse sobre los
requisitos comerciales, las entidades de datos y los modelos de proceso.
Una introducción detallada al Diseño Dirigido por Dominio está más allá del alcance de este libro. Sin embargo,
básicamente siempre es una muy buena idea nombrar componentes, clases y funciones de manera que los elementos y conceptos
del dominio de la aplicación puedan redescubrirse. Esto nos permite comunicar los diseños de software con la mayor naturalidad
posible. Hará que el código sea más comprensible para cualquier persona involucrada en la resolución de un problema, por
ejemplo, un probador o un experto en negocios.
Tomemos, por ejemplo, el alquiler de coches mencionado anteriormente. La clase responsable del caso de uso de la reserva
de un automóvil para un determinado cliente podría ser la siguiente:
Listado 47. La interfaz de una clase de controlador de caso de uso para reservar un automóvil
clase ReserveCarUseCaseController { público:
Identificación del clienteCliente(const UniqueIdentifier& customerId);
CarList getListOfAvailableCars(const Station& atStation, const RentalPeriod& deseadoRentalPeriod) const;
ConfirmationOfReservation reserveCar(const UniqueIdentifier& carId, const RentalPeriod& rentalPeriod) const;
privado:
Cliente& inquisitivoCliente; };
Ahora eche un vistazo a todos esos nombres usados para la clase, los métodos y los argumentos y tipos de retorno.
Representan cosas que son típicas del dominio de alquiler de automóviles. Si lee los métodos de arriba a abajo, estos son los pasos
individuales que se requieren para alquilar un automóvil. Este es código C++, pero existe una gran posibilidad de que también las
partes interesadas no técnicas con conocimiento del dominio puedan entenderlo.
Elija nombres en un nivel apropiado de abstracción
Para mantener bajo control la complejidad de los sistemas de software actuales, estos sistemas suelen descomponerse
jerárquicamente. La descomposición jerárquica de un sistema de software significa que todo el problema se divide en partes más
pequeñas, respectivamente, como subtareas hasta que los desarrolladores tengan la confianza de que pueden administrar estas
partes más pequeñas. Existen diferentes métodos y criterios para realizar este tipo de descomposición. El Diseño Dirigido por
Dominio que se mencionó en la sección anterior, y también el Análisis y Diseño Orientado a Objetos (OOAD) son dos métodos para
dicha descomposición, donde el criterio básico para la creación de componentes y clases en ambos métodos es el dominio comercial. .
Con tal descomposición, los módulos de software se crean en diferentes niveles de abstracción: desde grandes componentes o
subsistemas hasta bloques de construcción muy pequeños como clases. La tarea, que cumple un bloque de construcción en un nivel
de abstracción más alto, debe cumplirse mediante la interacción de los bloques de construcción en el siguiente nivel de abstracción
más bajo.
Los niveles de abstracción introducidos por este enfoque también tienen un impacto en la denominación. cada vez que vamos
un paso más abajo en la jerarquía, los nombres de los elementos se vuelven más concretos.
Imagina una tienda online. En el nivel superior puede existir un gran componente cuya única responsabilidad sea crear
facturas. Este componente podría tener un nombre corto y descriptivo como Facturación. Por lo general, este componente consta
de otros componentes o clases más pequeños. Por ejemplo, uno de estos módulos más pequeños podría ser responsable del
cálculo de un descuento. Otro módulo podría ser responsable de la creación de partidas de factura. Por lo tanto, buenos
nombres para estos módulos podrían ser DiscountCalculator y LineItemFactory. Si ahora profundizamos en la jerarquía de
descomposición, los identificadores de los componentes,
45
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
clases, y también funciones o métodos se vuelven cada vez más concretos, detallados y, por lo tanto, también más largos. Por
ejemplo, un método pequeño en una clase en el nivel más profundo podría tener un nombre muy detallado y alargado, como
calcularReducidoValorImpuestoAñadido().
Evite la redundancia al elegir un nombre
Es redundante elegir un nombre de clase u otros nombres que proporcionen un contexto claro y usarlos como parte para construir
el nombre de una variable miembro, por ejemplo, así:
Listado 48. No repita el nombre de la clase en sus atributos
#incluir <cadena>
class Movie
{ private:
std::string movieTitle; // ...
};
¡No hagas eso! Es una violación, aunque muy pequeña, del principio DRY. En su lugar, asígnele el nombre Título. La variable
miembro está en el espacio de nombres de la clase Película, por lo que queda claro sin ambigüedad a quién se refiere el título: ¡el
título de la película!
Aquí hay otro ejemplo de redundancia:
Listado 49. No incluya el tipo de atributo en su nombre
#incluir <cadena>
class Película
{ // ...
privado:
std::string stringTitle; };
Es el título de una película, ¡así que obviamente es una cadena y no un número entero! No incluya el tipo de
variable o constante en su nombre.
Evite las abreviaturas crípticas Al elegir un nombre
para sus variables o constantes, utilice palabras completas en lugar de abreviaturas crípticas. La razón es obvia: las abreviaturas
crípticas reducen significativamente la legibilidad de su código. Además, cuando los desarrolladores hablan sobre su código, los
nombres de las variables deben ser fáciles de pronunciar.
Recuerde la variable denominada nPar en la línea 8 de nuestro fragmento de código de Open Office. Tampoco su significado
claro, ni se puede pronunciar de buena manera.
Aquí hay algunos ejemplos más de lo que se debe y no se debe hacer:
Listado 410. Algunos ejemplos de buenos y malos nombres
estándar::tamaño_t idx; // ¡Malo! índice
std::size_t; // Bien; podría ser suficiente en algunos casos std::size_t customerIndex; // Se prefiere,
especialmente en situaciones donde // se indexan varios objetos
46
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Ctw del // ¡Malo!
coche; coche coche para lavar; // Bien
capa de polígono1; // ¡Malo!
Polígono primerPolígono; // Bien
sin firmar int nBottles; cantidad // ¡Malo!
de botella int sin firmar ; // Mejor int sin firmar botellas
por hora; // Ah, la variable tiene un valor de trabajo,
// y no un número absoluto. ¡Excelente!
const doble GOE = 9.80665; // ¡Malo! const
doble gravedad de la Tierra = 9.80665; // Más expresivo, pero engañoso. la constante es
// no una gravitación, que sería una fuerza en física. const double
gravitationalAccelerationOnEarth = 9.80665; // Bien. constexpr Aceleración
gravitacionalAccelerationOnEarth = 9.80665_ms2; // ¡Guau!
Fíjate en la última línea, que he comentado con “¡Guau!” Eso parece bastante conveniente, porque es una notación
familiar para los científicos. Parece casi como enseñar física en la escuela. Y sí, eso es realmente posible en C++, como
aprenderá en una de las siguientes secciones sobre programación rica en tipos en el Capítulo 5.
Evite la notación y los prefijos húngaros ¿Conoce a Charles Simonyi?
Charles Simonyi es un experto en software informático húngaroestadounidense que trabajó como arquitecto jefe en
Microsoft en la década de 1980. Tal vez recuerdes su nombre en un contexto diferente.
Charles Simonyi es un turista espacial y ha realizado dos viajes al espacio, uno de ellos a la Estación Espacial Internacional
(ISS).
Pero también desarrolló una convención de notación para nombrar variables en software de computadora,
denominada notación húngara, que ha sido ampliamente utilizada dentro de Microsoft y más tarde, también, por otros
fabricantes de software.
Cuando se usa la notación húngara, el tipo y, a veces, también el alcance de una variable se usan como prefijo de
nombre para esa variable. Aquí están algunos ejemplos:
Listado 411. Algunos ejemplos de notación húngara con explicaciones
bool fEnabled; int // f = una bandera booleana //
nContador; char* n = tipo de número (int, corto, sin signo, ...) // psz = un puntero
pszNombre; a una cadena terminada en cero
std::string strNombre; // str = una cadena stdlib de C++ int
m_nCounter; // El prefijo 'm_' indica que es una variable miembro, // es decir, tiene ámbito de clase.
char* g_pszAviso; // Esa es una variable global (!). Créeme, he visto // tal cosa. // d = coma flotante
de precisión doble.
rango int ; ¡En este caso es // una mentira fría como una piedra!
Mi consejo en el siglo XXI es este:
¡No utilice la notación húngara, ni ninguna otra notación basada en prefijos, codificando el tipo de una variable en su nombre!
47
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
La notación húngara fue potencialmente útil en un lenguaje débilmente tipificado como C. Puede haber sido útil en un momento
en que los desarrolladores usaban editores simples para programar, y no IDE que tienen una función como " IntelliSense".
Hoy en día, las herramientas de desarrollo modernas y sofisticadas brindan un excelente soporte al desarrollador y muestran el tipo
y alcance de una variable. No hay más buenas razones para codificar el tipo de una variable en su nombre. Lejos de ello, tales
prefijos pueden dificultar el tren de legibilidad del código.
En el peor de los casos, incluso puede suceder que durante el desarrollo se cambie el tipo de una variable sin adaptar el
prefijo de su nombre. En otras palabras: los prefijos tienden a convertirse en mentiras, como puede ver en la última variable del ejemplo
anterior. ¡Es realmente malo!
Y otro problema es que en lenguajes orientados a objetos que soportan polimorfismo, el prefijo no puede
especificarse fácilmente, o un prefijo puede incluso resultar desconcertante. ¿Qué prefijo húngaro es adecuado para una variable
polimórfica que puede ser un número entero o un doble? idX? diX? ¿Cómo determinar un prefijo adecuado e inconfundible para una
plantilla de C++ instanciada?
Por cierto, incluso las llamadas Convenciones generales de nomenclatura de Microsoft enfatizan que no debe usar la notación
húngara.
Evite usar el mismo nombre para diferentes propósitos Una vez que haya introducido un nombre
significativo y expresivo para cualquier tipo de entidad de software (por ejemplo, una clase o componente), una función o una variable,
debe tener cuidado de que su nombre nunca sea utilizado para cualquier otro propósito.
Creo que es bastante obvio que usar el mismo nombre para diferentes propósitos puede ser desconcertante y puede
engañar a los lectores del código. No hagas eso. Eso es todo lo que tengo que decir sobre ese tema.
Comentarios
La verdad solo se puede encontrar en un lugar: el código.
—Robert C. Martin, Código limpio [Martin09]
¿Recuerdas tus inicios como desarrollador de software profesional? ¿Todavía recuerda los estándares de codificación en su empresa
durante esos días? Tal vez aún sea joven y no tenga mucho tiempo en el negocio, pero los mayores confirmarán que la mayoría de
esos estándares contenían una regla de que el código profesional adecuado siempre debe comentarse adecuadamente. El
razonamiento absolutamente comprensible de esta regla era que cualquier otro desarrollador, o un nuevo miembro del equipo, podía
comprender fácilmente la intención del código.
A primera vista, esta regla parece una buena idea. En muchas empresas, por lo tanto, el código fue comentado extensamente. En
algunos proyectos, la relación entre las líneas de código productivas y los comentarios era de casi 50:50.
Desafortunadamente, no fue una buena idea. Al contrario: ¡ Esta regla fue una idea absolutamente mala! Fue, y es completamente
erróneo en varios aspectos, porque en la mayoría de los casos, los comentarios son un olor a código. Los comentarios son necesarios
cuando hay necesidad de explicación y aclaración. Y eso a menudo significa que el desarrollador no pudo escribir un código simple y
que se explicara por sí mismo.
Por favor, no lo malinterprete: hay algunos casos de uso razonables para los comentarios. En algunas situaciones un
comentario podría ser realmente útil. Presentaré algunos de estos casos bastante raros al final de esta sección.
Pero para cualquier otro caso, esta regla debe aplicarse, y ese es también el título de la siguiente sección: "¡Deje que el código cuente
una historia!"
48
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Deja que el código cuente una historia
Imagínese una película en el cine, que solo sería comprensible si las escenas individuales se explican mediante una descripción
textual debajo de la imagen. Esta película ciertamente no sería un éxito. Al contrario, sería despedazado por la crítica. Nadie
vería una película tan mala. Las buenas películas son, por lo tanto, muy exitosas, porque pueden contar principalmente una
historia apasionante, solo a través de las imágenes y los diálogos de los actores.
La narración es básicamente un concepto exitoso en muchos dominios, no solo en la producción cinematográfica.
Cuando piense en crear un gran producto de software, debe pensar en ello de la misma manera que le contaría al mundo una
gran y apasionante historia. No sorprende que los marcos de gestión de proyectos ágiles como Scrum utilicen cosas llamadas
"historias de usuario" como una forma de capturar los requisitos desde la perspectiva del usuario. Como ya expliqué en
una sección sobre la preferencia de nombres de dominios específicos, debe hablar con las partes interesadas en su propio
idioma.
Entonces, aquí está mi consejo:
El código debe contar una historia y explicarse por sí mismo. Los comentarios deben evitarse siempre que sea posible.
Los comentarios no son subtítulos. Siempre que sienta el deseo de escribir un comentario en su código porque
quiere explicar algo, debe pensar en cómo puede escribir mejor el código para que se explique por sí mismo y el comentario
se vuelva superfluo. Los lenguajes de programación modernos como C++ tienen todo lo necesario para escribir código
claro y expresivo. Los buenos programadores aprovechan esa expresividad para contar historias.
Cualquier tonto puede escribir un código que una computadora pueda entender. Los buenos programadores escriben
código que los humanos pueden entender.
—Martin Fowler, 1999
No Comentar Cosas Obvias
Una vez más, echamos un vistazo a una pequeña y típica pieza de código fuente que se comentó extensamente.
Listado 412. ¿Son útiles estos comentarios?
índicecliente++; // Incrementar índice
Cliente* cliente = getCustomerByIndex(customerIndex); // Recuperar el cliente en el // índice dado
CuentaCliente* cuenta = cliente>obtenerCuenta(); cuenta // Recuperar la cuenta del cliente
>establecerDescuentoDeFidelidadEnPorcentaje(descuento); // Otorgar un 10% de descuento
¡Por favor, no insultes la inteligencia del lector! Es obvio que estos comentarios son totalmente inútiles.
El código en sí es en gran parte autoexplicativo. Y no solo no agregan absolutamente ninguna información nueva
o relevante. Mucho peor es que estos comentarios inútiles son una especie de duplicación del código. Violan el principio DRY
que hemos discutido en el Capítulo 3.
Quizás hayas notado otro detalle. Echa un vistazo a la última línea. El comentario habla literalmente de un 10 % de
descuento, pero en el código hay una variable o constante con nombre de descuento que se pasa a la función o método
setLoyaltyDiscountInPercent(). ¿Qué ha pasado aquí? Una sospecha razonable es que este comentario se ha convertido
en una mentira porque se cambió el código, pero el comentario no se adaptó. Eso es realmente malo y engañoso.
49
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
No deshabilite el código con comentarios
A veces, los comentarios se utilizan para deshabilitar un montón de código que el compilador no traducirá. Un razonamiento
a menudo entregado por algunos desarrolladores para esta práctica es que uno podría usar este fragmento de código
nuevamente más tarde. Piensan: "Tal vez algún día... lo necesitaremos de nuevo".
Listado 413. Un ejemplo de código comentado
// Esta función ya no se usa (John Doe, 20131025): /* double
calcDisplacement(double t) { const double goe
= 9.81; // gravedad de la tierra double d = 0.5 * pow(t, 2); //
*
cálculo de la distancia
dvamos
e retorno d;
} */
Un problema importante con el código comentado es que agrega confusión sin ningún beneficio real. Solo imagine que
la función deshabilitada en el ejemplo anterior no es la única, sino uno de muchos lugares donde el código ha sido comentado.
El código pronto se convertirá en un gran lío y los fragmentos de código comentados agregarán mucho ruido que dificultará
la legibilidad. Además, los fragmentos de código comentados no tienen garantía de calidad, es decir, el compilador no los
traduce, no los prueba ni los mantiene. Mi consejo es este:
Excepto con el propósito de probar algo rápidamente, no use comentarios para deshabilitar el código. ¡Hay un sistema de
control de versiones!
Si el código ya no se usa, simplemente elimínelo. Déjalo ir. Tienes una “máquina del tiempo” para recuperarlo,
si es necesario: tu sistema de control de versiones. Sin embargo, a menudo resulta que este caso es muy raro. Solo eche un
vistazo a la marca de tiempo que el desarrollador agregó en el ejemplo anterior. Este fragmento de código es antiguo.
¿Cuál es la probabilidad de que se vuelva a necesitar?
Para probar algo rápidamente durante el desarrollo, por ejemplo, mientras busca la causa de un error, es útil comentar
una sección de código temporalmente. Pero debe asegurarse de que dicho código modificado no se registre en el sistema
de control de versiones.
No escribir comentarios de bloque
Comentarios como los siguientes se pueden encontrar en muchos proyectos.
Listado 414. Un ejemplo de bloque de comentarios
#ifndef _STUFF_H_
#define _STUFF_H_
//
// stuff.h: la interfaz de la clase Stuff
// John Doe, creado: 20070921 //
50
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
cosas de clase
{ público: //
// Interfaz pública //
// ...
protegido: //
// Anulables //
// ...
privado: //
// Funciones miembro privadas //
// ...
//
// Atributos privados //
// ...
};
#terminara si
Este tipo de comentarios (y no me refiero a los que usé para ocultar partes irrelevantes) se denominan "Bloquear
comentarios" o "Banners". A menudo se usan para poner un resumen sobre el contenido en la parte superior de un archivo
de código fuente. O se utilizan para marcar una posición especial en el código. Por ejemplo, están introduciendo una sección
de código donde se pueden encontrar todas las funciones de miembros privados de una clase.
Este tipo de comentarios son en su mayoría puro desorden y deben eliminarse de inmediato.
Hay muy pocas excepciones en las que tales comentarios podrían tener un beneficio. En algunos casos raros, un grupo
de funciones de una categoría especial se pueden agrupar debajo de dicho comentario. Pero entonces no debe
usar trenes de caracteres ruidosos que consisten en guiones (), barras inclinadas (/), signos de número (#) o asteriscos
(*) para envolverlo. Un comentario como el siguiente es absolutamente suficiente para presentar tal región:
Listado 415. A veces útil: un comentario para introducir una categoría de funciones
privado:
// Controladores de eventos:
void onUndoButtonClick(); void
onRedoButtonClick(); vacío
onCopyButtonClick(); // ...
51
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
En algunos proyectos, los estándares de codificación dicen que son obligatorios los encabezados grandes con texto de copyright y
licencia en la parte superior de cualquier archivo de código fuente. Pueden verse así:
Listado 416. El encabezado de la licencia en cualquier archivo de código fuente de Apache OpenOffice 3.4.1
/**************************************************** ************
*
* Con licencia de Apache Software Foundation (ASF) bajo una
*
o más acuerdos de licencia de colaborador. Ver el archivo AVISO
* distribuido con este trabajo para obtener información adicional * sobre la
propiedad de los derechos de autor. La ASF le otorga la licencia de este archivo *
bajo la Licencia Apache, Versión 2.0 (la * "Licencia"); no puede usar
este archivo excepto de conformidad * con la Licencia. Puede obtener una copia
de la Licencia en
*
* http://www.apache.org/licenses/LICENSE2.0
*
* A menos que lo exija la ley aplicable o se acuerde por escrito, * el software
distribuido bajo la Licencia se distribuye en un
* BASE "TAL CUAL", SIN GARANTÍAS O CONDICIONES DE CUALQUIER
*
AMABLE, ya sea expresa o implícita. Vea la Licencia para el
* lenguaje específico que rige los permisos y limitaciones * bajo la Licencia.
*
**************************************************** ***********/
Primero quiero decir algo fundamental sobre los derechos de autor. No necesita agregar comentarios sobre los derechos
de autor, ni hacer nada más, para tener derechos de autor sobre sus obras. De acuerdo con el Convenio de Berna para la
Protección de las Obras Literarias y Artísticas [Wipo1886] (o el Convenio de Berna para abreviar), tales comentarios no tienen
significado legal.
Hubo momentos en que tales comentarios fueron necesarios. Antes de que Estados Unidos firmara la Convención de
Berna en 1989, dichos avisos de derechos de autor eran obligatorios si deseaba hacer cumplir sus derechos de autor en
Estados Unidos. Pero eso es cosa del pasado. Hoy en día estos comentarios ya no son necesarios.
Mi consejo es simplemente omitirlos. Son solo equipaje engorroso e inútil. Sin embargo, si quieres
o incluso necesita ofrecer información de derechos de autor y licencia en su proyecto, entonces es mejor que los escriba
en archivos separados, como licencia.txt y derechos de autor.txt. Si una licencia de software requiere en todas las
circunstancias que la información de la licencia se incluya en el área principal de cada archivo de código fuente, entonces
puede ocultar estos comentarios si su IDE tiene el llamado editor plegable.
No use comentarios para sustituir el control de versiones
A veces, y esto es extremadamente malo, los comentarios de banner se usan para un registro de cambios, como en el
siguiente ejemplo.
Listado 417. Administrar el historial de cambios en el archivo de código fuente
// ############################################# ###########################
// Registro de cambios:
// 20160614 (John Smith) Método de cambio rebuildProductList para corregir el error #275
// 20151107 (Bob Jones) Extrajo cuatro métodos a la nueva clase ProductListSorter
// 20150923 (Ninja Dev) Arregló el error más estúpido de una manera muy inteligente //
######################### ##############################################
52
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
¡No hagas esto! Rastrear el historial de cambios de cada archivo en su proyecto es una de las principales tareas de su
sistema de control de versiones. Si está usando Git, por ejemplo, puede usar git log [nombre de archivo] para obtener el
historial de cambios de un archivo. Los programadores que han escrito los comentarios anteriores son más que probables aquellos
que siempre dejan el cuadro Comentarios de registro vacío en sus confirmaciones.
Los raros casos en los que los comentarios son útiles
Por supuesto, no todos los comentarios del código fuente son básicamente inútiles, falsos o malos. Hay algunos casos en los
que los comentarios son importantes o incluso indispensables.
En algunos casos muy concretos puede ocurrir que, aunque hayas utilizado nombres perfectos para todas las variables
y funciones, algunas secciones de su código necesitan más explicaciones para ayudar al lector. Por ejemplo, un comentario
está justificado si una sección de código tiene un alto grado de complejidad inherente, de modo que no puede ser entendida
fácilmente por todos los que no tienen un conocimiento experto profundo. Este puede ser el caso, por ejemplo, con un sofisticado
algoritmo o fórmula matemática. O el sistema de software se ocupa de un dominio (comercial) no cotidiano, es decir, un área o
campo de aplicación que no es fácilmente comprensible para todos, por ejemplo, física experimental, simulaciones complejas de
fenómenos naturales o métodos de cifrado ambiciosos. En tales casos, algunos comentarios bien escritos que expliquen las cosas
pueden ser muy valiosos.
Otra buena razón para escribir un comentario por una vez es una situación en la que debe desviarse deliberadamente de un
buen principio de diseño. Por ejemplo, el principio DRY (consulte el Capítulo 3) es, por supuesto, válido en la mayoría de las
circunstancias, pero puede haber algunos casos muy raros en los que deba duplicar intencionalmente una pieza de código, por
ejemplo, para cumplir requisitos de calidad ambiciosos con respecto al rendimiento. Esto justifica un comentario explicando por
qué ha violado el principio; de lo contrario, es posible que sus compañeros de equipo no puedan comprender su decisión.
El desafío es este: los comentarios buenos y significativos son difíciles de escribir. Puede ser más difícil que escribir el
código. Así como no todos los miembros de un equipo de desarrollo son buenos para diseñar una interfaz de usuario, tampoco
todos son buenos para escribir. La redacción técnica es una habilidad para la que suele haber especialistas.
Entonces, aquí hay algunos consejos para escribir comentarios que son inevitables por las razones
mencionadas anteriormente:
• Asegúrese de que sus comentarios agreguen valor al código. El valor en este contexto significa que los
comentarios agregan información importante para otros seres humanos (generalmente otros
desarrolladores) que no son evidentes en el código en sí.
• Explique siempre el Por qué, no el Cómo. El funcionamiento de una parte del código debe quedar bastante
claro a partir del propio código, y la denominación significativa de variables y funciones es la clave para
lograr este objetivo. Use comentarios únicamente para explicar por qué existe una determinada pieza
de código. Por ejemplo, puede proporcionar una justificación de por qué eligió un algoritmo o método
en particular.
• Trate de ser lo más breve y expresivo posible. Prefiere comentarios breves y concisos, idealmente de una
sola línea, y evita textos largos y parlanchines. Siempre tenga en cuenta que los comentarios
también deben mantenerse. En realidad, es mucho más fácil mantener comentarios breves que
explicaciones extensas y prolijas.
■ Sugerencia En entornos de desarrollo integrados (IDE) con colores de sintaxis, el color de los comentarios suele
estar preconfigurado en verde o verde azulado. ¡Deberías cambiar este color a rojo! Un comentario en el código
fuente debe ser algo especial que atraiga la atención del desarrollador.
53
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Generación de Documentación a partir de Código Fuente
Una forma especial de comentarios son las anotaciones que puede extraer un generador de documentación. Un ejemplo de tal herramienta
es Doxygen (http://doxygen.org) que está muy extendido en el mundo de C++ y se publica bajo una Licencia Pública General GNU
(GPLv2). Dicha herramienta analiza el código fuente C++ anotado y puede crear una documentación en forma de un documento
legible e imprimible (por ejemplo, PDF) o un conjunto de documentos web interrelacionados (HTML) que se pueden ver con un
navegador. Y en combinación con una herramienta de visualización, Doxygen puede incluso generar diagramas de clases, incluir
gráficos de dependencia y gráficos de llamadas. Por lo tanto, Doxygen también se puede utilizar para el análisis de código.
Para que una documentación significativa salga de dicha herramienta, el código fuente debe estar anotado
intensamente con comentarios específicos. Aquí hay un ejemplo no tan bueno con anotaciones en estilo Doxygen:
Listado 418. Una clase anotada con comentarios de documentación para Doxygen
//! Los objetos de esta clase representan una cuenta de cliente en nuestro sistema. clase CuentaCliente
{ // ...
//! Concede un descuento de fidelidad. //!
@param discount es el valor del descuento en porcentaje. void
grantLoyaltyDiscount ( descuento corto sin firmar);
// ... };
¿Qué? ¿Los objetos de clase CustomerAccount representan cuentas de clientes? ¡¿Ah, de verdad?! ¿Y
grantLoyaltyDiscount otorga un descuento por fidelidad? ¡Eh!
Pero en serio amigos! Para mí, esta forma de documentación funciona en ambos sentidos.
Por un lado, puede ser muy útil para anotar, especialmente la interfaz pública (API) de una biblioteca o un marco con este tipo
de comentarios, y generar documentación a partir de ella. En particular, si los clientes del software son desconocidos (el caso
típico con bibliotecas y marcos disponibles públicamente), dicha documentación puede ser muy útil si desean utilizar el software en sus
proyectos.
Por otro lado, tales comentarios agregan una gran cantidad de ruido a su código. La relación entre el código y las líneas de
comentarios puede llegar rápidamente a 50:50. Y como se puede ver en el ejemplo anterior, dichos comentarios también tienden a explicar
cosas obvias (recuerde la sección de este capítulo, “No comente cosas obvias”). Finalmente, la mejor documentación que existe,
una "documentación ejecutable", es un conjunto de pruebas unitarias bien diseñadas (consulte la sección sobre pruebas unitarias en
el Capítulo 2 ). y Capítulo 8 sección sobre Desarrollo basado en pruebas), que puede mostrar exactamente cómo se debe usar la API de
la biblioteca.
De todos modos, no tengo una opinión final sobre este tema. Si quiere, o tiene que, anotar la API pública de sus componentes
de software con comentarios estilo Doxygen a toda costa, entonces, por el amor de Dios, hágalo. Si está bien hecho, puede ser muy
útil. ¡Le recomiendo encarecidamente que preste atención exclusiva a los encabezados de su API pública! Para todas las demás partes
de su software, por ejemplo, módulos de uso interno o funciones privadas, le recomiendo que no los equipe con anotaciones de
Doxygen.
El ejemplo anterior se puede mejorar significativamente si se utilizan términos y explicaciones del dominio de las aplicaciones.
54
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Listado 419. Una clase anotada con comentarios desde una perspectiva comercial para Doxygen
//! Cada cliente debe tener una cuenta, por lo que se pueden hacer reservas. La cuenta //! también es
necesario para la creación de facturas mensuales. //! entidades @ingroup //!
@ingroup clase de
contabilidad CustomerAccount
{ // ...
//! Los clientes regulares ocasionalmente reciben un descuento regular en su //! compras void
grantDiscount(const
PorcentajeValor&descuento);
// ...
};
Tal vez haya notado que ya no he comentado sobre el parámetro del método con la etiqueta @param de Dogygen.
En cambio, cambié su tipo de un corto sin firmar sin sentido a una referencia constante de un tipo personalizado llamado
PercentageValue. Debido a esto, el parámetro se ha vuelto autoexplicativo. Por qué este es un enfoque mucho mejor que
cualquier comentario, puede leerlo en una sección sobre Programación rica en tipos en el Capítulo 5.
Aquí hay algunos consejos finales para las anotaciones de estilo Doxygen en el código fuente:
• No use la etiqueta @file [<name>] de Doxygen para escribir el nombre del archivo en alguna parte
en el propio archivo. Por un lado, esto es inútil, porque Dogygen lee el nombre del archivo de
todos modos y automáticamente. Por otro lado, viola el principio DRY (ver Capítulo 3). Es
información redundante, y si tiene que cambiar el nombre del archivo, también debe recordar
cambiar el nombre de la etiqueta @file.
• No edite las etiquetas @version, @author y @date manualmente, ya que su sistema de control de
versiones puede administrar y realizar un seguimiento de esta información mucho mejor que
cualquier desarrollador que deba editarlas manualmente. Si dicha información de gestión debe
aparecer en el archivo de código fuente en todas las circunstancias, el sistema de control de
versiones debe completar automáticamente estas etiquetas. En todos los demás casos
prescindiría por completo de ellos.
•No use las etiquetas @bug o @todo. En su lugar, debería corregir el error
inmediatamente, o use un software de seguimiento de problemas para archivar errores para una posterior resolución de
problemas, respectivamente, administre los puntos abiertos.
• Se recomienda encarecidamente proporcionar una página de inicio descriptiva del proyecto utilizando
la etiqueta @mainpage (idealmente en un archivo de encabezado separado solo para este propósito),
ya que dicha página de inicio sirve como guía de inicio y ayuda de orientación para los desarrolladores
que actualmente no están familiarizados. con el proyecto entre manos.
• No usaría la etiqueta @example para proporcionar un bloque de comentarios que contenga un
ejemplo de código fuente sobre cómo usar una API. Como ya se mencionó, tales comentarios
agregan mucho ruido al código. En su lugar, ofrecería un conjunto de pruebas unitarias bien
diseñadas (consulte el Capítulo 2 sobre las pruebas unitarias y el capítulo 8 sobre el desarrollo
basado en pruebas), ya que estos son los mejores ejemplos de uso: ¡ejemplos ejecutables!
Además, las pruebas unitarias siempre son correctas y están actualizadas, ya que deben
ajustarse cuando cambia la API (de lo contrario, las pruebas fallarán). Un comentario con un
ejemplo de uso, por otro lado, puede resultar erróneo sin que nadie lo note.
55
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
•Una vez que un proyecto ha crecido a un tamaño particular, es recomendable poner en común ciertos
categorías de unidades de software con la ayuda del mecanismo de agrupación de Dogygen
(Etiquetas: @defgroup <nombre>, @addtogroup <nombre> y @ingroup <nombre>). Esto es,
por ejemplo, muy útil cuando desea expresar el hecho de que ciertas unidades de software
pertenecen a un módulo cohesivo en un nivel superior de abstracción (por ejemplo, un componente
o subsistema). Este mecanismo también permite agrupar ciertas categorías de clases, por ejemplo,
todas las entidades, todos los adaptadores (consulte Patrón de adaptador en el Capítulo 9) o todas
las fábricas de objetos (consulte Patrón de fábrica en el Capítulo 9). La clase CustomerAccount
del ejemplo de código anterior está, por ejemplo, en el grupo de entidades (un grupo que contiene
todos los objetos comerciales), pero también forma parte del componente de contabilidad.
Funciones
Las funciones (sinónimos: métodos, procedimientos, servicios, operaciones) son el corazón de cualquier sistema de software.
Representan la primera unidad organizativa por encima de las líneas de código. Las funciones bien escritas fomentan
considerablemente la legibilidad y la mantenibilidad de un programa. Por esta razón, deben elaborarse bien y con cuidado. En
esta sección doy varias pistas importantes para escribir buenas funciones.
Sin embargo, antes de explicar las cosas que considero importantes para funciones bien diseñadas, vamos a
examine de nuevo un ejemplo disuasorio, tomado de OpenOffice 3.4.1 de Apache.
Listado 420. Otro extracto del código fuente de OpenOffice 3.4.1 de Apache
1780 sal_Bool BasicFrame::QueryFileName(String& rName, FileType nFileType, sal_Bool bSave ) 1781 { 1782 1783 1784 1785
1786
1787 NewFileDialog aDlg( esto, bSave ? WinBits( WB_SAVEAS ) :
1788 WinBits(WB_OPEN));
1789 aDlg.SetText( String( SttResId( bSave ? IDS_SAVEDLG : IDS_LOADDLG ) ) );
if ( nFileType & FT_RESULT_FILE )
{ aDlg.SetDefaultExt( String( SttResId( IDS_RESFILE ) ) );
aDlg.AddFilter( String( SttResId( IDS_RESFILTER ) ),
1790 Cadena (SttResId (IDS_RESFILE))));
1791 aDlg.AddFilter( String( SttResId( IDS_TXTFILTER ) ),
1792 String( SttResId( IDS_TXTFILE ) ) );
1793 aDlg.SetCurFilter( SttResId( IDS_RESFILTER ) );
1794 }
1795
1796 si ( nFileType & FT_BASIC_SOURCE ) {
1797
1798 aDlg.SetDefaultExt( String( SttResId( IDS_NONAMEFILE ) ) );
1799 aDlg.AddFilter( String( SttResId( IDS_BASFILTER ) ),
1800 String( SttResId( IDS_NONAMEFILE ) ) );
1801 aDlg.AddFilter( String( SttResId( IDS_INCFILTER ) ),
1802 String( SttResId( IDS_INCFILE ) ) );
1803 aDlg.SetCurFilter( SttResId( IDS_BASFILTER ) );
1804 }
1805
1806 si (nFileType & FT_BASIC_LIBRARY) {
1807
1808 aDlg.SetDefaultExt( String( SttResId( IDS_LIBFILE ) ) );
56
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
1809 aDlg.AddFilter( String( SttResId( IDS_LIBFILTER ) ),
1810 String( SttResId( IDS_LIBFILE ) ) );
1811 aDlg.SetCurFilter( SttResId( IDS_LIBFILTER ) );
1812 }
1813
1814 Config aConf(Config::GetConfigName( Config::GetDefDirectory(), CUniString("testtool") ));
1815 aConf.SetGroup( "Misc" ); ByteString
1816 aCurrentProfile =
1817 aConf.ReadKey( "CurrentProfile", "Path" ); aConf.SetGroup( aCurrentProfile ); ByteString
1818 aFilter( aConf.ReadKey( "LastFilterName") ); if
1819 ( aFilter.Len() ) aDlg.SetCurFilter( String( aFilter,
1820
1821 RTL_TEXTENCODING_UTF8 ) ); else aDlg.SetCurFilter( String( SttResId( IDS_BASFILTER ) ) );
1822
1823
1824
1825 aDlg.FilterSelect(); // Selecciona la última ruta utilizada 1826 // if ( bSave )
1827 if ( rName.Len() > 0 )
1828 aDlg.SetPath( rName ); 1829 1830
1831 1832 1833 /* 1834 1835 1836 1837 1838 */
1839
1840 if( aDlg.Execute() ) {
1841 }
rName = aDlg.GetPath();
rExtensión = aDlg.GetCurrentFilter(); var i: entero;
for ( i = 0 ; i <
aDlg.GetFilterCount() ; i++ ) if ( rExtension ==
aDlg.GetFilterName( i ) ) rExtension = aDlg.GetFilterType( i );
volver sal_True; }
más devuelve sal_False;
Pregunta: ¿Qué esperaba cuando vio una función miembro llamada QueryFileName() por primera vez?
¿Esperaría que se abriera un cuadro de diálogo de selección de archivos (recuerde el Principio de menor
asombro discutido en el Capítulo 3)? Probablemente no, pero eso es exactamente lo que se hace aquí. Obviamente,
se le pide al usuario que interactúe con la aplicación, por lo que un mejor nombre para esta función miembro sería
AskUserForFilename().
Pero eso no es suficiente. Si observa las primeras líneas en detalle, verá que hay un parámetro booleano
bSave que se usa para distinguir entre un cuadro de diálogo de archivo para abrir y un cuadro de diálogo de archivo para
guardar archivos. ¿Esperabas eso? ¿Y cómo coincide el término Consulta... en el nombre de la función con ese hecho?
Por lo tanto, un mejor nombre para esta función miembro podría ser AskUserForFilenameToOpenOrSave().
Las siguientes líneas tratan del argumento de la función nFileType. Aparentemente, se distinguen tres tipos de
archivos diferentes. El parámetro nFileType está enmascarado con algo llamado FT_RESULT_FILE, FT_BASIC_SOURCE
y FT_BASIC_LIBRARY. Dependiendo del resultado de esta operación AND bit a bit, el cuadro de diálogo del archivo se
configura de manera diferente, por ejemplo, se establecen filtros. Como ya lo ha hecho antes el parámetro booleano bSave,
las tres sentencias if introducen caminos alternativos. Eso aumenta lo que se conoce como la complejidad ciclomática de la
función.
57
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
COMPLEJIDAD CICLOMÁTICA
La complejidad ciclomática métrica cuantitativa del software fue desarrollada por Thomas J. McCabe, un
matemático estadounidense, en 1976.
La métrica es un recuento directo del número de rutas linealmente independientes a través de una sección de
código fuente, por ejemplo, una función. Si una función no contiene sentencias if o switch, ni bucles for o
while, solo hay un único camino a través de la función y su complejidad ciclomática es 1. Si la función contiene
una sentencia if que representa un único punto de decisión , hay dos caminos a través de la función y la
complejidad ciclomática es 2.
Si la complejidad ciclomática es alta, la pieza de código afectada suele ser más difícil de entender, probar y
modificar y, por lo tanto, es propensa a errores.
Los tres si plantean otra pregunta: ¿Es esta función el lugar adecuado para realizar este tipo de configuración?
¡Definitivamente no! Esto no pertenece aquí.
Las siguientes líneas (a partir de 1814) tienen acceso a datos de configuración adicionales. No se puede determinar con
exactitud, pero parece que el último filtro de archivo utilizado ("LastFilterName") se carga desde una fuente que contiene datos
de configuración, ya sea un archivo de configuración o el registro de Windows. Especialmente confuso es que el filtro ya definido,
que se estableció en los tres bloques if anteriores (aDlg.SetCurFilter(...)), siempre se sobrescribirá en este lugar (ver líneas
18201823). Entonces, ¿cuál es el sentido de configurar este filtro en los tres bloques if anteriores?
Poco antes del final, entra en juego el parámetro de referencia rName. Espera... ¿nombre de qué, por favor?
Probablemente sea el nombre del archivo, sí, pero ¿por qué no se llama filename para excluir todas las posibilidades de duda?
¿Y por qué el nombre del archivo no es el valor de retorno de esta función? (La razón por la que debe evitar los llamados argumentos
de salida es un tema que se analiza más adelante en este capítulo).
Como si esto no fuera suficientemente malo, la función también contiene código comentado.
Bueno, esta función consta de unas 50 líneas solamente, pero tiene muchos malos olores a código. La función es
demasiado larga, tiene una alta complejidad ciclomática, mezcla diferentes preocupaciones, tiene muchos argumentos y
contiene código muerto. El nombre de la función QueryFileName() no es específico y puede resultar engañoso. ¿Quién es consultado?
¿Una base de datos? AskUserForFilename() sería mucho mejor, porque enfatiza la interacción con el usuario. La mayor
parte del código es difícil de leer y difícil de entender. ¿Qué significa nFileType y FT_BASIC_ LIBRARY?
Pero el punto esencial es que la tarea a realizar por esta función (selección de nombre de archivo) justifica una
propia clase, porque la clase BasicFrame, que es parte de la interfaz de usuario de la aplicación, definitivamente no es
responsable de tales cosas.
Suficiente de eso. Echemos un vistazo a lo que debe tener en cuenta un creador de software al diseñar buenas funciones.
¡Una cosa, no más!
Una función debe tener una tarea definida muy precisa que debe estar representada por su nombre significativo. En su brillante
libro Clean Code, el desarrollador de software estadounidense Robert C. Martin lo formula de la siguiente manera:
Las funciones deben hacer una cosa. Deberían hacerlo bien. Deberían hacerlo solo.
—Robert C. Martin, Código limpio [Martin09]
58
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Puede preguntarse ahora: ¿Pero cómo sé cuándo una función hace demasiadas cosas? Aquí hay algunas indicaciones posibles:
1. La función es grande, es decir, contiene muchas líneas de código (ver la siguiente sección sobre funciones pequeñas).
2. Intenta encontrar un nombre significativo y expresivo para la función que exactamente
describe su propósito, pero no puede evitar el uso de conjunciones, como "y" o "o", para construir el nombre.
(Consulte también una de las siguientes secciones sobre nombres).
3. El cuerpo de una función se separa verticalmente usando líneas vacías en grupos
que representan pasos posteriores. A menudo, estos grupos también se presentan con comentarios que
son como titulares.
4. La complejidad ciclomática es alta. La función contiene muchos 'if', 'else' o
declaraciones de 'cambio de caso'.
5. La función tiene muchos argumentos (ver la sección sobre Argumentos y Retorno).
Valores más adelante en este capítulo), especialmente uno o más argumentos de bandera de tipo bool.
Déjalos ser pequeños
Una pregunta central con respecto a las funciones es esta: ¿Cuál debería ser la longitud máxima de una función?
Hay muchas reglas generales y heurísticas para la longitud de una función. Por ejemplo, algunos dicen que una función debe caber en la
pantalla verticalmente. Bien, a primera vista parece una regla no tan mala. Si una función cabe en la pantalla, no es necesario que el desarrollador se
desplace. Por otro lado, ¿la altura de mi pantalla debería realmente determinar el tamaño máximo de una función? Las alturas de las pantallas no
son todas iguales. Por lo tanto, personalmente no creo que sea una buena regla. Aquí está mi consejo sobre este tema:
Las funciones deben ser bastante pequeñas. Idealmente 4–5 líneas, máximo 12–15 líneas, pero no más.
¡Pánico! Ya puedo escuchar el clamor: “¿Muchas funciones diminutas? ¡¿HABLAS EN SERIO?!"
Sí, en serio. Como ya escribió Robert C. Martin en su libro Clean Code [Martin09]: Las funciones deberían ser pequeñas, y deberían ser
más pequeñas que eso.
Las funciones grandes suelen tener una alta complejidad. Los desarrolladores a menudo no son capaces de decir de un vistazo qué
tal función lo hace. Si una función es demasiado grande, normalmente tiene demasiadas responsabilidades (consulte la sección anterior) y
no hace exactamente una sola cosa. Cuanto más grande es una función, más difícil es entenderla y mantenerla. Tales funciones a menudo
contienen muchas decisiones, en su mayoría anidadas (if, else, switch) y bucles. Esto también se conoce como alta complejidad ciclomática.
Por supuesto, como con cualquier regla, puede haber pocas excepciones justificadas. Por ejemplo, una función que contiene
una sola declaración de cambio grande podría ser aceptable si es extremadamente limpia y fácil de leer.
Puede tener una declaración de cambio de 400 líneas en una función (a veces necesaria para manejar diferentes tipos de datos entrantes en los
sistemas de telecomunicaciones), y está perfectamente bien.
“¡Pero el tiempo de llamada por encima!”
La gente ahora podría objetar que muchas funciones pequeñas reducen la velocidad de ejecución de un programa.
Podrían argumentar que cualquier llamada de función es costosa.
Permítanme explicar por qué creo que estos temores son infundados en la mayoría de los casos.
59
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Sí, hubo momentos en que los compiladores de C++ no eran muy buenos para optimizar y las CPU eran comparativamente lentas.
Fue en un momento en que se difundió el mito de que C ++ es generalmente más lento que C. Tales mitos son propagados por personas que no
conocen muy bien el lenguaje. Y los tiempos han cambiado.
Hoy en día, los compiladores modernos de C++ son muy buenos para optimizar. Por ejemplo, pueden realizar múltiples
optimizaciones de aceleración locales y globales. Pueden reducir muchas construcciones de C++, como bucles o declaraciones condicionales, a
secuencias funcionalmente similares de código de máquina muy eficiente. Y ahora son lo suficientemente inteligentes para funciones en línea
automáticamente, si esas funciones pueden estar básicamente en línea (... por supuesto, a veces no es posible hacer eso).
E incluso el Linker puede realizar optimizaciones. Por ejemplo, VisualStudio Compiler/ Linker de Microsoft proporciona una función llamada
Optimización de todo el programa que permite que el compilador y el vinculador realicen optimizaciones globales con información sobre todos los
módulos del programa. Y con otra función de VisualStudio llamada Optimizaciones guiadas por perfiles, el compilador optimiza un programa
utilizando datos recopilados de las ejecuciones de prueba de creación de perfiles del archivo .exe o .dll.
Incluso si no queremos usar las opciones de optimización del compilador, ¿de qué estamos hablando cuando
consideramos una llamada de función?
Una CPU Intel Core i7 2600K es capaz de realizar 128.300 millones de instrucciones por segundo (MIPS) a una velocidad de reloj de 3,4 GHz.
Damas y caballeros, cuando hablamos de llamadas de función, ¡estamos hablando de unos pocos nanosegundos! La luz viaja aproximadamente 30 cm
en un nanosegundo (0,000000001 seg). En comparación con otras operaciones en una computadora, como el acceso a la memoria fuera del caché
o el acceso al disco duro, una llamada de función es mucho más rápida.
Los desarrolladores deberían invertir su precioso tiempo en problemas reales de rendimiento, que generalmente tienen su origen en una
mala arquitectura y diseño. Solo en circunstancias muy especiales tiene que preocuparse por la sobrecarga de llamadas a funciones.
Nomenclatura de funciones En general,
se puede decir que las mismas reglas de nomenclatura que las de las variables y constantes son, en la medida de lo posible, aplicables también a las
funciones, respectivamente, a los métodos. Los nombres de las funciones deben ser claros, expresivos y autoexplicativos. No debería tener que
leer el cuerpo de una función para saber lo que hace. Debido a que las funciones definen el comportamiento de un programa, normalmente tienen
un verbo en su nombre. Se utiliza algún tipo especial de funciones para proporcionar información sobre un estado. Sus nombres a menudo comienzan
con "is..." o "has...".
El nombre de una función debe comenzar con un verbo. Los predicados, es decir, declaraciones sobre un objeto que pueden ser
verdaderos o falsos, deben comenzar con "is" o "has".
Estos son algunos ejemplos de nombres de métodos expresivos:
Listado 421. Solo algunos ejemplos de nombres expresivos y autoexplicativos para funciones miembro
void CustomerAccount::grantDiscount(DiscountValue descuento); void Asunto::adjuntarObservador(const
Observador& observador); void Asunto::notificar a Todos los Observadores() const; int
Embotellado::getTotalAmountOfFilledBottles() const; bool
PuertaAutomática::isOpen() const; bool CardReader::isEnabled() const; bool
DoubleLinkedList::hasMoreElements() const;
60
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Use nombres reveladores de intenciones
Eche un vistazo a la siguiente línea de código, que es, por supuesto, solo un pequeño extracto de un programa más grande:
std::string head = html.substr(startOfHeader, lengthOfHeader);
Esta línea de código se ve bien en principio. Hay una cadena de C++ (encabezado <cadena>) llamada html,
que contiene una parte de HTML (lenguaje de marcado de hipertexto), obviamente. Cuando se ejecuta esta línea, se
recupera una copia de una subcadena de html y se asigna a una nueva cadena llamada head. La subcadena se define
mediante dos parámetros: uno que establece el índice inicial de la subcadena y otro que define la cantidad de caracteres
que se incluirán en la subcadena.
Bien, acabo de explicar en detalle cómo se extrae el encabezado de un fragmento de HTML. Deja que te enseñe
otra versión del mismo código:
Listado 422. Después de introducir un nombre que revele la intención, el código es mejor comprensible.
std::string ReportRenderer::extractHtmlHeader(const std::string& html) {
return html.substr(startOfHeader, lengthOfHeader); }
// ...
std::string head = extractHtmlHeader(html);
¿Puedes ver cuánta claridad podría aportar un pequeño cambio como este a tu código? Hemos introducido una
pequeña función miembro que explica su intención por su nombre semántico. Y en el lugar donde originalmente se podía
encontrar la operación de cadena, hemos reemplazado la invocación directa de std::string::substr() por una llamada de
nuestra nueva función.
El nombre de una función debe expresar su intención/propósito y no explicar cómo funciona.
Cómo se realiza el trabajo, eso es lo que debería ver en el código del cuerpo de la función. No expliques el
Cómo en un nombre de funciones. En su lugar, exprese el propósito de la función desde una perspectiva comercial.
Además, tenemos otra ventaja. La funcionalidad parcial de cómo se extrae el encabezado de
la página HTML ha sido casi aislada y ahora es más fácil de reemplazar sin andar a tientas en aquellos lugares donde se
llama a la función.
Argumentos y valores devueltos Después de
haber discutido los nombres de funciones en detalle, hay otro aspecto que es importante para funciones buenas
y limpias: los argumentos de la función y los valores devueltos. Ambos también contribuyen significativamente al
hecho de que una función o método se puede entender bien y es fácil de usar para los clientes.
61
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Número de argumentos ¿Cuántos
argumentos (también conocidos como parámetros) debe tener una función (sinónimos: método, operación) como máximo?
En Clean Code encontramos la siguiente recomendación:
El número ideal de argumentos para una función es cero (niládico). Luego viene uno (monádico),
seguido de cerca por dos (diádico). Deben evitarse tres argumentos (triádicos) siempre que sea
posible. Más de tres (poliádico) requiere una justificación muy especial, y de todos modos no
debería usarse.
— Robert C. Martin, Código limpio [Martin09]
Este consejo es por tanto muy interesante ya que Martin recomienda que una función ideal no debe tener argumentos. Esto es
un poco extraño porque una función en el sentido matemático puro (y = f(x)) siempre tiene al menos un argumento (vea también el
capítulo sobre Programación Funcional). Esto significa que una “función sin argumentos” por lo general debe tener algún tipo de efecto
secundario.
Tenga en cuenta que Martin usa ejemplos de código escritos en Java en su libro, por lo que en realidad se refiere a los métodos
de una clase cuando habla de funciones. Tenemos que considerar que hay un “argumento” implícito adicional disponible para los
métodos de un objeto: ¡este! El puntero this representa el contexto de ejecución. Con la ayuda de esto, una función miembro puede
acceder a los atributos de su clase, leerlos o manipularlos. En otras palabras: desde la perspectiva de una función miembro, los
atributos de una clase no son más que variables globales. Entonces, la regla de Martin parece ser una guía adecuada, pero creo
que es principalmente apropiada para diseños orientados a objetos.
Pero, ¿por qué demasiados argumentos son malos?
En primer lugar, todos los argumentos de la lista de argumentos de una función pueden conducir a una dependencia, con
la excepción de los argumentos de tipos integrados estándar como int o double. Si usa un tipo complejo (por ejemplo, una clase) en la
lista de argumentos de una función, su código depende de ese tipo. Se debe incluir el archivo de cabecera que contiene el tipo utilizado.
Además, cada argumento debe procesarse en algún lugar dentro de una función (si no, el argumento
es innecesario y debe eliminarse inmediatamente). Tres argumentos pueden llevar a una función relativamente compleja, como
hemos visto en el ejemplo de la función miembro BasicFrame::QueryFileName() de OpenOffice de Apache.
En la programación de procedimientos, a veces puede ser muy difícil no exceder los tres argumentos. En C, por ejemplo, a
menudo verá funciones con más argumentos. Un ejemplo disuasorio es la anticuada Win32API de Windows.
Listado 423. La función Win32 CreateWindowEx para crear ventanas
HWND CreateWindowEx (
DWORD dwExStyle,
LPCTSTR lpClassName,
LPCTSTR lpWindowName,
DWORD dwStyle,
int x, int
y, int
nWidth, int
nHeight,
HWND hWndPadre,
HMENÚ hMenú,
HINSTANCIA hInstancia,
LPVOID lpParam);
62
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Bueno, este feo código viene de la antigüedad, obviamente. Estoy bastante seguro de que si se diseñara hoy en día, la API de
Windows ya no se vería así. No sin razón, existen numerosos marcos, como Microsoft Foundation Classes (MFC), Qt (https://www.qt.io),
o wxWidgets (https://www.wxwidgets.org ), que envuelve esta espeluznante interfaz y ofrece formas más simples y más orientadas a objetos
para crear una interfaz gráfica de usuario (UI).
Y hay pocas posibilidades de reducir el número de argumentos. Puede combinar x, y, nWidth y nHeight en una nueva estructura llamada
Rectangle, pero todavía hay nueve argumentos. Un agravante es que algunos de los argumentos de esta función son punteros a otras
estructuras complejas, que a su vez están compuestas por muchos atributos.
En buenos diseños orientados a objetos, por lo general no se requieren listas de argumentos tan largas. Pero C++ no es un
lenguaje orientado a objetos puro, como Java o C#. En Java, todo debe estar incrustado en una clase, lo que a veces conduce a mucho
código repetitivo. En C++ esto no es necesario. Se le permite implementar funciones independientes en C++, es decir, funciones que no
son miembros de una clase. Y eso está bastante bien.
Así que aquí está mi consejo sobre este tema:
Las funciones reales deben tener la menor cantidad de argumentos posible. Un argumento es el número ideal. Las
funciones miembro (métodos) de una clase a menudo no tienen argumentos. Por lo general, esas funciones manipulan
el estado interno del objeto, o se usan para consultar algo del objeto.
Evite los argumentos de bandera
Un argumento de bandera es un tipo de argumento que le dice a una función que realice una operación diferente dependiendo de su valor.
Los argumentos de marca son en su mayoría de tipo bool y, a veces, incluso una enumeración.
Listado 424. Un argumento de bandera para controlar el nivel de detalle de una factura
Facturación de facturas::createInvoice(const BookingItems& items, const bool withDetails) { if (withDetails) { //...
} más { //...
} }
El problema básico con los argumentos de bandera es que introduces dos (oa veces incluso más) caminos a través de tu
función. Dicho argumento generalmente se evalúa en algún lugar dentro de la función en una declaración if o switch/case. Se utiliza para
determinar si se debe o no realizar una determinada acción. Significa que la función no está haciendo una cosa exactamente como
debería (consulte la sección "Una cosa, no más", anteriormente en este capítulo). Es un caso de cohesión débil (ver Capítulo 3) y viola el
Principio de Responsabilidad Única (ver Capítulo 6 sobre la Orientación a Objetos).
Y si ve la llamada a la función en algún lugar del código, no sabe exactamente qué significa verdadero o falso sin analizar la función
Billing::createInvoice() en detalle:
Listado 425. Desconcertante: ¿Qué significa el 'verdadero' en la lista de argumentos?
Facturación facturación;
Factura factura = facturación.createInvoice(bookingItems, true);
Mi consejo es que simplemente evites los argumentos de bandera. Este tipo de argumentos son siempre
necesario si la preocupación de realizar una acción no está separada de su configuración.
63
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Una solución podría ser proporcionar funciones separadas y bien nombradas en su lugar:
Listado 426. Más fácil de comprender: dos funciones miembro con nombres reveladores de intenciones
Facturación Factura::createSimpleInvoice(const BookingItems& items) {
//...
}
Facturación de facturas::createInvoiceWithDetails(const BookingItems& items)
{ Factura de factura = createSimpleInvoice(items); //...añadir
detalles a la factura...
}
Otra solución sería una jerarquía de especialización de facturación:
Listado 427. Diferentes niveles de detalles para facturas, realizados de forma orientada a objetos.
class Billing
{ public:
virtual Invoice createInvoice(const BookingItems& items) = 0; // ... };
class SimpleBilling : public Billing { public:
virtual
Invoice createInvoice(const BookingItems& items) override; // ... };
clase FacturaciónDetallada: facturación pública
{ público:
factura virtual createInvoice(const BookingItems& items) override; // ... privado:
SimpleBilling simpleBilling; };
La variable de miembro privado de tipo SimpleBilling se requiere en la clase DetailBilling para poder
realizar primero una creación de factura simple sin duplicación de código y luego agregar los detalles a la
factura.
ANULAR ESPECIFICADOR [C++11]
Desde C ++ 11, se puede especificar explícitamente que una función virtual anulará una función virtual de clase
base. Para este propósito, se ha introducido el identificador de anulación .
Si override aparece inmediatamente después de la declaración de una función miembro, el compilador comprobará
que la función es virtual y está anulando una función virtual de una clase base. Por lo tanto, los desarrolladores
están protegidos de errores sutiles que pueden surgir cuando simplemente piensan que han anulado una función
virtual, pero en realidad han alterado/añadido una nueva función, por ejemplo, debido a un error tipográfico.
64
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Evite los argumentos de salida
Un parámetro de salida, a veces también llamado parámetro de resultado, es un argumento de función que se usa para el valor de
retorno de la función.
Uno de los beneficios mencionados con frecuencia del uso de argumentos de salida es que las funciones que los usan pueden
devolver más de un valor a la vez. Aquí está un ejemplo típico:
bool ScriptInterpreter::executeCommand(const std::string& nombre,
const std::vector<std::string>& argumentos,
resultado& resultado);
Esta función miembro de la clase ScriptInterpreter devuelve no solo un bool. El tercer argumento es una referencia no
constante a un objeto de tipo Result, que representa el resultado real de la función. El valor de retorno booleano es para determinar
si la ejecución del comando fue exitosa por parte del intérprete. Una llamada típica de esta función miembro podría verse así:
Intérprete de ScriptInterpreter; // Muchas
otras preparaciones...
resultado resultado;
if (interpreter.executeCommand(commandName, argumentList, result)) {
// Continuar normalmente... } else
{ // Manejar
la ejecución fallida del comando...
}
Mi simple consejo es este:
Evite los argumentos de salida a toda costa.
Los argumentos de salida no son intuitivos y pueden generar confusión. A veces, la persona que llama no puede averiguar
fácilmente si un objeto pasado se trata como un parámetro de salida y posiblemente la función lo modifique.
Además, los parámetros de salida complican la fácil composición de expresiones. Si las funciones sólo tienen
un valor de retorno, se pueden interconectar con bastante facilidad a llamadas de función encadenadas. Por el contrario, si las funciones
tienen múltiples parámetros de salida, los desarrolladores se ven obligados a preparar y manejar todas las variables que contendrán los
valores de los resultados. Por lo tanto, el código que llama a estas funciones puede convertirse rápidamente en un desastre.
Especialmente si se debe fomentar la inmutabilidad y se deben reducir los efectos secundarios, entonces los parámetros de
salida son una idea absolutamente terrible. Como era de esperar, todavía es imposible pasar un objeto inmutable (consulte el Capítulo 9)
como un parámetro de salida.
Si un método debe devolver algo a sus llamadores, deje que el método lo devuelva como el valor devuelto por los métodos.
Si el método debe devolver varios valores, rediseñarlo para que devuelva una única instancia de un objeto que contenga los valores.
Alternativamente, se puede usar una std::tuple (ver Barra lateral) o un std::pair.
sesenta y cinco
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
std::tupla Y std::make_tuple [C++11]
Una plantilla de clase a veces útil está disponible desde C ++ 11 que puede contener una colección de tamaño fijo de
valores heterogéneos resp. objetos: std::tuple. Se define en el encabezado <tuple> de la siguiente manera:
plantilla< clase... Tipos >
clase tupla;
Es una plantilla llamada variádica, es decir, es una plantilla que puede tomar un número variable de argumentos de
plantilla. Por ejemplo, si debe contener varios valores diferentes de diferentes tipos como un solo objeto, puede escribir lo
siguiente:
usando Cliente = std::tuple<std::string, std::string, std::string, Money, unsigned int>; // ...
Cliente unCliente = std::make_tuple("Stephan", "Roth", "Bad Schwartau",
saldo pendiente, timeForPaymentInDays);
std::make_tuple crea el objeto tupla, deduciendo el tipo objetivo de los tipos de argumentos. Con la palabra clave auto puede
dejar que el compilador deduzca el tipo de aCustomer de su inicializador:
auto aCustomer = std::make_tuple("Stephan", "Roth", "Bad Schwartau", saldo
pendiente, timeForPaymentInDays);
Desafortunadamente, el acceso a elementos individuales de una instancia de std::tuple solo es posible a través de su índice.
Por ejemplo, para recuperar la ciudad de un Cliente, debe escribir el siguiente código:
auto city = std::get<2>(unCliente);
Esto es contrario a la intuición y puede reducir la legibilidad del código.
Mi consejo es usar la plantilla de clase std::tuple solo en casos excepcionales. Solo debe usarse para combinar cosas
temporalmente, que de todos modos no van juntas. Una vez que los datos (atributos, objetos) deben mantenerse juntos,
debido a que su cohesión es alta, generalmente justifica la introducción de un tipo explícito para este conjunto de datos: ¡una
clase!
Si también debe distinguir básicamente entre el éxito y el fracaso, entonces puede usar el llamado
Patrón de objeto de caso especial (consulte el Capítulo 9 sobre patrones de diseño) para devolver un objeto que representa un
resultado no válido.
66
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
No pase ni devuelva 0 (NULL, nullptr)
EL ERROR DEL MIL MILLÓN DE DÓLARES
Sir Charles Antony Richard Hoare, comúnmente conocido como Tony Hoare o CAR Hoare, es un famoso
científico informático británico. Es conocido principalmente por el algoritmo Quick Sort. En 1965, Tony Hoare
trabajó junto con el informático suizo Niklaus E. Wirth en el desarrollo del lenguaje de programación ALGOL.
Introdujo referencias nulas en el lenguaje de programación ALGOL W, que fue el predecesor de PASCAL.
Más de 40 años después, Tony Hoare lamenta esta decisión. En una charla en la Conferencia QCon 2009 en
Londres, dijo que la introducción de referencias nulas probablemente había sido un error histórico de mil
millones de dólares. Argumentó que las referencias nulas ya habían causado tantos problemas durante los siglos
pasados, que el costo probablemente podría ser de aproximadamente mil millones de dólares.
En C++, los punteros pueden apuntar a NULL o 0. Concretamente, esto significa que el puntero apunta a la memoria
dirección 0. NULL es solo una definición de macro:
#define NULL 0
Desde C++11, el lenguaje proporciona la nueva palabra clave nullptr, que es del tipo std::nullptr_t.
A veces veo funciones como esta:
Customer* findCustomerByName(const std::string& name) const { // Código que busca
al cliente por nombre... // ...y si no se encuentra el cliente: return
nullptr; // ...o NULL; }
Recibiendo NULL o nullptr (A partir de aquí, solo usaré nullptr en el siguiente texto por el bien
de simplicidad) como valor de retorno de una función puede ser confuso. ¿Qué debe hacer la persona que llama con él?
¿Qué significa? En el ejemplo anterior, podría ser que no exista un cliente con el nombre dado. Pero también puede significar
que podría haber habido un error crítico. Un nullptr puede significar fracaso, puede significar éxito y puede significar casi
cualquier cosa.
Mi consejo es este:
Si es inevitable devolver un puntero regular como resultado de una función o método, ¡no devuelva nullptr!
En otras palabras: si se ve obligado a devolver un puntero normal como resultado de una función (veremos más adelante
en que puede haber mejores alternativas), asegúrese de que el puntero que está devolviendo siempre apunte a una dirección
válida. Estas son mis razones por las que creo que esto es importante.
67
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
La razón principal por la que no debe devolver nullptr de una función es que transfiere la responsabilidad
de decidir qué hacer a sus llamadores. Tienen que comprobarlo. Tienen que lidiar con eso. Si las funciones pueden
potencialmente devolver nullptr, esto lleva a muchas verificaciones nulas, como esta:
Cliente* cliente = findCustomerByName("Stephan");
if (cliente != nullptr)
{ ProductosPedidos* productospedidos = cliente>getAllOrderedProducts(); if (productospedidos !
= nullptr) {
// Hacer algo con productospedidos... } else { // ¿Y
qué
debemos hacer aquí?
} } else { //
¿Y qué debemos hacer aquí?
}
Muchas comprobaciones nulas reducen la legibilidad del código y aumentan su complejidad. Y hay otro problema visible
que nos lleva directamente al siguiente punto.
Si una función puede devolver un puntero válido o nullptr, introduce una ruta de flujo alternativa que debe continuar la
persona que llama. Y debería conducir a una reacción razonable y sensata. Esto es a veces bastante problemático. ¿Cuál sería
la respuesta correcta e intuitiva en nuestro programa cuando nuestro puntero a Customer no apunta a una instancia válida,
sino a nullptr? ¿Debe el programa cancelar la operación en ejecución con un mensaje? ¿Existe algún requisito de que
cierto tipo de continuación del programa sea obligatoria en tales casos? Estas preguntas a veces no se pueden responder
bien. La experiencia ha demostrado que a menudo es relativamente fácil para las partes interesadas describir todos los llamados
Happy Day Cases de su software, que son los casos positivos durante el funcionamiento normal. Es mucho más difícil describir
el comportamiento deseado del software para las excepciones, errores y casos especiales.
La peor consecuencia puede ser esta: si se olvida cualquier verificación nula, esto puede conducir a un tiempo de ejecución crítico
errores Eliminar la referencia de un puntero nulo provocará un error de segmentación y la aplicación se bloqueará.
En C++ todavía hay otro problema a considerar: la propiedad del objeto.
Para la persona que llama a la función, es vago qué hacer con el recurso señalado por el puntero después de su uso.
¿Quién es su dueño? ¿Es necesario eliminar el objeto? En caso afirmativo: ¿Cómo se desechará el recurso? ¿Se debe
eliminar el objeto con eliminar, porque se asignó con el operador nuevo en algún lugar dentro de la función? ¿O la
propiedad del objeto de recurso se administra de manera diferente, por lo que se prohíbe una eliminación y dará como
resultado un comportamiento indefinido (consulte la sección "No permitir un comportamiento indefinido" en el Capítulo 5 )? ¿Es
quizás incluso un recurso del sistema operativo que debe manejarse de una manera muy especial?
De acuerdo con el Principio de ocultación de información (consulte el Capítulo 3), esto no debería tener relevancia
para la persona que llama, pero de hecho le hemos impuesto la responsabilidad del recurso. Y si la persona que llama no
maneja el puntero correctamente, puede provocar errores graves, por ejemplo, fugas de memoria, eliminación doble,
comportamiento indefinido y, en ocasiones, vulnerabilidades de seguridad.
Estrategias para evitar punteros regulares
Prefiere la construcción de objetos simples en la pila en lugar de en el montón
La forma más sencilla de crear un nuevo objeto es simplemente creándolo en la pila, de esta manera:
#include "Cliente.h" // ...
cliente cliente;
68
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
En el ejemplo anterior, se crea una instancia de la clase Cliente (definida en el encabezado Cliente.h) en el
pila. La línea de código que crea la instancia generalmente se puede encontrar en algún lugar dentro del cuerpo de una
función o método. Eso significa que la instancia se destruye automáticamente si la función o el método se queda fuera del
alcance, lo que sucede cuando regresamos de la función o el método respectivo.
Hasta ahora, todo bien. Pero, ¿qué haremos si un objeto que se creó en una función o método debe devolverse a la
persona que llama?
En el estilo antiguo de C++, este desafío a menudo se enfrentaba de tal manera que el objeto se creaba en el
montón (usando el operador nuevo) y luego devuelto desde la función como un puntero a este recurso asignado.
Cliente* createDefaultCustomer() {
Cliente* cliente = nuevo Cliente(); // Hacer algo
más con el cliente, por ejemplo, configurarlo, y al final... volver al cliente;
La razón comprensible de este enfoque es que, si estamos tratando con un objeto grande, un costoso
La construcción de copias se puede evitar de esta manera. Pero ya hemos discutido los inconvenientes de esta solución en la
sección anterior. Por ejemplo, ¿qué debe hacer la persona que llama si el puntero devuelto es nullptr? Además, la persona que
llama a la función se ve obligada a estar a cargo de la gestión de recursos (por ejemplo, eliminar el puntero devuelto de la
manera correcta).
Buenas noticias: desde C++ 11, podemos simplemente devolver objetos grandes como valores sin preocuparnos por una
construcción de copia costosa.
Cliente createDefaultCustomer() {
cliente cliente; // Hacer
algo con el cliente, y al final... devolver al cliente;
La razón por la que ya no tenemos que preocuparnos por la gestión de recursos en este caso es la llamada semántica de
movimiento, que se admite desde C++ 11. En pocas palabras, el concepto de semántica de movimiento permite que los recursos
se "muevan" de un objeto a otro en lugar de copiarlos. El término "mover" significa en este contexto que los datos internos de un
objeto se eliminan del antiguo objeto de origen y se colocan en un nuevo objeto. Es una transferencia de propiedad de los datos
de un objeto a otro objeto, y esto se puede realizar extremadamente rápido (la semántica de movimiento de C++ 11 se analiza en
detalle en el siguiente Capítulo 5 ).
Con C++11, todas las clases de contenedores de la biblioteca estándar se han ampliado para admitir la semántica de movimiento.
Esto no solo los ha hecho muy eficientes, sino también mucho más fáciles de manejar. Por ejemplo, para devolver un vector
grande que contiene cadenas de una función de una manera muy eficiente, puede hacerlo como se muestra en el siguiente
ejemplo:
Listado 428. Desde C++ 11, un objeto grande instanciado localmente puede devolverse fácilmente por valor
#include <vector>
#include <cadena>
utilizando StringVector = std::vector<std::string>; const
StringVector::size_type CANTIDAD_DE_CADENAS = 10000;
StringVector crearLargeVectorOfStrings() {
StringVector theVector(CANTIDAD_DE_CADENAS, "Prueba");
devuelve el Vector; // ¡No se garantiza la construcción de copias aquí! }
69
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
La explotación de la semántica de movimiento es una muy buena manera de deshacerse de muchos punteros regulares. Pero podemos
hacer mucho más…
En la lista de argumentos de una función, use referencias (const) en lugar de punteros
En vez de escribir...
función vacía (tipo * argumento);
…deberías usar referencias de C++, así:
función vacía (tipo y argumento);
La principal ventaja de usar referencias en lugar de punteros para los argumentos es que no es necesario verificar que la
referencia no sea nullptr. La razón simple de esto es que las referencias nunca son "NULAS". (Está bien, sé que hay algunas posibilidades
sutiles en las que aún puede terminar con una referencia nula, pero esto presupone un estilo de programación muy tonto o amateur).
Y otra ventaja es que no necesita desreferenciar nada dentro de la función con la ayuda del operador de desreferencia (*). Eso
conducirá a un código más limpio. La referencia se puede usar dentro de la función, ya que se ha creado localmente en la pila. Por supuesto,
si no desea tener ningún efecto secundario, debe convertirlo en una referencia constante (consulte la próxima sección sobre Corrección
constante).
Si es inevitable lidiar con un puntero a un recurso, use uno inteligente
Si es inevitable usar un puntero porque el recurso debe crearse en el montón de manera obligatoria, debe envolverlo de inmediato y
aprovechar el llamado lenguaje RAII (Resource Acquisition is Initialization). Eso significa que debe usar un puntero inteligente
para ello. Dado que los punteros inteligentes y el lenguaje RAII juegan un papel importante en el C++ moderno, hay una sección
dedicada a este tema en el Capítulo 5.
Si una API devuelve un puntero sin formato...
…, bueno, entonces tenemos un “problemadepende”.
A menudo, las API devuelven punteros que están más o menos fuera de nuestras manos. Los ejemplos típicos son las
bibliotecas de terceros.
En el caso afortunado de que nos enfrentemos a una API bien diseñada que proporcione métodos de fábrica para crear
recursos y también métodos para devolverlos a la biblioteca para su eliminación segura y adecuada, hemos ganado. En este caso, una
vez más podemos aprovechar el lenguaje RAII (Resource Acquisition Is Initialization; consulte el Capítulo 5). Podemos crear un
puntero inteligente personalizado para envolver el puntero normal, cuyo asignador resp. deallocator podría manejar el recurso
administrado como lo esperaba la biblioteca de terceros.
El poder de la corrección constante
La corrección constante es un enfoque poderoso para un código mejor y más seguro en C++. El uso de const puede ahorrar muchos
problemas y tiempo de depuración, porque las violaciones de const provocan errores en tiempo de compilación. Y como una especie de
efecto secundario, el uso de const también puede ayudar al compilador a aplicar algunos de sus algoritmos de optimización. Eso significa
que el uso adecuado de este calificador también es una manera fácil de aumentar un poco el rendimiento de ejecución del programa.
70
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Desafortunadamente, muchos desarrolladores subestiman los beneficios de un uso intensivo de const. Mi consejo es este:
Preste atención a la corrección constante. Use const tanto como sea posible y elija siempre una declaración adecuada
de variables u objetos como mutables o inmutables.
En general, la palabra clave const en C++ evita que el programa pueda mutar los objetos. Pero const se puede usar en
diferentes contextos. La palabra clave tiene muchas caras.
Su uso más simple es definir una variable como una constante:
const largo doble PI = 3.141592653589794;
Otro uso es evitar que se modifiquen los parámetros que se pasan a una función. Dado que hay varias variaciones, a
menudo genera confusión. Aquí hay unos ejemplos:
unsigned int determineWeightOfCar(Car const* car); // 1 void lacarCoche(Coche*
const coche); // 2 unsigned int determineWeightOfCar(Car
const* const car); // 3 void imprimirMensaje(const std::string& mensaje); // 4 void
imprimirMensaje(std::string const& mensaje); // 5
1. El coche puntero apunta a un objeto constante de tipo Coche, es decir, el objeto Coche (el
“apuntado”) no se puede modificar.
2. El puntero coche es un puntero constante de tipo Coche, es decir, puede modificar el objeto Coche,
pero no puede modificar el puntero (por ejemplo, asignándole una nueva instancia de Coche).
3. En este caso, tanto el puntero como la punta (el objeto Coche) no se pueden
modificado.
4. El mensaje de argumento se pasa por referencia constante a la función, es decir, el
La variable de cadena a la que se hace referencia no se puede cambiar dentro de la función.
5. Esta es solo una notación alternativa para un argumento de referencia const. Es
funcionalmente equivalente a la línea 4 (…que por cierto prefiero).
■ Sugerencia Hay una regla general simple para leer los calificadores const de la manera correcta. Si los lee de
derecha a izquierda, entonces cualquier calificador const que aparezca modifica lo que está a la izquierda. Excepción: si
no hay nada a la izquierda, por ejemplo, al principio de una declaración, entonces const modifica la cosa a su derecha.
71
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Otro uso de la palabra clave const es declarar una función miembro (no estática) de una clase como const, como en este
ejemplo en la línea 5:
#incluir <cadena>
clase Coche
{ public:
std::string getRegistrationCode() const; void
setRegistrationCode(const std::string& registrationCode); // ...
privado:
std::string _registrationCode; // ... };
A diferencia del setter de la línea 6, la función miembro getRegistrationCode de la línea 5 no puede modificar las variables
miembro de la clase Car. La siguiente implementación de getRegistrationCode provocará un error de compilación, porque la función
intenta asignar una nueva cadena a _registrationCode:
std::string Coche::getRegistrationCode() {
std::string toBeReturned = código de registro; código de
registro = "foo"; // ¡Error en tiempo de compilación! volver a ser
devuelto;
}
Acerca del estilo antiguo de C en proyectos de C++
Si observa programas C++ relativamente nuevos (por ejemplo, en GitHub o Sourceforge), se sorprenderá de cuántos de
estos programas supuestamente "nuevos" todavía contienen innumerables líneas de código C antiguo.
Bueno, C sigue siendo un subconjunto del lenguaje C++. Esto significa que los elementos de lenguaje de C todavía están disponibles.
Desafortunadamente, muchas de estas antiguas construcciones de C tienen importantes inconvenientes cuando se trata de
escribir código limpio, seguro y moderno. Y claramente hay mejores alternativas.
Por lo tanto, un consejo básico es dejar de usar esas construcciones de C antiguas y propensas a errores siempre
que existan mejores alternativas de C++. Y hay muchas de estas posibilidades. Hoy en día, puede prescindir casi por completo de
la programación en C en C++ moderno.
Preferir cadenas y flujos de C++ a caracteres antiguos de estilo C* Una cadena denominada C++
forma parte de la biblioteca estándar de C++ y es de tipo std::string o std::wstring (ambas definidas en el encabezado
<string>). De hecho, ambas son definiciones de tipo en la plantilla de clase std::basic_string<T> y se definen de esta manera:
typedef basic_string<char> cadena; typedef
basic_string<wchar_t> wstring;
72
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Para crear una cadena de este tipo, se debe crear una instancia de un objeto de una de estas dos plantillas, por ejemplo, con su
constructor de inicialización:
std::string nombre("Stephan");
En comparación con esto, una cadena de estilo C es simplemente una matriz de caracteres (tipo char o wchar_t) que termina con
un terminador cero (a veces también llamado terminador nulo). Un terminador cero es un carácter especial ('\0', código ASCII 0) que se
utiliza para indicar el final de la cadena. Una cadena de estilo C se puede definir de esta manera:
char nombre[] = "Stephan";
En este caso, el terminador cero se agrega automáticamente al final de la cadena, es decir, la longitud de la cadena es de 8
caracteres. Un punto importante es que debemos tener en cuenta que todavía estamos tratando con una variedad de personajes.
Esto significa, por ejemplo, que tiene un tamaño fijo. Puede cambiar el contenido de la matriz utilizando el operador de índice, pero
no se pueden agregar más caracteres al final de la matriz. Y si el terminador cero al final se sobrescribe accidentalmente, esto puede
causar varios problemas.
La matriz de caracteres se usa a menudo con la ayuda de un puntero que apunta al primer elemento, por ejemplo, cuando se
pasa como argumento de función:
char* pointerToName = nombre;
función vacía (char* pointerToCharacterArray) {
//...
}
Sin embargo, en muchos programas de C++, así como en los libros de texto, las cadenas C todavía se usan con frecuencia.
¿Hay alguna buena razón para usar cadenas de estilo C en C++ hoy en día?
Sí, hay algunas situaciones en las que aún puede usar cadenas de estilo C. Presentaré algunas de estas excepciones.
Pero para la abrumadora cantidad de cadenas en un programa C++ moderno, deben implementarse usando cadenas C++. Los objetos
de tipo std::string respectivamente std::wstring brindan numerosas ventajas en comparación con las antiguas cadenas de estilo C:
•Los objetos de cadena C++ administran su memoria por sí mismos, de modo que puede copiarlos, crearlos y
destruirlos fácilmente. Eso significa que lo liberan de la administración de la vida útil de los datos de la
cadena, lo que puede ser una tarea difícil y desalentadora al usar matrices de caracteres de estilo C.
•Son mutables. La cadena se puede manipular fácilmente de varias maneras: agregando cadenas o caracteres
individuales, concatenando cadenas, reemplazando partes de la cadena, etc.
•Las cadenas C++ proporcionan una interfaz iteradora conveniente. Al igual que con todos los demás Estándar
Tipos de contenedores de biblioteca, std::string respectivamente std::wstring le permite iterar sobre sus
elementos (es decir, sobre sus caracteres). Esto también significa que todos los algoritmos adecuados
que se definen en el encabezado <algoritmo> se pueden aplicar a la cadena.
•Las cadenas de C++ funcionan perfectamente junto con flujos de E/S de C++ (p. ej., ostream,
stringstream, fstream, etc.) para que pueda aprovechar fácilmente todas esas útiles funciones de
transmisión.
•Desde C++11, la biblioteca estándar utiliza ampliamente la semántica de movimiento. Muchos
los algoritmos y contenedores ahora están optimizados para movimiento. Esto también se aplica a las cadenas de C++.
Por ejemplo, una instancia de std::string a menudo se puede devolver simplemente como el valor
de retorno de una función. Los enfoques que antes eran todavía necesarios con punteros o referencias
para devolver de manera eficiente objetos de cadena grandes desde una función, es decir, sin la costosa
copia de los datos de la cadena, ahora ya no son necesarios.
73
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
En resumen, se pueden dar los siguientes consejos:
Aparte de unas pocas excepciones, las cadenas en un programa C++ moderno deben estar representadas por cadenas C++
tomadas de la Biblioteca estándar.
Bueno, pero ¿cuáles son las pocas excepciones que justifican el uso de cadenas antiguas de estilo C?
Por un lado, están las constantes de cadena, es decir, cadenas inmutables. Si solo necesita una matriz fija de caracteres fijos,
entonces std::string ofrece poca ventaja. Por ejemplo, puede definir una constante de cadena de este tipo de esta manera:
const char* const EDITOR = "Apress Media LLC";
En este caso, no se puede cambiar el valor al que se apunta, ni se puede modificar el puntero en sí.
(ver también la sección sobre Corrección de constantes).
Otra razón para trabajar con cadenas C es la compatibilidad con las bibliotecas de API de estilo C, respectivamente. Muchas
bibliotecas de terceros suelen tener interfaces de bajo nivel para garantizar la compatibilidad con versiones anteriores y mantener su área de
uso lo más amplia posible. Las cadenas a menudo se esperan como cadenas de estilo C en dicha API. Sin embargo, incluso en este
caso, el uso de cadenas de estilo C debe limitarse localmente al manejo de esta interfaz. Lejos del intercambio de datos con una API de
este tipo, las cadenas C++ mucho más cómodas deben usarse siempre que sea posible.
Evite el uso de printf(), sprintf(), gets(), etc. printf(), que forma parte de la biblioteca
C para realizar operaciones de entrada/salida (definidas en el encabezado <cstdio>), imprime datos formateados en la salida estándar
(stdout ). Algunos desarrolladores todavía usan muchos printfs con fines de seguimiento/registro en su código C++. A menudo
argumentan que printf es... no... debe ser mucho más rápido que C++ I/OStreams, ya que falta toda la sobrecarga de C++.
Primero, la E/S es un cuello de botella de todos modos, sin importar si está usando printf() o std::cout. para escribir cualquier cosa en
La salida estándar es generalmente lenta, con magnitudes más lentas que la mayoría de las otras operaciones en un programa.
Bajo ciertas circunstancias, std::cout puede ser un poco más lento que printf(), pero en relación con el costo general de una operación de E/
S, esos pocos microsegundos suelen ser insignificantes. En este punto también me gustaría recordarles a todos que tengan cuidado
con las optimizaciones (prematuras) (recuerden la sección “Cuidado con las optimizaciones” en el Capítulo 3).
En segundo lugar, printf() es fundamentalmente de tipos inseguros y, por lo tanto, propenso a errores. La función espera una
secuencia de argumentos sin tipo relacionados con una cadena C llena de especificadores de formato, que es el primer argumento. Las
funciones que no se pueden usar de manera segura nunca deben usarse, ya que esto puede generar errores sutiles, comportamiento
indefinido (consulte la sección sobre Comportamiento indefinido en el Capítulo 5) y vulnerabilidades de seguridad.
74
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
estándar::to_String() [C++11]
No utilice la función de C sprintf() (encabezado <cstdio>) con fines de conversión en un programa C++
moderno. Desde C++11, todas las variables de tipo numérico se pueden convertir fácilmente a una
cadena de C++ utilizando la función segura y conveniente std::to_string() respectivamente std::to_wstring() ,
definida en el encabezado <string>. Por ejemplo, un entero con signo se puede convertir en un std::string
que contiene una representación textual del valor de la siguiente manera:
valor int { 42 };
std::string valueAsString = std::to_string(value);
std::to_string() respectivamente std::to_wstring() está disponible para todos los tipos integrales o de punto
flotante, como int, long, long long, unsigned int, float, double, etc. Pero uno de los principales inconvenientes de
este simple asistente de conversión es su inexactitud en ciertos casos.
doble d { 1e9 };
std::cout << std::to_string(d) << "\n"; // ¡Precaución! Salida: 0.000000
Además, no hay capacidades de configuración para controlar cómo to_string() da formato a la cadena de
salida, por ejemplo, la cantidad de lugares decimales. Eso significa que esta función solo se puede usar
de facto en una medida menor en un programa real. Si necesita una conversión más precisa y personalizada,
debe proporcionarla usted mismo. En lugar de usar sprintf(), puede aprovechar los flujos de cadena
(encabezado <sstream>) y las capacidades de configuración de los manipuladores de E/S definidos en
el encabezado <iomanip>, como en el siguiente ejemplo:
#include <iomanip>
#include <sstream>
#include <cadena>
std::string convertDoubleToString(const long double valueToConvert, const int precision)
{ std::stringstream
stream { }; stream << std::fixed <<
std::setprecision(precision) << valueToConvert; volver corriente.str(); }
En tercer lugar, a diferencia de printf, los flujos de E/S de C++ permiten que los objetos complejos se transmitan fácilmente proporcionando
un operador de inserción personalizado (operador<<). Supongamos que tenemos una factura de clase (definida en un archivo de encabezado
llamado Factura.h) que se parece a esto:
Listado 429. Un extracto del archivo Invoice.h con números de línea
01 #ifndef INVOICE_H_ 02
#define INVOICE_H_ 03 04
#include <crono> 05
#include <memoria> 06
#include <ostream>
75
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
07 #include <string> 08
#include <vector> 09 10
#include "Customer.h" 11 #include
"InvoiceLineItem.h" 12 #include "Money.h"
13 #include "UniqueIdentifier.h"
14
15 usando InvoiceLineItemPtr = std::shared_ptr<InvoiceLineItem>; 16 usando
InvoiceLineItems = std::vector<InvoiceLineItemPtr>; 17
18 usando InvoiceRecipient = Cliente; 19 usando
InvoiceRecipientPtr = std::shared_ptr<InvoiceRecipient>; 20
21 usando DateTime = std::chrono::system_clock::time_point; 22 23 clase
Factura { 24 public:
explícito
Factura(const UniqueIdentifier& númerofactura); 25 Factura() = borrar; 26 27
void setRecipient(const
InvoiceRecipientPtr& recipiente); 28 void setDateTimeOfInvoicing(const DateTime&
dateTimeOfInvoicing); Dinero getSum() const; 29 Dinero getSumWithoutTax() const; 30 void
addLineItem(const
InvoiceLineItemPtr& lineItem); 31 // ...posiblemente
más funciones miembro aquí... 32 33 34 private: amigo std::ostream&
operator<<(std::ostream& outstream, const Invoice& factura); 35
std::string
getDateTimeOfInvoicingAsString() const; 36 37 38 39 40
UniqueIdentifier número de factura;
DateTime dateTimeOfInvoicing;
destinatario FacturaRecipientPtr;
Elementos de línea de factura elementos de
línea
de factura; 41 42 }; 43 //...
La clase tiene dependencias con un destinatario de la factura (que en este caso es un alias para el Cliente
definido en el encabezado Cliente.h; consulte la línea n.º 18) y utiliza un identificador (tipo UniqueIdentifier) que representa un
número de factura que se garantiza que será único entre todos los números de factura. Además, la factura utiliza un tipo
de datos que puede representar montos de dinero (consulte también la sección "Clase de dinero" en el Capítulo 9 sobre
patrones de diseño), así como una dependencia a otro tipo de datos que representa un único elemento de línea de factura.
Este último se utiliza para administrar una lista de artículos de factura dentro de la factura usando un std::vector (ver línea
n. ° 16 respectivamente 41). Y para representar el momento de la facturación, usamos el tipo de datos time_point de la
biblioteca Chrono (definida en el encabezado <chrono>), que está disponible desde C++11.
Ahora imaginemos además que queremos transmitir la factura completa con todos sus datos a la salida estándar.
¿No sería bastante simple y conveniente si pudiéramos escribir algo como...
std::cout << instanciaDeFactura;
76
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Bueno, eso es posible con C++. El operador de inserción (<<) para flujos de salida se puede sobrecargar para cualquier clase.
Solo tenemos que agregar una función operator<< a nuestra declaración de clase en el encabezado. Es importante hacer de esta
función un amigo de la clase (ver línea n.° 35) porque se llamaría sin crear un objeto.
Listado 430. El operador de inserción para la clase Factura
43 // ... 44
std::ostream& operator<<(std::ostream& outstream, const Factura& factura) { << factura.númeroFactura << "\n"; 45 46
"
outstream << "Factura No.:
"Destinatario: " << << *(factura.destinatario) << "\n"; outstream <<
47 factura.getDateTimeOfInvoicingAsString() << "\n"; salida << "Fecha/hora: "
48 outstream << "Elementos:" << "\n"; for (const
49 auto& item : factura.invoiceLineItems) { << *item << "\n";
" "
50 aguas afuera <<
51
52 } outstream << "Importe facturado: " << factura.getSum() << std::endl;
53 retorno aguas abajo; 54 }
55 // ...
Todos los componentes estructurales de la clase Factura se escriben en un flujo de salida dentro de la función.
Esto es posible porque también las clases UniqueIdentifier, InvoiceRecipient y InvoiceLineItem tienen sus propias funciones de operador
de inserción (que no se muestran aquí) para los flujos de salida. Para imprimir todos los elementos de línea en el vector, se utiliza un
ciclo for basado en rangos de C++11. Y para obtener una representación textual de la fecha de facturación, usamos un método de ayuda
interno llamado getDateTimeOfInvoicingAsString() que devuelve una cadena de fecha/hora bien formateada.
Entonces, mi consejo para un programa C++ moderno es este:
Evite usar printf(), y también otras funciones C inseguras, como sprintf(), puts(), etc.
Prefiere los contenedores de biblioteca estándar a las matrices simples de estilo C En lugar de usar matrices de estilo C,
debe usar la plantilla std::array<TYPE, N> que está disponible desde C++11 (header <array>). Las instancias de std::array<TYPE,
N> son contenedores de secuencias de tamaño fijo y son tan eficientes como las matrices ordinarias de estilo C.
Los problemas con las matrices de estilo C son más o menos los mismos que con las cadenas de estilo C (consulte la sección anterior).
Las matrices C son malas, porque se transmiten como un puntero sin formato a su primer elemento. Esto podría ser
potencialmente peligroso, porque no hay comprobaciones vinculadas que protejan a los usuarios de esa matriz para acceder a
elementos inexistentes. Las matrices creadas con std::array son más seguras porque no se degradan a punteros (consulte también la
sección "Estrategias para evitar punteros regulares", anteriormente en este capítulo).
77
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Una ventaja de usar std::array es que conoce su tamaño (número de elementos). Cuando se trabaja con arreglos,
el tamaño de ese arreglo es información importante que a menudo se requiere. Las matrices ordinarias de estilo C no
conocen su propio tamaño. Por lo tanto, el tamaño de la matriz a menudo debe manejarse como una información
adicional, por ejemplo, en una variable adicional. Por ejemplo, el tamaño debe pasarse como un argumento adicional
a las llamadas a funciones como en el siguiente ejemplo.
const std::size_t arraySize = 10;
MiArrayType array[arraySize];
void function(MyArrayType const* array, const std::size_t arraySize) {
// ...
}
Estrictamente hablando, en este caso la matriz y su tamaño no forman una unidad cohesiva (ver la
sección sobre Cohesión Fuerte en el Capítulo 3). Además, ya sabemos por una sección anterior sobre argumentos y
valores devueltos que el número de argumentos de la función debe ser lo más pequeño posible.
Por el contrario, las instancias de std::array llevan su tamaño y se puede consultar cualquier instancia al respecto. Por lo tanto, la
Las listas de argumentos de funciones o métodos no requieren parámetros adicionales sobre el tamaño de la matriz:
#incluye <matriz>
usando MyTypeArray = std::array<MyType, 10>;
función vacía (const MyTypeArray& array) {
const std::size_t arraySize = array.size(); //...
Otra ventaja notable de std::array es que tiene una interfaz compatible con STL. La plantilla de clase
proporciona funciones de miembros públicos para que se vea como cualquier otro contenedor en la Biblioteca estándar.
Por ejemplo, los usuarios de una matriz pueden obtener un iterador que apunte al comienzo y al final de la secuencia
usando std::array::begin() respectivamente std::array::end(). Esto también significa que los algoritmos del
encabezado <algoritmo> se pueden aplicar a la matriz (consulte también la sección sobre algoritmos en el siguiente capítulo).
#incluye <matriz>
#incluye <algoritmo>
usando MyTypeArray = std::array<MyType, 10>;
matriz MiTipoArray;
void hacerAlgoConCadaElemento(const MiTipo&elemento) {
// ...
}
std::for_each(std::cbegin(arreglo), std::cend(arreglo), hacerAlgoConCadaElemento);
78
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
NO MIEMBRO std::begin( ) Y std::end( ) [C++11/14]
Cada contenedor de la biblioteca estándar de C++ tiene una función miembro begin() respectivamente cbegin() y end()
respectivamente cend() para recuperar iteradores respectivamente constiteradores para ese contenedor.
C++11 ha introducido funciones gratuitas para no miembros con ese fin: std::begin(<container>) y
std::end(<container>). Con C++14, las funciones aún faltantes std::cbegin(<container>),
std::cend(<container>), std::rbegin(<container>) y std::rend(<container>) tienen sido agregado En lugar
de usar las funciones miembro, ahora se recomienda usar estas funciones que no son miembros
(todas definidas en el encabezado <iterador>) para obtener iteradores respectivamente constiteradores
para un contenedor, como este:
#incluir <vector>
std::vector<CualquierTipo> aVector;
iteración automática = std::begin(aVector); // ...en lugar de 'auto iter = aVector.begin();'
La razón es que esas funciones libres permiten un estilo de programación más flexible y genérico. Por ejemplo,
muchos contenedores definidos por el usuario no tienen una función de miembro begin() y end() , lo que los hace
imposibles de usar con los algoritmos de la biblioteca estándar (consulte la sección sobre algoritmos en el Capítulo
5) o cualquier otra plantilla definida por el usuario. función que requiere iteradores. Las funciones que no son
miembros para recuperar iteradores son extensibles en el sentido de que pueden sobrecargarse para cualquier tipo de
secuencia, incluidas las antiguas matrices de estilo C. En otras palabras: los contenedores no compatibles con STL
(personalizados) se pueden adaptar con capacidades de iterador.
Por ejemplo, suponga que tiene que lidiar con una matriz de enteros de estilo C, como esta:
int fibonacci[] = { 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144 };
Este tipo de matriz ahora se puede adaptar con una interfaz de iterador compatible con la biblioteca estándar.
Para las matrices de estilo C, estas funciones ya se proporcionan en la Biblioteca estándar, por lo que no es necesario
que las programe usted mismo. Se ven más o menos así:
template <typename Tipo, std::size_t size>
Tipo* begin(Tipo (&elemento)[tamaño])
{ return &elemento[0];
}
template <typename Tipo, std::size_t size>
Tipo* end(Tipo (&elemento)[tamaño])
{ return &elemento[0] + tamaño;
}
79
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Para insertar todos los elementos de la matriz en un flujo de salida, por ejemplo, para imprimirlos en la salida estándar, ahora
podemos escribir:
int main() { for
(auto it = begin(fibonacci); it != end(fibonacci); ++it) { std::cout << *it << ", "; } estándar::cout
<< estándar::endl; devolver 0; }
Proporcionar funciones begin() y end() sobrecargadas para tipos de contenedores personalizados o matrices antiguas de
estilo C permite la aplicación de todos los algoritmos de la biblioteca estándar a estos tipos.
Además, std::array puede acceder a elementos que incluyen cheques enlazados con la ayuda de la función miembro
std::array::at(size_type n). Si el índice dado está fuera de los límites, se lanza una excepción de tipo std::out_ of_bounds.
Usar moldes de C++ en lugar de moldes antiguos de estilo C
Antes de que surja una falsa impresión, primero me gustaría hacer una advertencia importante:
■ Advertencia ¡Los moldes tipográficos son básicamente malos y deben evitarse siempre que sea posible! Son una indicación confiable
de que debe haber, aunque solo sea un problema de diseño relativamente pequeño.
Sin embargo, si no se puede evitar una conversión de tipo en una situación determinada, bajo ninguna circunstancia debe
usar una conversión de estilo C:
doble d { 3.1415 }; int i =
(int)d;
En este caso, el doble se degrada a un número entero. Esta conversión explícita va acompañada de una pérdida de
precisión ya que los lugares decimales del número de punto flotante se desechan. La conversión explícita con el molde de estilo C
dice algo como esto: "El programador que escribió esta línea de código estaba al tanto de las consecuencias".
Bueno, esto es ciertamente mejor que una conversión de tipo implícita. Sin embargo, en lugar de usar el antiguo estilo C
conversiones, debe usar conversiones de C++ para conversiones de tipo explícitas, como esta:
int i = static_cast<int>(d);
80
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
La explicación simple para este consejo es: ¡el compilador verifica las conversiones de estilo C ++ durante el tiempo de compilación! Las
conversiones de estilo C no se verifican de esta manera y, por lo tanto, pueden fallar en el tiempo de ejecución, lo que puede causar errores
desagradables o fallas en la aplicación. Por ejemplo, una conversión de estilo C usada de manera imprevista puede causar una pila corrupta,
como en el siguiente caso.
int32_t i { 200 }; // Reserva y usa memoria de 4 bytes int64_t*
pointerToI = (int64_t*)&i; // El puntero apunta a 8 bytes
*punteroToI = 9223372036854775807; // Puede causar un error en tiempo de ejecución a través de la corrupción de la pila
Obviamente, en este caso es posible escribir un valor de 64 bits en un área de memoria que solo tiene un tamaño de 32 bits.
El problema es que el compilador no puede llamar nuestra atención sobre este código potencialmente peligroso. El compilador
traduce este código, incluso con configuraciones muy conservadoras (g++ std=c++17 pedantic pedantic errors Wall Wextra
Werror Wconversion), sin quejas. Esto puede conducir a errores muy insidiosos durante la ejecución del programa.
Ahora veamos qué sucederá si usamos un static_cast de C++ en la segunda línea en lugar del viejo y malo
Reparto estilo C:
int64_t* pointerToI = static_cast<int64_t*>(&i); // El puntero apunta a 8 bytes
El compilador ahora puede detectar la conversión problemática e informa el mensaje de error correspondiente:
error: static_cast no válido del tipo 'int32_t* {aka int*}' para escribir 'int64_t* {aka long int*}'
Otra razón por la que debe usar conversiones de C++ en lugar de las antiguas conversiones de estilo C es que las
conversiones de estilo C son muy difíciles de detectar en un programa. Ni pueden ser descubiertos fácilmente por el desarrollador,
ni pueden ser buscados convenientemente utilizando un editor o procesador de textos común. Por el contrario, es muy fácil
buscar términos como static_cast<>, const_cast<> o dynamic_cast<>.
De un vistazo, aquí están todos los consejos sobre conversiones de tipos para un programa C++ moderno y bien
diseñado:
1. Trate de evitar las conversiones de tipos (casts) en todas las circunstancias. En su lugar, intente
eliminar el error de diseño subyacente que lo obliga a utilizar la conversión.
2. Si no se puede evitar una conversión de tipo explícita, utilice únicamente conversiones de
estilo C++ (static_cast<> o const_cast<>), ya que el compilador comprueba estas
conversiones. Nunca use moldes de estilo C viejos y malos.
3. Tenga en cuenta que dynamic_cast<> nunca debe usarse porque se considera un mal diseño. La
necesidad de un dynamic_cast<> es una indicación confiable de que algo anda mal dentro de una
jerarquía de especialización (este tema se profundizará en el Capítulo 6 sobre la Orientación a
Objetos).
4. Bajo ninguna circunstancia, nunca use reinterpret_cast<>. Este tipo de tipo
La conversión marca una conversión insegura, no portátil y dependiente de la implementación.
Su nombre largo e inconveniente es una pista general para hacerte pensar en lo que estás
haciendo actualmente.
81
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
Evite las macros
Quizás uno de los legados más severos del lenguaje C son las macros. Una macro es una pieza de código que se puede
identificar por un nombre. Si el llamado preprocesador encuentra el nombre de una macro en el código fuente del programa durante la
compilación, el nombre se reemplaza por su fragmento de código relacionado.
Un tipo de macros son las macros de tipo objeto que a menudo se usan para dar nombres simbólicos a números.
constantes, como en el siguiente ejemplo.
Listado 431. Dos ejemplos de macros similares a objetos
#define BUFFER_SIZE 1024 #define
PI 3.14159265358979
Otros ejemplos típicos de macros son los siguientes:
Listado 432. Dos ejemplos de macros similares a funciones
#define MIN(a,b) (((a)<(b))?(a):(b)) #define MAX(a,b)
(((a)>(b))?(a): (b))
MÍN. resp. MAX compara dos valores y devuelve el más pequeño respectivamente el más grande. Este tipo de macros
se denominan macros similares a funciones. Aunque estas macros parecen casi funciones, no lo son. El preprocesador C realiza
simplemente una sustitución del nombre por el fragmento de código relacionado (de hecho, es una operación textual de buscar y
reemplazar).
Las macros son potencialmente peligrosas. A menudo no se comportan como se esperaba y pueden tener efectos secundarios no deseados.
efectos Por ejemplo, supongamos que ha definido una macro como esta:
#define PELIGROSO 1024+1024
Y en algún lugar de tu código escribes esto:
valor int = PELIGROSO * 2;
Probablemente alguien esperaba que el valor de la variable contuviera 4096, pero en realidad sería 3072.
Recuerde el orden de las operaciones matemáticas que nos dice que la división y la multiplicación, de izquierda a derecha, deben ocurrir
primero.
Otro ejemplo de efectos secundarios inesperados debido al uso de una macro es el uso de 'MAX' en el siguiente
forma:
int máximo = MAX(12, valor++);
El preprocesador generará lo siguiente:
int máximo = (((12)>(valor++))?(12):(valor++));
Como se puede ver fácilmente ahora, la operación posterior al incremento en el valor se realizará dos veces. Esto era
ciertamente no era la intención del desarrollador que había escrito el código anterior.
¡No uses más macros! Al menos desde C++ 11, están casi obsoletos. Con algunos muy raros
excepciones, las macros simplemente ya no son necesarias y ya no deberían usarse en un programa C++ moderno.
Tal vez la introducción del llamado Reflection (es decir, la capacidad de que el programa pueda examinar, introspeccionar y modificar
su propia estructura y comportamiento en tiempo de ejecución) como posible parte de un futuro estándar de C++ pueda ayudar a
deshacerse de las macros por completo. Pero hasta que llegue el momento, las macros todavía se necesitan actualmente para algunos
propósitos especiales, por ejemplo, cuando se usa un marco de prueba de unidad o registro.
82
Machine Translated by Google
Capítulo 4 ■ Conceptos básicos de Clean C++
En lugar de macros similares a objetos, use expresiones constantes para definir constantes:
constexpr int INOFENSIVO = 1024 + 1024;
Y en lugar de macros similares a funciones, simplemente use funciones verdaderas, por ejemplo, las plantillas de
funciones std::min o std::max que se definen en el encabezado <algoritmo> (consulte también la sección sobre el encabezado
<algoritmo> en el siguiente capítulo):
#incluir <algoritmo> // ... int
máximo
= std::max(12, value++);
83
Machine Translated by Google
CAPÍTULO 5
Conceptos avanzados de C++ moderno
En los capítulos 3 y 4 discutimos los principios y prácticas básicos que construyen una base sólida para un código C++ limpio y
moderno. Con estos principios y reglas en mente, un desarrollador puede aumentar significativamente la calidad del código
C++ interno de un proyecto de software y, por lo tanto, a menudo, su calidad externa. El código se vuelve más comprensible, más
fácil de mantener, más fácil de extender, menos susceptible a errores, y esto conduce a una vida mejor para cualquier creador de
software, porque es más divertido trabajar con una base de código sólida como esa. Y en el Capítulo 2 también aprendimos
que, sobre todo, un conjunto bien mantenido de Pruebas unitarias bien diseñadas puede mejorar aún más la calidad del
software, así como la eficiencia del desarrollo.
Pero, ¿podemos hacerlo mejor? Por supuesto que podemos.
Como ya he explicado en la introducción de este libro, el viejo dinosaurio C++ ha experimentado algunas mejoras
considerables durante los últimos años. El lenguaje estándar C++11 (abreviatura de ISO/IEC 14882:2011), pero también los
siguientes estándares C++14 (que era solo una pequeña extensión de C++11) y la versión más reciente C++17 ( que llegó al
proceso de votación final de ISO en junio de 2017), han creado una herramienta de desarrollo moderna, flexible y eficiente a
partir del lenguaje de programación ya algo polvoriento.
Algunos de los nuevos conceptos introducidos a través de estos estándares, como la semántica de movimientos, son prácticamente un
cambio de paradigma.
Ya he usado algunas de las características de estos estándares de C++ en los capítulos anteriores y la mayor parte de
las expliqué en las barras laterales. Ahora es el momento de profundizar en algunos de ellos y explorar cómo pueden ayudarnos
a escribir código C++ excepcionalmente sólido y moderno. Por supuesto, no es posible discutir aquí todas las características
del lenguaje de los nuevos estándares C++. Eso iría mucho más allá del alcance de este libro, dejando de lado el hecho de
que esto está cubierto por muchos otros libros. Por lo tanto, he seleccionado algunos temas que creo que respaldan muy bien
la escritura de código C++ limpio.
Gestión de recursos
La gestión de recursos es un negocio básico para los desarrolladores de software. Una multitud de recursos misceláneos deben
asignarse, usarse y devolverse regularmente después de su uso. Estos incluyen lo siguiente:
•Memoria (ya sea en la pila o en el montón);
• Identificadores de archivos necesarios para acceder a los archivos (lectura/escritura) en el disco
duro u otros medios;
•Conexiones de red (por ejemplo, a un servidor, una base de datos, etc.);
•Hilos, bloqueos, temporizadores y transacciones;
•Otros recursos del sistema operativo, como identificadores GDI en sistemas operativos Windows.
(La abreviatura GDI significa Interfaz de dispositivo gráfico. GDI es un componente central del
sistema operativo de Microsoft Windows y es responsable de representar objetos gráficos).
© Stephan Roth 2017 85
S. Roth, C++ limpio, DOI 10.1007/9781484227930_5
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
El manejo adecuado de los recursos puede ser una tarea complicada. Considere el siguiente ejemplo:
Listado 51. Tratar con un recurso que se asignó en el montón
void hacerAlgo() {
ResourceType* recurso = new ResourceType(); intente
{ // ...haga algo con el recurso... recurso>foo(); }
catch (...) { eliminar
recurso; tirar; }
eliminar recurso; }
¿Cuál es el problema aquí? Tal vez haya notado las dos declaraciones de eliminación idénticas. el catchall
El mecanismo de manejo de excepciones introduce al menos dos caminos posibles en nuestro programa. Esto también
significa que tenemos que asegurarnos de que el recurso se libere en dos lugares. En circunstancias normales, estos
manejadores de excepciones catchall están mal vistos. Pero en este caso, no tenemos otra oportunidad que atrapar todas
las posibles excepciones que ocurran aquí solo porque primero debemos liberar el recurso, antes de lanzar el objeto de
excepción más lejos para tratarlo en otro lugar (por ejemplo, en el sitio de llamada de la función).
Y en este ejemplo simplificado solo tenemos dos caminos. En programas reales, pueden existir muchas más rutas de
ejecución. La probabilidad de que se olvide una eliminación es mucho mayor. Y cualquier eliminación olvidada resultará en
una peligrosa fuga de recursos.
■ Advertencia ¡No subestime las fugas de recursos! Las fugas de recursos son un problema grave, especialmente para los
procesos de larga duración y para los procesos que asignan rápidamente muchos recursos sin desasignarlos después del uso. Si
un sistema operativo tiene una falta de recursos, esto puede conducir a estados críticos del sistema. Además, las fugas de
recursos pueden ser un problema de seguridad, ya que los atacantes pueden aprovecharlas para realizar ataques de
denegación de servicio.
La solución más simple para nuestro pequeño ejemplo anterior podría ser que asignemos el recurso en la pila,
en lugar de asignarlo en el montón:
Listado 52. Mucho más fácil: Tratar con un recurso en la pila
void hacerAlgo() {
recurso ResourceType;
// ...hacer algo con el recurso... resource.foo();
Con este cambio, el recurso se elimina de forma segura en cualquier caso. Pero a veces no es posible asignar todo en la
pila, como ya hemos discutido en la sección "No pase o devuelva 0 (NULL, nullptr)" en el Capítulo 4. ¿Qué pasa con los
identificadores de archivos, los recursos del sistema operativo, etc. ? ?
La pregunta central es esta: ¿ Cómo podemos garantizar que los recursos asignados estén siempre libres?
86
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
La adquisición de recursos es inicialización (RAII)
La adquisición de recursos es inicialización (RAII) es una expresión idiomática (consulte el Capítulo 9 sobre modismos) que pueden
ayudar a hacer frente a los recursos de una manera segura. El idioma también se conoce como Constructor Acquires, Destructor
Releases (CADRe) y Scopebased Resource Management (SBRM).
RAII aprovecha la simetría de una clase por su constructor y su correspondiente destructor. Nosotros
podemos asignar un recurso en el constructor de una clase, y podemos desasignarlo en el destructor. Si creamos dicha clase como
una plantilla, se puede usar para diferentes tipos de recursos.
Listado 53. Una plantilla de clase muy simple que puede administrar varios tipos de recursos
template <typename RESTYPE> class
ScopedResource final { public:
ScopedResource() { ManagedResource = new RESTYPE(); }
~ScopedResource() { eliminar el recurso administrado; }
RESTYPE* operador>() const { return recursoadministrado; }
privado:
RESTYPE* recurso gestionado; };
Ahora podemos usar la plantilla de clase ScopedResource de la siguiente manera:
Listado 54. Uso de ScopedResource para administrar una instancia de ResourceType
#incluye "AlcanceRecurso.h" #incluye
"TipoRecurso.h"
void hacerAlgo() {
ScopedResource<ResourceType> recurso;
intente
{ // ...haga algo con el recurso... recurso>foo(); }
atrapar (...) { lanzar; } }
Como puede verse fácilmente, no es necesario crear ni eliminar. Si el recurso se queda fuera del alcance, lo que puede suceder
en varios puntos de este método, la instancia envuelta de tipo ResourceType se elimina automáticamente a través del destructor
de ScopedResource.
Pero por lo general no hay necesidad de reinventar la rueda e implementar un contenedor de este tipo, que usted también llama un
puntero inteligente.
Punteros inteligentes
Desde C++ 11, la biblioteca estándar ofrece implementaciones de puntero inteligente diferentes y eficientes para facilitar su uso.
Estos punteros se han desarrollado durante un largo período dentro del conocido proyecto de biblioteca Boost antes de que se
introdujeran en el estándar C++ y se pueden considerar tan infalibles como sea posible. Los punteros inteligentes reducen la
probabilidad de fugas de memoria. Además, están diseñados para ser seguros para subprocesos.
87
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Esta sección proporciona una breve descripción general.
Propiedad única con std::unique_ptr<T>
La plantilla de clase std::unique_ptr<T> (definida en el encabezado <memoria>) administra un puntero a un objeto de tipo
T. Como sugiere el nombre, este puntero inteligente proporciona propiedad única, es decir, un objeto puede ser propiedad de
solo una instancia de std::unique_ptr<T> a la vez, que es la principal diferencia de std::shared_ptr<T>, que se explica a
continuación. Esto también significa que la construcción de copias y la asignación de copias no están permitidas.
Su uso es bastante simple:
#include <memoria>
clase Tipo de recurso { //... };
//...
std::unique_ptr<ResourceType> resource1 { std::make_unique<ResourceType>() }; // ... auto resource2
o más corto con tipo de deducción...
{ std::make_unique<ResourceType>() };
Después de esta construcción, el recurso se puede usar como un puntero normal a una instancia de
ResourceType. (std::make_unique<T> se explica a continuación en la sección "Evitar nuevos y eliminar"). Por ejemplo, puede
usar el operador * y > para desreferenciar:
recurso>foo();
Por supuesto, si el recurso se queda fuera del alcance, la instancia contenida de tipo ResourceType se libera de forma segura.
Pero la mejor parte es que el recurso se puede poner fácilmente en contenedores, por ejemplo, en un std::vector:
#include "ResourceType.h"
#include <memoria>
#include <vector>
usando ResourceTypePtr = std::unique_ptr<ResourceType>; usando
ResourceVector = std::vector<ResourceTypePtr>;
//...
ResourceTypePtr recurso { std::make_unique<ResourceType>() }; ResourceVector
unaColecciónDeRecursos;
aCollectionOfResources.push_back(std::move(recurso)); // IMPORTANTE:
¡En este punto, la instancia de 'recurso' está vacía!
Tenga en cuenta que nos aseguramos de que std::vector::push_back() llame al constructor de movimiento respectivamente al
operador de asignación de movimiento de std::unique_ptr<T> (consulte la sección sobre semántica de movimiento en el próximo capítulo).
Como consecuencia, el recurso ya no gestiona un objeto y se indica como vacío.
88
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
■ Precaución ¡No utilice más std::auto_ptr<T> en su código! Con la publicación del estándar C++11, std::auto_ptr<T> se marcó
como "obsoleto" y ya no se debe usar. ¡Con el estándar C++ 17 más nuevo, esta plantilla de clase de puntero inteligente
finalmente se eliminó del lenguaje!
La implementación de este puntero inteligente no admitía referencias de valor real ni semántica de movimiento (consulte la
sección sobre semántica de movimiento más adelante en este capítulo) y no se puede almacenar dentro de los contenedores de la
biblioteca estándar. std::unique_ptr<T> es el reemplazo apropiado.
Como ya se mencionó, la construcción de copias de std::unique_ptr<T> no está permitida. Sin embargo, la exclusiva
la propiedad del recurso administrado se puede transferir a otra instancia de std::unique_ptr<T> usando la semántica de
movimiento (hablaremos de la semántica de movimiento en detalle en una sección posterior) de la siguiente manera:
std::unique_ptr<ResourceType> pointer1 = std::make_unique<ResourceType>();
std::unique_ptr<ResourceType> pointer2; // pointer2 no posee nada todavía
puntero2 = std::mover(puntero1); // Ahora puntero1 está vacío, puntero2 es el nuevo propietario
Propiedad compartida con std::shared_ptr<T>
Las instancias de la plantilla de clase std::shared_ptr<T> (definida en el encabezado <memoria>) pueden tomar posesión
de un recurso de tipo T y pueden compartir esta propiedad con otras instancias de std::shared_ptr<T>. En otras palabras,
muchos propietarios compartidos pueden asumir la propiedad de una sola instancia de tipo T y, por lo tanto, la responsabilidad
de su eliminación .
std::shared_ptr<T> proporciona algo así como una funcionalidad simple de recolección de basura limitada. El inteligente
La implementación del puntero tiene un contador de referencia que supervisa cuántas instancias de puntero que poseen el
objeto compartido aún existen. Libera el recurso administrado si se destruye la última instancia del puntero.
La Figura 51 muestra un diagrama de objetos UML que representa una situación en un sistema en ejecución
donde tres instancias (cliente1, cliente2 y cliente3) comparten el mismo recurso (:Recurso) utilizando tres instancias de
puntero inteligente.
Figura 51. Un diagrama de objetos que muestra cómo tres clientes comparten un recurso a través de punteros inteligentes
89
www.allitebooks.com
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
En contraste con el std::unique_ptr<T> discutido anteriormente, std::shared_ptr<T> es, por supuesto, una copia
construible como se esperaba. Pero puede exigir que el recurso administrado se mueva mediante std::move<T>:
std::shared_ptr<ResourceType> pointer1 = std::make_shared<ResourceType>();
std::shared_ptr<ResourceType> pointer2;
puntero2 = std::mover(puntero1); // El conteo de referencias no se modifica, puntero1 está vacío
En este caso no se modifica el contador de referencia, pero hay que tener cuidado al usar la variable pointer1
después del movimiento, porque está vacío, es decir, contiene un nullptr. La semántica de movimiento y la función de
utilidad std::move<T> se analizan en una sección posterior.
Sin propiedad, pero con acceso seguro con std::weak_ptr<T>
A veces es necesario tener un puntero no propietario a un recurso que es propiedad de uno o más punteros compartidos. Al
principio podrías decir: “Está bien, pero ¿cuál es el problema? Simplemente puedo obtener el puntero sin procesar de una
instancia de std::shared_ptr<T> en cualquier momento llamando a su función miembro get()”.
Listado 55. Recuperando el puntero normal de una instancia de std::shared_ptr<T>
std::shared_ptr<ResourceType> resource = std::make_shared<ResourceType>(); // ...
ResourceType* rawPointerToResource = resource.get();
¡Cuida tu paso! Esto podría ser peligroso. ¿Qué pasará si la última instancia de std::shared_
ptr<ResourceType> se destruye en algún lugar de su programa y este puntero sin procesar todavía está en uso en
alguna parte? El puntero en bruto apuntará a la Tierra de nadie y su uso puede causar serios problemas (recuerde mi
advertencia sobre el comportamiento indefinido en el capítulo anterior). No tiene absolutamente ninguna posibilidad de determinar
que el puntero sin procesar apunta a una dirección válida de un recurso, o a una ubicación arbitraria en
memoria.
Si necesita un puntero al recurso sin tener propiedad, debe usar std::weak_ptr<T> (definido en el encabezado
<memoria>), que no influye en la duración del recurso. std::weak_ptr<T> simplemente "observa" el recurso administrado y puede
ser interrogado si es válido.
Listado 56. Uso de std::weak_ptr<T> para gestionar recursos que no son de propiedad
01 #incluir <memoria> 02
03 void hacerAlgo(const std::weak_ptr<ResourceType>& debilRecurso) { if (! debilRecurso.caducado())
{ 04
05 // Ahora sabemos que el recurso débil contiene un puntero a un objeto válido std::shared_ptr<Tipo
06 de recurso> recurso compartido = recurso débil.lock(); // Usar recurso compartido...
07
08 }
09 } 10
11 int principal() { 12
auto sharedResource(std::make_shared<ResourceType>());
13 std::weak_ptr<ResourceType> débilRecurso(sharedResource);
90
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
14
15 hacerAlgo(recursodébil); recurso
16 compartido.reset(); // Elimina la instancia administrada de ResourceType doSomething(weakResource);
17
18
19 devolver 0;
20 }
Como puede ver en la línea 4 del ejemplo de código anterior, podemos interrogar al objeto de puntero débil si administra un
recurso válido. Esto se hace llamando a su función miembro expired(). std::weak_ptr<T> no proporciona operadores de desreferencia,
como * o >. Si queremos usar el recurso, primero debemos llamar a la función lock() (ver línea n.° 6) para obtener un objeto puntero
compartido.
Tal vez ahora se esté preguntando cuáles son los casos de uso de este tipo de puntero inteligente. ¿Por qué es
necesario, porque también podría tomar un std::shared_ptr<T> en cualquier lugar donde se necesite un recurso?
En primer lugar, con std::shared_ptr<T> y std::weak_ptr<T>, puede distinguir entre los propietarios de un recurso y los
usuarios de un recurso en un diseño de software. No todas las unidades de software que requieren un recurso solo para una tarea
determinada y limitada en el tiempo quieren convertirse en sus propietarios. Como podemos ver en la función doSomething() en el
ejemplo anterior, a veces es suficiente simplemente "promover" un puntero débil a un puntero fuerte solo por una cantidad de tiempo
limitada.
Un buen ejemplo sería una caché de objetos que, con el fin de mejorar la eficiencia del rendimiento, mantiene los objetos
a los que se ha accedido recientemente en la memoria durante un cierto período de tiempo. Los objetos en el caché se mantienen
con instancias std::shared_ptr<T>, junto con una marca de tiempo utilizada por última vez. Periódicamente, se ejecuta una
especie de proceso de recolección de basura, que escanea el caché y decide destruir aquellos objetos que no se han utilizado
durante un período de tiempo definido.
En aquellos lugares donde se usan los objetos almacenados en caché, se usan instancias de std::weak_ptr<T> para contener
punteros no propietarios a estos objetos. Si la función miembro expired() de esas instancias std::weak_ptr<T> devuelve
verdadero, el proceso del recolector de elementos no utilizados ya ha borrado los objetos de la memoria caché. En el otro caso, la
función std::weak_ptr<T>::lock() se puede usar para recuperar un std::shared_ptr<T> de ella. Ahora el objeto se puede usar de forma
segura, incluso si el proceso del recolector de basura se activa. El proceso evalúa el contador de uso de std::shared_ptr<T> y
comprueba que el objeto tiene actualmente al menos un usuario fuera de la memoria caché. Como consecuencia, se prolonga la vida
útil de los objetos. O el proceso elimina el objeto del caché, lo que no interfiere con sus usuarios.
Otro ejemplo es tratar con dependencias circulares. Por ejemplo, si tiene una clase A que necesita un puntero a otra
clase B y viceversa, terminará con una dependencia circular. Si usa std::shared_ptr<T> para apuntar a la otra clase respectiva como
se muestra en el siguiente ejemplo de código, puede terminar con una pérdida de memoria. La razón de esto es que el contador de uso
en la respectiva instancia de puntero compartido nunca contará hasta 0. Por lo tanto, los objetos nunca se eliminarán.
Listado 57. El problema con las dependencias circulares causado por un uso irreflexivo de std::shared_ptr<T>
#include <memoria>
clase B; // Declaración de reenvío
class A
{ public:
void setB(std::shared_ptr<B>& pointerToB) { myPointerToB =
pointerToB; }
91
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
privado:
std::shared_ptr<B> myPointerToB; };
class B
{ public:
void setA(std::shared_ptr<A>& pointerToA)
{ myPointerToA = pointerToA; }
privado:
std::shared_ptr<A> myPointerToA; };
int principal() {
{ // Las llaves crean un alcance auto
pointerToA = std::make_shared<A>(); auto
pointerToB = std::make_shared<B>(); punteroAA
>setB(punteroAB); punteroAB
>setA(punteroAA); }
// En este punto, respectivamente, una instancia de A y B está "perdida en el espacio" (pérdida de memoria)
devolver 0;
}
Si las variables miembro std::shared_ptr<T> en las clases se reemplazan por punteros débiles no propietarios
(std::weak_ptr<T>) a la otra clase respectiva, se resuelve el problema con la fuga de memoria.
Listado 58. Dependencias circulares implementadas de la manera correcta con std::weak_ptr<T>
clase B; // Declaración de reenvío
class A
{ public:
void setB(std::shared_ptr<B>& pointerToB)
{ myPointerToB = pointerToB; }
privado:
std::weak_ptr<B> myPointerToB; };
class B
{ public:
void setA(std::shared_ptr<A>& pointerToA)
{ myPointerToA = pointerToA; }
privado:
std::weak_ptr<A> myPointerToA; }; // ...
92
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Básicamente, las dependencias circulares son un mal diseño en el código de la aplicación y deben evitarse siempre que
posible. Puede haber algunas excepciones en las bibliotecas de bajo nivel, donde las dependencias circulares no causan
problemas graves. Pero aparte de eso, debe seguir el Principio de dependencia acíclica que se analiza en una sección dedicada en el
Capítulo 6.
Evite los nuevos y borrados explícitos En un programa C++
moderno, al escribir el código de la aplicación debe evitar llamar a new y delete explícitamente.
¿Por qué? Bueno, la explicación simple y breve es esta: new y delete aumentan la complejidad.
La respuesta más detallada es esta: cada vez que es inevitable llamar a nuevo y eliminar, uno tiene que lidiar
con una situación excepcional, no morosa, situación que requiere un tratamiento especial. Para comprender cuáles son estos casos
excepcionales, echemos un vistazo a los casos predeterminados: las situaciones por las que cualquier desarrollador de C++ debería
esforzarse.
Las llamadas explícitas de nuevo y/o eliminación se pueden evitar mediante las siguientes medidas:
• Utilice asignaciones en la pila siempre que sea posible. Las asignaciones en la pila son simples (recuerde el
principio KISS discutido en el Capítulo 3) y seguras. Es imposible perder nada de esa memoria que se
asignó en la pila. El recurso se destruirá una vez que quede fuera del alcance. Incluso puede devolver
el objeto de una función por valor, transfiriendo así su contenido a la función que llama.
• Para asignar un recurso en el montón, use "hacer funciones". Use std::make_ unique<T> o
std::make_shared<T> para crear una instancia del recurso y envuélvalo inmediatamente en
un objeto administrador que se ocupa del recurso, un puntero inteligente.
• Use contenedores (Standard Library, Boost u otros) donde sea apropiado.
Container gestiona el espacio de almacenamiento de sus elementos. En cambio, en el caso de
secuencias y estructuras de datos desarrolladas por uno mismo, se ve obligado a implementar toda la
administración de almacenamiento por su cuenta, lo que puede ser una tarea compleja y propensa a errores.
• Proporcionar envoltorios para recursos de bibliotecas propietarias de terceros que requieran una
gestión de memoria específica (consulte la siguiente sección).
Gestión de recursos patentados Como ya se mencionó en la
introducción a esta sección sobre la gestión de recursos, a veces es necesario gestionar otros recursos que no están asignados o
desasignados en el montón mediante el operador predeterminado nuevo o eliminar. Ejemplos de este tipo de recursos son
los archivos abiertos de un sistema de archivos, un módulo cargado dinámicamente (p. ej., una biblioteca de vínculos dinámicos
(DLL) en los sistemas operativos Windows) u objetos específicos de la plataforma de una interfaz gráfica de usuario (p. ej.,
Windows, Buttons , campos de entrada de texto, etc.).
A menudo, este tipo de recursos se administran a través de algo que se denomina identificador . Un identificador es una
referencia abstracta y única a un recurso del sistema operativo. En Windows, el tipo de datos HANDLE se usa para definir dichos
identificadores. De hecho, este tipo de datos se define de la siguiente manera en el encabezado WinNT.h, un archivo de encabezado
de estilo C que define varios tipos y macros de la API de Win32:
typedef void *HANDLE;
Por ejemplo, si desea acceder a un proceso de Windows en ejecución con un determinado ID de proceso, puede recuperar
un identificador de este proceso mediante la función OpenProcess() de la API de Win32.
#include <windows.h> // ...
const
DWORD processId = 4711; HANDLE
processHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processId);
93
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Una vez que haya terminado con el identificador, debe cerrarlo utilizando la función CloseHandle():
BOOL exito = CloseHandle(processHandle);
Por lo tanto, tenemos una simetría similar al operador new y su correspondiente operador delete. Por lo tanto, también debería
ser posible aprovechar el lenguaje RAII y utilizar punteros inteligentes para dichos recursos.
Primero, solo tenemos que cambiar el eliminador predeterminado (que llama a eliminar) por un eliminador personalizado que
llame a CloseHandle():
#include <windows.h> // Declaraciones de la API de Windows
class Win32HandleCloser { public:
void
operator()(HANDLE handle) const { if (handle !=
INVALID_HANDLE_VALUE) { CloseHandle(handle); }
} };
¡Ten cuidado! Si ahora define un alias de tipo escribiendo algo como lo siguiente, std::shared_ ptr<T> administrará algo que es
de tipo void**, porque HANDLE ya está definido como un puntero vacío:
usando Win32SharedHandle = std::shared_ptr<HANDLE>; // ¡Precaución!
Por lo tanto, los punteros inteligentes para Win32 HANDLE deben definirse de la siguiente manera:
usando Win32SharedHandle = std::shared_ptr<void>; usando
Win32WeakHandle = std::weak_ptr<void>;
■ Nota ¡No está permitido definir un std::unique_ptr<void> en C++! Esto se debe a que std::shared_ptr<T> implementa
el borrado de tipos, mientras que std::unique_ptr<T> no lo hace. Si una clase admite el borrado de tipos, significa que
puede almacenar objetos de un tipo arbitrario y destruirlos correctamente.
Si desea utilizar el identificador compartido, debe prestar atención a pasar una instancia del eliminador personalizado
Win32HandleCloser como parámetro durante la construcción:
const DWORD processId = 4711;
Win32SharedHandle processHandle { OpenProcess(PROCESS_ALL_ACCESS, FALSE, processId),
Win32HandleCloser () };
Nos gusta moverlo
Si alguien me preguntara qué característica de C++ 11 tiene probablemente el impacto más profundo en cómo se escribirán los programas
modernos de C++ ahora y en el futuro, claramente mencionaría la semántica de movimiento. Ya he discutido brevemente la semántica
de movimiento de C++ en el Capítulo 4, en la sección sobre estrategias para evitar punteros regulares.
Pero creo que son tan importantes que quiero profundizar aquí en esta característica del lenguaje.
94
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
¿Qué son las semánticas de movimiento?
En muchos casos anteriores en los que el antiguo lenguaje C++ nos obligaba a usar un constructor de copias, en realidad no
queríamos crear una copia profunda de un objeto. En cambio, simplemente queríamos "mover la carga útil del objeto".
La carga útil de un objeto no es más que los datos incrustados que el objeto lleva consigo, así que nada más que otros objetos o
variables miembro de tipos primitivos como int.
Estos casos de antaño en los que teníamos que copiar un objeto en lugar de moverlo eran, por ejemplo, los siguientes:
•La devolución de una instancia de objeto local como un valor de retorno de una función o
método. Para evitar la construcción de copias en estos casos anteriores a C++ 11, se usaban con
frecuencia punteros.
• Insertar un objeto en un std::vector u otros contenedores.
•La implementación de la función de plantilla std::swap<T>.
En muchas de las situaciones antes mencionadas, no es necesario mantener intacto el objeto de origen, es decir, crear una copia
profunda y, en términos de eficiencia de tiempo de ejecución, a menudo costosa, para que los objetos de origen sigan siendo utilizables.
C++11 ha introducido una función de lenguaje que ha hecho que mover los datos incrustados de un objeto sea una
operación de primera clase. Además del constructor de copia y el operador de asignación de copia, el desarrollador de la clase
ahora puede implementar constructores de movimiento y operadores de asignación de movimiento (¡más adelante veremos
por qué no debería hacerlo!). Las operaciones de traslado suelen ser muy eficientes. A diferencia de una operación de
copia real, los datos del objeto de origen simplemente se transfieren al objeto de destino y el argumento (el objeto de origen)
de la operación se coloca en una especie de estado "vacío" o inicial.
El siguiente ejemplo muestra una clase arbitraria que implementa explícitamente ambos tipos de semántica:
constructor de copia (línea n.° 6) y operador de asignación (línea n.° 8), así como constructor de movimiento (línea n.° 7) y
operador de asignación (línea n.° . 9).
Listado 59. Una clase de ejemplo que declara explícitamente funciones miembro especiales para copiar y mover
01 #incluir <cadena> 02 03
clase Clazz { 04 público:
Clazz() no excepto; // Constructor por defecto 05
06 Clazz (const. Clazz y otros); // Copiar constructor
07 Clazz(Clazz&& otros) noexcept; // Mover constructor
08 Operador Clazz&=(const Clazz& otro); // Copiar operador de asignación
09 Operador Clazz&=(Clazz&& otro) noexcept; // Mover operador de asignación 10 virtual ~Clazz()
noexcept; // Destructor 11
12 privado: // ...
13 14 };
Como veremos más adelante en la sección "La regla del cero", debería ser un objetivo principal de cualquier desarrollador de C++
no declarar y definir dichos constructores y operadores de asignación explícitamente.
La semántica de movimiento está estrechamente relacionada con algo que se llama referencias de rvalue (consulte la siguiente sección).
El constructor u operador de asignación de una clase se denomina "constructor de movimiento" respectivamente
"operador de asignación de movimiento", cuando toma una referencia de valor r como parámetro. Una referencia de
valor r se marca mediante el operador de doble ampersand (&&). Para una mejor distinción, la referencia ordinaria con su
único ampersand (&) ahora también se denomina referencia lvalue.
95
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
El asunto con esos lvalues y rvalues
Los llamados lvalue y rvalue son históricamente términos (heredados del lenguaje C), porque los lvalues generalmente
pueden aparecer en el lado izquierdo de una expresión de asignación, mientras que los rvalues generalmente pueden
aparecer en el lado derecho de una expresión de asignación. En mi opinión, una explicación mucho mejor para lvalue es
que es un valor localizador. Esto deja en claro que un lvalue representa un objeto que ocupa una ubicación en la memoria
(es decir, tiene una dirección de memoria accesible e identificable).
Por el contrario, los valores r son todos aquellos objetos en una expresión que no son valores l. Es un objeto temporal,
o subobjeto del mismo. Por lo tanto, no es posible asignar nada a un valor r.
Aunque estas definiciones provienen del antiguo mundo C, y C++ 11 aún ha introducido más categorías
(xvalue, glvalue y prvalue) para habilitar la semántica de movimiento, son bastante buenos para el uso diario.
La forma más simple de una expresión lvalue es una declaración de variable:
Escriba var1;
La expresión var1 es un valor l de tipo Tipo. Las siguientes declaraciones también representan lvalues:
Tipo* puntero;
tipo y referencia;
Tipo y función ();
Un lvalue puede ser el operando izquierdo de una operación de asignación, como la variable entera
theAnswerToAllQuestions en este ejemplo:
int theAnswerToAllQuestions = 42;
Además, la asignación de una dirección de memoria a un puntero deja en claro que el puntero es un valor l:
Escriba* pointerToVar1 = &var1;
El literal "42" en cambio es un valor r. No representa una ubicación identificable en la memoria, por lo que no es
es posible asignarle cualquier cosa (por supuesto, los valores r también ocupan memoria en la sección de datos de la
pila, pero esta memoria se asigna temporalmente y se libera inmediatamente después de completar la operación de
asignación):
número entero = 23; // Funciona, porque 'número' es un lvalue 42 = número; //
Error del compilador: se requiere lvalue como operando izquierdo de la asignación
¿No cree que la función () en la tercera línea de los ejemplos genéricos anteriores es un valor l? ¡Es!
Puede escribir el siguiente fragmento de código (sin duda, algo extraño) y el compilador lo compilará sin quejas:
int theAnswerToAllQuestions = 42;
int& function()
{ devuelve la respuesta a todas las
preguntas; }
int principal()
{ función() = 23; // ¡Obras! devolver
0; }
96
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Referencias de valor
Como ya se mencionó anteriormente, la semántica de movimiento de C++ 11 está estrechamente relacionada con algo que se
llama referencias de rvalue. Estas referencias de rvalue ahora hacen posible abordar la ubicación de memoria de rvalues. En el
siguiente ejemplo, la memoria temporal se asigna a una referencia de valor r y, por lo tanto, la convierte en "permanente".
Incluso puede recuperar un puntero que apunte a esta ubicación y manipular la memoria a la que hace referencia la referencia
rvalue usando este puntero.
int&& rvalueReference = 25 + 17; int*
pointerToRvalueReference = &rvalueReference;
*pointerToRvalueReference = 23;
Al introducir referencias de valor r, estas pueden, por supuesto, aparecer también como parámetros en funciones o
métodos. La Tabla 51 muestra las posibilidades.
Tabla 51. Diferentes funciones respectivamente firmas de métodos y sus tipos de parámetros permitidos
Firma de función/método Tipos de parámetros permitidos
void function(Type param) void Tanto lvalues como rvalues pueden pasarse como parámetros.
X::method(Type param) void
function(Type& param) void Solo se pueden pasar lvalues como parámetros.
function(const Type& param) void
X::method(Type& param) void
X::method(const Type& param) void
function( Tipo&& parámetro) void Solo se pueden pasar valores r como parámetros.
X::método(Tipo&& parámetro)
La Tabla 52 muestra la situación de los tipos de devolución de una función o método y lo que se permite para la
declaración de devolución de la función/método:
Tabla 52. Tipos posibles de tipos de retorno de funciones respectivamente parámetros
Firma de función/método Posibles tipos de datos devueltos por la declaración de devolución
int función() int [const] int, [const] int&, o [const] int&&.
X::método() int&
función() int& No const int o int&.
X::método() int&&
función() int&& Literales (p. ej., devuelve 42), o una referencia rvalue (obtenida con
X::método() std::move()) a un objeto con una duración mayor que el alcance del método
respectivo de la función.
Aunque, por supuesto, se permite el uso de referencias rvalue para parámetros en cualquier función o método,
su campo de aplicación predestinado está en los constructores de movimientos y los operadores de asignación de movimientos.
Listado 510. Una clase que define explícitamente la semántica de copiar y mover
#incluir <utilidad> // estándar::mover<T>
clase Clazz
{ público:
Clazz() = predeterminado;
97
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Clazz (const Clazz y otros) {
// Construcción de copia clásica para lvalues }
Clazz(Clazz&& otros) no excepto {
// Mover constructor para rvalues: mueve contenido de 'otro' a este
}
Operador Clazz& =(const Clazz& otro) {
// Asignación de copia clásica para lvalues return *this;
Operador Clazz& =(Clazz&& otro) noexcept {
// Mover asignación para rvalues: mueve contenido de 'otro' a este return *this;
} // ... };
int principal() {
Clazz anObjeto;
Clazz otroObjeto1(unObjeto); // Llama al constructor de copias
Clazz otroObjeto2(std::move(unObjeto)); // Llama al constructor de movimiento anObject =
anotherObject1; // Llama al operador de asignación de copia
anotherObject2 = std::move(anObject); // Llama al operador de asignación de
movimiento return 0;
}
No aplique Move Everywhere Tal vez haya notado el uso
de la función auxiliar std::move<T>() (definida en el encabezado <utility>) en el ejemplo de código anterior para obligar al
compilador a usar la semántica de movimiento.
En primer lugar, el nombre de esta pequeña función auxiliar es engañoso. std::move<T>() no se mueve
cualquier cosa. Es más o menos un molde que produce una referencia de valor r a un objeto de tipo T.
En la mayoría de los casos, no es necesario hacer eso. En circunstancias normales, la selección entre las versiones
de copia y movimiento de los constructores o los operadores de asignación se realiza automáticamente en tiempo de
compilación a través de la resolución de sobrecarga. El compilador determina si se confronta con un valor l o un valor r, y
luego selecciona el constructor u operador de asignación que mejor se ajuste en consecuencia. Las clases contenedoras
de la biblioteca estándar de C++ también tienen en cuenta el nivel de seguridad de excepción que garantizan las operaciones
de movimiento (analizaremos este tema con más detalle más adelante en la sección "La prevención es mejor que el cuidado posterior").
Tenga en cuenta esto especialmente: no escriba código como este:
Listado 511. Un uso inapropiado de std::move()
#include <cadena>
#include <utilidad>
#include <vector>
utilizando StringVector = std::vector<std::string>;
98
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
StringVector crearVectorDeCadenas() {
Resultado de
StringVector; // ...hacer algo para que el vector se llene con muchas cadenas... return
std::move(result); // Malo e innecesario, solo escribe "return result;"! }
El uso de std::move<T>() con la declaración de retorno no solo es completamente innecesario, porque el compilador
ya sabe que la variable es candidata para ser movida fuera de la función (desde C++11, la semántica de movimiento es compatible
con todos los contenedores de la biblioteca estándar, así como por muchas otras clases de la biblioteca estándar, como
std::string). Un impacto posiblemente aún peor podría ser que puede interferir con la RVO (Optimización del valor de retorno),
también conocida como elisión de copia, que realizan casi todos los compiladores en la actualidad. La elisión de copia respectiva
de RVO permite a los compiladores optimizar una construcción de copia costosa al devolver valores de una función o método.
Piense siempre en el importante principio del Capítulo 3: ¡Tenga cuidado con las optimizaciones! No arruine su código con
declaraciones std::move<T>() en todas partes, solo porque cree que puede ser más inteligente que su compilador con la
optimización de su código. ¡Usted no! La legibilidad de su código se verá afectada con todos esos std::move<T>() en todas
partes, y es posible que su compilador no pueda realizar sus estrategias de optimización correctamente.
La regla del cero
Como desarrollador experimentado de C++, es posible que ya conozca la regla de los tres y la regla de los cinco.
La regla de los tres [Koenig01], acuñada originalmente por Marshall Cline en 1991, establece que si una clase define un
destructor de forma explícita, casi siempre debe definir un constructor de copia y un operador de asignación de copia.
Con la llegada de C++ 11, esta regla se amplió y se convirtió en la Regla de los cinco, porque el constructor de movimiento y el
operador de asignación de movimiento se agregaron al lenguaje, y también estas dos funciones miembro especiales deben
definirse también si una clase define un destructor.
La razón por la cual la Regla de tres y, respectivamente, la Regla de cinco fueron buenos consejos durante mucho tiempo en
el diseño de clases de C++, y son errores sutiles que pueden ocurrir cuando los desarrolladores no los están considerando, como
se demuestra con el siguiente ejemplo de código intencionalmente incorrecto .
Listado 512. Una implementación incorrecta de una clase de cadena
#incluir <ccadena>
class MyString
{ público:
explícito MyString(const std::size_t sizeOfString) : data { new char[sizeOfString] } { }
MyString(const char* const charArray, const std::size_t sizeOfArray) { data = new char[sizeOfArray];
strcpy(datos, charArray); } virtual
~MyString() { eliminar[] datos; };
char& operator[](const std::size_t index) { return data[index]; }
const char& operador[]
(const std::size_t index) const {
devolver datos[índice]; } // ...
privado:
char* datos; };
99
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
De hecho, esta es una clase de cadena implementada de manera muy amateur con algunos defectos, por ejemplo, una
verificación faltante de que no se pasa un nullptr al constructor de inicialización, e ignorando por completo el hecho de que las cadenas
generalmente pueden crecer y reducirse. Por supuesto, hoy en día nadie tiene que implementar una clase de cadena y, por lo tanto,
reinventar la rueda. Con std::string, una clase de cadena a prueba de viñetas está disponible en la biblioteca estándar de C++.
Sin embargo, sobre la base del ejemplo anterior, es muy fácil demostrar por qué es importante adherirse a la regla de los cinco.
Para que la memoria asignada por los constructores de inicialización para la representación de cadena interna
se libere de forma segura, se debe definir un destructor explícito y se debe implementar para hacer esto.
En la clase anterior, sin embargo, se viola la regla de los cinco y faltan los constructores explícitos de copiar/mover, así como los
operadores de asignación de copiar/mover.
Ahora, supongamos que estamos usando la clase MyString de la siguiente manera:
int main()
{ MiCadena unaCadena("Prueba", 4);
MiCadena otraCadena { unaCadena }; // ¡UH oh! :( devuelve 0;
Debido al hecho de que nuestra clase MyString no define explícitamente un constructor de copia o movimiento, el
compilador sintetizará estas funciones miembro especiales, es decir, el compilador generará un constructor de copia predeterminado
respectivamente y un constructor de movimiento predeterminado. Y estas implementaciones predeterminadas solo crean una copia
plana de las variables miembro del objeto de origen. En nuestro caso, el valor de dirección almacenado en los datos del puntero de
carácter se copia, pero no el área de la memoria a la que apunta este puntero.
Eso significa lo siguiente: después de llamar al constructor de copia predeterminado generado automáticamente para crear
otra Cadena, ambas instancias de Mi Cadena comparten los mismos datos, como se puede ver fácilmente en la Vista de Variables del
Depurador que se muestra en la Figura 52.
Figura 52. Ambos punteros de caracteres apuntan a la misma dirección de memoria
Esto resultará en una doble eliminación de los datos internos si se destruyen los objetos de cadena y, por lo tanto, puede
causar problemas críticos, como fallas de segmentación o comportamiento indefinido.
En circunstancias normales, no hay motivo para definir un destructor explícito para una clase. Cada vez que se ve obligado
a definir un destructor, esta es una excepción notable, porque indica que necesita hacer algo especial con los recursos al final de la
vida útil de un objeto que requiere un esfuerzo considerable. Por lo general, se requiere un destructor no trivial para desasignar
recursos, por ejemplo, memoria en el montón. Como consecuencia, también necesita definir constructores de copiar/mover
explícitos y operadores de asignación de copiar/mover para manejar estos recursos correctamente mientras copia o mueve.
Eso es lo que implica la regla de cinco.
100
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Existen diferentes enfoques para tratar el problema descrito anteriormente. Por ejemplo, podemos proporcionar
constructores explícitos de copiar/mover y también operadores de asignación de copiar/mover para manejar
correctamente la memoria asignada, por ejemplo, creando una copia profunda del área de memoria a la que
apunta el puntero. Otro enfoque sería prohibir copiar y mover, y evitar que el compilador genere versiones
predeterminadas de estas funciones. Esto se puede hacer desde C ++ 11 eliminando estas funciones miembro
especiales para que cualquier uso de una función eliminada esté mal formado, es decir, el programa no se compilará.
Listado 513. Una clase MyString modificada que elimina explícitamente el constructor de copia y el operador de asignación
de copia
class MyString
{ público:
explícito MyString(const std::size_t sizeOfString) : data { new char[sizeOfString] } { }
MyString(const char* const charArray, const int sizeOfArray) { data = new
char[sizeOfArray]; strcpy(datos,
charArray); } virtual ~MyString()
{ eliminar[] datos; }; MiCadena(const MiCadena&)
= delete; MiCadena& operator=(const
MiCadena&) = borrar; // ... };
El problema es que al eliminar las funciones miembro especiales, la clase ahora tiene un área de uso muy
limitada. Por ejemplo, MyString no se puede usar en un std::vector ahora, porque std::vector requiere que su tipo
de elemento T sea asignable por copia y construible por copia.
Bien, ahora es el momento de elegir un enfoque diferente y pensar de manera diferente. Lo que tenemos que hacer es
deshacernos del destructor que libera el recurso asignado. Si esto tiene éxito, tampoco es necesario, de acuerdo con la Regla de los
Cinco, proporcionar explícitamente las otras funciones especiales de los miembros. Así que, aquí vamos:
Listado 514. Reemplazar el puntero char por un vector de char hace que un destructor explícito sea superfluo
#incluir <vector>
class MyString
{ public:
explicit MyString(const std::size_t sizeOfString)
{ data.resize(sizeOfString, ' '); }
MyString(const char* const charArray, const int sizeOfArray) : MyString(sizeOfArray) { if (charArray != nullptr)
{ for (int index = 0; index <
sizeOfArray; index++) { data[index] = charArray[index]; } } }
char& operator[](const std::size_t index) { return
data[index]; }
101
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
const char& operator[](const std::size_t index) const {
devolver datos[índice]; } // ...
privado:
std::vector<char> datos; };
Una vez más, un comentario: sé que esta es una implementación poco práctica y amateur de una cadena hecha a sí misma,
que no es necesaria hoy en día, pero es solo para fines de demostración.
¿Qué ha cambiado ahora? Bueno, hemos reemplazado el miembro privado de tipo char* por un std::vector con el tipo
de elemento char. Por lo tanto, ya no necesitamos un destructor explícito, porque no tenemos nada que hacer si se destruye un
objeto de nuestro tipo MyString. No es necesario desasignar ningún recurso. Como resultado, las funciones miembro especiales
generadas por el compilador, como el constructor de copiar/mover o el operador de asignación de copiar/mover, hacen lo correcto
automáticamente si se usan, y no tenemos que definirlas explícitamente.
Y esas son buenas noticias, porque hemos seguido el principio KISS (consulte el Capítulo 3).
¡Y eso nos lleva a la Regla del Cero! La Regla del Cero fue acuñada por R. Martinho Fernandes en un blog
puesto en 2012 [Fernandes12]. La regla también fue promovida por el miembro del comité de normas ISO, Prof. Peter
Sommerlad, Director del Instituto IFS para Software en HSR Hochschule für Technik Rapperswil (Suiza), en una conferencia sobre
Meeting C++ 2013 [Sommerlad13]. Esto es lo que dice la regla:
Escriba sus clases de manera que no necesite declarar/definir ni un destructor, ni un constructor de copiar/mover
ni un operador de asignación de copiar/mover. Use punteros inteligentes de C++ y clases y contenedores de
biblioteca estándar para administrar recursos.
En otras palabras, la regla del cero establece que sus clases deben diseñarse de manera que las funciones miembro
generadas por el compilador para copiar, mover y destruir automáticamente hagan lo correcto. Esto hace que sus clases sean
más fáciles de entender (piense siempre en el principio KISS del Capítulo 3), menos propensas a errores y más fáciles de
mantener. El principio detrás de esto es este: hacer más escribiendo menos código.
El compilador es tu colega
Como ya he escrito en otro lugar, la llegada del estándar de lenguaje C++ 11 ha cambiado fundamentalmente la forma en que se
diseñarán los programas C++ modernos y limpios en la actualidad. Los estilos, patrones y modismos que utilizan los programadores
al escribir el código C++ moderno son totalmente diferentes a los anteriores. Además del hecho de que los estándares C++ más
nuevos ofrecen muchas características nuevas y útiles para escribir código C++ que es fácil de mantener, comprensible, eficiente
y comprobable, algo más ha cambiado: ¡ el papel del compilador!
En tiempos anteriores, el compilador era solo una herramienta para traducir el código fuente a una máquina ejecutable
instrucciones (código objeto) para una computadora; pero ahora se está convirtiendo cada vez más en una herramienta
para apoyar al desarrollador en diferentes niveles. Los tres principios rectores para trabajar con un compilador de C++ hoy en día son
los siguientes:
•Todo lo que se puede hacer en tiempo de compilación también debe hacerse en tiempo de compilación.
•Todo lo que se puede verificar en tiempo de compilación también debe verificarse en tiempo de compilación.
• Todo lo que el compilador puede saber acerca de un programa también debe ser determinado por
el compilador
102
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
En capítulos y secciones anteriores, ya hemos experimentado en algunos puntos cómo el compilador puede ayudarnos.
Por ejemplo, en la sección sobre la semántica de movimiento, hemos visto que los compiladores modernos de C++ hoy en día
pueden realizar múltiples optimizaciones sofisticadas (p. ej., elisión de copias) que ya no nos tienen que importar. En las siguientes
secciones, le mostraré cómo el compilador puede ayudarnos a los desarrolladores y facilitarnos muchas cosas.
Deducción automática de tipos ¿Recuerda el
significado de la palabra clave auto de C++ antes de C++11? Estoy bastante seguro de que probablemente fue la palabra clave menos
conocida y utilizada en el idioma. Tal vez recuerde que auto en C++98 o C++03 era un especificador de clase de almacenamiento y se
usaba para definir que una variable local tiene "duración automática", es decir, la variable se crea en el punto de definición, y se destruye
cuando se sale del bloque del que formaba parte.
Desde C++11, todas las variables tienen una duración automática por defecto a menos que se especifique lo contrario. Por lo tanto, la
semántica anterior de auto se estaba volviendo inútil y la palabra clave adquirió un significado completamente nuevo.
Hoy en día, auto se usa para la deducción automática de tipos, a veces también llamada inferencia de tipos. Si se usa como
especificador de tipo para una variable, especifica que el tipo de la variable que se declara se deducirá (o inferirá) automáticamente de
su inicializador, como en los siguientes ejemplos:
auto theAnswerToAllQuestions = 42; iteración
automática = comenzar (miMapa);
const auto gravitationalAccelerationOnEarth = 9.80665; constexpr suma
automática = 10 + 20 + 12; auto strings = { "El",
"grande", "marrón", "zorro", "salta", "sobre", "el", "perezoso", "perro" }; auto númeroDeCadenas = cadenas.tamaño();
ARGUMENTO DE BUSQUEDA DE NOMBRE DEPENDIENTE (ADL)
La búsqueda dependiente de argumentos (nombre) (abreviatura: ADL), también conocida como búsqueda de Koenig
(llamada así por el científico informático estadounidense Andrew Koenig), es una técnica de compilación para buscar un
nombre de función no calificado (es decir, un nombre de función sin un prefijo). calificador de espacio de nombres) dependiendo
de los tipos de argumentos pasados a la función en su sitio de llamada.
Suponga que tiene un std::map<K, T> (definido en el encabezado <map>) como el siguiente:
#include <mapa>
#include <cadena>
std::map<unsigned int, std::string> palabras;
Debido a ADL, no es necesario especificar el espacio de nombres estándar si usa la función begin() o end() para recuperar un
iterador del contenedor. Simplemente puede escribir:
iterador de palabras automático = comenzar (palabras);
El compilador no solo mira el ámbito local, sino también los espacios de nombres que contienen el tipo del argumento (en
este caso, el espacio de nombres de map<T>, que es estándar). Por lo tanto, en el ejemplo anterior, el compilador encuentra
una función begin() adecuada para los mapas en el espacio de nombres estándar.
En algunos casos, debe definir explícitamente el espacio de nombres, por ejemplo, si desea usar std::begin() y
std::end() con una matriz de estilo C simple.
103
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
A primera vista, usar auto en lugar de un tipo concreto parece ser una característica conveniente. Los
desarrolladores ya no están obligados a recordar el nombre de un tipo. Simplemente escriben auto, const auto, auto&
(para referencias) o const auto& (para referencias const), y el compilador hace el resto, porque conoce el tipo del valor
asignado. Por supuesto, la deducción automática de tipos también se puede usar junto con constexpr (consulte
la sección sobre cálculos en tiempo de compilación).
Por favor, no tenga miedo de usar auto (o auto& respectivamente const auto&) tanto como sea posible. El código
todavía se escribe estáticamente y los tipos de las variables están claramente definidos. Por ejemplo, el tipo de cadenas
variables del ejemplo anterior es std::initializer_list<const char*>, el tipo de numberOfStrings es std::initializer_list<const
char*>::size_type.
STD::INITIALIZER_LIST<T> [C++11]
En días anteriores (antes de C++ 11), si queríamos inicializar un contenedor de biblioteca estándar usando literales,
teníamos que hacer lo siguiente:
std::vector<int> integerSequence;
secuencia_entera.push_back(14);
secuencia_entera.push_back(33);
secuencia_entera.push_back(69); // ...etcétera...
Desde C++ 11, simplemente podemos hacerlo de esta manera:
std::vector<int> integerSequence { 14, 33, 69, 104, 222, 534 };
La razón de esto es que std::vector<T> tiene un constructor sobrecargado que acepta una llamada lista de
inicializadores como parámetro. Una lista de inicializadores es un objeto de tipo std::initializer_list<T> (definido
en el encabezado <initializer_list>).
Una instancia de tipo std::initializer_list<T> se construye automáticamente cuando usa una lista de literales separados
por comas que están rodeados por un par de llaves, lo que se conoce como lista de inicio entre llaves.
Puede equipar sus propias clases con constructores que acepten listas de inicializadores, como se muestra en este
ejemplo:
#include <cadena>
#include <vector>
usando WordList = std::vector<std::string>;
class LexicalRepository { público:
explícito
LexicalRepository(const std::initializer_list<const char*>& words) {
listaPalabras.insert(comienzo(ListaPalabras), comienzo(palabras),
fin(palabras)); } // ...
privado:
lista de palabras lista de
palabras; };
104
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
int main()
{ LexicalRepository repo { "El", "grande", "marrón", "zorro", "salta", "sobre", "el", "perezoso",
"perro" }; // ... devuelve 0; }
Nota: ¡Esta lista de inicializadores no debe confundirse con una clase de su lista de inicializadores de miembros!
Desde C++14, también se admite la deducción automática del tipo de devolución para funciones. Esto es especialmente útil
cuando un tipo de devolución tiene un nombre difícil de recordar o imposible de pronunciar, que suele ser el caso cuando se trata
de tipos de datos complejos no estándar como tipos de devolución.
auto function()
{ std::vector<std::map<std::pair<int, double>, int>> returnValue; // ...llenar 'returnValue'
con datos... return returnValue; }
No hemos discutido las funciones lambda hasta ahora (se discutirán en detalle en el Capítulo 7), pero
C++ 11 y versiones posteriores le permiten almacenar expresiones lambda en variables con nombre:
*
cuadrado automático = [](int x) { retorno x X; };
Tal vez te estés preguntando ahora esto: bueno, en el Capítulo 4 el autor nos dijo que una expresiva y buena
la asignación de nombres es importante para la legibilidad del código y debe ser un objetivo importante para todos los
programadores profesionales. El mismo autor ahora promueve el uso de la palabra clave auto, que hace más difícil reconocer
rápidamente el tipo de una variable con solo leer el código. ¿No es eso una contradicción?
Mi respuesta clara es esta: ¡no, todo lo contrario! Aparte de unas pocas excepciones, el auto puede aumentar la
legibilidad del código. Veamos las siguientes dos alternativas de una asignación de variable:
Listado 515. ¿Cuál de las siguientes dos versiones preferirías?
// 1ra versión: sin auto
std::shared_ptr<controller::CreateMonthlyInvoicesController> createMonthlyInvoicesController =
std::make_shared<controller::CreateMonthlyInvoicesController>();
// 2da versión: con auto: auto
createMonthlyInvoicesController =
std::make_shared<controller::CreateMonthlyInvoicesController>();
Desde mi punto de vista, la versión que usa auto es más fácil de leer. No hay necesidad de repetir el tipo
explícitamente, porque es bastante claro a partir de su inicializador qué tipo será createMonthlyInvoicesController. Por cierto,
repetir el tipo explícito también sería una especie de violación del principio DRY (ver Capítulo 3). Y si piensa en la
expresión lambda anterior llamada cuadrado, cuyo tipo es un tipo de clase único, sin nombre y sin unión, ¿cómo se puede
definir explícitamente dicho tipo?
105
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Entonces, aquí está mi consejo:
¡Si no oscurece la intención de su código, use automático siempre que sea posible!
Cálculos durante el tiempo de compilación Los fanáticos de la computación
de alto rendimiento (HPC), pero también los desarrolladores de software integrado y los programadores que prefieren usar tablas estáticas y
constantes para separar datos y códigos, desean calcular tanto como sea posible en el momento de la compilación. Las razones de esto
son muy fáciles de comprender: todo lo que se puede calcular o evaluar en tiempo de compilación no tiene que calcularse o evaluarse
en tiempo de ejecución. En otras palabras: el cálculo de tanto como sea posible en el momento de la compilación es una tarea fácil
para aumentar la eficiencia del tiempo de ejecución de su programa. Esta ventaja a veces va acompañada de un inconveniente, que es el
tiempo más o menos creciente que se tarda en compilar nuestro código.
Desde C++11 existe el especificador constexpr (expresión constante) para definir que es posible evaluar el valor de una función
o una variable en tiempo de compilación. Y con el estándar posterior C++14, se eliminaron algunas de las estrictas restricciones para
constexpr que existían antes. Por ejemplo, se permitía que una función especificada por constexpr tuviera exactamente una sola declaración
de retorno. Esta restricción ha sido abolida desde C++14.
Uno de los ejemplos más simples es que el valor de una variable se calcula a partir de literales mediante operaciones
aritméticas en tiempo de compilación, así:
constexpr int laRespuestaATodasLasPreguntas = 10 + 20 + 12;
La variable theAnswerToAllQuestions también es una constante como si hubiera sido declarada con const; así tú
no puede manipularlo durante el tiempo de ejecución:
int principal() { // ...
la respuesta a todas las preguntas = 23; // Error del compilador: ¡asignación de variable de solo lectura! devolver 0; }
También hay funciones constexpr:
constexpr int multiplicar(const int multiplicador, const int multiplicando) { return multiplicador * multiplicando; }
Estas funciones se pueden llamar en tiempo de compilación, pero también se usan como funciones ordinarias con argumentos no
constantes en tiempo de ejecución. Esto ya es necesario por la razón de probar esas funciones con la ayuda de Unit Tests (ver Capítulo 2).
constexpr int theAnswerToAllQuestions = multiplicar (7, 6);
Como era de esperar, también las funciones específicas de constexpr se pueden llamar recursivamente, como se muestra a continuación
ejemplo de una función para calcular factoriales.
106
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Listado 516. Cálculo del factorial de un entero no negativo 'n' en tiempo de compilación
01 #incluye <iostream>
02
03 constexpr sin signo largo largo factorial(const sin signo corto n) { * factorial(n 1) : 1; 04
devolver n > 1 ? norte
05 } 06
07 int main() { número
corto sin signo = 6; 08 resultado
09
automático1 = factorial(número); constexpr auto
10 resultado2 = factorial(10);
11
"
12 std::cout << "resultado1: " return << resultado1 << ", resultado2: << resultado2 << std::endl;
13 0; 14 }
El ejemplo anterior ya funciona bajo C++11. La función factorial() consta de una sola declaración, y la recursividad
se permitió desde el principio en las funciones constexpr. La función main() contiene dos llamadas de la función factorial().
Vale la pena echar un vistazo más de cerca a estas dos llamadas de función.
La primera llamada en la línea no. 9 usa el número de variable como argumento para el parámetro de la función n, y su
resultado se asigna a una variable no constante resultado1. La segunda llamada de función en la línea no. 10 utiliza un literal
numérico como argumento y su resultado se asigna a una variable con un especificador constexpr. La diferencia entre
estas dos llamadas de función en tiempo de ejecución se puede ver mejor en el código objeto desensamblado. La Figura
53 muestra el código de objeto en nuestro punto clave en la ventana de desmontaje de Eclipse CDT.
Figura 53. El código objeto desensamblado
La primera llamada de función en la línea no. 9 da como resultado cinco instrucciones de máquina. La 4ª de estas instrucciones
(callq) es el salto a la función factorial() en la dirección de memoria 0x5555555549bd. En otras palabras, es obvio que la
función se llama en tiempo de ejecución. En contraste, vemos que la segunda llamada de factorial() en la línea no. 10 da
como resultado una sola instrucción de máquina simple. La instrucción movq copia una palabra cuádruple del operando
de origen al operando de destino. No hay llamada de función costosa en tiempo de ejecución. El resultado de
factorial(10), que es 0x375f00 en hexadecimal y respectivamente 3.628.800 en decimal, ha sido calculado en tiempo
de compilación y está disponible como una constante en el código objeto.
Como ya he escrito anteriormente, algunas restricciones para las funciones específicas de contexto en C++11 se
han derogado desde C++14. Por ejemplo, una función especificada constexpr ahora puede tener más de una declaración
de retorno, puede tener condicionales como ifelsebranches, variables locales de tipo "literal" o bucles.
Básicamente, casi todas las declaraciones de C++ están permitidas si no presuponen o requieren algo que solo está disponible
en el contexto de un entorno de tiempo de ejecución, por ejemplo, asignar memoria en el montón o generar excepciones.
107
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Plantillas variables
Creo que es menos sorprendente que constexpr también se pueda usar en plantillas, como se muestra en el siguiente ejemplo.
Listado 517. Una plantilla variable para la constante matemática pi
template <typename T>
constexpr T pi = T(3.1415926535897932384626433L);
Esto se conoce como plantilla variable y es una alternativa buena y flexible al estilo arcaico de definiciones
constantes mediante el uso de #define para macros (consulte la sección "Evitar macros" en el Capítulo 4). Según su contexto
de uso durante la instanciación de la plantilla, la constante matemática pi se escribe como float, double o long double.
Listado 518. Cálculo de la circunferencia de un círculo en tiempo de compilación usando la plantilla variable 'pi'
plantilla <nombre de tipo T>
constexpr T computarCircunferencia(const T radio) { pi<T>;
retorno 2 * radio *
}
int main() { const
long doble radio { 10.0L }; constexpr larga doble
circunferencia = calcularCircunferencia(radio); std::cout << circunferencia << std::endl; devolver
0; }
Por último, pero no menos importante, también puede usar clases en los cálculos en tiempo de compilación. Puede definir constexpr
constructores y funciones miembro para clases.
Listado 519. Rectangle es una clase constexpr
#incluir <iostream> #incluir
<cmath>
class Rectangle { public:
constexpr
Rectangle() = delete; constexpr
Rectangle(const double width, const double height) : ancho { ancho }, alto { alto } { }
constexpr double getWidth() const { return ancho; }
constexpr double getHeight() const { altura de retorno ; } constexpr
double getArea() const { return ancho * alto; } constexpr double
getLengthOfDiagonal() const { return std::sqrt(std::pow(ancho, 2.0) + std::pow(alto,
2.0)); }
privado:
doble ancho;
doble altura; };
108
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
int main()
{ constexpr Rectangle americanFootballPlayingField { 48.76, 110.0 }; constexpr doble área =
campo de juego de fútbol americano. getArea (); constexpr doble diagonal =
campodejuegodefútbolamericano.getLengthOfDiagonal();
"
std::cout << "El área de un campo de fútbol americano es el área << "m^2 y la longitud de <<
"
su diagonal es << diagonal << "m". << estándar::endl; devolver 0;
Además, las clases constexpr se pueden usar tanto en tiempo de compilación como en tiempo de ejecución. Sin
embargo, a diferencia de las clases ordinarias, no está permitido definir funciones miembro virtuales (no hay polimorfismo en tiempo
de compilación), y una clase constexpr no debe tener un destructor definido explícitamente.
■ Nota El ejemplo de código anterior podría fallar al compilar en algunos compiladores de C++. Según los estándares
actuales, el estándar C++ no especifica funciones matemáticas comunes de la biblioteca numérica (encabezado <cmath>)
como constexpr, como std::sqrt() y std::pow(). Las implementaciones del compilador son libres de hacerlo de todos modos,
pero no es un requisito obligatorio.
Sin embargo, ¿cómo se deberían haber juzgado estos cálculos en tiempo de compilación a partir de un código limpio?
¿perspectiva? ¿Es básicamente una buena idea agregar constexpr a cualquier cosa que pueda tenerlo?
Bueno, mi opinión es que constexpr no reduce la legibilidad del código. El especificador siempre está delante de las definiciones
de variables y constantes, respectivamente delante de las declaraciones de funciones o métodos. Por lo tanto, no molesta tanto. Por
otro lado, si definitivamente sé que algo nunca se evaluará en tiempo de compilación, también debería renunciar al especificador.
No permitir un comportamiento indefinido
En C++ (y también en algunos otros lenguajes de programación), la especificación del lenguaje no define el comportamiento
en ninguna situación posible. En algunos lugares, la especificación dice que el comportamiento de una determinada
operación no está definido en determinadas circunstancias. En tal tipo de situación, no puede predecir lo que sucederá, porque
el comportamiento del programa depende de la implementación del compilador, el sistema operativo subyacente o los
interruptores de optimización especiales. ¡Es realmente malo! El programa puede fallar o generar silenciosamente resultados
incorrectos.
Aquí hay un ejemplo de comportamiento indefinido, un uso incorrecto de un puntero inteligente:
const std::size_t NUMBER_OF_STRINGS { 100 };
std::shared_ptr<std::string> arrayOfStrings(new std::string[NÚMERO_DE_CADENAS]>);
Supongamos que este objeto std::shared_ptr<T> es el último que apunta al recurso de matriz de cadenas
y se queda sin alcance en alguna parte, ¿qué pasará?
Respuesta: El destructor de std::shared_ptr<T> disminuye el número de propietarios compartidos y el contador llega
a cero. Como consecuencia, el recurso administrado por el puntero inteligente (la matriz de std::string) se destruye llamando
a su destructor. Pero lo hará mal, porque cuando asigna el recurso administrado usando new[], debe llamar al formulario de
matriz delete[], y no eliminar, para liberar el recurso y el eliminador predeterminado de std::shared_ptr<T> usa eliminar.
109
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Eliminar una matriz con delete en lugar de delete[] da como resultado un comportamiento indefinido. No se especifica qué
sucede Tal vez resulte en una pérdida de memoria, pero eso es solo una suposición.
■ Precaución ¡Evite el comportamiento indefinido! Es un grave error y termina con programas que silenciosamente se comportan mal.
Hay varias soluciones para permitir que el puntero inteligente elimine la matriz de cadenas correctamente. Por ejemplo tu
puede proporcionar un eliminador personalizado como un objeto similar a una función (también conocido como "Functor", consulte el Capítulo 7):
template< typename Type > struct
CustomArrayDeleter { void operator()
(Type const* pointer) {
borrar [] puntero; } };
Ahora puede usar su propio eliminador de la siguiente manera:
const std::size_t NUMBER_OF_STRINGS { 100 };
std::shared_ptr<std::string> arrayOfStrings(new std::string[NUMBER_OF_STRINGS], CustomArrayD eleter<std::string>());
En C++ 11, hay un eliminador predeterminado para los tipos de matriz definidos en el encabezado <memoria>:
const std::size_t NUMBER_OF_STRINGS { 100 };
std::shared_ptr<std::string> arrayOfStrings(new std::string[NÚMERO_DE_CADENAS], std::default_delete<std::string[]>());
Por supuesto, debe tenerse en cuenta, dependiendo de los requisitos a cumplir, si el
el uso de un std::vector no siempre es la mejor solución para implementar una "matriz de cosas".
Programación rica en tipos
No confíes en los nombres.
Tipos de confianza.
Los tipos no mienten.
¡Los tipos son tus amigos!
—Mario Fusco (@mariofusco), 13 de abril de 2016, en Twitter
El 23 de septiembre de 1999, la NASA perdió su Mars Climate Orbiter I, una sonda espacial robótica, después de un viaje de 10
meses al cuarto planeta de nuestro Sistema Solar. Cuando la nave espacial entró en inserción orbital, la transferencia de datos importantes
falló entre el equipo de propulsión de Lockheed Martin Astronautics en Colorado y el equipo de navegación de la misión de la NASA
en Pasadena (California). Este error empujó a la nave espacial demasiado cerca de la atmósfera de Marte, donde se quemó
inmediatamente.
110
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Figura 54. Representación artística del Mars Climate Orbiter (Autor: NASA/JPL/Corby Waste; Licencia: Dominio público)
La causa de la transferencia de datos fallida fue que el equipo de navegación de la misión de la NASA usó el Sistema Internacional
de Unidades (SI), mientras que el software de navegación de Lockheed Martin usó unidades inglesas (Sistema de Medición Imperial).
El software utilizado por el equipo de navegación de la misión ha enviado valores en librasfuerzasegundo (lbf∙s), pero el software
de navegación del Orbiter esperaba valores en newtonsegundo (N∙s). La pérdida financiera total de la NASA fue de 328 millones
de dólares estadounidenses. El trabajo de toda una vida de alrededor de 200 buenos ingenieros de naves espaciales fue destruido en
unos pocos segundos.
Esta falla no es un ejemplo típico de un simple error de software. Ambos sistemas por sí mismos pueden haber funcionado
correctamente. Pero revela un aspecto interesante en el desarrollo de software. Parece que los problemas de comunicación y
coordinación entre ambos equipos de ingeniería serán la razón elemental de este fallo.
Es obvio: ni se realizaron pruebas conjuntas del sistema con ambos subsistemas, ni se diseñaron adecuadamente las interfaces
entre ambos subsistemas.
La gente a veces comete errores. El problema aquí no fue el error, fue la falla de la ingeniería de
sistemas de la NASA y los controles y equilibrios en nuestros procesos para detectar el error. Por
eso perdimos la nave espacial.
Dr. Edward Weiler, Administrador Asociado de Ciencias Espaciales de la NASA [JPL99]
De hecho, no conozco ningún detalle sobre el software del sistema Mars Climate Orbiter. Pero según el informe de examen de
la falla, entendí que una pieza de software produjo resultados en una unidad del "sistema inglés", mientras que la otra pieza de software
que usó esos resultados esperaba que estuvieran en unidades métricas.
111
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Creo que todo el mundo conoce las declaraciones de funciones miembro de C++ que se parecen a las de la siguiente clase:
class SpacecraftTrajectoryControl { public: void
applyMomentumToSpacecraftBody(const double impulseValue); };
¿Qué significa el doble? ¿De qué unidad es el valor que espera la función miembro denominada
applyMomentumToSpacecraftBody? ¿Es un valor medido en newton (N), newtonsegundo (N∙s), librafuerzasegundo (lbf∙s) o
cualquier otra unidad? De hecho no lo sabemos. El doble puede ser cualquier cosa. Es, por supuesto, un tipo, pero no es un tipo
semántico. Tal vez se haya documentado en alguna parte, o podríamos darle al parámetro un nombre más significativo y detallado
como impulseValueInNewtonSeconds, que sería mejor que nada. Pero incluso la mejor documentación o nombre de parámetro no
puede garantizar que un cliente de esta clase pase un valor de una unidad incorrecta a esta función miembro.
¿Podemos hacerlo mejor? Por supuesto que podemos.
Lo que realmente queremos tener para definir una interfaz correctamente y rica en semántica, es algo como esto:
clase SpacecraftTrajectoryControl { public: void
applyMomentumToSpacecraftBody(const Momentum& impulseValue); };
En mecánica, la cantidad de movimiento se mide en newtonsegundo (Ns). Un newtonsegundo (1 Ns) es la fuerza
de un Newton (que es 1 kg m/s2 en unidades básicas del SI) actuando sobre un cuerpo (un objeto físico) durante un segundo.
Para usar un tipo como Momentum en lugar del doble de tipo de punto flotante inespecífico, debemos introducir que
escriba primero. En un primer paso definimos una plantilla que se puede utilizar para representar cantidades físicas sobre la base
del sistema de unidades MKS. La abreviatura MKS significa metro (longitud), kilogramo (masa) y segundos (tiempo). Estas tres
unidades fundamentales se pueden utilizar para expresar cualquier medida física dada.
Listado 520. Una plantilla de clase para representar unidades MKS
template <int M, int K, int S> struct MksUnit
{ enum { metro = M,
kilogramo = K, segundo = S}; };
Además, necesitamos una plantilla de clase para la representación de valores:
Listado 521. Una plantilla de clase para representar valores de unidades MKS
plantilla <typename MksUnit> clase
Valor { privado:
largo doble
magnitud { 0.0 };
público:
Valor explícito (const long double magnitud) : magnitud(magnitud) {} long double getMagnitude() const
{
magnitud de retorno ; } };
112
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
A continuación, podemos usar ambas plantillas de clase para definir alias de tipo para cantidades físicas concretas. Aquí
hay unos ejemplos:
usando DimensionlessQuantity = Value<MksUnit<0, 0, 0>>; usando Longitud
= Valor<MksUnit<1, 0, 0>>; usando Área =
Valor<MksUnit<2, 0, 0>>; usando Volumen =
Valor<MksUnit<3, 0, 0>>; usando Masa =
Valor<MksUnit<0, 1, 0>>; usando Tiempo =
Valor<MksUnit<0, 0, 1>>; usando Velocidad =
Valor<MksUnit<1, 0, 1>>; usando Aceleración =
Valor<MksUnit<1, 0, 2>>; usando Frecuencia = Valor<MksUnit<0,
0, 1>>; usando Force = Value<MksUnit<1, 1, 2>>; usando
Presión = Valor<MksUnit<1, 1, 2>>; // ... etc. ...
Ahora también es posible definir Momentum, que se requiere como tipo de parámetro para nuestra función
miembro applyMomentumToSpacecraftBody:
usando Momentum = Value<MksUnit<1, 1, 1>>;
Después de haber introducido el alias de tipo Momentum, el siguiente código no se compilará porque no hay un constructor
adecuado para convertir de doble a Value<MksUnit<1,1,1>>:
Control de control de trayectoria de la nave
espacial; const doble algunValor = 13.75;
control.applyMomentumToSpacecraftBody(algúnValor); // ¡Error en tiempo de compilación!
Incluso el siguiente ejemplo conducirá a errores en tiempo de compilación, porque una variable de tipo Force no debe
usarse como Momentum, y debe evitarse una conversión implícita entre estas diferentes dimensiones:
Control de control de trayectoria de la nave
espacial; Fuerza fuerza
{ 13.75 }; control.applyMomentumToSpacecraftBody(fuerza); // ¡Error en tiempo de compilación!
Pero esto funcionará bien:
Control de control de trayectoria de la nave
espacial; Momento impulso { 13,75 };
control.applyMomentumToSpacecraftBody(momentum);
Las unidades también se pueden utilizar para la definición de constantes. Para este propósito, necesitamos modificar
ligeramente el valor de la plantilla de clase. Agregamos la palabra clave constexpr (consulte la sección “Cálculos durante el tiempo
de compilación” en el Capítulo 4) al constructor de inicialización y la función miembro getMagnitude(). Esto nos permite no solo
crear constantes de valor en tiempo de compilación que no tienen que inicializarse durante el tiempo de ejecución. Como
veremos más adelante, ahora también podemos realizar cálculos con nuestros valores físicos durante el tiempo de compilación.
template <typename MksUnit> clase
Valor { público:
constexpr
valor explícito (const long doble magnitud) noexcept : magnitud { magnitud } {}
113
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
constexpr long double getMagnitude() const noexcept { return
magnitud; }
privado:
largo doble magnitud { 0.0 }; };
A partir de entonces, las constantes de diferentes unidades físicas se pueden definir como en el siguiente ejemplo:
constexpr Aceleración gravitacionalAccelerationOnEarth { 9.80665 }; constexpr
Presión standardPressureOnSeaLevel { 1013.25 }; constexpr
Velocidad speedOfLight { 299792458.0 }; constexpr
Frecuencia concertPitchA { 440.0 }; constexpr Mass
neutronMass { 1.6749286e27 };
Además, los cálculos entre unidades son posibles si se implementan los operadores necesarios.
Por ejemplo, estas son las plantillas de operadores de suma, resta, multiplicación y división para realizar
diferentes cálculos con dos valores de diferentes unidades MKS:
template <int M, int K, int S>
constexpr Value<MksUnit<M, K, S>> operador+
(const Value<MksUnit<M, K, S>>& lhs, const Value<MksUnit<M, K, S >>& rhs) noexcept { return
Value<MksUnit<M, K, S>>(lhs.getMagnitude() + rhs.getMagnitude()); }
template <int M, int K, int S>
constexpr Value<MksUnit<M, K, S>> operador
(const Value<MksUnit<M, K, S>>& lhs, const Value<MksUnit<M, K, S>>& rhs) noexcept { return
Value<MksUnit<M, K, S>>(lhs.getMagnitude() rhs.getMagnitude()); }
template <int M1, int K1, int S1 , int M2, int K2, int S2> constexpr
Value<MksUnit<M1 + M2, K1 + K2, S1 + S2>> operador* (const
Value<MksUnit<M1, K1, S1>>& lhs, const Value<MksUnit<M2, K2, S2>>& rhs) noexcept { return
Value<MksUnit<M1 + M2, K1 + K2, S1 + S2>>(lhs.getMagnitude() * rhs. getMagnitud()); }
template <int M1, int K1, int S1 , int M2, int K2, int S2> constexpr
Value<MksUnit<M1 M2, K1 K2, S1 S2>> operador/ (const
Value<MksUnit<M1, K1, S1>>& lhs, const Value<MksUnit<M2, K2, S2>>& rhs) noexcept { return
Value<MksUnit<M1 M2, K1 K2, S1 S2>>(lhs.getMagnitude() / rhs. getMagnitud()); }
Ahora podrás escribir algo como esto:
constexpr Momentum impulseValueForCourseCorrection = Force { 30.0 } * Time { 3.0 }; Control de
control de trayectoria de la nave espacial;
control.applyMomentumToSpacecraftBody(impulseValueForCourseCorrection);
114
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Eso es obviamente una mejora significativa sobre la multiplicación de dos dobles sin sentido y la asignación de su
resultado a otro doble sin sentido. Es bastante expresivo. Y es más seguro, porque no podrás asignar el resultado de la multiplicación
a algo diferente a una variable de tipo Momentum.
Y la mejor parte es esta: ¡ la seguridad de tipos está garantizada durante el tiempo de compilación! No hay gastos generales durante
tiempo de ejecución, porque un compilador compatible con C++ 11 (y superior) puede realizar todas las comprobaciones de compatibilidad de tipos
necesarias.
Vayamos un paso más allá. ¿No sería muy conveniente e intuitivo si pudiéramos escribir algo como lo siguiente?
constexpr Aceleración gravitacionalAccelerationOnEarth = 9.80665_ms2;
Incluso eso es posible con C++ moderno. Desde C++ 11 podemos proporcionar sufijos personalizados para literales por
definiendo funciones especiales, los llamados operadores literales , para ellos:
constexpr Operador de fuerza "" _N(magnitud doble larga ) { return
Force(magnitud); }
constexpr Operador de aceleración "" _ms2( magnitud doble larga ) { return
Aceleración(magnitud); }
constexpr Operador de tiempo "" _s(magnitud doble larga ) { return
Tiempo(magnitud); }
constexpr Operador Momentum "" _Ns( magnitud doble larga) { return
Momentum(magnitud); }
// ...más operadores literales aquí...
LITERALES DEFINIDOS POR EL USUARIO [C++11]
Básicamente, un literal es una constante de tiempo de compilación cuyo valor se especifica en el archivo fuente. Desde C++ 11,
los desarrolladores pueden producir objetos de tipos definidos por el usuario definiendo sufijos definidos por el usuario para los
literales. Por ejemplo, si se debe inicializar una constante con un literal de US$ 145,67, esto se puede hacer escribiendo la
siguiente expresión:
constexpr Cantidad de dinero = 145.67_USD;
En este caso, “_USD” es el sufijo definido por el usuario para los literales de coma flotante que representan cantidades de dinero.
Para que se pueda utilizar dicho literal definido por el usuario, se debe definir una función que se conoce como operador
literal:
constexpr Operador de dinero "" _USD (cantidad doble constante larga ) {
devolver dinero (cantidad); }
115
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Una vez que hemos definido los literales definidos por el usuario para nuestras unidades físicas, podemos trabajar con ellos de la siguiente manera
manera:
Fuerza fuerza = 30.0_N; Tiempo
tiempo = 3.0_s; cantidad de
movimiento cantidad de movimiento = fuerza * tiempo;
Esta notación no solo es familiar para los físicos y otros científicos. Es aún más seguro. Con la programación rica en tipos y los literales
definidos por el usuario, está protegido contra la asignación de un literal que exprese un valor de segundos a una variable de tipo Force.
Fuerza fuerza1 = 3,0; // ¡Error en tiempo de compilación!
Fuerza fuerza2 = 3.0_s; // ¡Error en tiempo de compilación!
Fuerza fuerza3 = 3.0_N; // ¡Obras!
Por supuesto, también es posible utilizar literales definidos por el usuario junto con la deducción automática de tipos y/o expresiones constantes:
fuerza automática = 3.0_N;
aceleración automática constexpr = 100.0_ms2;
Eso es bastante conveniente y bastante elegante, ¿no? Entonces, aquí está mi consejo para el diseño de interfaz pública:
Cree interfaces (API) fuertemente tipadas.
En otras palabras: debe evitar en gran medida los tipos integrados generales de bajo nivel, como int, double o, en el peor de los casos,
void*, en las interfaces públicas, respectivamente, las API. Estos tipos no semánticos son peligrosos en determinadas circunstancias, porque pueden
representar casi cualquier cosa.
■ Sugerencia Ya hay disponibles algunas bibliotecas basadas en plantillas que proporcionan tipos para cantidades físicas,
incluidas todas las unidades SI. Un ejemplo muy conocido es Boost.Units (parte de Boost desde la versión 1.36.0;
consulte http://www.boost.org).
Conozca sus bibliotecas
¿Alguna vez has oído hablar del síndrome “No inventado aquí” (NIH)? Es un antipatrón organizacional.
El síndrome NIH es un término despectivo para una postura en muchas organizaciones de desarrollo que describe el desconocimiento del conocimiento
existente o soluciones probadas basadas en su lugar de origen. Es una forma de "reinventar la rueda", es decir, volver a implementar algo (una
biblioteca o un marco) que ya está disponible en algún lugar de bastante alta calidad. El razonamiento detrás de esta actitud suele ser la creencia
de que los desarrollos internos deben ser mejores en varios aspectos. A menudo se las considera erróneamente más baratas, más seguras, más
flexibles y más controlables que las soluciones existentes y bien establecidas.
116
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
De hecho, solo unas pocas empresas logran desarrollar una alternativa verdaderamente equivalente, o incluso
mejor, a una solución que ya existe en el mercado. A menudo, el enorme esfuerzo de tales desarrollos no justifica el
escaso beneficio. Y no es raro que la biblioteca o el marco de desarrollo propio sean claramente peores en calidad
en comparación con las soluciones existentes y maduras que ya existen desde hace años.
Durante las últimas décadas, han surgido muchas bibliotecas y marcos excelentes en el entorno C++.
Estas soluciones tuvieron la oportunidad de madurar durante mucho tiempo y se han utilizado con éxito en decenas
de miles de proyectos. No hay necesidad de reinventar la rueda. Los buenos artesanos del software deben conocer
estas bibliotecas. No es necesario conocer cada pequeño detalle sobre estas bibliotecas y sus API. Sin embargo, es bueno
saber que ya existen soluciones probadas para ciertos campos de aplicación, que vale la pena considerar para tener
una selección más limitada para su proyecto de desarrollo de software.
Aproveche el <algoritmo>
Si desea mejorar la calidad del código en su organización, reemplace todas sus pautas de
codificación con un objetivo: ¡No bucles sin procesar!
—Sean Parent, arquitecto de software principal de Adobe, en CppCon 2013
Jugar con colecciones de elementos es una actividad cotidiana en la programación. Independientemente de si
estamos tratando con colecciones de datos de medición, con correos electrónicos, cadenas, registros de una base
de datos u otros elementos, el software debe filtrarlos, clasificarlos, eliminarlos, manipularlos y más.
En muchos programas podemos encontrar "bucles en bruto" (por ejemplo, bucles for o while hechos a mano)
para visitar algunos o todos los elementos en un contenedor, o secuencia, para hacer algo con ellos. Un ejemplo simple es
invertir un orden de enteros que se almacenan en un std::vector de esta manera:
#incluir <vector>
std::vector<int> enteros { 2, 5, 8, 22, 45, 67, 99 };
// ...en algún lugar del programa:
std::size_t leftIndex = 0; std::size_t
rightIndex = integers.size() 1;
while (índiceizquierdo <índicederecho) {
int buffer = enteros[rightIndex];
enteros[índicederecho] = enteros[índiceizquierdo];
enteros[índiceizquierdo] = búfer; +
+índiceizquierdo;
índicederecho; }
Básicamente, este código funcionará. Pero tiene varias desventajas. Es difícil ver inmediatamente qué
esta pieza de código está haciendo (de hecho, las tres primeras líneas dentro del bucle while podrían sustituirse
por std::swap from header <utility>). Además, escribir código de esta manera es muy tedioso y propenso a errores.
Solo imagine que, por cualquier motivo, violamos los límites del vector e intentamos acceder a un elemento en una
posición fuera de rango. A diferencia de la función miembro std::vector::at(), std::vector::operator[] no genera una
excepción std::out_of_range entonces. Conducirá a un comportamiento indefinido.
La biblioteca estándar de C++ proporciona más de 100 algoritmos útiles que se pueden aplicar a los contenedores.
o secuencias para buscar, contar y manipular elementos. Se recogen en la cabecera <algoritmo>.
117
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Por ejemplo, para invertir el orden de los elementos en cualquier tipo de contenedor de biblioteca estándar, por ejemplo, en
un std::vector, simplemente podemos usar std::reverse:
#include <algoritmo>
#include <vector>
std::vector<int> enteros = { 2, 5, 8, 22, 45, 67, 99 }; // ...en algún lugar del
programa: std::reverse(std::begin(integers),
std::end(integers)); // El contenido de 'enteros' ahora es: 99, 67, 45, 22, 8, 5, 2
A diferencia de nuestra solución autoescrita anterior, este código no solo es mucho más compacto, menos propenso
a errores y más fácil de leer. Dado que std::reverse es una plantilla de función (al igual que todos los demás algoritmos), es
universalmente aplicable a todos los contenedores de secuencias de la biblioteca estándar, contenedores asociativos,
contenedores asociativos desordenados, std::string y también matrices primitivas (que, por cierto, , ya no debe usarse en
un programa C++ moderno; consulte la sección "Preferir contenedores de biblioteca estándar sobre matrices simples de
estilo C" en el Capítulo 4).
Listado 522. Aplicando std::reverse a una matriz de estilo C y una cadena
#include <algoritmo>
#include <cadena>
// Funciona, pero las matrices primitivas no deben usarse en un programa C++ moderno int integers[] =
{ 2, 5, 8, 22, 45, 67, 99 }; std::reverse(std::begin(enteros),
std::end(enteros));
std::string text { "¡El gran zorro marrón salta sobre el perro perezoso!" };
std::reverse(std::begin(texto), std::end(texto)); // El contenido del
'texto' es ahora: "!god yzal eht revo spmuj xof nworb gib ehT"
El algoritmo inverso se puede aplicar, por supuesto, también a subrangos de un contenedor o secuencia:
Listado 523. Solo se invierte una subárea de la cadena
std::string text { "¡El gran zorro marrón salta sobre el perro perezoso!" };
std::reverse(std::begin(texto) + 13, std::end(texto) 9); // El contenido del 'texto'
es ahora: "¡El gran perro marrón eht revo spmuj xof lazy dog!"
Paralelización más fácil de algoritmos desde C++17
Tu almuerzo gratis pronto terminará.
—Hierba Sutter [Sutter05]
La cita anterior, dirigida a desarrolladores de software de todo el mundo, está tomada de un artículo publicado por Herb
Sutter, miembro del comité de estandarización de ISO C++ en ese momento, en 2005. Fue en un momento en que las
velocidades de reloj de los procesadores dejó de aumentar año tras año. En otras palabras, la velocidad de procesamiento
en serie ha alcanzado un límite físico. En cambio, los procesadores estaban cada vez más equipados con más
118
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
núcleos Este desarrollo en las arquitecturas de procesador lleva a una grave consecuencia: los desarrolladores ya no pueden
aprovechar el rendimiento cada vez mayor del procesador por frecuencias de reloj (el "almuerzo gratis" del que hablaba
Herb), sino que se verán obligados a desarrollar programas masivos de subprocesos múltiples como un manera de
utilizar mejor los procesadores multinúcleo modernos. Como resultado, los desarrolladores y arquitectos de software ahora
deben considerar la paralelización en su diseño y arquitectura de software.
Antes de la llegada de C++11, el estándar C++ solo admitía la programación de un solo subproceso y usted
tiene que usar bibliotecas de terceros (p. ej., Boost.Thread) o extensiones de compilador (p. ej., Open MultiProcessing
(OpenMP)) para paralelizar sus programas. Desde C ++ 11, la llamada Biblioteca de soporte de subprocesos está disponible
para admitir la programación paralela y multiproceso. Esta extensión de la Biblioteca estándar ha introducido subprocesos,
exclusiones mutuas, variables de condición y futuros.
Paralelizar una sección de código requiere un buen conocimiento del problema y debe ser considerado en el
diseño del software en consecuencia. De lo contrario, pueden ocurrir errores sutiles causados por condiciones de carrera
que podrían ser muy difíciles de depurar. Especialmente para los algoritmos de la biblioteca estándar, que a menudo
tienen que operar en contenedores llenos de una gran cantidad de objetos, la paralelización debe simplificarse para los
desarrolladores a fin de aprovechar los modernos procesadores multinúcleo de la actualidad.
A partir de C++17, partes de la Biblioteca estándar se han rediseñado de acuerdo con la Especificación técnica para
extensiones de C++ para paralelismo (ISO/IEC TS 19570:2015), también conocida como Paralelismo TS (TS =
especificación técnica) en resumen. En otras palabras, con C++17 estas extensiones se convirtieron en parte del estándar
principal ISO C++. Su objetivo principal es aliviar un poco a los desarrolladores de la compleja tarea de jugar con las
funciones de lenguaje de bajo nivel de la biblioteca de soporte de subprocesos, como std::thread, std::mutex, etc.
De hecho, eso significa que 69 algoritmos bien conocidos estaban sobrecargados y ahora también están disponibles
en una o más versiones que aceptan un parámetro de plantilla adicional para la paralelización llamado ExecutionPolicy (ver
barra lateral). Algunos de estos algoritmos son, por ejemplo, std::for_each, std::transform, std::copy_if o std::sort.
Además, se han añadido siete nuevos algoritmos que también se pueden paralelizar, como std::reduce,
std::exclusive_scan o std::transform_reduce. Estos nuevos algoritmos son particularmente útiles en la programación
funcional, razón por la cual los analizaré más adelante en el Capítulo 7.
POLÍTICAS DE EJECUCIÓN [C++17]
La mayoría de las plantillas de algoritmos del encabezado <algorithm> se han sobrecargado y ahora también
están disponibles en una versión paralelizable. Por ejemplo, además de la plantilla ya existente para la función
std::find, se ha definido otra versión que toma un parámetro de plantilla adicional para especificar la política de
ejecución:
// Versión estándar (un solo subproceso): template<
class InputIt, class T >
InputIt find( InputIt primero, InputIt último, const T& value );
// Versión adicional con política de ejecución definible por el usuario (desde C++17): template< class
ExecutionPolicy, class ForwardIt, class T >
ForwardIt find(ExecutionPolicy&& policy, ForwardIt first, ForwardIt last, const T& value);
Las tres etiquetas de política estándar que están disponibles para el parámetro de plantilla ExecutionPolicy son:
• std::execution::seq : un tipo de política de ejecución que define que un paralelo
la ejecución del algoritmo puede ser secuencial. Por lo tanto, es más o menos lo mismo que
usaría la versión estándar de subproceso único de la función de plantilla de algoritmo sin
una política de ejecución.
119
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
• std::execution::par : un tipo de política de ejecución que define que un paralelo
la ejecución del algoritmo puede ser paralelizada. Permite la implementación para ejecutar el
algoritmo en múltiples subprocesos. Importante: ¡ Los algoritmos paralelos no protegen
automáticamente contra carreras de datos críticos o interbloqueos! Usted es responsable de
asegurarse de que no se produzcan condiciones de carrera de datos mientras ejecuta la función.
• std::execution::par_unseq : un tipo de política de ejecución que define que un paralelo
la ejecución del algoritmo puede ser vectorizada y paralelizada. La vectorización aprovecha el conjunto
de comandos SIMD (instrucción única, datos múltiples) de las CPU modernas. SIMD significa
que un procesador puede realizar la misma operación en múltiples puntos de datos
simultáneamente.
Por supuesto, no tiene absolutamente ningún sentido ordenar un vector pequeño con algunos elementos en
paralelo. La sobrecarga de la gestión de subprocesos sería mucho mayor que la ganancia en el rendimiento.
Por lo tanto, una política de ejecución también se puede seleccionar dinámicamente durante el tiempo de ejecución, por
ejemplo, teniendo en cuenta el tamaño del vector. Lamentablemente, la política de ejecución dinámica aún no se ha
aceptado para el estándar C++17. Ahora está planificado para el próximo estándar C++20.
Una discusión completa de todos los algoritmos disponibles está mucho más allá del alcance de este libro. Pero después
de esta breve introducción al encabezado <algoritmo> y las nuevas posibilidades de paralelización con C++17, echemos un vistazo
a algunos ejemplos de lo que se puede hacer con los algoritmos.
Clasificación y salida de un contenedor
El siguiente ejemplo usa dos plantillas del encabezado <algoritmo>: std::sort y std::for_each.
Internamente, std::sort utiliza el algoritmo quicksort. Por defecto, las comparaciones dentro de std::sort se
realizan con la función operator< de los elementos. Esto significa que si desea ordenar una secuencia de
instancias de una de sus propias clases, debe asegurarse de que operator< esté implementado correctamente
en ese tipo.
Listado 524. Ordenar un vector de cadenas e imprimirlas en stdout
#include <algoritmo>
#include <iostream>
#include <cadena>
#include <vector>
void printCommaSeparated(const std::string& text) {
std::cout << texto << ", "; }
int main()
{ std::vector<std::string> nombres = { "Peter", "Harry", "Julia", "Marc", "Antonio", "Glenn" };
std::sort(std::begin(nombres), std::end(nombres));
std::for_each(std::begin(nombres), std::end(nombres), printCommaSeparated); devolver
0; }
120
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Comparación de dos secuencias El
siguiente ejemplo compara dos secuencias de cadenas utilizando std::equal.
Listado 525. Comparando dos secuencias de cadenas
#include <algoritmo>
#include <iostream>
#include <cadena>
#include <vector>
int main()
{ const std::vector<std::string> nombres1 { "Peter", "Harry", "Julia", "Marc", "Antonio",
"Glenn" };
const std::vector<std::string> nombres2 { "Pedro", "Harry", "Julia", "Juan", "Antonio",
"Glenn" };
const bool isEqual = std::equal(std::begin(names1), std::end(names1), std::begin(names2), std::end(names2));
if (isEqual)
{ std::cout << "El contenido de ambas secuencias es igual.\n"; } else
{ std::cout
<< "Los contenidos de ambas secuencias difieren.\n"; } devuelve 0;
Por defecto, std::equal compara elementos usando operator==. Pero puedes definir "igualdad" como tú
desear. La comparación estándar se puede reemplazar por una operación de comparación personalizada:
Listado 526. Comparación de dos secuencias de cadenas mediante una función de predicado personalizada
#include <algoritmo>
#include <iostream>
#include <cadena>
#include <vector>
bool compareFirstThreeCharactersOnly(const std::string& string1,
const std::string& string2) { return
(string1.compare(0, 3, string2, 0, 3) == 0); }
int main()
{ const std::vector<std::string> nombres1 { "Peter", "Harry", "Julia", "Marc", "Antonio",
"Glenn" };
const std::vector<std::string> nombres2 { "Pedro", "Harold", "Julia", "María", "Antonio",
"Glenn" };
121
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
const bool isEqual = std::equal(std::begin(names1), std::end(names1), std::begin(names2),
std::end(nombres2), compareFirstThreeCharactersOnly);
if (isEqual)
{ std::cout << "Los primeros tres caracteres de todas las cadenas en ambas secuencias son iguales.\n"; } else
{ std::cout
<< "Los primeros tres caracteres de todas las cadenas en ambas secuencias difieren.\n"; } devuelve 0;
Si no se requiere reutilización para la función de comparación compareFirstThreeCharactersOnly(), la línea
anterior en la que se lleva a cabo la comparación también se puede implementar usando una expresión lambda
(Discutiremos las expresiones lamda con más detalle en el Capítulo 7), así :
// Compara solo los tres primeros caracteres de cada cadena para determinar la igualdad: const bool
isEqual =
std::equal(std::begin(nombres1), std::end(nombres1), std::begin(nombres2), std::end(nombres2), []( const auto&
string1, const auto& string2) {
return (cadena1.compare(0, 3, cadena2, 0, 3) == 0); });
Esta alternativa puede parecer más compacta, pero no necesariamente contribuye a la legibilidad del código.
La función explícita compareFirstThreeCharactersOnly() tiene un nombre semántico que expresa muy claramente lo
que se compara (no el Cómo; consulte la sección "Usar nombres que revelan la intención" en el Capítulo 4). Lo que se
compara exactamente no necesariamente se puede ver a primera vista en la versión con la expresión lambda.
Siempre tenga en cuenta que la legibilidad de nuestro código debe ser uno de nuestros primeros objetivos. Además, siempre
tenga en cuenta que los comentarios del código fuente son básicamente un olor a código y no son adecuados para explicar el
código difícil de leer (recuerde la sección sobre Comentarios en el Capítulo 4 ).
Aproveche Boost No puedo dar una
introducción amplia a la famosa biblioteca de Boost (http://www.boost.org, distribuido bajo la licencia de software
de Boost, versión 1.0) aquí. La biblioteca (de hecho, es una biblioteca de bibliotecas) es demasiado grande y
poderosa, y discutirla en detalle está más allá del alcance de este libro. Además, hay numerosos buenos libros y
tutoriales sobre Boost.
Pero creo que es muy importante conocer esta biblioteca y su contenido. Muchos problemas y
Los desafíos a los que se enfrentan los desarrolladores de C++ en su trabajo diario se pueden resolver bastante bien con las
bibliotecas de Boost.
Más allá de eso, Boost es una especie de "incubadora" para varias bibliotecas que a veces se aceptan
para formar parte del estándar del lenguaje C++, si tienen un cierto nivel de madurez. Ojo: ¡eso no significa
necesariamente que sean totalmente compatibles! Por ejemplo, std::thread (parte del estándar desde C++11)
es parcialmente igual a Boost.Thread, pero hay algunas diferencias. Por ejemplo, la implementación de Boost
admite la cancelación de subprocesos, los subprocesos de C++ 11 no. Por otro lado, C++11 admite std::async,
pero Boost no.
Desde mi perspectiva, vale la pena conocer las bibliotecas de Boost y recordar cuando tienes un
problema adecuado que puede ser resuelto adecuadamente por ellos.
122
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Más bibliotecas que debe conocer
Además de los contenedores de la biblioteca estándar, <algorithm> y Boost, existen algunas bibliotecas más que puede tener
en cuenta al escribir su código. Aquí hay una lista de bibliotecas, ciertamente incompleta, que vale la pena mirar cuando se
enfrenta a un determinado problema adecuado:
• Utilidades de fecha y hora (<crono>): desde C++11, el lenguaje proporciona una colección de tipos para
representar relojes, puntos de tiempo y duraciones. Por ejemplo, puede representar intervalos
de tiempo con la ayuda de std::chrono::duration. Y con std::chrono::system_clock, está
disponible un reloj en tiempo real para todo el sistema. Puede usar la biblioteca desde C ++ 11
simplemente incluyendo el encabezado <chrono>.
• Biblioteca de expresiones regulares (<regex>): desde C++11, hay disponible una biblioteca de
expresiones regulares que se puede usar para realizar coincidencias de patrones dentro de
cadenas. También se admite la sustitución de texto dentro de una cadena basada en expresiones
regulares. Puede usar la biblioteca desde C++ 11 simplemente incluyendo el encabezado <regex>.
• Biblioteca del sistema de archivos (<sistema de archivos>): desde C++17, la biblioteca del sistema de
archivos se ha convertido en parte del estándar. Antes de que se convirtiera en parte del
estándar principal de C++, ha sido una especificación técnica (ISO/IEC TS 18822:2015). La
biblioteca independiente del sistema operativo proporciona varias instalaciones para realizar
operaciones en los sistemas de archivos y sus componentes. Con la ayuda de <filesystem>
puede crear directorios, copiar archivos, iterar sobre las entradas del directorio, recuperar el tamaño de un archivo, etc.
Puede usar la biblioteca desde C ++ 17 simplemente incluyendo el encabezado <filesystem>.
■ Sugerencia TSi actualmente aún no trabaja de acuerdo con el último estándar C++17, Boost.Filesystem podría
ser una alternativa.
• Rangev3: una biblioteca de rangos para C++11/14/17 escrita por Eric Niebler, miembro del Comité
de estandarización de ISO C++. Rangev3 es una biblioteca de solo encabezado que simplifica
el manejo de contenedores de la biblioteca estándar de C++ o contenedores de otras bibliotecas
(por ejemplo, Boost). Con la ayuda de esta biblioteca, puede deshacerse de los malabarismos a
veces un poco complicados con los iteradores en diversas situaciones. Por ejemplo, en lugar de
escribir std::sort(std::begin(container), std::end(container)), simplemente puede escribir
ranges::sort(container).
Rangev3 está disponible en GitHub, URL: https://github.com/ericniebler/rangev3.
La documentación se puede encontrar aquí: https://ericniebler.github.io/rangev3/.
• Estructuras de datos concurrentes (libcds): una biblioteca de plantillas de C++ en su mayoría solo de
encabezado escrita por Max Khizhinsky, que proporciona algoritmos sin bloqueo e
implementaciones de estructuras de datos concurrentes para computación paralela de alto
rendimiento. La biblioteca está escrita en C++ 11 y publicada bajo una licencia BSD. libcds y su
documentación se pueden encontrar en SourceForge, URL: http://libcds.sourceforge.net.
Manejo adecuado de excepciones y errores
Tal vez ya haya escuchado el término preocupaciones transversales. Esta expresión incluye todas aquellas cosas que son
difíciles de abordar a través de un concepto de modularización y, por lo tanto, requieren un tratamiento especial por parte de la
arquitectura y el diseño del software. Una de estas típicas preocupaciones transversales es la Seguridad. Si tiene que cuidar la
Seguridad de los Datos y las Restricciones de Acceso en su sistema de software, porque lo exigen ciertos requisitos de
calidad, es un tema delicado que impregna todo el sistema. Tienes que lidiar con eso en casi todas partes, en prácticamente
todos los componentes.
123
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Otra preocupación transversal es el manejo de transacciones. Especialmente en las aplicaciones de software que usan
bases de datos, debe asegurarse de que una llamada Transacción, que es una serie coherente de operaciones individuales, tenga
éxito o falle como una unidad completa; nunca puede ser sólo parcialmente completo.
Y como otro ejemplo, también Logging es una preocupación transversal. El registro suele ser necesario en todas
partes en un sistema de software. A veces, el código productivo y específico del dominio está plagado de declaraciones de registro,
lo que es perjudicial para la legibilidad y la comprensión del código.
Si la arquitectura del software no se ocupara de estas preocupaciones transversales, esto podría conducir a soluciones
inconsistentes. Por ejemplo, se podrían usar dos marcos de registro diferentes en el mismo proyecto, porque dos equipos de
desarrollo que trabajan en el mismo sistema decidieron elegir marcos diferentes.
El manejo de excepciones y errores es otra preocupación transversal. Tratar con errores y excepciones
impredecibles que requieren respuestas y tratamientos especiales es obligatorio en todo sistema de software. Y, por supuesto,
las estrategias de manejo de errores en todo el sistema deben ser uniformes y consistentes. Por lo tanto, es muy importante que las
personas responsables de la arquitectura del software diseñen y desarrollen una estrategia de manejo de errores bastante temprano
en el proyecto.
Bueno, pero ¿cuáles son los principios que nos guían en el desarrollo de una buena estrategia de manejo de errores?
¿Cuándo está justificado lanzar una excepción? ¿Cómo trato las excepciones lanzadas? ¿Y con qué fines nunca deben utilizarse
las excepciones? ¿Cuáles son las alternativas?
Las siguientes secciones presentan algunas reglas, pautas y principios que ayudan a los programadores de C++ a diseñar e
implementar una buena estrategia de manejo de errores.
La prevención es mejor que el cuidado posterior
Una estrategia básica fundamentalmente buena para lidiar con errores y excepciones es evitarlos en general.
La razón de esto es obvia: todo lo que no puede suceder no tiene que ser tratado.
Tal vez dirás ahora: “Bueno, esto es una perogrullada. Por supuesto que es mucho mejor evitar errores o
excepciones, pero a veces no es posible prevenirlos”. Tienes razón, suena banal a primera vista.
Y sí, especialmente cuando se utilizan bibliotecas de terceros, se accede a bases de datos o se accede a un sistema externo,
pueden ocurrir cosas imprevisibles. Pero para su propio código, es decir, las cosas que puede diseñar como desee, puede tomar
las medidas adecuadas para evitar excepciones en la medida de lo posible.
David Abrahams, un programador estadounidense, exmiembro del comité de estandarización de ISO C++ y miembro
fundador de Boost C++ Libraries creó una comprensión de lo que se llama seguridad de excepciones y las presentó en un documento
[Abrahams98] en 1998. El conjunto de pautas contractuales formuladas en este documento, que también se conocen como las
"Garantías de Abraham", tuvieron una influencia significativa en el diseño de la biblioteca estándar de C++ y en cómo esta biblioteca
trata las excepciones. Pero estas pautas no solo son relevantes para los implementadores de bibliotecas de bajo nivel. También
pueden ser considerados por los desarrolladores de software que escriben el código de la aplicación en niveles de abstracción más
altos.
La seguridad de excepciones es parte del diseño de la interfaz. Una interfaz (API) no solo consta de firmas de función,
es decir, los parámetros de una función y los tipos de devolución. También las excepciones que pueden lanzarse si se invoca
una función son parte de su interfaz. Además, hay tres aspectos más que deben tenerse en cuenta:
• Precondición: Una precondición es una condición que siempre debe ser cierta antes de que
se invoca la función o el método de una clase. Si se viola una condición previa, no se puede garantizar
que la llamada a la función produzca el resultado esperado: la llamada a la función puede tener éxito,
puede fallar, puede causar efectos secundarios no deseados o mostrar un comportamiento indefinido.
• Invariante: Una invariante es una condición que siempre debe ser cierta durante la ejecución de una función
o método. En otras palabras, es una condición que se cumple al principio y al final de la ejecución de una
función. Una forma especial de un invariante en la orientación a objetos es un invariante de clase.
Si se viola tal invariante, el objeto (instancia) de la clase queda en un estado incorrecto e inconsistente
después de una llamada al método.
124
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
• Poscondición: una poscondición es una condición que siempre debe cumplirse inmediatamente después de
la ejecución de una función o método. Si se viola una condición posterior, debe haber ocurrido un
error durante la ejecución de la función o el método.
La idea detrás de la seguridad de excepción es que las funciones, o una clase y sus métodos, dan a sus clientes una
especie de promesa, o garantía, sobre invariantes, condiciones posteriores y sobre excepciones que pueden lanzarse o no. Hay
cuatro niveles de excepción de seguridad. En las siguientes subsecciones los analizo brevemente en orden creciente de seguridad.
Sin ExcepcionesSeguridad
Con este nivel más bajo de seguridad de excepción, literalmente, sin seguridad de excepción, no se garantiza absolutamente nada.
Cualquier excepción que ocurra puede tener consecuencias desastrosas. Por ejemplo, se violan las invariantes y las condiciones
posteriores de la función o el método llamado, y una parte de su código, por ejemplo, un objeto, posiblemente se quede en un estado
corrupto.
¡Creo que no hay duda de que el código escrito por usted nunca debería ofrecer este nivel inadecuado de
seguridad de excepción! Solo pretenda que no existe tal cosa como "sin excepción, seguridad".
Eso es todo; no hay nada más que decir al respecto.
Excepción básica de seguridad
La garantía básica de seguridad de excepción es la garantía que cualquier pieza de código debe ofrecer al menos. También es el
nivel de seguridad excepcional que se puede lograr con un esfuerzo de implementación relativamente pequeño. Este nivel garantiza
lo siguiente:
• Si se lanza una excepción durante una llamada de función o método, ¡se garantiza que no se filtren
recursos! Esta garantía incluye recursos de memoria así como otros recursos. Esto se puede lograr
aplicando el patrón RAII (consulte la sección sobre RAII y Smart Pointers).
• Si se lanza una excepción durante una llamada de función o método, se conservan todas las
invariantes.
• Si se lanza una excepción durante una llamada de función o método, no habrá corrupción
de datos o memoria después, y todos los objetos estarán en un estado saludable y consistente.
Sin embargo, no se garantiza que el contenido de los datos sea el mismo que antes de llamar a la
función o al método.
La regla estricta es esta:
Diseñe su código, especialmente sus clases, de modo que garanticen al menos la seguridad de excepción básica. ¡Este debería
ser siempre el nivel de seguridad de excepción predeterminado!
Es importante saber que la biblioteca estándar de C++ espera que todos los tipos de usuarios proporcionen siempre al menos la
garantía de excepción básica.
Fuerte excepciónseguridad
La fuerte seguridad de excepción garantiza todo lo que también está garantizado por el nivel básico de seguridad de excepción, pero
asegura además que, en caso de una excepción, el contenido de los datos se recupera exactamente igual que antes de que se
llamara a la función o método. En otras palabras, con este nivel de seguridad de excepción obtenemos semántica de
compromiso o reversión como en el manejo de transacciones en bases de datos.
125
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Es fácil comprender que este nivel de seguridad excepcional lleva a un mayor esfuerzo de implementación y puede ser costoso en
tiempo de ejecución. Un ejemplo de este esfuerzo adicional es el llamado modismo de copiar e intercambiar que debe usarse para
garantizar una fuerte seguridad de excepción para la asignación de copias.
Equipar todo su código con una fuerte seguridad de excepción sin ninguna buena razón violaría el
principios KISS y YAGNI (ver Capítulo 3). Por lo tanto, la directriz al respecto es la siguiente:
Emita la fuerte garantía de seguridad de excepciones para su código solo si es absolutamente necesario.
Por supuesto, si hay ciertos requisitos de calidad con respecto a la integridad de los datos y la corrección de los datos
que deben cumplirse, debe proporcionar el mecanismo de reversión que está garantizado a través de una fuerte seguridad de
excepción.
La garantía de no tirar
Este es el nivel de seguridad de excepción más alto, también conocido como transparencia de fallas. En pocas palabras, este nivel
significa que, como llamador de una función o método, no tiene que preocuparse por las excepciones. La llamada a la función o al
método tendrá éxito. ¡Siempre! Nunca arrojará una excepción, porque todo se maneja correctamente internamente. Nunca se violarán
invariantes y poscondiciones.
Este es el paquete completo y despreocupado de seguridad de excepción, pero a veces es muy difícil o incluso imposible de
lograr, especialmente en C++. Por ejemplo, si usa algún tipo de asignación de memoria dinámica dentro de una función, como
operator new, ya sea directa o indirectamente (por ejemplo, a través de std::make_shared<T>), ya no tiene ninguna posibilidad de
terminar con un procesamiento exitoso. después de que se encontró una excepción.
Estos son los casos en los que la garantía de no tirar es absolutamente obligatoria o al menos explícitamente recomendada:
• ¡ Los destructores de clases deben garantizar no tirar en todas las circunstancias!
La razón es que, entre otras situaciones, también se llama a los destructores mientras se desenrolla la
pila después de que se haya encontrado una excepción. Sería fatal si ocurriera otra excepción
durante el desenrollado de la pila, porque el programa terminaría inmediatamente.
Como consecuencia, cualquier operación dentro de un destructor que trate con recursos asignados
e intente cerrarlos, como archivos abiertos o memoria asignada en el montón, no debe fallar.
• Las operaciones de movimiento (constructores de movimiento y operadores de asignación de movimiento;
consulte la sección anterior sobre la semántica de movimiento) deben garantizar que no se
produzca ningún lanzamiento. Si una operación de movimiento arroja una excepción, la probabilidad
de que el movimiento no haya tenido lugar es enormemente alta. Por lo tanto, debe evitarse a toda
costa que las implementaciones de operaciones de movimiento asignen recursos a través de técnicas
de asignación de recursos que pueden generar excepciones. Además, es importante otorgar la
garantía de no generación de tipos destinados a usarse con los contenedores de biblioteca estándar
de C++. Si el constructor de movimiento para un tipo de elemento en un contenedor no ofrece una
garantía de no lanzamiento (es decir, el constructor de movimiento no se declara con el especificador
noexcept, consulte la barra lateral a continuación), entonces el contenedor preferirá usar las operaciones
de copia en lugar de las operaciones de movimiento.
• Los constructores predeterminados deben ser preferentemente sin tiro. Básicamente, lanzar una
excepción en un constructor no es deseable, pero es la mejor manera de lidiar con las fallas del
constructor. Es muy probable que un "objeto construido a medias" viole invariantes.
Y un objeto en un estado corrupto que viola sus invariantes de clase es inútil y
126
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
peligroso. Por lo tanto, no hay nada en contra de lanzar una excepción en un constructor predeterminado
cuando es inevitable. Sin embargo, es una buena estrategia de diseño para evitarlo en gran medida. Los
constructores predeterminados deben ser simples. Si un constructor predeterminado puede lanzar,
probablemente esté haciendo demasiadas cosas complejas. Por lo tanto, al diseñar una clase, debe intentar
evitar excepciones en el constructor predeterminado.
• ¡ Una función de intercambio debe garantizar que no se produzca ningún lanzamiento en todas las
circunstancias! Una función swap() implementada por expertos no debe asignar ningún recurso (por
ejemplo, memoria) utilizando técnicas de asignación de memoria que potencialmente pueden generar excepciones.
Sería fatal si swap() puede lanzar, porque puede terminar con un estado inconsistente. Y la mejor
manera de escribir un operador seguro de excepción = () es mediante el uso de una función de
intercambio () que no lanza para su implementación.
NOEXCEPTO ESPECIFICADOR Y OPERADOR [C++11]
Antes de C++ 11, existía la palabra clave throw que podía estar en la declaración de una función. Se usó para enumerar todos
los tipos de excepción en una lista separada por comas que una función podría arrojar directa o indirectamente, conocida
como especificación de excepción dinámica. El uso de throw(exceptionType,ExceptionType, ...) está en desuso desde C++11 y
ahora finalmente se eliminó del estándar con C++17.
Lo que todavía está disponible, pero también está marcado como obsoleto desde C++ 11, es el especificador throw() sin una
lista de tipos de excepción. Su semántica ahora es la misma que la del especificador noexcept (verdadero) .
El especificador noexcept en la firma de una función declara que la función no puede generar ninguna excepción.
Lo mismo es válido para noexcept (verdadero), que es solo un sinónimo de noexcept. En su lugar, una función que se declara
con noexcept (falso) está generando potencialmente, es decir, puede generar excepciones.
Aquí hay unos ejemplos:
void nonThrowingFunction() noexcept; anular otra
función que no arroja () no excepto (verdadero); void
aPotentiallyThrowingFunction() noexcept(false);
Hay dos buenas razones para el uso de noexcept: Primero, las excepciones que una función o método podría lanzar (o no)
son partes de la interfaz de la función. Se trata de semántica y ayuda al desarrollador que está leyendo el código a saber qué
puede pasar y qué no. noexcept les dice a los desarrolladores que pueden usar esta función de manera segura en sus
propias funciones que no son de lanzamiento. Por lo tanto, la presencia de noexcept es algo similar a const.
Segundo, puede ser usado por el compilador para optimizaciones. noexcept permite potencialmente que un compilador
compile la función sin agregar la sobrecarga de tiempo de ejecución que antes requería el throw(...) eliminado, es decir, el
código objeto que era necesario para llamar a std::unexpected() cuando se producía una excepción. no listado fue arrojado.
Para los implementadores de plantillas, también hay un operador noexcept , que realiza una verificación en tiempo de
compilación que devuelve verdadero si se declara que la expresión no genera excepciones:
constexpr auto isNotThrowing = noexcept(nonThrowingFunction());
Nota: También las funciones constexpr (consulte la sección "Cálculos durante el tiempo de compilación") pueden generarse cuando se
evalúan en tiempo de ejecución, por lo que es posible que también necesite noexcept para algunas de ellas.
127
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Una excepción es una excepción, ¡literalmente!
En el Capítulo 4 discutimos en la sección "No pasar o devolver 0 (NULL, nullptr)" que no debe devolver nullptr
como valor de retorno de una función. Como ejemplo de código, hemos tenido una pequeña función que debe
realizar una búsqueda de un cliente por nombre, lo que, por supuesto, no genera ningún resultado si no se puede
encontrar a este cliente. A alguien se le podría ocurrir la idea de lanzar una excepción para un cliente no
encontrado, como se muestra en el siguiente código de ejemplo.
#include "Cliente.h"
#include <cadena>
#include <excepción>
clase CustomerNotFoundException: public std::exception { virtual const
char* what() const noexcept override {
volver "¡Cliente no encontrado!"; } };
// ...
Cliente CustomerService::findCustomerByName(const std::string& name) const noexcept(false) {
// Código que busca al cliente por su nombre... // ...y si
no se encuentra al cliente:
throw CustomerNotFoundException(); }
Y ahora echemos un vistazo al sitio de invocación de esta función:
cliente cliente; pruebe
{ cliente = findCustomerByName("Nombre no existente");
} catch (const CustomerNotFoundException& ex) {
// ...
} // ...
A primera vista, esto parece una solución factible. Si tenemos que evitar devolver nullptr desde el
función, podemos lanzar una CustomerNotFoundException en su lugar. En el sitio de invocación, ahora podemos
distinguir entre el caso feliz y el caso malo con la ayuda de una construcción de prueba y captura.
De hecho, ¡es una solución realmente mala! No encontrar un cliente solo porque su nombre no existe
definitivamente no es un caso excepcional. Estas son cosas que sucederán normalmente. Lo que se ha hecho en el ejemplo
anterior es un abuso de las excepciones. Las excepciones no existen para controlar el flujo normal del programa. ¡Las
excepciones deben reservarse para lo que es verdaderamente excepcional!
¿Qué significa “verdaderamente excepcional”? Bueno, significa que no hay nada que puedas hacer al respecto, y
realmente no puede manejar esa excepción. Por ejemplo, supongamos que se enfrenta a una excepción std::bad_
alloc, lo que significa que hubo un error en la asignación de memoria. ¿Cómo debe continuar el programa
ahora? ¿Cuál fue la causa raíz de este problema? ¿El sistema de hardware subyacente tiene falta de memoria?
Bueno, ¡entonces tenemos un problema realmente serio! ¿Hay alguna forma significativa de recuperarse de esta
grave excepción y reanudar la ejecución del programa? ¿Podemos seguir asumiendo la responsabilidad de que el
programa simplemente siga funcionando como si nada hubiera pasado?
128
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Estas preguntas no se pueden responder fácilmente. Quizás el desencadenante real de este problema fue un puntero
colgante, que se ha utilizado de manera inexperta en millones de instrucciones antes de encontrar la excepción std::bad_
alloc. Todo esto rara vez puede reproducirse en el momento de la excepción.
Aquí está mi consejo:
Lanzar excepciones solo en casos muy excepcionales. No haga mal uso de las excepciones para controlar el flujo
normal del programa.
Tal vez te preguntes ahora: “Bueno, es malo usar nullptr respectivamente NULL como valor de retorno,
y las excepciones tampoco son deseadas... ¿qué debo hacer ahora en su lugar? En la sección “Objeto de caso
especial (Objeto nulo)” en el Capítulo 9 sobre patrones de diseño, presentaré una solución factible para manejar estos casos de
manera adecuada.
Si no puede recuperarse, salga rápidamente
Si se enfrenta a una excepción de la que no puede recuperarse, a menudo el mejor enfoque es registrar la excepción (si es
posible) o generar un archivo de volcado de memoria para fines de análisis posteriores y finalizar el programa
inmediatamente. Un buen ejemplo en el que una terminación rápida puede ser la mejor reacción es una asignación de
memoria fallida. Si un sistema carece de memoria, bueno, ¿qué debe hacer entonces en el contexto de su programa?
El principio detrás de esta estricta estrategia de manejo de algunas excepciones y errores críticos se denomina "Dead
Programs Tell No Lies” y se describe en el libro Pragmatic Programmer [Hunt99].
Nada es peor que continuar después de un error grave como si nada hubiera pasado y producir, por ejemplo, decenas
de miles de reservas erróneas, o enviar el ascensor por centésima vez desde el sótano hasta el último piso y viceversa. En su
lugar, salga antes de que ocurran demasiados daños consecuentes.
Definir tipos de excepción específicos del usuario Aunque puede
lanzar lo que quiera en C++, como un int o un const char*, no lo recomendaría. Las excepciones son capturadas por sus
tipos; por lo tanto, es una muy buena idea crear sus clases de excepción personalizadas para ciertas excepciones, en su
mayoría específicas del dominio. Como ya expliqué en el Capítulo 4, un buen nombre es crucial para la legibilidad y el
mantenimiento del código, y también los tipos de excepción deben tener buenos nombres. Y también otros principios, que
son válidos para el diseño del código de programa "normal", por supuesto también son válidos para los tipos de
excepción (discutiremos estos principios en detalle en el Capítulo 6) . sobre la orientación a objetos).
Para proporcionar su propio tipo de excepción, simplemente puede crear su propia clase y derivarla de
std::exception (definida en el encabezado <stdexcept>):
#incluir <stdexcept>
clase MyCustomException: public std::exception {
virtual const char* what() const noexcept override {
return "¡Proporcione algunos detalles sobre lo que estaba fallando aquí!"; } };
129
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Al anular la función miembro virtual what() heredada de std::exception, podemos proporcionar cierta información
a la persona que llama sobre lo que salió mal. Además, derivar nuestra propia clase de excepción de std::exception la
hará capturable mediante una cláusula catch genérica (que, por cierto, solo debe considerarse como la última posibilidad
de capturar una excepción), como esta:
#incluir <iostream>
// ...
intenta
{ hacerAlgoQueLanza(); } catch
(const std::exception& ex) {
std::cerr << ex.what() << std::endl; }
Básicamente, las clases de excepción deben tener un diseño simple, pero si desea proporcionar más detalles sobre
la causa de la excepción, también puede escribir clases más sofisticadas, como las siguientes:
Listado 527. Una clase de excepción personalizada para divisiones por cero
clase DivisionByZeroException: public std::exception { public:
DivisionByZeroException() = borrar; explícito
DivisionByZeroException(const int dividendo) {
buildErrorMessage(dividendo); }
virtual const char* what() const noexcept override {
devolver mensaje de error.c_str(); }
privado:
void buildErrorMessage(const int dividendo) {
mensaje de error = "Una división con dividendo = "; mensaje
de error += std::to0_string(dividendo); errorMessage +=
", y divisor = 0, no está permitido (división por cero)!"; }
std::string mensaje de error; };
Tenga en cuenta que, debido a su implementación, la función de miembro privado buildErrorMessage() solo puede
garantizar una fuerte seguridad de excepción, es decir, puede generarse debido al uso de std::string::operator+=().
Por lo tanto, el constructor de inicialización tampoco puede dar la garantía de no lanzamiento. Esa es la razón por la cual las
clases de excepción generalmente deberían tener un diseño bastante simple.
Aquí hay un pequeño ejemplo de uso de nuestra clase DivisionByZeroException:
int divide(const int dividendo, const int divisor) { if (divisor == 0) {
throw DivisionByZeroException(dividendo); } devolver
dividendo / divisor; }
130
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
int principal ()
{ probar { dividir (10,
0); } catch (const DivisionByZeroException& ex) { std::cerr
<< ex.what() << std::endl; devolver 1; }
devuelve
0; }
Lanzamiento por valor, captura por constante Referencia
A veces he visto que los objetos de excepción se asignan en el montón con la ayuda de new y se lanzan como un
puntero, como en este ejemplo:
pruebe { CFile f(_T("M_Cause_File.dat"), CFile::modeWrite); // Si
"M_Cause_File.dat" no existe, el constructor de CFile lanza una excepción // de esta manera: throw new
CFileException() } catch(CFileException* e) {
if( e>m_cause == CFileException::fileNotFound)
TRACE(_T("ERROR: Archivo no encontrado\n"));
e>Borrar(); }
Tal vez haya reconocido este estilo de codificación de C++: lanzar y capturar excepciones de esta manera
se puede encontrar en la antigua biblioteca de MFC (Microsoft Foundation Classes) en abundancia. Y es importante
que no olvide llamar a la función miembro Delete() al final de la cláusula catch; de lo contrario, puede decir "¡Hola!" a
las fugas de memoria.
Bueno, lanzar excepciones con new y capturarlas como un puntero es posible en C++, pero es un mal
diseño. ¡No lo hagas! Si olvida eliminar el objeto de excepción, se producirá una pérdida de memoria. Lance siempre
el objeto de excepción por valor y captúrelos por referencia constante, como se puede ver en todos los ejemplos anteriores.
Preste atención al orden correcto de las cláusulas catch Si proporciona más de una
cláusula catch después de un bloque de prueba, por ejemplo, para distinguir entre diferentes tipos de
excepciones, es importante que tenga cuidado con el orden correcto. Las cláusulas catch se evalúan en el orden
en que aparecen. Esto significa que las cláusulas catch para los tipos de excepción más específicos deben ir
primero. En el siguiente ejemplo, las clases de excepción DivisionByZeroException y
CommunicationInterruptedException se derivan de std::exception.
131
Machine Translated by Google
Capítulo 5 ■ Conceptos avanzados de C++ moderno
Listado 528. Las excepciones más específicas deben manejarse primero.
intente { hacerAlgoQuePuedeLanzarVariasExcepciones(); }
captura (const DivisionByZeroException& ex) {
// ...
} catch (const CommunicationInterruptedException& ex) {
// ...
} catch (const std::exception&ex) {
// Manejar todas las demás excepciones aquí que se derivan de std::exception
} catch (...) { // El
resto...
}
Creo que la razón es obvia: supongamos que la cláusula catch para la excepción general std::exception
sería la primera, ¿qué pasaría? Los más específicos a continuación nunca tendrían una oportunidad
porque están "ocultos" por el más general. Por lo tanto, los desarrolladores deben prestar atención para
colocarlos en el orden correcto.
132
Machine Translated by Google
CAPÍTULO 6
Orientación a objetos
Las raíces históricas de la orientación a objetos (OO) se pueden encontrar a fines de la década de 1950. Los informáticos
noruegos Kristen Nygaard y OleJohan Dahl llevaron a cabo cálculos de simulación para el desarrollo y la construcción del
primer reactor nuclear de Noruega en el instituto de investigación militar Norwegian Defense Research Establishment
(NDRE). Mientras desarrollaban los programas de simulación, los dos científicos notaron que los lenguajes de programación
de procedimientos utilizados para esa tarea no eran adecuados para la complejidad de los problemas que debían
abordarse. Dahl y Nygaard sintieron la necesidad de posibilidades adecuadas en esos lenguajes para abstraer y reproducir
las estructuras, conceptos y procesos del mundo real.
En 1960, Nygaard se mudó al Norwegian Computing Center (NCC) que se había establecido en Oslo dos años antes.
Tres años más tarde, OleJohan Dahl también se unió al NCC. En esta fundación de investigación privada, independiente y
sin fines de lucro, los dos científicos desarrollaron las primeras ideas y conceptos para un lenguaje de programación
orientado a objetos, desde el punto de vista actual. Nygaard y Dahl buscaban un lenguaje que fuera adecuado para todos los
dominios y menos especializado para ciertos campos de aplicación, como, por ejemplo, Fortran para cálculos numéricos y
álgebra lineal; o COBOL, que está diseñado especialmente para uso empresarial.
El resultado de sus actividades de investigación fue finalmente el lenguaje de programación Simula67, una extensión
del lenguaje de programación procedimental ALGOL 60. El nuevo lenguaje introdujo clases, subclases, objetos, variables
de instancia, métodos virtuales e incluso un recolector de basura. Simula67 se considera el primer lenguaje de programación
orientado a objetos y ha influido en muchos otros de los siguientes lenguajes de programación, por ejemplo, el lenguaje
de programación completamente orientado a objetos Smalltalk, que fue diseñado por Alan Kay y su equipo a principios de la
década de 1970.
Mientras el científico informático danés Bjarne Stroustrup trabajaba en su tesis doctoral Comunicación y
Control en Sistemas Computacionales Distribuidos en la Universidad de Cambridge a fines de 1970, usó Simula67 y lo
encontró bastante útil, pero demasiado lento para un uso práctico. Entonces comenzó a buscar posibilidades para combinar
los conceptos de abstracción de datos orientados a objetos de Simula67 con la alta eficiencia de los lenguajes de
programación de bajo nivel. El lenguaje de programación más eficiente en ese momento era C, que había sido desarrollado
por el científico informático estadounidense Dennis Ritchie en Bell Telephone Laboratories a principios de la década de
1970. Stroustrup, quien se unió al Centro de Investigación de Ciencias de la Computación de Bell Telephone Laboratories
en 1979, comenzó a agregar características orientadas a objetos, como clases, herencia, verificación de tipo fuerte y muchas
otras cosas al lenguaje C y lo llamó "C con Clases". ” En 1983, el nombre del lenguaje se cambió a C ++, una palabra
creada por el asociado de Stroustrups Rick Mascitti, por lo que ++ se inspiró en el operador de incremento posterior del
lenguaje.
En las décadas siguientes, la orientación a objetos se convirtió en el paradigma de programación dominante.
Pensamiento orientado a objetos
Hay un punto muy importante que debemos tener en cuenta. Solo porque hay varios lenguajes de programación disponibles
en el mercado que admiten conceptos orientados a objetos, no hay absolutamente ninguna garantía de que los desarrolladores
que utilicen estos lenguajes produzcan un diseño de software orientado a objetos automáticamente. Especialmente
© Stephan Roth 2017 133
S. Roth, C++ limpio, DOI 10.1007/9781484227930_6
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
aquellos desarrolladores que han trabajado con lenguajes procedimentales durante mucho tiempo suelen tener dificultades con la
transición a ese paradigma de programación. La orientación a objetos no es un concepto simple de comprender. Requiere que los
desarrolladores vean el mundo de una manera nueva.
Dr. Alan Curtis Kay, quien desarrolló el lenguaje de programación orientado a objetos Smalltalk con algunos
colegas de Xerox PARC a principios de la década de 1970, es bien conocido como uno de los padres del término
"orientación a objetos". En una discusión documentada por correo electrónico con el profesor universitario alemán Dipl.Ing. Stefan
Ram de Freie Universität Berlin desde el año 2003, Kay explicó lo que hace que la orientación a objetos para él:
Pensé que los objetos eran como células biológicas y/o computadoras individuales en una red, solo
capaces de comunicarse con mensajes (por lo que la mensajería apareció desde el principio; tomó
un tiempo ver cómo enviar mensajes en un lenguaje de programación lo suficientemente eficiente
como para ser útil). (…) OOP para mí significa solo mensajería, retención local y protección y
ocultamiento del proceso estatal, y vinculación tardía extrema de todas las cosas.
Dr. Alan Curtis Kay, informático estadounidense, 23 de julio de 2003 [Ram03]
Una célula biológica se puede definir como la unidad estructural y funcional más pequeña de todos los organismos. A menudo se les
llama los "bloques de construcción de la vida". Alan Kay consideró el software de la misma manera que un biólogo ve organismos
vivos complejos. Esta perspectiva de Alan Kay no debería sorprender, porque tiene una licenciatura en matemáticas y biología
molecular.
Las células de Alan Kay son lo que llamamos objetos en OO. Un objeto puede ser considerado una “cosa” que tiene estructura
y comportamiento. Una célula biológica tiene una membrana que la rodea y la encapsula. Esto también se puede aplicar a objetos en
orientación a objetos. Un objeto debe estar bien encapsulado y ofrecer sus servicios a través de interfaces bien definidas.
Además, Alan Kay enfatizó que la "mensajería" juega un papel central para él en la orientación a objetos.
Sin embargo, no define exactamente lo que quiere decir con eso. ¿Llamar a un método llamado foo() en un objeto es lo
mismo que enviar un mensaje llamado "foo" a ese objeto? ¿O Alan Kay tenía en mente una infraestructura de paso de
mensajes, como CORBA (Common Object Request Broker Architecture) y tecnologías similares? El Dr. Kay también es
matemático, por lo que también podría referirse a un modelo matemático prominente de paso de mensajes llamado modelo Actor,
que es muy popular en el cálculo concurrente.
En cualquier caso y sea lo que sea lo que Alan Kay tenía en mente cuando hablaba de mensajería, considero esta visión
interesante y, en general, aplicable para explicar la estructura típica de un programa orientado a objetos en un nivel abstracto.
Pero las elucidaciones del Sr. Kay definitivamente no son suficientes para responder las siguientes preguntas importantes:
•¿Cómo encuentro y formo las “células” (objetos)?
•¿Cómo diseño la interfaz pública disponible de esas celdas?
•¿Cómo controlo quién puede comunicarse con quién (dependencias)?
La orientación a objetos (OO) es principalmente una mentalidad y menos una cuestión del lenguaje utilizado. Y también puede
ser abusado y mal aplicado.
He visto muchos programas escritos en C++, o en un lenguaje OO puro como Java, donde se usan clases,
pero estas clases solo han constituido grandes espacios de nombres que envuelven un programa procedimental. O expresado
con un poco de sarcasmo: los programas similares a Fortran se pueden escribir en casi cualquier lenguaje de programación,
obviamente. Por otro lado, todo desarrollador que haya interiorizado el pensamiento orientado a objetos podrá desarrollar software
con un diseño orientado a objetos incluso en lenguajes como ANSIC, Assembler o utilizando scripts de shell.
134
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Abstracción: la clave para dominar la complejidad
La idea básica detrás de OO es que estamos modelando cosas y conceptos de partes relevantes de nuestro dominio en nuestro
software. Por lo tanto, nos limitamos solo a aquellas cosas que deben estar representadas en nuestro sistema de software para
satisfacer las necesidades de las partes interesadas, también conocidas como requisitos. La abstracción es la herramienta más
importante para modelar estas cosas y conceptos de manera adecuada. No queremos modelar una reproducción de todo el mundo
real. Solo necesitamos un extracto del mundo real, reducido a los detalles que son relevantes para realizar los casos de uso del
sistema.
Por ejemplo, si queremos representar a un cliente en un sistema de librería, es muy probable y no interesa en absoluto
qué grupo sanguíneo tiene el cliente. Por otro lado, para un sistema de software del dominio médico, el grupo sanguíneo de un ser
humano puede ser un detalle importante.
Para mí, la orientación a objetos se trata de abstracción de datos, responsabilidades, modularización y también de divide y
vencerás. Si tengo que resumirlo, diría que OO se trata del dominio de la complejidad. Me explico con un pequeño ejemplo.
Considere un automóvil. Un automóvil es una composición de varias partes, por ejemplo, carrocería, motor, engranajes,
ruedas, asientos, etc. Cada una de estas partes también consta de partes más pequeñas. Tomemos por ejemplo el motor del
automóvil (supongamos que es un motor de combustión y no un motor eléctrico). El motor consta del bloque de cilindros, la
bomba de encendido de gasolina, el eje impulsor, el árbol de levas, los pistones, una unidad de control del motor (ECU), un
subsistema de refrigerante, etc. El subsistema de refrigerante nuevamente consta de un intercambiador de calor, una bomba de
refrigerante, depósito de refrigerante, ventilador, termostato y núcleo del calentador. En teoría, la descomposición del automóvil
puede continuar hasta el tornillo más pequeño. Y cada subsistema o parte identificado tiene una responsabilidad bien definida.
Pero solo todas las partes juntas y ensambladas de la manera correcta construyen un automóvil que brinda los servicios que los conductores esper
Los sistemas de software complejos se pueden considerar de la misma manera. Se pueden descomponer jerárquicamente.
en módulos de grano grueso a fino. Eso ayuda a hacer frente a la complejidad del sistema, proporciona más flexibilidad y
fomenta la reutilización, el mantenimiento y la capacidad de prueba. Los principios rectores para hacer esta descomposición
son principalmente los siguientes:
• Ocultación de información (ver la sección homónima en el Capítulo 3),
•Fuerte cohesión (ver la sección homónima en el Capítulo 3),
•Acoplamiento flojo (consulte la sección homónima en el Capítulo 3), y
• Principio de responsabilidad única (SRP; consulte la sección homónima más adelante en este
capítulo).
Principios para un buen diseño de clases
El mecanismo generalizado y bien conocido para la formación de los módulos descritos anteriormente en lenguajes
orientados a objetos es el concepto de clase. Las clases se consideran módulos de software encapsulados que combinan
características estructurales (sinónimos: atributos, miembros de datos, campos) y características de comportamiento (sinónimos:
funciones de miembros, métodos, operaciones) en una unidad cohesiva.
En los lenguajes de programación con funciones orientadas a objetos como C++, las clases son el siguiente
concepto estructurante más alto que las funciones. A menudo se describen como los planos de los objetos (sinónimo: instancias).
Esa es razón suficiente para investigar más a fondo el concepto de clases. En este capítulo doy varias pistas importantes para
diseñar y escribir buenas clases en C++.
135
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Mantenga las clases pequeñas
En mi carrera como desarrollador de software, he visto muchas clases que eran muy grandes. Muchos miles de líneas de código no eran una
rareza. En una inspección más cercana, noté que estas clases grandes a menudo solo se usaban como espacios de nombres para un
programa más o menos procedimental, cuyos desarrolladores comúnmente no entendían la orientación a objetos.
Creo que los problemas con clases tan grandes son obvios. Si las clases contienen varios miles de líneas de código, son difíciles de entender
y su mantenibilidad y capacidad de prueba suelen ser malas, sin mencionar la reutilización. Y según varios estudios, las clases grandes generalmente
contienen una mayor cantidad de defectos.
EL ANTIPATRÓN DE LA CLASE DIOS
En muchos sistemas, existen clases excepcionalmente grandes con muchos atributos y varios cientos de
operaciones. Los nombres de estas clases a menudo terminan con "...Controller", "...Manager" o "...Helpers".
Los desarrolladores a menudo argumentan que en algún lugar del sistema debe haber una instancia central
que mueva los hilos y coordine todo. Los resultados de esta forma de pensar son clases gigantes con una cohesión
muy pobre (ver la sección sobre cohesión fuerte en el Capítulo 3). Son como una tienda de conveniencia que ofrece
una paleta colorida de productos.
Tales clases se llaman Clases de Dios, Objetos de Dios o, a veces, también The Blob (The Blob es una película
estadounidense de terror / ciencia ficción de 1958 sobre una ameba alienígena que se come a los ciudadanos de
una aldea). Este es el llamado AntiPatrón, un sinónimo de lo que se percibe como un mal diseño. Una clase de
Dios es una bestia indomable, horrible de mantener, difícil de entender, no comprobable, propensa a errores y también
tiene una gran cantidad de dependencias con otras clases. Durante el ciclo de vida del sistema, dichas clases son
cada vez más grandes. Esto empeora los problemas.
Lo que se ha demostrado como una buena regla para el tamaño de una función (consulte la sección "Que sean pequeños" en el Capítulo 4),
lo que también parece ser un buen consejo para el tamaño de las clases: ¡ las clases deben ser pequeñas!
Si el tamaño pequeño es un objetivo en el diseño de la clase, entonces la siguiente pregunta inmediata es: ¿Qué tan pequeño?
Para funciones, he dado un número de líneas de código en el Capítulo 4. ¿No sería siquiera posible definir un
número de líneas para clases que serían percibidas como buenas o apropiadas?
En The ThoughtWorks® Anthology [ThoughtWorks08], Jeff Bay contribuyó con un ensayo titulado "Object Calisthenics: 9 steps to
better software design today" que recomienda no más de 50 líneas de código para una sola clase.
Un límite superior de unas 50 líneas parece estar fuera de discusión para muchos desarrolladores. Parece que sienten una especie de
resistencia inexplicable contra la creación de clases. A menudo argumentan de la siguiente manera: “¿No más de 50 líneas? Pero eso dará como
resultado una gran cantidad de pequeñas clases pequeñas, con solo unos pocos miembros y funciones”. Y entonces seguramente evocarán un
ejemplo irreductible a clases de tamaño tan reducido.
Estoy convencido de que esos desarrolladores están totalmente equivocados. Estoy bastante seguro de que cada sistema de software se
puede descomponer en bloques de construcción elementales tan pequeños.
Sí, si las clases van a ser pequeñas, tendrá más de ellas. Pero eso es OO! En el desarrollo de software orientado a objetos, una clase
es un elemento de lenguaje igualmente natural, como una función o una variable. En otras palabras: no tengas miedo de crear clases pequeñas. Las
clases pequeñas son mucho más fáciles de usar, comprender y probar.
No obstante, eso lleva a una pregunta fundamental: ¿Es la definición de un límite superior para las líneas de código básicamente la forma
correcta? Creo que la métrica de líneas de código (LOC) puede ser un indicador útil. Demasiados LOC son un olor. Puede echar un vistazo
cuidadoso a las clases con más de 50 líneas. Pero no es necesariamente el caso que muchas líneas de código sean siempre un problema. Un
criterio mucho mejor es la cantidad de responsabilidades de una clase.
136
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Principio de responsabilidad única (PRS)
El Principio de Responsabilidad Única (SRP) establece que cada unidad de software, y esto incluye, entre otros, componentes,
clases y funciones, debe tener una sola responsabilidad única y bien definida.
SRP se basa en el principio general de cohesión que he discutido en el Capítulo 3. Si una clase tiene un
responsabilidad bien definida, normalmente también su cohesión es fuerte.
Pero, ¿qué es exactamente una responsabilidad? En la literatura a menudo podemos encontrar la explicación de que solo
debe haber una razón para cambiar de clase. Y un ejemplo que se menciona con frecuencia es que esta regla se viola cuando es
necesario cambiar la clase debido a requisitos nuevos o modificados para diferentes aspectos del sistema.
Estos aspectos pueden ser, por ejemplo, el controlador del dispositivo y la interfaz de usuario. Si se debe cambiar la
misma clase, ya sea porque la interfaz del controlador del dispositivo ha cambiado o se debe implementar un nuevo requisito con
respecto a la interfaz gráfica de usuario, entonces esta clase obviamente tiene demasiadas responsabilidades.
Otro tipo de aspectos se relaciona con el dominio del sistema. Si se debe cambiar la misma clase, ya sea porque hay
nuevos requisitos con respecto a la gestión de clientes, o hay nuevos requisitos con respecto a la facturación, entonces esta clase
tiene demasiadas responsabilidades.
Las clases que siguen el SRP suelen ser pequeñas y tienen pocas dependencias. Son claros, fáciles de entender y
se pueden probar fácilmente.
La responsabilidad es un criterio mucho mejor que la cantidad de líneas de código de una clase. Puede haber clases
con 100, 200 o incluso 500 líneas, y puede estar perfectamente bien si esas clases no violan el principio de responsabilidad
única. No obstante, un recuento alto de LOC puede ser un indicador. Es una pista que dice: “¡Deberías echar un vistazo a
estas clases! Tal vez todo esté bien, pero tal vez son tan grandes porque tienen demasiadas responsabilidades”.
Principio abiertocerrado (OCP)
Todos los sistemas cambian durante sus ciclos de vida. Esto debe tenerse en cuenta al desarrollar
sistemas que se espera que duren más que la primera versión.
—Ivar Jacobson, informático sueco, 1992
Otra pauta importante para cualquier tipo de unidad de software, pero especialmente para el diseño de clases, es el
Principio Abierto Cerrado (OCP). Establece que las entidades de software (módulos, clases, funciones, etc.) deben estar abiertas
para la extensión, pero cerradas para la modificación.
Es un hecho simple que los sistemas de software evolucionarán con el tiempo. Deben satisfacerse constantemente
nuevos requisitos, y los requisitos existentes deben cambiarse de acuerdo con las necesidades del cliente o el progreso de la tecnología.
Estas extensiones deben hacerse no solo de manera elegante y con el menor esfuerzo posible. Deben estar hechos especialmente
de tal manera que no sea necesario cambiar el código existente. Sería fatal si cualquier requisito nuevo diera lugar a una cascada
de cambios y ajustes en partes del software existentes y bien probadas.
Una forma de apoyar este principio en la orientación a objetos es el concepto de herencia. Con herencia es posible agregar
nueva funcionalidad a una clase sin modificar esa clase. Además, hay muchos patrones de diseño orientados a objetos que
fomentan OCP, como Estrategia o Decorador (consulte el Capítulo 9) . sobre patrones de diseño).
En la sección sobre acoplamiento suelto en el Capítulo 3 ya hemos discutido un diseño que soporta OCP
muy bien (ver Figura 36). Allí hemos desacoplado un interruptor y una lámpara a través de una interfaz. A través de este
paso, el diseño está cerrado contra modificaciones pero agradablemente abierto para extensiones. Podemos agregar más
dispositivos conmutables fácilmente, y no necesitamos tocar las clases Switch, Lamp y la interfaz Switchable.
Y como puede imaginar fácilmente, otra ventaja de dicho diseño es que ahora es muy fácil proporcionar un Doble de prueba (por
ejemplo, un objeto simulado) con fines de prueba (consulte la sección sobre Dobles de prueba (Objetos falsos) en el Capítulo 2) .
137
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Principio de sustitución de Liskov (LSP)
Básicamente, el principio de sustitución de Liskov establece que no se puede crear un pulpo
extendiendo un perro con cuatro patas falsas adicionales.
—Mario Fusco (@mariofusco), 15 de septiembre de 2013, en Twitter
Los conceptos clave orientados a objetos de herencia y polimorfismo parecen relativamente simples a primera vista.
La herencia es un concepto taxonómico que debe usarse para construir una jerarquía de especialización de tipos, es decir, los subtipos
se derivan de un tipo más general. Polimorfismo significa, en general, que se proporciona una sola interfaz como posibilidad de acceso a
objetos de diferentes tipos.
Hasta ahora, todo bien. Pero a veces te encuentras en situaciones en las que un subtipo realmente no quiere encajar en un
jerarquía de tipos. Discutamos un ejemplo muy popular que se usa a menudo para ilustrar el problema.
El dilema cuadradorectángulo
Supongamos que estamos desarrollando una biblioteca de clases con tipos primitivos de formas para dibujar en un lienzo, por ejemplo,
un círculo, un rectángulo, un triángulo y una etiqueta de texto. Visualizada como un diagrama de clases UML, esta biblioteca podría
parecerse a la Figura 61.
Figura 61. Una biblioteca de clases de diferentes formas.
138
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
La clase base abstracta Forma tiene atributos y operaciones que son iguales para todas las formas específicas. Para
Por ejemplo, es lo mismo para todas las formas cómo se pueden mover de una posición a otra en el lienzo. Sin
embargo, la Forma no puede saber cómo se pueden mostrar (sinónimo: dibujar) u ocultar (sinónimo: borrar) formas
específicas. Por lo tanto, estas operaciones son abstractas, es decir, no pueden implementarse (totalmente) en Shape.
En C++, una implementación de la clase abstracta Shape (y la clase Point que requiere Shape) podría verse así:
Listado 61. Así es como se ven las dos clases Punto y Forma
clase Punto final { público:
Punto() : x { 5 }, y { 5 } { }
Punto (const unsigned int initialX, const unsigned int initialY):
x { initialX }, y { initialY } { } void
setCoordinates(const unsigned int newX, const unsigned int newY) {
x = nuevoX;
y =
nuevoY; } // ...más funciones miembro aquí...
privado:
sin firmar int x; int
sin firmar y; };
forma de clase
{ público:
Shape() : isVisible { false } { } virtual
~Shape() = predeterminado; void
moveTo(const Point& newCenterPoint) {
esconder(); centerPoint =
newCenterPoint; espectáculo(); }
espectáculo vacío virtual () = 0;
ocultar vacío virtual () = 0; // ...
privado:
Punto centroPunto;
bool esVisible; };
void Shape::show()
{ isVisible = true; }
void Shape::hide()
{ isVisible = false; }
139
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
ESPECIFICADOR FINAL [C++11]
El especificador final , disponible desde C++11, se puede utilizar de dos formas.
Por un lado, puede usar este especificador para evitar que las funciones de miembros virtuales individuales se anulen
en clases derivadas, como en este ejemplo:
clase AbstractBaseClass
{ public:
virtual void hacerAlgo() = 0; };
class Derived1 : public AbstractBaseClass { public:
virtual
void doSomething() final {
//...
} };
class Derived2 : public Derived1 { public:
virtual
void doSomething() override { // ¡Causa un error de compilación!
//...
} };
Además, también puede marcar una clase completa como final, como la clase Point en nuestra biblioteca Shape.
Esto garantiza que un desarrollador no pueda usar una clase de este tipo como clase base para la herencia.
clase no derivable final { // ... };
De todas las clases concretas de la biblioteca Shapes, podemos echar un vistazo ejemplar a una clase, la
Rectángulo:
Listado 62. Las partes importantes de la clase Rectángulo
clase Rectángulo: Forma pública
{ pública:
Rectángulo() : ancho { 2 }, alto { 1 } { }
Rectangle(const unsigned int initialWidth, const unsigned int initialHeight) :
ancho {anchurainicial}, altura {alturainicial} { }
virtual void show() override
{ Shape::show(); // ...código para mostrar un
rectángulo aquí... }
140
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
virtual void hide() override
{ Shape::hide(); // ...código para ocultar un
rectángulo aquí... }
void setWidth(const unsigned int newWidth) { ancho =
newWidth; }
void setHeight(const unsigned int newHeight) { altura =
newHeight; }
void setEdges(const unsigned int newWidth, const unsigned int newHeight) { ancho = newWidth;
altura = alturanueva; }
unsigned long long getArea() const {
return static_cast<unsigned long long>(ancho) * altura; } // ...
privado:
ancho int sin firmar ;
altura int sin firmar ; };
El código del cliente quiere usar todas las formas de manera similar, sin importar con qué instancia en
particular (Rectángulo, Círculo, etc.) se enfrente. Por ejemplo, todas las formas deben mostrarse en un lienzo de una
sola vez, lo que se puede lograr usando el siguiente código:
#include "Formas.h" // Círculo, Rectángulo, etc. #include
<memoria> #include
<vector>
utilizando ShapePtr = std::shared_ptr<Forma>;
usando ShapeCollection = std::vector<ShapePtr>;
void showAllShapes (const ShapeCollection y formas) {
para (auto y forma: formas) {
forma>mostrar(); }
int principal() {
Formas ShapeCollection;
formas.push_back(std::make_shared<Círculo>());
formas.push_back(std::make_shared<Rectangle>());
formas.push_back(std::make_shared<TextLabel>());
141
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
// ...etc...
mostrarTodasLasFormas(formas);
devolver 0;
}
Y ahora supongamos que los usuarios formulan un nuevo requisito para nuestra biblioteca: ¡ quieren tener un
cuadrado!
Probablemente todos recuerden de inmediato sus lecciones de geometría en la escuela primaria. En ese momento
también tu maestro quizás haya dicho que un cuadrado es un tipo especial de rectángulo que tiene cuatro lados de igual
longitud y cuatro ángulos iguales (ángulos de 90 grados). Por lo tanto, una primera solución obvia parece ser derivar una
nueva clase Square de Rectangle, como se muestra en la figura 62.
Figura 62. Derivar un cuadrado de la clase Rectángulo: ¿una buena idea?
A primera vista, esta parece ser una solución factible. Square hereda la interfaz y la implementación de
Rectangle. Esto es bueno para evitar la duplicación de código (vea el principio DRY que hemos discutido en el Capítulo 3),
porque Square puede reutilizar fácilmente el comportamiento que se implementa en Rectangle.
Y un cuadrado solo tiene que cumplir un requisito adicional y simple que se muestra en el diagrama UML
arriba como una restricción en la clase Square: {ancho = alto}. Esta restricción significa que una instancia de tipo Square
asegura en todas las circunstancias que sus bordes siempre tengan la misma longitud.
Entonces, primero implementamos nuestro Cuadrado derivándolo de nuestro Rectángulo:
clase Cuadrado: Rectángulo público
{ público: //...
};
Pero, de hecho, ¡no es una buena solución!
142
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Tenga en cuenta que el Cuadrado hereda todas las operaciones del Rectángulo. Eso significa que podemos hacer el
siguiendo con una instancia de Square:
cuadrado cuadrado;
cuadrado.setHeight(10); // Err... ¡¿Cambiar solo la altura de un cuadrado?! cuadrado.setEdges(10,
20); // ¡UH oh!
En primer lugar, sería muy desconcertante para los usuarios de Square que proporcione un setter con dos parámetros
(recuerde el Principio del Mínimo Asombro en el Capítulo 3). Ellos piensan: ¿Por qué hay dos parámetros?
¿Qué parámetro se utiliza para establecer la longitud de todos los bordes? ¿Debo quizás poner ambos parámetros al mismo valor?
¿Qué pasa si no lo hago?
La situación es aún más dramática cuando hacemos lo siguiente:
std::unique_ptr<Rectángulo> rectángulo = std::make_unique<Cuadrado>(); // ...y en algún
otro lugar del código... rectángulo>setEdges(10, 20);
En este caso, el código del cliente usa un setter que tiene sentido. Ambos bordes de un rectángulo se pueden manipular.
independientemente. Eso no es una sorpresa; es exactamente la expectativa. Sin embargo, el resultado puede ser extraño.
La instancia de tipo Square ya no sería un cuadrado después de tal llamada, porque tiene dos longitudes de borde diferentes. Así
que hemos cometido una vez más una violación del Principio del Mínimo Asombro, y mucho peor: violado el invariante de
clase del Cuadrado.
Sin embargo, ahora se podría argumentar que podemos declarar setEdges(), setWidth() y setHeight() como virtuales
en la clase Rectangle y anular estas funciones miembro en la clase Square con una implementación alternativa, que
genera una excepción en caso de uso no solicitado. Además, proporcionamos una nueva función miembro setEdge() en la
clase Square en su lugar, de la siguiente manera:
Listado 63. Una implementación realmente mala de Square que intenta "borrar" funciones heredadas no deseadas
#include <stdexcept> // ...
class IllegalOperationCall : public std::logic_error { public: explícito
IllegalOperationCall(const std::string& message) : logic_error(message) { } virtual ~IllegalOperationCall() { } };
clase Cuadrado: Rectángulo público { público:
Cuadrado() : Rectángulo { 5, 5 } { } explícito
Cuadrado(const unsigned int edgeLength) : Rectangle { edgeLength, edgeLength } { }
virtual void setEdges([[maybe_unused]] const unsigned int newWidth, [[maybe_unused]] const
unsigned int newHeight) override {
lanzar IllegalOperationCall { ILLEGAL_OPERATION_MSG }; }
virtual void setWidth([[maybe_unused]] const unsigned int newWidth) override {
lanzar IllegalOperationCall { ILLEGAL_OPERATION_MSG }; }
143
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
virtual void setHeight([[maybe_unused]] const unsigned int newHeight) override {
lanzar IllegalOperationCall { ILLEGAL_OPERATION_MSG }; }
void setEdge( longitud int sin signo const) {
Rectángulo::setEdges(longitud, longitud); }
private:
static const constexpr char* const ILLEGAL_OPERATION_MSG { "Llamada no solicitada de una "operación prohibida en
"
una instancia
de clase Square!" };
};
Bueno, creo que es obvio que ese sería un diseño terriblemente malo. Viola un principio fundamental de la orientación a objetos,
que una clase derivada no debe eliminar las propiedades heredadas de su clase base. Definitivamente no es una solución a nuestro
problema. Primero, el nuevo setter setEdge() no sería visible si queremos usar una instancia de Square como Rectangle. Además,
todos los demás setters lanzan una excepción si se usan, ¡esto es realmente abismal! Arruinó la orientación a objetos.
Entonces, ¿cuál es el problema fundamental aquí? ¿Por qué la derivación obviamente sensata de una clase Cuadra
de un Rectángulo causan tantas dificultades?
La explicación es esta: derivar Square de Rectangle viola un principio importante en object
diseño de software orientado: ¡el principio de sustitución de Liskov (LSP)!
Barbara Liskov, una científica informática estadounidense que es profesora de instituto en la Universidad de Massachusetts
Institute of Technology (MIT), y Jeannette Wing, quien fue profesora del presidente de Ciencias de la Computación en
Carnegie Mellon University hasta 2013, formuló el principio en un documento de 1994 de la siguiente manera:
Sea q(x) una propiedad demostrable sobre objetos x de tipo T. Entonces q(y) debería ser demostrable
para objetos y de tipo S, donde S es un subtipo de T.
—Barbara Liskov, Jeanette Wing [Liskov94]
Bueno, esa no es necesariamente una definición para el uso diario. Robert C. Martin formuló este principio en
un artículo en 1996 de la siguiente manera:
Las funciones que usan punteros o referencias a clases base deben poder usar objetos de clases
derivadas sin saberlo.
—Robert C. Martín [Martin96]
De hecho, eso significa lo siguiente: los tipos derivados deben ser completamente sustituibles por sus tipos base.
En nuestro ejemplo esto no es posible. Una instancia de tipo Cuadrado no puede sustituir un Rectángulo. La razón de esto radica en la
restricción {ancho = alto} (una llamada invariante de clase) que sería impuesta por el Cuadrado, pero el Rectángulo no puede cumplir
con esa restricción.
El principio de sustitución de Liskov estipula las siguientes reglas para las jerarquías de tipos y clases:
•Las condiciones previas (consulte también la sección "La prevención es mejor que el cuidado posterior" en el
Capítulo 5 sobre las condiciones previas) de una clase base no se pueden fortalecer en una subclase derivada.
• Condiciones posteriores (ver también la sección “La prevención es mejor que el cuidado posterior” en el Capítulo 5)
de una clase base no se puede debilitar en una subclase derivada.
144
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
•Todas las invariantes de una clase base no deben ser cambiadas o violadas a través de una derivada
subclase.
•La restricción del historial (también conocida como la "regla del historial"): el estado (interno) de los objetos
solo debe cambiarse mediante llamadas a métodos en su interfaz pública (encapsulación).
Dado que las clases derivadas pueden introducir nuevos atributos y métodos que no existen en la clase
base, la introducción de estos métodos puede permitir cambios de estado en los objetos de la clase derivada
que no están permitidos en la clase base. La llamada restricción Historial prohíbe esto. Por ejemplo,
si la clase base está diseñada para ser el modelo de un objeto inmutable (consulte el Capítulo 9 sobre clases
inmutables), la clase derivada no debería invalidar esta propiedad de inmutabilidad con la ayuda de las
funciones miembro recién introducidas.
La interpretación de la relación de generalización (la flecha entre Cuadrado y Rectángulo) en el
El diagrama de clases anterior (Figura 62) a menudo se traduce como “…ES UN…”: Square IS A Rectangle. Pero eso podría ser
engañoso. En Matemáticas es posible decir que un cuadrado es un tipo especial de rectángulo, ¡pero en programación no lo es!
Para hacer frente a este problema, el cliente tiene que saber con qué tipo específico está trabajando. Algunos
desarrolladores ahora podrían decir: "No hay problema, esto se puede hacer usando información de tipo de tiempo de ejecución (RTTI)".
INFORMACIÓN DE TIPO DE TIEMPO DE EJECUCIÓN (RTTI)
El término Información de tipo de tiempo de ejecución (a veces también Identificación de tipo de tiempo de ejecución) denota un
mecanismo de C++ para acceder a información sobre el tipo de datos de un objeto en tiempo de ejecución. El concepto general
detrás de RTTI se denomina introspección de tipos y también está disponible en otros lenguajes de programación, como Java.
En C++, el operador typeid (definido en header <typeinfo>) y dynamic_cast<T> (consulte la sección sobre conversiones de C++ en
el Capítulo 4) pertenecen a RTTI. Por ejemplo, para determinar la clase de un objeto en tiempo de ejecución, puede escribir:
const std::type_info& typeInformationAboutObject = typeid(instancia);
La referencia const de tipo std::type_info (también definida en el encabezado <typeinfo>) ahora contiene información
sobre la clase del objeto, por ejemplo, el nombre de la clase. Desde C++11, también está disponible un código hash
(std::type_info::hash_code()), que es idéntico para los objetos std::type_info que se refieren al mismo tipo.
Es importante saber que RTTI está disponible solo para clases que son polimórficas, es decir, para clases que tienen al menos
una función virtual, ya sea directamente o por herencia. Además, RTTI se puede activar o desactivar en algunos compiladores.
Por ejemplo, cuando se usa gcc (GNU Compiler Collection), RTTI se puede deshabilitar usando la opción fnortti .
145
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Listado 64. Solo otro "truco": usar RTTI para distinguir entre diferentes tipos de forma durante el tiempo de ejecución
utilizando ShapePtr = std::shared_ptr<Forma>; usando
ShapeCollection = std::vector<ShapePtr>; //...
void resizeAllShapes (const ShapeCollection y formas) {
pruebe
{ para (const auto& forma: formas) { const auto
rawPointerToShape = forma.get(); if (typeid(*rawPointerToShape)
== typeid(Rectángulo)) {
Rectángulo* rectángulo = dynamic_cast<Rectangle*>(rawPointerToShape); rectángulo
>establecerBordes(10, 20); // Haz más
cosas específicas de Rectángulo aquí...
} else if (typeid(*rawPointerToShape) == typeid(Square)) {
Square* square = dynamic_cast<Square*>(rawPointerToShape); cuadrado
>establecerBorde(10); } más
{ // ...
} }
} captura (const std::bad_typeid& ex) {
// ¡Intenté un typeid de puntero NULL! } }
¡No hagas esto! Esta no puede, y no debería, ser la solución adecuada, especialmente en un programa C++ limpio y moderno.
Se contrarrestan muchos de los beneficios de la orientación a objetos, como el polimorfismo dinámico.
■ Precaución Cada vez que se vea obligado a utilizar en su programa para distinguir entre diferentes tipos,
RTTI es un claro "olor de diseño", es decir, un indicador obvio de un mal diseño de software orientado a objetos.
Además, nuestro código estará muy contaminado con pésimas construcciones ifelse y la legibilidad disminuirá.
por el desagüe. Y como si esto no fuera suficiente, la construcción trycatch también deja en claro que algo podría salir mal.
¿Pero que podemos hacer?
En primer lugar, deberíamos echar otra mirada cuidadosa a lo que realmente es un cuadrado.
Desde un punto de vista matemático puro, un cuadrado puede considerarse como un rectángulo con lados de igual longitud.
Hasta ahora, todo bien. Pero esta definición no se puede transferir directamente a una jerarquía de tipos orientada a objetos. ¡Un
cuadrado no es un subtipo de un rectángulo!
En cambio, tener una forma cuadrada es simplemente un estado especial de un rectángulo. Si un rectángulo tiene longitudes de
borde idénticas, que es únicamente un estado del rectángulo, generalmente le damos a ese rectángulo en particular un nombre especial en
nuestro lenguaje natural: ¡entonces hablamos de un cuadrado!
Eso significa que solo necesitamos agregar un método de inspección a nuestra clase Rectangle para consultar su estado, lo
que nos permite renunciar a una clase Square explícita. De acuerdo con el principio KISS (ver Capítulo 3), esta solución podría ser
completamente suficiente para satisfacer el nuevo requisito. Además, podemos proporcionar a los clientes un método de ajuste
conveniente para configurar ambas longitudes de borde por igual.
146
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Listado 65. Una solución simple sin una clase explícita Square
clase Rectángulo: Forma pública
{ pública: // ...
void setEdgesToEqualLength(const unsigned int newLength) {
setEdges(nuevaLongitud, nuevaLongitud); }
bool esCuadrado() const {
retorno ancho == altura; } //...
};
Favorecer la composición sobre la herencia
Pero, ¿qué podemos hacer si se requiere inflexiblemente un Cuadrado de clase explícito, por ejemplo, porque alguien
lo exige? Bueno, si ese es el caso, entonces nunca deberíamos heredar de Rectangle, sino de la clase Shape,
como se muestra en la Figura 63. Para no violar el principio DRY, usamos una instancia de la clase Rectangle para
la implementación interna de Square.
Figura 63. Square usa y delega a una instancia incrustada de Rectangle
Expresado en código fuente, la implementación de esta clase Square se vería así:
Listado 66. Square delega todas las llamadas de método a una instancia incrustada de Rectangle
clase Cuadrado: Forma pública
{ público:
Cuadrado ()
{ impl.setEdges (5, 5); }
147
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Cuadrado explícito (const unsigned int edgeLength)
{ impl.setEdges (edgeLength, edgeLength); }
void setEdge
( longitud int sin signo const ) {
impl.setEdges(longitud, longitud); }
virtual void moveTo(const Point& newCenterPoint) override
{ impl.moveTo(newCenterPoint); }
virtual void show() override { impl.show(); }
virtual void hide() override { impl.hide(); }
unsigned lomg longgetArea() const { return
impl.getArea(); }
privado:
Rectángulo impl; };
Tal vez haya notado que el método moveTo() también se sobrescribió. Para ello, el método moveTo() también
debe hacerse virtual en la clase Shape. Debemos anularlo, porque moveTo() heredado de Shape opera en el centerPoint
de la clase base Shape, y no en la instancia incrustada del Rectangle utilizado. Este es un pequeño inconveniente de
esta solución: algunas partes heredadas de la clase base Shape quedan en barbecho.
Obviamente, con esta solución perderemos la posibilidad de que una instancia de Square pueda ser asignada a un
Rectangle:
std::unique_ptr<Rectángulo> rectángulo = std::make_unique<Cuadrado>(); // ¡Error del compilador!
El principio detrás de esta solución para hacer frente a los problemas de herencia en OO se llama
"Favorecer la composición sobre la herencia" (FCoI), a veces también llamado "Favorecer la delegación sobre la
herencia". Para la reutilización de la funcionalidad, la programación orientada a objetos tiene básicamente dos
opciones: herencia (“reutilización de caja blanca”) y composición o delegación (“reutilización de caja negra”). A veces es
mejor tratar otro tipo como si fuera una caja negra, es decir, usarlo solo a través de su interfaz pública bien definida,
en lugar de derivar un subtipo de este tipo. La reutilización por composición/delegación fomenta un acoplamiento más
flexible entre clases que la reutilización por herencia.
148
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Principio de segregación de interfaz (ISP)
Ya conocemos las interfaces como una forma de fomentar el acoplamiento flexible entre clases. En una sección anterior sobre
el Principio AbiertoCerrado, vimos que las interfaces son una forma de tener un punto de extensión y variación en el
código. Una interfaz es como un contrato: las clases pueden solicitar servicios a través de este contrato, que pueden ser
ofrecidos por otras clases que cumplen el contrato.
Pero, ¿qué problemas pueden surgir cuando estos contratos se vuelven demasiado extensos, es decir, si una interfaz se vuelve
demasiado amplio o "gordo"? Las consecuencias se pueden demostrar mejor con un ejemplo. Supongamos que tenemos
la siguiente interfaz:
Listado 67. Una interfaz para Birds
clase Pájaro
{ público:
virtual ~ Pájaro () = predeterminado;
vuelo vacío virtual () = 0; vacío
virtual comer() = 0; ejecución
de vacío virtual () = 0; tweet
vacío virtual () = 0; };
Esta interfaz es implementada por varios pájaros concretos, por ejemplo, por un gorrión.
Listado 68. La clase Sparrow anula e implementa todas las funciones de miembros virtuales puros de Bird
class Sparrow : public Bird { public:
virtual
void fly() override { //...
} virtual void eat() override { //...
} virtual void run() override { //...
} invalidación de tweet virtual vacío () {
//...
} };
Hasta ahora, todo bien. Y ahora supongamos que tenemos otro pájaro concreto: un pingüino.
Listado 69. El pinguino de la clase
class Penguin : public Bird { public:
virtual
void fly() override { // ???
} //... };
149
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Aunque un pingüino es inequívocamente un pájaro, no puede volar. Aunque nuestra interfaz es relativamente
pequeña, porque declara solo cuatro funciones de miembros simples, estos servicios declarados no pueden, obviamente, ser
ofrecidos por cada especie de ave.
El Principio de Segregación de Interfaz (ISP) establece que una interfaz no debe estar inflada con miembros
funciones que no son requeridas por las clases de implementación, o que estas clases no pueden implementar de manera
significativa. En nuestro ejemplo anterior, la clase Penguin no puede proporcionar una implementación significativa
para Bird::fly(), pero se obliga a Penguin a sobrescribir esa función miembro.
El Principio de Segregación de la Interfaz dice que debemos segregar una “interfaz gorda” en partes más pequeñas y
interfaces altamente cohesivas. Las pequeñas interfaces resultantes también se conocen como interfaces de roles.
Listado 610. Las tres interfaces de roles como una mejor alternativa a la amplia interfaz de Bird
class Lifeform { public:
virtual
void eat() = 0; movimiento vacío
virtual () = 0; };
clase Flyable { public:
virtual
void fly() = 0; };
class Audible { public:
virtual
void makeSound() = 0; };
Estas interfaces de roles pequeños ahora se pueden combinar de manera muy flexible. Esto significa que las clases
de implementación solo necesitan proporcionar una funcionalidad significativa para aquellas funciones miembro declaradas, que
pueden implementar de manera sensata.
Listado 611. Las clases Sparrow y Penguin implementan respectivamente las interfaces relevantes
clase Sparrow: forma de vida pública , voladora pública , audible pública {
//... };
clase Pingüino: forma de vida pública , Audible pública {
//... };
Principio de dependencia acíclica A veces existe la
necesidad de que dos clases se “conozcan” entre sí. Por ejemplo, supongamos que estamos desarrollando una tienda
web. Para que se puedan implementar ciertos casos de uso, la clase que representa a un cliente en esta tienda web debe
conocer su cuenta relacionada. Para otros casos de uso es necesario que la cuenta pueda acceder a su titular, que es un
cliente.
En UML, esta relación mutua se parece a la de la figura 64.
150
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Figura 64. Las relaciones de asociación entre la clase Cliente y la clase Cuenta
Esto se conoce como una dependencia circular. Ambas clases, ya sea directa o indirectamente, dependen una de la otra.
Y en este caso, solo hay dos clases. Las dependencias circulares también pueden ocurrir con varias unidades de software involucradas.
Veamos cómo se puede implementar en C++ la dependencia circular que se muestra en la figura 64.
Lo que definitivamente no funcionaría en C++ es lo siguiente:
Listado 612. El contenido del archivo Customer.h
#ifndef CLIENTE_H_
#define CLIENTE_H_
#include "Cuenta.h"
class Cliente { // ...
private:
Cuenta
cuentacliente; };
#terminara si
Listado 613. El contenido del archivo Account.h
#ifndef CUENTA_H_
#define CUENTA_H_
#include "Cliente.h"
clase Cuenta { privada:
propietario del
cliente; };
#terminara si
Creo que el problema es obvio aquí. Tan pronto como alguien usa la clase Cuenta, o la clase Cliente, él
desencadenaría una reacción en cadena durante la compilación. Por ejemplo, la Cuenta posee una instancia de Cliente que posee
una instancia de Cuenta que posee una instancia de Cliente, y así sucesivamente... Debido al estricto orden de procesamiento
de los compiladores de C++, la implementación anterior generará errores de compilación.
Estos errores del compilador se pueden evitar, por ejemplo, mediante el uso de referencias o punteros en combinación con
declaraciones de avance. Una declaración directa es la declaración de un identificador (por ejemplo, de un tipo, como una clase)
sin definir la estructura completa de ese identificador. Por lo tanto, estos tipos a veces también se denominan tipos incompletos.
Por lo tanto, solo se pueden usar para punteros o referencias, pero no para una variable de miembro de instancia, porque el
compilador no sabe nada sobre su tamaño.
151
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Listado 614. El Cliente modificado con una Cuenta declarada a futuro
#ifndef CLIENTE_H_
#define CLIENTE_H_
Cuenta de clase ;
clase Cliente
{ público: // ...
void setCuenta(Cuenta* cuenta)
{ cuentacliente = cuenta; } // ...
privado:
Cuenta* cuentacliente; };
#terminara si
Listado 615. La cuenta modificada con un cliente declarado a plazo
ifndef CUENTA_H_
#definir CUENTA_H_
clase Cliente;
class Cuenta
{ public: //... void setOwner(Cliente* cliente)
{ propietario = cliente;
} //...
privado:
Cliente* propietario; };
#terminara si
Mano a la obra: ¿te sientes un poco mal con esta solución? Si es así, ¡es por buenas razones! Los errores del
compilador desaparecieron, pero esta "solución" produce un mal presentimiento. Veamos cómo se usan ambas clases:
Listado 616. Creando las instancias de Cliente y Cuenta, y conectándolas circularmente juntas
#incluye "Cuenta.h" #incluye
"Cliente.h"
// ...
Cuenta* cuenta = nueva Cuenta { }; Cliente*
cliente = nuevo Cliente { }; cuenta
>setOwner(cliente); cliente
>setAccount(cuenta); // ...
152
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Estoy seguro de que un problema grave es obvio: ¿qué sucede si, por ejemplo, se eliminará la instancia de Cuenta,
pero la instancia de Cliente aún existe? Bueno, la instancia de Cliente contendrá un puntero colgante, es decir, ¡un puntero
a Tierra de nadie! El uso o la anulación de la referencia de un puntero de este tipo puede causar problemas graves, como
un comportamiento indefinido o bloqueos de la aplicación.
Las declaraciones de reenvío son bastante útiles para ciertas cosas, pero usarlas para lidiar con
dependencias circulares es una práctica realmente mala. Es una solución espeluznante que se supone que oculta un
problema de diseño fundamental.
El problema es la dependencia circular en sí. Este es un mal diseño. Las dos clases Cliente y Cuenta no
pueden separarse. Por lo tanto, no pueden usarse independientemente uno de otro, ni pueden probarse independientemente
uno de otro. Esto hace que las pruebas unitarias sean considerablemente más difíciles.
Y el problema empeora aún más si tenemos la situación representada en la figura 65.
Figura 65. El impacto de las dependencias circulares entre clases en diferentes componentes
Nuestras clases Cliente y Cuenta están ubicadas cada una en diferentes componentes. Quizás haya muchas
más clases en cada uno de estos componentes, pero estas dos clases tienen una dependencia circular. La consecuencia
es que esta dependencia circular tiene también un impacto negativo a nivel arquitectónico. La dependencia circular en
el nivel de clase conduce a una dependencia circular en el nivel de componente. CustomerManagement y Accounting están
estrechamente relacionados (recuerde la sección sobre acoplamiento flexible en el Capítulo 3) y no se pueden (re)utilizar
de forma independiente. Y, por supuesto, ya no es posible realizar una prueba de componentes independientes.
La modularización a nivel de arquitectura se ha reducido prácticamente al absurdo.
El principio de dependencia acíclica establece que el gráfico de dependencia de componentes o clases no debe
tener ciclos. Las dependencias circulares son una mala forma de acoplamiento estrecho y deben evitarse a toda costa.
¡No te preocupes! Siempre es posible romper una dependencia circular, y la siguiente sección mostrará cómo evitar,
respectivamente, romperlas.
Principio de inversión de dependencia (DIP)
En la sección anterior experimentamos que las dependencias circulares son malas y deben evitarse bajo todas las
circunstancias. Y como con muchos otros problemas con dependencias no deseadas, el concepto de la interfaz (en C++,
las interfaces se simulan usando clases abstractas) es nuestro amigo para lidiar con problemas como en el caso anterior.
153
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Por lo tanto, el objetivo debe ser romper la dependencia circular sin perder la posibilidad necesaria de que
la clase Cliente pueda acceder a Cuenta y viceversa.
El primer paso es que ya no permitimos que una de las dos clases tenga acceso directo a la otra clase.
En cambio, permitimos dicho acceso solo a través de una interfaz. Básicamente, no importa de cuál de las
dos clases (Cliente o Cuenta) se extrae la interfaz. Decidí extraer una interfaz llamada Propietario de Cliente.
A modo de ejemplo, la interfaz de propietario declara solo una función de miembro virtual pura que debe ser
anulada por las clases que implementan esta interfaz.
Listado 617. Una implementación ejemplar de la nueva interfaz Owner (Archivo: Owner.h)
#ifndef PROPIETARIO_H_
#define PROPIETARIO_H_
#include <memoria>
#include <cadena>
propietario de
la clase
{ public: virtual ~Owner() =
predeterminado; virtual std::string getName()
const = 0; };
usando OwnerPtr = std::shared_ptr<Owner>;
#terminara si
Listado 618. La clase Cliente que implementa la interfaz Propietario (Archivo: Cliente.h)
#ifndef CLIENTE_H_
#define CLIENTE_H_
#incluye "Propietario.h"
#incluye "Cuenta.h"
clase Cliente : public Owner { public:
void
setAccount(AccountPtr cuenta)
{ customerAccount = cuenta;
}
virtual std::string getName() const override { // devuelve
aquí el nombre del Cliente...
} // ...
privado:
AccountPtr cuentacliente; // ... };
usando CustomerPtr = std::shared_ptr<Cliente>;
#terminara si
154
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Como se puede ver fácilmente en el código fuente de la clase Cliente que se muestra arriba, el Cliente todavía sabe
su Cuenta. Pero cuando echamos un vistazo ahora a la implementación modificada de la clase Cuenta, ya no hay dependencia con el
Cliente:
Listado 619. La implementación modificada de la clase Cuenta (Archivo: Cuenta.h)
#ifndef CUENTA_H_ #define
CUENTA_H_
#include "Propietario.h"
class Cuenta { public:
void
setOwner(OwnerPtr propietario) { este>propietario
= propietario; } //...
privado:
PropietarioPtr propietario; };
usando AccountPtr = std::shared_ptr<Cuenta>;
#terminara si
Representado como un diagrama de clase UML, el diseño modificado en el nivel de clase es como se muestra en la Figura 66.
Figura 66. La introducción de la interfaz ha eliminado la dependencia circular del nivel de clase.
155
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
¡Excelente! Con este primer paso en el rediseño, ahora hemos logrado que no haya más dependencias
circulares en el nivel de clase. Ahora la clase Cuenta ya no sabe absolutamente nada sobre la clase Cliente. Pero,
¿cómo se ve la situación cuando subimos al nivel del componente como se muestra en la figura 67?
Figura 67. La dependencia circular entre los componentes sigue ahí.
Desafortunadamente, la dependencia circular entre los componentes aún no se ha roto. Los dos
las relaciones de asociación todavía van de un elemento en un componente a un elemento en el otro componente.
Sin embargo, el paso para lograr este objetivo es increíblemente fácil: solo necesitamos reubicar el propietario de la
interfaz en el otro componente, como se muestra en la figura 68.
156
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Figura 68. La reubicación de la interfaz también soluciona el problema de dependencia circular a nivel de arquitectura.
¡Excelente! Ahora las dependencias circulares entre los componentes han desaparecido. La contabilidad
El componente ya no depende de CustomerManagement y, como resultado, la calidad de la modularización ha
mejorado significativamente. Además, el componente Contabilidad ahora se puede probar de forma independiente.
De hecho, la mala dependencia entre ambos componentes no se eliminó literalmente. Por el contrario, a través de la
introducción de la interfaz Owner, incluso hemos obtenido una dependencia más en el nivel de clase. Lo que realmente
habíamos hecho era invertir la dependencia.
El Principio de Inversión de Dependencia (DIP) es un principio de diseño orientado a objetos para desacoplar software
módulos. El principio establece que la base de un diseño orientado a objetos no son las propiedades especiales de módulos
de software concretos. En su lugar, sus características comunes deben consolidarse en una abstracción utilizada
compartida (por ejemplo, una interfaz). Robert C. Martin, también conocido como “Tío Bob”, formuló el principio de la siguiente manera:
A. Los módulos de alto nivel no deben depender de los módulos de bajo nivel. Ambos deberían depender de abstracciones.
B. Las abstracciones no deben depender de los detalles. Los detalles deben depender de las abstracciones.
—Robert C. Martín [Martin03]
■ Nota Los términos “módulos de alto nivel” y “módulos de bajo nivel” en esta cita pueden ser engañosos. No se refieren necesariamente
a su posición conceptual dentro de una arquitectura en capas. Un módulo de alto nivel en este caso particular es un módulo de
software que requiere servicios externos de otro módulo, el llamado módulo de bajo nivel.
Los módulos de alto nivel son aquellos en los que se invoca una acción, los módulos de bajo nivel son aquellos en los que se
realiza la acción. En algunos casos, estas dos categorías de módulos también pueden ubicarse en diferentes niveles de una
arquitectura de software (por ejemplo, capas) o, como en nuestro ejemplo, en diferentes componentes.
157
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
El principio de inversión de dependencia es fundamental para lo que se percibe como una buena orientación a objetos.
diseño. Fomenta el desarrollo de módulos de software reutilizables al definir los servicios externos proporcionados y requeridos
únicamente a través de abstracciones (por ejemplo, interfaces). Aplicado consistentemente a nuestro caso discutido anteriormente,
también tendríamos que rediseñar la dependencia directa entre el Cliente y la Cuenta en consecuencia, como se muestra
en la Figura 69.
Figura 69. Principio de inversión de dependencia aplicado
Las clases en ambos componentes dependen únicamente de las abstracciones. Por lo tanto, ya no es importante para
el cliente del componente Contabilidad qué clase requiere la interfaz de Propietario o proporciona la interfaz de Cuenta (recuerde la
sección sobre Ocultación de información en el Capítulo 3). He insinuado esta circunstancia al presentar una clase que se llama
AnyClass. , que implementa Cuenta y utiliza Propietario.
Por ejemplo, si tenemos que cambiar o reemplazar la clase Cliente ahora, por ejemplo, porque queremos montar la Contabilidad
contra un dispositivo de prueba para la prueba de componentes, entonces no es necesario cambiar nada en la clase AnyClass para
lograrlo. Esto también se aplica al caso inverso.
El principio de inversión de dependencia permite a los desarrolladores de software diseñar dependencias entre
módulos a propósito, es decir, definir en qué dirección apuntan las dependencias. ¿Desea invertir la dependencia entre los
componentes, es decir, la contabilidad debe depender de CustomerManagement? No hay problema: simplemente reubique
ambas interfaces de Contabilidad a CustomerManagement y la dependencia cambiará. Las malas dependencias,
que reducen la mantenibilidad y la capacidad de prueba del código, se pueden rediseñar y reducir con elegancia.
No hables con extraños (Ley de Deméter)
¿Recuerda el automóvil del que hablé anteriormente en este capítulo? Describí este automóvil como una composición de varias
partes, por ejemplo, carrocería, motor, engranajes, etc. Y he explicado que estas partes pueden consistir nuevamente en partes,
que por sí mismas también pueden consistir en varias partes, etc. Esto lleva a una descomposición jerárquica de arriba hacia
abajo de un automóvil. Y, por supuesto, un automóvil puede tener un conductor que quiera usarlo.
Visualizado como un diagrama de clase UML, un extracto de la descomposición del automóvil puede parecerse a lo que
se muestra en la figura 610.
158
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Figura 610. La descomposición jerárquica de un automóvil simple.
De acuerdo con el Principio de Responsabilidad Única discutido en el Capítulo 5, todo está bien, porque
cada clase tiene una responsabilidad bien definida.
Ahora supongamos que el conductor quiere conducir el automóvil. Esto podría implementarse de la siguiente manera en el
controlador de clase:
Listado 620. Un extracto de la implementación de la clase Driver
controlador de clase
{ público: // ...
void drive(Coche&coche) const {
Motor& motor = coche.getEngine();
BombaCombustible& BombaCombustible =
motor.getBombaCombustible(); bombadecombustible.bomba();
Encendido& encendido =
motor.getIgnition(); encendido.powerUp(); Motor de
arranque& motor de
arranque = motor.getStarter(); starter.revolve(); } // ...
};
¿Cuál es el problema aquí? Bueno, como conductor de un automóvil, ¿esperaría que tuviera que acceder directamente
al motor de su automóvil, encender la bomba de combustible, encender el sistema de encendido y dejar girar el motor de arranque?
Voy aún más lejos: ¿está interesado en el hecho de que su automóvil consta de estas partes si solo quiere conducirlo?
159
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Estoy bastante seguro de que su respuesta clara sería: ¡ No!
Y ahora echemos un vistazo a la Figura 611, que representa la parte relevante del diagrama de clases UML para ver
qué impacto tiene esta implementación en el diseño.
Figura 611. Las malas dependencias de la clase Driver
Como se puede ver fácilmente en el diagrama anterior, la clase Driver tiene muchas dependencias incómodas. El controlador no
solo depende del motor. La clase también tiene varias relaciones de dependencia con partes del motor. Es fácil imaginar que esto tiene
algunas consecuencias desventajosas.
¿Qué pasaría, por ejemplo, si se sustituyera el motor de combustión por un tren de potencia eléctrico? Un motor eléctrico no
tiene bomba de combustible, sistema de encendido ni motor de arranque. Así, las consecuencias serían que la implementación de la
clase Driver tiene que ser adaptada. Esto viola el Principio AbiertoCerrado (ver sección anterior). Además, todos los captadores
públicos que exponen las entrañas del automóvil y el motor a su entorno están violando el principio de ocultación de información
(consulte el Capítulo 3).
Esencialmente, el diseño de software anterior viola la Ley de Demeter (LoD), también conocida como el Principio
de Menos Conocimiento. La Ley de Deméter puede considerarse como un principio que dice algo así como "No hables con
extraños" o "Solo habla con tus vecinos inmediatos". Este principio establece que debes hacer una programación tímida, y el objetivo
es gobernar la estructura de comunicación dentro de un diseño orientado a objetos.
160
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
La Ley de Deméter postula las siguientes reglas:
• Una función miembro puede llamar a otras funciones miembro en su propio ámbito de clase
directamente.
•Se permite que una función miembro llame a funciones miembro en variables miembro que
están en su ámbito de clase directamente.
• Si una función miembro tiene parámetros, la función miembro puede llamar directamente a las
funciones miembro de estos parámetros.
• Si una función miembro crea objetos locales, la función miembro puede llamar a funciones miembro
en esos objetos locales.
Si uno de estos cuatro tipos de llamadas a funciones miembro antes mencionadas devuelve un objeto que es estructuralmente
más lejos que los vecinos inmediatos de la clase, está prohibido llamar a una función miembro en esos objetos.
POR QUÉ ESTA REGLA SE NOMBRA LEY DE DEMÉTER
El nombre de este principio se remonta al Proyecto Demeter sobre Desarrollo de Software Orientado a
Aspectos, donde se formularon y aplicaron estrictamente estas reglas. El Proyecto Demeter fue un proyecto
de investigación a fines de la década de 1980 con un enfoque principal en hacer que el software sea más fácil de
mantener y expandir a través de la programación adaptativa. La Ley de Deméter fue descubierta y propuesta
por Ian M. Holland y Karl Lieberherr quienes trabajaron en ese proyecto. En la mitología griega, Deméter es la
hermana de Zeus y la diosa de la agricultura.
Entonces, ¿cuál es ahora la solución en nuestro ejemplo para deshacerse de las malas dependencias? Sencillamente, deberíamos
preguntarnos: ¿qué es lo que realmente quiere hacer un conductor? La respuesta es fácil: ¡quiere encender el auto!
class Driver
{ public: // ... void drive(Car& car) const
{ car.start(); } // ... };
¿Y qué hace el coche con este mando de arranque? También bastante simple: delega esta llamada de método a su motor.
class Car
{ public: // ... void
start()
{ motor.start(); } // ...
privado: motor motor; };
161
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Y por último, pero no menos importante, el motor sabe cómo puede ejecutar el proceso de inicio llamando al correspondiente
miembro funciona en el orden correcto en sus partes, que son sus vecinos inmediatos en el diseño del software.
class Engine
{ public: // ...
void
start()
{ fuelPump.pump();
encendido.powerUp();
starter.revolve(); } // ...
privado:
bomba de
combustible bomba de
combustible; Encendido
encendido; Arrancador
de arranque; };
El efecto positivo de estos cambios en el diseño orientado a objetos se puede ver muy claramente en el diagrama de clases que se
muestra en la figura 612.
Figura 612. Menos dependencias tras la aplicación de la Ley de Deméter
Se desvanecen las molestas dependencias del conductor con respecto a las piezas del automóvil. En su lugar, el conductor puede iniciar el
coche, independientemente de la estructura interna del coche. La clase Conductor ya no sabe que hay un Motor, una Bomba de Combustible,
etc. Todas esas malas funciones públicas captadoras, que habían revelado las entrañas del coche o el motor a todas las demás clases, se
han ido. Esto también significa que los cambios en el motor y sus partes solo tienen impactos muy locales y no darán como resultado cambios
en cascada directamente a través de todo el diseño.
Seguir la Ley de Demeter al diseñar software puede reducir significativamente el número de dependencias. Esto conduce a un
acoplamiento débil y fomenta tanto el Principio de ocultación de información como el Principio abierto cerrado. Al igual que con muchos otros
principios y reglas, también puede haber algunas excepciones justificadas en las que un desarrollador debe desviarse de este principio por muy
buenas razones.
162
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Evite las clases anémicas
En varios proyectos he visto clases que se veían de la siguiente manera:
Listado 621. Una clase sin funcionalidad que solo sirve como depósito para un montón de datos
clase Cliente { public:
void
setId(const unsigned int id); unsigned int getId()
const; void setForename(const
std::string& nombre); std::string getForename() const; void
setSurname(const std::string&surname);
std::string getApellido() const; //...más setters/getters aquí...
privado: id
int sin firmar ; std::string
nombre; std::string
apellido; // ...más atributos
aquí... };
Esta clase de dominio, que representa a un cliente en un sistema de software arbitrario, no contiene ninguna lógica.
La lógica está en otro lugar, incluso aquella lógica que representa una funcionalidad exclusiva para el Cliente, es decir, operando
solo sobre atributos del Cliente.
Los programadores que hicieron esto están usando objetos como bolsas para un montón de datos. Esto es solo
programación procedimental con estructuras de datos y no tiene nada que ver con la orientación a objetos. Además, todos esos
setters/getters son totalmente tontos y violan severamente el principio de ocultación de información; en realidad, podríamos usar
una estructura C simple (palabra clave: struct) aquí.
Estas clases se denominan clases anémicas y deben evitarse a toda costa. A menudo se pueden encontrar en un diseño
de software que es un AntiPatrón que ha sido llamado Modelo de Dominio Anémico por Martin Fowler [Fowler03]. Es exactamente
lo contrario de la idea básica del diseño orientado a objetos, que es combinar datos y la funcionalidad que trabaja con los datos en
unidades cohesivas.
Siempre que no viole la Ley de Deméter, debe insertar lógica también en las clases (dominio), si esto
la lógica está operando en los atributos de esa clase o colabora solo con los vecinos inmediatos de la clase.
¡Di, no preguntes!
El principio Di, no preguntes tiene algunas similitudes con la Ley de Deméter discutida anteriormente. Este principio es la
“declaración de guerra” a todos aquellos métodos get públicos, que revela algo sobre el estado interno de un objeto. También Tell
Don't Ask fomenta la encapsulación, fortalece la ocultación de información (consulte el Capítulo 3), pero ante todo, este principio
se trata de una fuerte cohesión.
Examinemos un pequeño ejemplo. Supongamos que la función miembro Engine::start() del
ejemplo anterior se implementa de la siguiente manera:
Listado 622. Una implementación posible, pero no recomendable, de la función miembro Engine::start()
motor de clase
{ público: // ...
void start() { if (!
fuelPump.isRunning()) {
163
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
bombadecombustible.powerUp();
if (bombadecombustible.getFuelPressure() < PRESIÓN_COMBUSTIBLE_NORMAL)
{ bombadecombustible.setPresiónCombustible(PRESIÓN_COMBUSTIBLE_NORMAL); }
} if (! encendido.esPoweredUp())
{ encendido.powerUp(); } if
(! starter.isRotating()) { starter.revolve(); } si
(motor.hasStarted()) {
motor de arranque.openClutchToEngine();
starter.stop(); }
} // ...
privado:
bomba de combustible
bomba de combustible;
Encendido encendido;
Arrancador de arranque; static const unsigned int NORMAL_FUEL_PRESSURE
{ 120 }; };
Como es fácil de ver, el método start() de la clase Engine consulta muchos estados de sus partes y responde en consecuencia.
Además, el Motor verifica la presión de combustible de la bomba de combustible y la ajusta si es demasiado baja. Esto también significa
que el motor debe conocer el valor de la presión normal de combustible. Debido a las numerosas ramas si, la complejidad ciclomática es
alta.
El principio Diga, no pregunte, nos recuerda que no debemos pedirle a un objeto que suelte información sobre su estado interno y
que decida fuera de este objeto qué hacer, si este objeto pudiera decidirlo por sí mismo. Básicamente, este principio nos recuerda que en la
orientación a objetos, los datos y las operaciones que operan sobre estos datos deben combinarse en unidades cohesivas.
Si aplicamos este principio a nuestro ejemplo, el método Engine::start() solo le diría a sus partes lo que deben hacer:
Listado 623. Delegación de etapas del procedimiento de arranque a las partes responsables del motor
motor de clase
{ público: // ...
void start()
{ bombadecombustible.bomba();
encendido.powerUp();
starter.revolve(); } // ...
privado:
bomba de combustible bomba de combustible;
Encendido encendido;
Arrancador de arranque; };
164
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Las partes pueden decidir por sí mismas cómo quieren ejecutar este comando, porque tienen el conocimiento al respecto,
por ejemplo, FuelPump puede hacer todo lo que tiene que hacer para acumular presión de combustible:
Listado 624. Un extracto de la clase FuelPump
bomba de combustible
de clase
{ público: // ...
void pump() { if (!
isRunning) { powerUp();
setPresiónNormalCombustible(); } } // ...
privado:
vacío powerUp() { //...
void setPresiónCombustibleNormal() { if
(presión != PRESIÓN_COMBUSTIBLE_NORMAL)
{ presión = PRESIÓN_COMBUSTIBLE_NORMAL; } }
bool se está
ejecutando; presión int sin
firmar ; static const unsigned int NORMAL_FUEL_PRESSURE { 120 }; };
Por supuesto, no todos los getters son intrínsecamente malos. A veces es necesario recuperar información de un
objeto, por ejemplo, si esta información debe mostrarse en una interfaz gráfica de usuario.
Evitar miembros de clase estáticos
Bien puedo imaginar que muchos lectores se están preguntando ahora: ¿qué diablos está mal con las variables miembro
estáticas y, respectivamente, las funciones miembro estáticas?
Bueno, tal vez todavía recuerdes el AntiPatrón de Clase Dios que he descrito en la sección anterior sobre clases pequeñas.
Allí describí que las clases de utilidad generalmente tienden a convertirse en "Clases de Dios" tan grandes.
Además, estas clases de utilidad generalmente también constan de muchas funciones miembro estáticas, a menudo incluso sin
excepción. La justificación tranquila y comprensible para esto es: ¿por qué debo obligar a los usuarios de la clase de utilidad a
crear una instancia de ella? Y debido a que tales clases ofrecen una colorida variedad de diferentes funciones para
propósito diferente, que por cierto es un signo de cohesión débil, he creado un nombre de patrón especial para estas cosas
desordenadas: el antipatrón de la tienda de chatarra. Según la enciclopedia en línea Wikipedia, una tienda de chatarra es un punto de
venta minorista similar a una tienda de segunda mano que ofrece una amplia variedad de productos en su mayoría usados a
precios económicos.
165
Machine Translated by Google
Capítulo 6 ■ Orientación a objetos
Listado 625. Extracto de alguna clase de utilidad
class JunkShop
{ public: // ...muchas funciones de utilidad pública... static
int oneOfManyUtilityFunctions(int param); // ...más funciones de utilidad
pública... };
Listado 626. Otra clase que usa la clase Utility
#include "Tienda de chatarra.h"
class Client { // ...
void
hacerAlgo() { // ... y =
JunkShop::oneOfManyUtilityFunctions(x); // ...
} };
El primer problema es que su código se conecta con todas esas funciones auxiliares estáticas en estas "Tiendas de chatarra".
Como se puede ver fácilmente en el ejemplo anterior, estas funciones estáticas de las clases de utilidad se utilizan en algún lugar de
la implementación de otro módulo de software. Por lo tanto, no hay una manera fácil de reemplazar esta llamada de función con otra
cosa. Pero en las pruebas unitarias (consulte el Capítulo 2), esto es exactamente lo que desea hacer.
Además, las funciones miembro estáticas fomentan un estilo de programación procedimental. Su uso junto con
variables estáticas reduce la orientación a objetos al absurdo. Compartir el mismo estado en todas las instancias de una clase con
la ayuda de una variable miembro estática no es intrínsecamente OOP, porque rompe la encapsulación, porque un objeto ya no tiene
el control total de su estado.
Por supuesto, C++ no es un lenguaje de programación orientado a objetos puro como Java o C#, y es básicamente
No está prohibido escribir código de procedimiento en C++. Pero cuando quiera hacer eso, debe ser honesto consigo mismo y,
en consecuencia, usar procedimientos independientes simples, respectivamente funciones, variables globales y espacios de nombres.
Mi consejo es evitar las variables miembro estáticas respectivamente y las funciones miembro en gran medida.
Una excepción a esta regla son las constantes privadas de una clase, porque son de solo lectura y no representan el estado
de un objeto. Otra excepción son los métodos de fábrica, es decir, funciones miembro estáticas que crean instancias de un objeto,
generalmente instancias del tipo de clase que también sirve como espacio de nombres de la función miembro estática.
166
Machine Translated by Google
CAPÍTULO 7
Programación funcional
Durante varios años, un paradigma de programación experimentó un renacimiento, que a menudo se ve como una especie de
contracorriente de la orientación a objetos. La charla es sobre Programación Funcional.
Uno de los primeros lenguajes de programación funcionales fue Lisp (La mayúscula "LISP" es una ortografía más antigua,
porque el nombre del lenguaje es una abreviatura de “LISt Processing”), que fue diseñado por el informático y científico
cognitivo estadounidense John McCarthy en 1958 en el Instituto Tecnológico de Massachusetts (MIT). McCarthy también acuñó
el término "inteligencia artificial" (IA), y usó Lisp como lenguaje de programación para aplicaciones de IA. Lisp se basa en el
llamado Lambda Calculus (cálculo λ), un modelo formal que fue introducido en la década de 1930 por el matemático
estadounidense Alonzo Church (ver la siguiente barra lateral).
De hecho, Lisp es una familia de lenguajes de programación de computadoras. Varios dialectos de Lisp han surgido en
el pasado. Por ejemplo, todos los que alguna vez hayan usado un miembro de la famosa familia de editores de texto Emacs, por
ejemplo, GNU Emacs o X Emacs, conocen el dialecto Emacs Lisp que se usa como lenguaje de secuencias de comandos para
extensión y automatización.
Los lenguajes de programación funcionales dignos de mención, que se han desarrollado más allá de Lisp, fueron, entre
otros:
• Scheme: un dialecto Lisp con enlace estático que se desarrolló en la década de 1970 en el MIT
Laboratorio de Inteligencia Artificial (AI Lab).
• Miranda: el primer lenguaje funcional puro y perezoso que fue comercialmente
soportado.
• Haskell: un lenguaje de programación puramente funcional y de propósito general que lleva el nombre
del lógico y matemático estadounidense Haskell Brooks Curry.
• Erlang: desarrollado por la compañía sueca de telecomunicaciones Ericsson con un enfoque
principal en la construcción de sistemas de software en tiempo real masivos, escalables y
altamente confiables.
• F# (pronunciado F sostenido): un lenguaje de programación multiparadigma y un
miembro de Microsoft .NET Framework. El paradigma principal de F# es la programación funcional,
pero permite al desarrollador cambiar también al mundo imperativo/orientado a objetos del
ecosistema .NET.
• Clojure: un dialecto moderno del lenguaje de programación Lisp creado por Rich Hickey.
Clojure es puramente funcional y se ejecuta en la máquina virtual Java™ y Common Language
Runtime (CLR; el entorno de tiempo de ejecución de Microsoft .NET framework).
© Stephan Roth 2017 167
S. Roth, C++ limpio, DOI 10.1007/9781484227930_7
Machine Translated by Google
Capítulo 7 ■ Programación funcional
EL CÁLCULO LAMBDA
Es difícil encontrar una introducción indolora al Cálculo Lambda. Muchos ensayos sobre este tema están
muy científicamente escritos y requieren un buen conocimiento de las matemáticas y la lógica. E incluso no
trataré de explicar el Cálculo Lambda aquí, porque no es el enfoque principal de este libro hacer esto. Pero
puedes encontrar innumerables explicaciones en Internet; solo pregunta al buscador de tu confianza, y
obtendrás cientos de visitas.
Solo eso: Lambda Calculus puede considerarse como el lenguaje de programación más simple y
pequeño que es posible. Consta sólo de dos partes: un único esquema de definición de funciones y una
única regla de transformación. Estos dos componentes son suficientes para tener un modelo genérico para
la descripción formal de lenguajes de programación funcionales, como LISP, Haskell, Clojure, etc.
A día de hoy, los lenguajes de programación funcionales todavía no se utilizan tanto como sus parientes imperativos, por
ejemplo, como los orientados a objetos, pero su difusión aumenta. Algunos ejemplos son JavaScript y Scala, que ciertamente son
lenguajes multiparadigmáticos (es decir, no son puramente funcionales), pero que se hicieron cada vez más populares, especialmente
en el desarrollo web, entre otros debido a sus capacidades de programación funcional.
Esta es razón suficiente para profundizar en este tema y explorar de qué se trata este estilo de programación y qué tiene que
ofrecer el C++ moderno en esta dirección.
¿Qué es la programación funcional?
Es difícil encontrar una definición generalmente aceptada para Programación Funcional (a veces abreviada como FP). A menudo,
uno lee que la Programación Funcional es un estilo de programación en el que todo el programa se construye exclusivamente a
partir de funciones puras. Esto plantea inmediatamente la pregunta: ¿qué se entiende por “función pura” en este contexto? Bien,
abordaremos esta pregunta en la siguiente sección. Sin embargo, básicamente es correcto: los fundamentos de la programación
funcional son funciones en su sentido matemático. Los programas se construyen mediante una composición de funciones y la
evaluación de funciones y cadenas de funciones.
Al igual que la Orientación a Objetos (ver Capítulo 6), también la Programación Funcional es un paradigma de programación.
Eso significa que es una forma de pensar sobre la construcción de software. Sin embargo, el paradigma de la Programación Funcional
también suele definirse por todas aquellas propiedades positivas que se le atribuyen. Estas propiedades, que se consideran
ventajosas en comparación con otros paradigmas de programación, especialmente la orientación a objetos, son las
siguientes:
• Sin efectos secundarios al evitar un estado mutable compartido (globalmente). En la programación funcional
pura, una llamada de función no tiene ningún efecto secundario. Esta importante propiedad de las
funciones puras se analiza en detalle en la siguiente sección, "¿Qué es una función?"
• Datos y objetos inmutables. En la Programación Funcional pura, todos los datos son
inmutable, es decir, una vez que se ha creado una estructura de datos, nunca se puede cambiar.
En cambio, si aplicamos una función a una estructura de datos, se crea como resultado una nueva
estructura de datos que es nueva o una variante de la anterior. Como consecuencia agradable, los datos
inmutables tienen la gran ventaja de ser seguros para subprocesos.
• Composición de funciones y funciones de orden superior. En Programación Funcional,
Las funciones pueden ser tratadas como datos. Puede almacenar una función en una variable. Puede
pasar una función como argumento a otras funciones. Las funciones se pueden devolver como
resultados de otras funciones. Las funciones se pueden encadenar fácilmente. En otras palabras: las
funciones son ciudadanos de primera clase del lenguaje.
168
Machine Translated by Google
Capítulo 7 ■ Programación funcional
• Mejor y más fácil paralelización. La concurrencia es básicamente difícil. Un software
El diseñador debe prestar atención a muchas cosas en un entorno de múltiples subprocesos de las
que normalmente no tiene que preocuparse cuando solo hay un único subproceso de ejecución. Y
encontrar errores en dicho programa puede ser muy doloroso. Pero si las llamadas a funciones
nunca tienen efectos secundarios, si no hay estados globales y si tratamos únicamente con
estructuras de datos inmutables, es mucho más fácil hacer una pieza de software paralela. En cambio,
con los lenguajes imperativos, como los orientados a objetos, y sus estados a menudo mutables,
necesita mecanismos de bloqueo y sincronización para proteger los datos y evitar que varios
subprocesos accedan a ellos y los manipulen simultáneamente (consulte la sección "El
poder de la inmutabilidad" en el Capítulo 9) . sobre cómo crear una clase inmutable
respectivamente objeto en C++).
• Fácil de probar. Si las funciones puras tienen todas las propiedades positivas mencionadas anteriormente,
también son muy fáciles de probar. No es necesario considerar estados mutables globales u otros efectos
secundarios en los casos de prueba.
Veremos que la programación en un estilo funcional en C++ no puede garantizar completamente todos estos aspectos
positivos automáticamente. Por ejemplo, si necesitamos un tipo de datos inmutable, tenemos que diseñarlo de esa manera, como
se explica en el Capítulo 9. Pero ahora profundicemos en este tema y discutamos la pregunta central: ¿qué es una función en la
Programación Funcional?
¿Qué es una función?
En el desarrollo de software podemos encontrar muchas cosas que se denominan “función”. Por ejemplo, algunas de las
funciones que una aplicación de software ofrece a sus usuarios a menudo también se denominan funciones del programa. En C++,
los métodos de una clase a veces se denominan funciones miembro. Las subrutinas de un programa de computadora generalmente
se consideran funciones. Sin duda, estos ejemplos también son "funciones" en cierto modo, pero no las funciones que tratamos
en Programación Funcional.
Cuando hablamos de funciones en Programación Funcional, estamos hablando de verdaderas funciones matemáticas. Eso
significa que consideramos una función como una relación entre un conjunto de parámetros de entrada y un conjunto de
parámetros de salida permisibles, donde cada conjunto de parámetros de entrada está relacionado exactamente con un conjunto
de parámetros de salida. Representada como una fórmula simple y general, una función es una expresión como se muestra en la
figura 71.
Figura 71. La función f asigna x a y
Esta sencilla fórmula define el patrón básico de cualquier función. Expresa que el valor de y depende, y únicamente, del valor
de x. Y otro punto importante es que para los mismos valores de x, ¡también el valor de y es siempre el mismo! En otras palabras, la
función f asigna cualquier valor posible de x a exactamente un valor único de y. En matemáticas y programación informática, esto
también se conoce como transparencia referencial.
169
Machine Translated by Google
Capítulo 7 ■ Programación funcional
TRANSPARENCIA REFERENCIAL
Una ventaja esencial que se menciona a menudo junto con la programación funcional es que las funciones puras
siempre son referencialmente transparentes.
El término “Transparencia referencial” tiene su origen en la filosofía analítica, que es un término general para
ciertos movimientos filosóficos que se desarrollan desde principios del siglo XX. La filosofía analítica se basa en
una tradición que inicialmente operaba principalmente con lenguajes ideales (lógicas formales) o analizando
el lenguaje cotidiano de uso cotidiano. El término “Transparencia referencial” se atribuye al filósofo y lógico
estadounidense Willard Van Orman Quine (1908 – 2000).
Si una función es referencialmente transparente, significa que cada vez que llamamos a la función con los
mismos valores de entrada, siempre recibiremos la misma salida. Una función escrita en un lenguaje
verdaderamente funcional, que evalúa una expresión y devuelve su valor, no hace nada más. En otras
palabras, teóricamente podemos sustituir la llamada de función directamente con su valor de resultado, y
este cambio no tendrá ningún impacto. Esto nos permite encadenar funciones como si fueran cajas negras.
La transparencia referencial nos lleva directamente al concepto de función pura.
Funciones puras frente a funciones impuras Aquí
hay un ejemplo simple de una función pura en C++:
Listado 71. Un ejemplo simple de una función pura en C++
cuadrado doble ( valor doble const ) noexcept { valor de retorno
* valor; };
Como se puede ver fácilmente, el valor de salida de square() depende únicamente del valor del argumento que se pasa
a la función, por lo que llamar a square() dos veces con el mismo valor de argumento producirá el mismo resultado cada vez.
No tenemos efectos secundarios, porque si se completa alguna llamada de esta función, no deja ninguna "suciedad" que pueda
influir en las llamadas posteriores de square(). Tales funciones, que son completamente independientes de un estado externo,
que no tienen efectos secundarios y que producirán siempre la misma salida para las mismas entradas, en concreto: que son
referencialmente transparentes, se denominan funciones puras .
Por el contrario, los paradigmas de programación imperativa, como la programación orientada a objetos o de procedimientos,
no proporcione esta garantía de ausencia de efectos secundarios, como muestra el siguiente ejemplo:
Listado 72. Un ejemplo que demuestra que las funciones miembro de las clases pueden causar efectos secundarios
#incluir <iostream>
class Clazz
{ public:
int functionWithSideEffect(const int value) noexcept { return value * value +
someKindOfMutualState++; }
privado: int
someKindOfMutualState { 0 }; };
170
Machine Translated by Google
Capítulo 7 ■ Programación funcional
int main() { Clazz
instanciaDeClazz { }; std::cout <<
instanciaDeClazz.functionWithSideEffect(3) << std::endl; // Salida: "9" std::cout << instanceOfClazz.functionWithSideEffect(3)
<< std::endl; // Salida: "10" std::cout << instanciaDeClazz.functionWithSideEffect(3) << std::endl; // Salida: "11" devuelve
0; }
En este caso, cada llamada de la función miembro con el nombre revelador Clazz::functionWithSideEffect() alterará un estado
interno de la instancia de la clase Clazz. Como consecuencia, cada llamada de esta función miembro devuelve un resultado
diferente, aunque el argumento dado para el parámetro de la función es siempre el mismo. Puede tener efectos similares en la
programación de procedimientos con variables globales que son manipuladas por procedimientos.
Las funciones que pueden producir diferentes salidas incluso si se llaman siempre con los mismos argumentos, se denominan
funciones impuras. Otro indicador claro de que una función es impura es cuando tiene sentido llamarla sin usar su valor de retorno.
Si puede hacer eso, esta función debe tener algún tipo de efecto secundario.
En un entorno de ejecución de subproceso único, los estados globales pueden causar pocos problemas y molestias. Pero
ahora imagine que tiene un entorno de ejecución de subprocesos múltiples, donde se ejecutan varios subprocesos, llamando a
funciones en un orden no determinista. En un entorno de este tipo, los estados globales, o los estados de instancias de
todo el objeto, a menudo son problemáticos y pueden causar un comportamiento impredecible o errores sutiles.
Programación funcional en C++ moderno
Lo crea o no, ¡pero la programación funcional siempre ha sido parte de C++! Con este lenguaje multiparadigma, siempre
podías programar en un estilo funcional, incluso con C++98. La razón por la que puedo afirmar esto con la mejor conciencia
es la existencia de la metaprogramación de plantilla conocida (TMP) desde el comienzo de C ++ (TMP es, por cierto, un tema
muy complicado y, por lo tanto, un desafío para muchos, incluso los expertos y desarrolladores experimentados).
Programación funcional con plantillas de C++ Lo que saben muchos desarrolladores
de C++ es que la metaprogramación de plantillas es una técnica en la que un compilador utiliza las denominadas plantillas para
generar código fuente de C++ en un paso antes de que el compilador traduzca el código fuente a código objeto. Lo que
muchos programadores pueden no saber es el hecho de que la metaprogramación de plantilla es programación
funcional y que es Turing Complete.
TOTALIDAD DE TURING
El término Turing Complete, llamado así por el conocido informático, matemático, lógico y criptoanalista inglés
Alan Turing (1912 1954), se usa a menudo para definir qué hace que un lenguaje sea un lenguaje de programación
"real". Un lenguaje de programación se caracteriza como Turing Completo, si puede resolver cualquier problema
posible con él que pueda ser computado teóricamente por una Máquina de Turing. Una máquina de Turing es una
máquina abstracta y teórica inventada por Alan Turing que sirve como modelo idealizado para los cálculos.
En la práctica, ningún sistema informático es realmente Turing Completo. La razón es que la Completitud de Turing
ideal requiere memoria ilimitada y recurrencias ilimitadas, lo que los sistemas informáticos actuales no pueden ofrecer.
Por lo tanto, algunos sistemas se aproximan a la integridad de Turing modelando una memoria ilimitada, pero restringida
por una limitación física en el hardware subyacente.
171
Machine Translated by Google
Capítulo 7 ■ Programación funcional
Como prueba, calcularemos el máximo común divisor (MCD) de dos enteros utilizando únicamente TMP.
El MCD de dos enteros, que no son cero, es el entero positivo más grande que divide a los dos enteros dados.
Listado 73. Cálculo del máximo común divisor usando metaprogramación de plantilla
01 #incluye <iostream>
02
03 template< sin signo int x, sin signo int y > 04 struct
GreatestCommonDivisor { static const unsigned
05 int resultado = GreatestCommonDivisor< y, x % y >::result; 06 }; 07
08 template< unsigned int x > 09 struct
GreatestCommonDivisor< x, 0 > { static const unsigned
10 int result = x; 11};
12
13 int main() { std::cout
"
<< "El GCD de 40 y 10 es: 14 std::endl; std::cout << << MayorDivisorComún<40u, 10u>::resultado <<
15
"El GCD de 366 y 60 es:
"
16 std::endl; devolver 0; << MayorDivisorComún<366u, 60u>::resultado <<
17
18
19 }
Esta es la salida que genera nuestro programa:
El MCD de 40 y 10 es: 10
El MCD de 366 y 60 es: 6
Lo notable de este estilo de calcular el GCD en tiempo de compilación usando plantillas es que es una programación
funcional real. Las dos plantillas de clase utilizadas están completamente libres de estados. No hay variables mutables,
lo que significa que ninguna variable puede cambiar su valor una vez que se ha inicializado. Durante la instanciación de
la plantilla, se inicia un proceso recursivo que se detiene cuando entra en juego la plantilla de clase especializada en la
línea 9 11. Y, como ya se mencionó anteriormente, tenemos Completitud de Turing en la metaprogramación de plantillas,
lo que significa que cualquier cálculo concebible se puede realizar en tiempo de compilación utilizando esta técnica.
Bueno, la metaprogramación de plantillas es sin duda una herramienta poderosa, pero también tiene algunas desventajas.
En particular, la legibilidad y la comprensión del código pueden sufrir drásticamente si se utiliza una gran cantidad de
metaprogramación de plantilla. La sintaxis y las expresiones idiomáticas de TMP son difíciles de entender, sin mencionar
esos mensajes de error extensos y, a menudo, crípticos cuando algo sale mal. Y, por supuesto, el tiempo de compilación
también aumenta con un uso extensivo de la metaprogramación de plantillas. Por lo tanto, TMP es sin duda una forma
adecuada de diseñar y desarrollar bibliotecas genéricas (consulte Biblioteca estándar de C++), pero solo debe usarse en
código de aplicación moderno y bien elaborado si se requiere este tipo de programación genérica (p. ej., para minimizar la
duplicación de código) .
Por cierto, desde C++11 ya no es necesario usar metaprogramación de plantillas para los cálculos en tiempo de
compilación. Con la ayuda de expresiones constantes (constexpr; consulte la sección sobre cálculos durante el tiempo de
compilación en el Capítulo 5), el GCD se puede implementar fácilmente como una función recursiva habitual, como en el
siguiente ejemplo:
172
Machine Translated by Google
Capítulo 7 ■ Programación funcional
Listado 74. Una función GCD que usa recursividad que se puede evaluar en tiempo de compilación
constexpr unsigned int greatCommonDivisor(const unsigned int x,
const unsigned int y) noexcept { return y ==
0 ? x : mayorDivisorComún(y, x % y); }
Por cierto, el algoritmo matemático detrás de esto se llama algoritmo de Euclides, o algoritmo de Euclides,
lleva el nombre del antiguo matemático griego Euclides.
Y con C++17, el algoritmo numérico std::gcd() se ha convertido en parte de la biblioteca estándar de C++
(definido en el encabezado <numérico>), por lo tanto, ya no es necesario implementarlo por su cuenta.
Listado 75. Usando la función std::gcd del encabezado <numeric>
#include <iostream>
#include <numérico>
int main()
{ constexpr resultado automático = std::gcd(40, 10);
"
std::cout << "El MCD de 40 y 10 es: << resultado << std::endl; return 0;
Objetos similares a funciones (funtores)
Lo que siempre fue posible en C++ desde el principio es la definición y el uso de los llamados objetos similares a funciones,
también conocidos como funtores (otro sinónimo es funcional) en resumen. Técnicamente hablando, un Functor es más o
menos una clase que define el operador de paréntesis, es decir, el operador(). Después de la creación de instancias de estas
clases, se pueden usar prácticamente como funciones.
Dependiendo de si el operador () no tiene ninguno, uno o dos parámetros, el Funtor se llama Generador, Función
unaria o Función binaria. Veamos primero un Generador.
Generador
Como revela el nombre "Generador", este tipo de Funtor se utiliza para producir algo.
Listado 76. Un ejemplo de un generador, un funtor que se llama sin argumento
clase GeneradorNumeroCreciente { public: int
operator()
() noexcept { return numero++; }
privado:
número int { 0 }; };
173
Machine Translated by Google
Capítulo 7 ■ Programación funcional
El principio de funcionamiento es bastante simple: cada vez que se llama a AumentarNúmeroGenerador::operador(), el
valor real de la variable miembro número se devuelve a la persona que llama, y luego el valor de esta variable miembro se
incrementa en 1. El siguiente ejemplo de uso se imprime una secuencia de los números 0 a 2 en la salida estándar:
int principal() {
Generador de números crecientes generador de números { };
std::cout << numberGenerator() << std::endl; std::cout <<
numberGenerator() << std::endl; std::cout << numberGenerator()
<< std::endl; devolver 0;
Recuerde la cita de Sean Parent que presenté en la sección sobre algoritmos en el Capítulo 5: ¡nada de bucles sin
procesar! Para llenar un std::vector<T> con una cierta cantidad de valores crecientes, no debemos implementar un bucle hecho
a mano. En su lugar, podemos usar std::generate definido en el encabezado <algoritmo>, una plantilla de función que asigna
a cada elemento en un cierto rango un valor generado por un objeto Generador dado.
Por lo tanto, podemos escribir el siguiente código simple y bien legible para llenar un vector con una secuencia de números
crecientes usando nuestro Generador de Números Crecientes:
Listado 77. Llenar un vector con una secuencia numérica creciente usando std::generate
#include <algoritmo>
#include <vector>
usando Números = std::vector<int>;
int main() { const
std::size_t CANTIDAD_DE_NUMEROS { 100 }; números
números (AMOUNT_OF_NUMBERS);
std::generate(std::begin(números), std::end(números), GeneradorDeNúmeroCreciente()); // ...ahora los 'números'
contienen valores del 0 al 99... devuelve 0; }
Como uno puede imaginar fácilmente, este tipo de Funtores no cumplen con los requisitos estrictos de las
funciones puras. Los generadores suelen tener un estado mutable, es decir, cuando se llama a operator(), estos Functors suelen
tener algún efecto secundario. En nuestro caso, el estado mutable está representado por la variable miembro privada
GeneradorDeNúmeroCreciente::número, que se incrementa después de cada llamada del operador de paréntesis.
■ Sugerencia El encabezado <numeric> ya contiene una plantilla de función std::iota(), denominada así por el símbolo
funcional (Iota) del lenguaje de programación APL, que no es un funtor generador, pero se puede usar para llenar
un contenedor con una secuencia ascendente de valores de forma elegante.
Otro ejemplo de un objeto similar a una función de tipo Generador es la siguiente plantilla de funtor generador de números
aleatorios. Este Functor encapsula todo lo necesario para la inicialización y el uso de un generador de números pseudoaleatorios
(PRNG) basado en el llamado algoritmo Mersenne Twister (definido en el encabezado <random>).
174
Machine Translated by Google
Capítulo 7 ■ Programación funcional
Listado 78. Una plantilla de clase de funtor generador, que encapsula un generador de números pseudoaleatorios
#incluir <aleatorio>
template <typename NUMTYPE>
class RandomNumberGenerator
{ public:
RandomNumberGenerator()
{ mersenneTwisterEngine.seed(randomDevice()); }
NUMTYPE operator()()
{ distribución de retorno (mersenneTwisterEngine); }
privado:
std::random_device randomDevice;
std::uniform_int_distribution<NUMTYPE> distribución; std::mt19937_64
mersenneTwisterEngine; };
Y así es como se podría usar el Functor RandomNumberGenerator:
Listado 79. Llenar un vector con 100 números aleatorios
#include "RandomGenerator.h"
#include <algoritmo>
#include <funcional>
#include <iostream>
#include <vector>
usando Números = std::vector<short>; const
std::size_t CANTIDAD_DE_NUMEROS { 100 };
Números createVectorFilledWithRandomNumbers() {
RandomNumberGenerator<corto> randomNumberGenerator { };
Números números aleatorios (AMOUNT_OF_NUMBERS);
std::generate(begin(randomNumbers), end(randomNumbers), std::ref(randomNumberGenerator)); devuelve números
aleatorios;
}
void printNumbersOnStdOut(const Numbers& randomNumbers) { for (const
auto& number : randomNumbers) { std::cout << number
<< std::endl;
}
}
int principal() {
Números números aleatorios = createVectorFilledWithRandomNumbers();
imprimirNúmerosEnStdOut(NúmerosAleatorios);
devolver 0;
}
175
Machine Translated by Google
Capítulo 7 ■ Programación funcional
Función unaria A
continuación, veamos un ejemplo de un objeto similar a una función unaria, que es un Functor cuyo operador de
paréntesis tiene un parámetro.
Listado 710. Un ejemplo de un funtor unario
class ToSquare
{ public:
constexpr int operator()(const int value) const noexcept { return value * value; } };
Como sugiere su nombre, este Functor eleva al cuadrado los valores que se le pasan en el operador de paréntesis.
El operator() se declara como const, que es un indicador de que se comporta como una función pura, es decir, una llamada no
tendrá efectos secundarios. Esto no necesariamente tiene que ser siempre el caso, porque, por supuesto, también un Functor
unario puede tener variables miembro privadas y, por lo tanto, un estado mutable.
Con el ToSquare Functor, ahora podemos extender el ejemplo anterior y aplicarlo al vector con la secuencia de enteros
ascendentes.
Listado 711. Los 100 números de un vector están elevados al cuadrado
#include <algoritmo>
#include <vector>
usando Números = std::vector<int>;
int main() { const
std::size_t CANTIDAD_DE_NUMEROS = 100; números
números (AMOUNT_OF_NUMBERS);
std::generate(std::begin(números), std::end(números), GeneradorDeNúmeroCreciente());
std::transform(std::begin(números), std::end(números), std::begin(números), ToSquare()); // ...
devolver 0; }
El algoritmo usado std::transform (definido en el encabezado <algoritmo>) aplica la función u objeto de función
dado a un rango (definido por los dos primeros parámetros) y almacena el resultado en otro rango (definido por el tercer
parámetro). En nuestro caso, ambos rangos son iguales.
predicados
Un tipo especial de funtores son los predicados. Un functor unario se llama predicado unario si tiene un parámetro y un valor
de retorno booleano que indica el resultado verdadero o falso de alguna prueba, como en el siguiente ejemplo:
Listado 712. Un ejemplo de predicado
class IsAnOddNumber
{ public:
constexpr bool operator()(const int value) const noexcept { return (valor % 2) != 0; } };
176
Machine Translated by Google
Capítulo 7 ■ Programación funcional
Este Predicado ahora se puede aplicar a nuestra secuencia numérica usando el algoritmo std::remove_if para deshacerse
de todos los números impares. El problema es que el nombre de este algoritmo es engañoso. En realidad, no elimina nada.
Cualquier elemento que no coincida con el Predicado (en nuestro caso, todos los números pares), se mueve al principio del
contenedor para que los elementos que se eliminen estén al final. Luego, std::remove_if devuelve un iterador que apunta al
comienzo del rango que se eliminará. Este iterador puede ser utilizado por la función miembro std::vector::erase() para eliminar
verdaderamente los elementos no deseados del vector. Esta, por cierto, técnica muy eficiente se llama la expresión Eraseremove.
Listado 713. Todos los números impares del vector se eliminan usando la expresión Borrareliminar
#include <algoritmo>
#include <vector>
usando Números = std::vector<int>;
int main() { const
std::size_t CANTIDAD_DE_NUMEROS = 100; números
números (AMOUNT_OF_NUMBERS);
std::generate(std::begin(números), std::end(números), GeneradorDeNúmeroCreciente()); std::transform(std::begin(números),
std::end(números), std::begin(números), ToSquare()); números.erase(std::remove_if(std::begin(numbers), std::end(numbers),
IsAnOddNumber()), std::end(numbers)); // ...
devolver 0; }
Para poder usar un Functor de una manera más flexible y genérica, generalmente se implementa como una plantilla de
clase. Por lo tanto, podemos refactorizar nuestro funtor unario IsAnOddNumber en una plantilla de clase para que pueda usarse
con todos los tipos integrales, como short, int, unsigned int, etc. Y desde C++ 11, el lenguaje proporciona los llamados rasgos
de tipo (definido en el encabezado <type_traits>), podemos asegurar que la plantilla se use únicamente con tipos integrales,
como se muestra en el siguiente ejemplo:
Listado 714. Asegurarse de que el parámetro de la plantilla sea un tipo de datos integral
#incluir <tipo_rasgos>
template <typename INTTYPE> class
IsAnOddNumber { public:
static_assert(std::is_integral<INTTYPE>::value, "IsAnOddNumber
requiere un tipo entero para su parámetro de plantilla INTTYPE!"); constexpr bool operator()(const INTTYPE
value) const noexcept { return (value % 2) != 0; } };
Desde C++11, el lenguaje proporciona static_assert(), una verificación de afirmación que se realiza en tiempo de compilación.
En nuestro caso, static_assert() se utiliza para comprobar durante la instanciación de la plantilla que el parámetro de plantilla
INTTYPE es de tipo integral utilizando el rasgo de tipo std::is_integral<T>. La ubicación dentro del cuerpo de la función
main(), donde se usa el predicado (la construcción borrareliminar), ahora debe ajustarse un poco:
// ...
números.erase(std::remove_if(std::begin(numbers), std::end(numbers),
IsAnOddNumber<Numbers::value_type>()), std::end(numbers)); // ...
177
Machine Translated by Google
Capítulo 7 ■ Programación funcional
Si ahora usamos inadvertidamente la plantilla con un tipo de datos no integral, como doble, obtenemos un
convincente mensaje de error del compilador:
[...] ../
src/Functors.h: En instanciación de 'class IsAnOddNumber<double>': ../src/Main.cpp:13:94:
requerido desde aquí ../src/Functors.h: 42:3: error: la
aserción estática falló: ¡IsAnOddNumber requiere un tipo entero para su parámetro de plantilla INTTYPE! [...]
RASGOS DEL TIPO
Las plantillas son la base de la programación genérica. Los contenedores de la biblioteca estándar de C++, pero también
los iteradores y los algoritmos, son ejemplos destacados de programación genérica muy flexible que utiliza el concepto de
plantilla de C++. Sin embargo, desde un punto de vista técnico, solo se lleva a cabo un simple procedimiento textual
de búsqueda y reemplazo si se crea una instancia de una plantilla con argumentos de plantilla. Por ejemplo, si un
parámetro de plantilla se llama T, cada aparición de T se reemplaza por el tipo de datos que se pasa como argumento de
plantilla durante la instanciación de la plantilla.
El problema es este: no todos los tipos de datos son adecuados para la creación de instancias de cada plantilla.
Por ejemplo, si ha definido una operación matemática como una plantilla de C++ Funtor para que pueda
usarse para diferentes tipos de datos numéricos (cortos, enteros, dobles, etc.), no tiene ningún sentido crear una
instancia de esta plantilla con std ::cadena.
El encabezado de la biblioteca estándar de C++ <type_traits> (disponible desde C++11) proporciona una colección
completa de comprobaciones para recuperar información sobre los tipos pasados como argumentos de plantilla en tiempo
de compilación. En otras palabras, con la ayuda de los rasgos de tipo, puede definir requisitos verificables por el compilador
que deben cumplir los argumentos de la plantilla.
Por ejemplo, puede asegurarse de que el tipo que se usa para la creación de instancias de plantilla debe ser
construible por copia combinado con la garantía de seguridad de excepción de no lanzamiento (consulte la sección
"La garantía de no lanzamiento" en el Capítulo 5) usando el rasgo de tipo std : :is_nothrow_copy_construible<T>.
template <typename T>
class Clazz
{ static_assert(std::is_nothrow_copy_construtible<T>::value,
"¡El tipo dado para T debe ser construible por copia y no puede arrojar!"); // ...
};
Los rasgos de tipo no solo se pueden usar junto con static_assert() para cancelar la compilación con un mensaje de error.
Por ejemplo, también se pueden usar para una expresión idiomática llamada SFINAE (la falla de sustitución no es un
error) que se analiza con más detalle en la sección sobre expresiones idiomáticas en el Capítulo 9 .
Por último, pero no menos importante, echemos un vistazo al Binary Funtor.
178
Machine Translated by Google
Capítulo 7 ■ Programación funcional
Funtores binarios
Como ya se mencionó anteriormente, un Binary Funtor es un objeto similar a una función que toma dos parámetros. Si
dicho Functor opera sobre sus dos parámetros para realizar algún cálculo (por ejemplo, una suma) y devuelve el resultado de
esta operación, se denomina Operador binario. Si dicho Functor tiene un valor de retorno booleano como resultado de alguna
prueba, como se muestra en el siguiente ejemplo, se denomina Predicado binario.
Listado 715. Un ejemplo de un predicado binario que compara sus dos parámetros
class IsGreaterOrEqual { public:
bool
operator()(const auto& value1, const auto& value2) const noexcept {
devolver valor1 >= valor2;
} };
■ Nota Hasta C++11, era una buena práctica que los funtores, dependiendo de su número de parámetros,
se derivaran de las plantillas std::unary_function respectivamente std::binary_function (ambas
definidas en el encabezado <funcional>). Estas plantillas se etiquetaron como obsoletas con C++ 11 y se
eliminaron de la biblioteca estándar con el estándar C++ 17 reciente.
Carpetas y envoltorios de funciones
El próximo paso de desarrollo en términos de programación funcional en C++ se realizó con la publicación del borrador
del Informe técnico 1 de C++ (TR 1) en 2005, que es el nombre común para las extensiones de biblioteca estándar ISO/
IEC TR 19768:2007 C++. El TR 1 especifica una serie de extensiones de la biblioteca estándar de C++, incluidas, entre
otras cosas, extensiones para la programación funcional. Este informe técnico fue la propuesta de extensión de biblioteca para
el estándar C++11 posterior y, de hecho, 12 de las 13 bibliotecas propuestas (con ligeras modificaciones) también se
incorporaron al nuevo estándar de lenguaje que se publicó en 2011.
En términos de programación funcional, el TR 1 introdujo las dos plantillas de funciones std::bind y
std::function, que se definen en el encabezado de la biblioteca <funcional>.
La plantilla de función std::bind es un contenedor de carpetas para funciones y sus argumentos. Puedes tomar
una función (o un puntero de función, o un Funtor) y "vincular" los valores reales a uno o todos los parámetros de la
función. En otras palabras, puede crear nuevos objetos similares a funciones a partir de funciones o Funtores existentes.
Comencemos con un ejemplo simple:
Listado 716. Usando std::bind para envolver la función binaria multiplicar()
#include <funcional> #include
<iostream>
constexpr double multiplicand(const doble multiplicando, const doble multiplicador) noexcept {
devuelve multiplicando * multiplicador; }
179
Machine Translated by Google
Capítulo 7 ■ Programación funcional
int main()
{ const auto resultado1 = multiplicar(10.0, 5.0);
autoboundMultiplyFunctor = std::bind(multiply, 10.0, 5.0); const auto result2
=boundMultiplyFunctor();
" "
std::cout << "resultado1 = << resultado1 << ", resultado2 = << resultado2 << std::endl;
devolver
0; }
En este ejemplo, la función multiplicar() está envuelta, junto con dos literales numéricos de coma flotante (10.0 y
5.0), usando std::bind. Los literales numéricos representan los parámetros reales que están vinculados a los dos
argumentos de función multiplicando y multiplicador. Como resultado, obtenemos un nuevo objeto similar a una función
que se almacena en la variableboundMultiplyFunctor. Luego se puede llamar como un Functor ordinario usando el
operador de paréntesis.
Tal vez te preguntes ahora: Bien, pero no lo entiendo. ¿Cuál es el propósito de eso? Cuál es el
beneficio práctico de la plantilla de función de carpeta?
Bueno, std::bind permite algo que se conoce como aplicación parcial (o aplicación de función parcial) en
programación. La aplicación parcial es un proceso en el que solo un subconjunto de los parámetros de la función está
vinculado a valores o variables, mientras que la otra parte aún no está vinculada. Los parámetros independientes se
reemplazan por los marcadores de posición _1, _2, _3, etc., que se definen en el espacio de nombres std::placeholders.
Listado 717. Un ejemplo de aplicación de función parcial
#incluir <funcional>
#incluir <iostream>
constexpr double multiplicand(const doble multiplicando, const doble multiplicador) noexcept {
devuelve multiplicando * multiplicador; }
int main()
{ usando el espacio de nombres std::placeholders;
auto multiplicar con 10 = std::bind(multiplicar, _1, 10.0); <<
"
<< "resultado = multiplicarCon10(5.0) << std::endl; estándar::cout
devolver
0; }
En el ejemplo anterior, el segundo parámetro de la función multiplicar() está vinculado al número de punto flotante
literal 10.0, pero el primer parámetro está vinculado a un marcador de posición. El objeto similar a una función, que es el
valor de retorno de std::bind(), se almacena en la variable multiplicarCon10. Esta variable ahora se puede usar como
una función, pero solo necesitamos pasar un parámetro: el valor que se multiplicará por 10.0.
La aplicación de función parcial es una técnica de adaptación que nos permite utilizar una función o un Functor en
varias situaciones, donde necesitamos su funcionalidad, pero donde solo podemos proporcionar algunos pero no
todos los argumentos. Además, con la ayuda de los marcadores de posición, el orden de los parámetros de las
funciones se puede adaptar al orden que espera el código del cliente. Por ejemplo, la posición del multiplicando y el
multiplicador en la lista de parámetros se pueden intercambiar asignándolos a un nuevo objeto similar a una función de
la siguiente manera:
multiplicación automática con la posición del parámetro intercambiado = std::bind(multiply, _2, _1);
180
Machine Translated by Google
Capítulo 7 ■ Programación funcional
En nuestro caso con la función multiplicar(), esto obviamente no tiene sentido (recuerde la propiedad conmutativa
de la multiplicación), porque el nuevo objeto de función producirá exactamente los mismos resultados que la función
multiplicar() original, pero en otras situaciones una adaptación de la función El orden de los parámetros puede mejorar
la usabilidad de una función. La aplicación de función parcial es una herramienta para la adaptación de la interfaz.
Por cierto, especialmente en conjunto con funciones como parámetros de retorno, la deducción automática de tipos con
su palabra clave auto (vea la sección “Deducción automática de tipos” en el Capítulo 5 ) puede proporcionar servicios
valiosos, porque si inspeccionamos lo que devuelve el compilador GCC de lo anterior llamada de std::bind(), es un objeto
del siguiente tipo complejo:
std::_Bind_helper<bool0,doble (&)(doble, doble),const _Marcador<int2> &, const _Marcador<int1> &>::tipo
Aterrador, ¿no? Escribir dicho tipo explícitamente en el código fuente no solo es un poco útil, sino que, aparte de
eso, la legibilidad del código también sufre considerablemente. Gracias a la palabra clave auto no es necesario definir
estos tipos explícitamente. Pero en esos casos excepcionales, en los que debe hacerlo, entra en juego la plantilla de clase
std::function, que es un contenedor de funciones polimórficas de propósito general. Esta plantilla puede envolver un objeto
invocable arbitrario (una función ordinaria, un Functor, un puntero de función, etc.) y administra la memoria utilizada para
almacenar ese objeto. Por ejemplo, para envolver nuestra función de multiplicación multiplicar() en un objeto std::function,
el código tiene el siguiente aspecto:
std::function<doble(doble, doble)> multiplicarFunc = multiplicar; resultado automático
= multiplicarFunc(10.0, 5.0);
Ahora que hemos discutido std::bind, std::function y la técnica de aplicación parcial, tengo un mensaje posiblemente
decepcionante para usted: desde C++ 11 y la introducción de expresiones lambda, la mayoría de estas plantillas provienen
de C++ La biblioteca estándar rara vez se requiere.
Expresiones lambda Con la llegada
de C++11, el lenguaje se ha ampliado con una característica nueva y notable: ¡las expresiones lambda! Otros términos
de uso frecuente para ellos son funciones lambda, literales de funciones o simplemente lambdas.
A veces también se les llama Closures, que en realidad es un término general de la programación funcional, y que, por cierto,
tampoco es del todo correcto.
CIERRE
En los lenguajes de programación imperativos, estamos acostumbrados al hecho de que una variable deja de
estar disponible cuando la ejecución del programa sale del ámbito en el que se define la variable. Por ejemplo,
si se realiza una función y vuelve a la persona que la llamó, todas las variables locales de esa función se eliminan
de la pila de llamadas y se eliminan de la memoria.
Por otro lado, en Programación Funcional, podemos construir un Closure, que es un objeto de función con un ámbito
de variable local persistente. En otras palabras, los cierres permiten que un ámbito con algunas o todas sus variables
locales esté vinculado a una función, y que este objeto de ámbito persista mientras exista esa función.
En C++, dichos cierres se pueden crear con la ayuda de expresiones lambda debido a su lista de captura en el
introductor lambda. Un Closure no es lo mismo que una expresión lambda, así como un objeto (instancia) en
orientación a objetos no es lo mismo que su clase.
181
Machine Translated by Google
Capítulo 7 ■ Programación funcional
Lo especial de las expresiones lambda es que generalmente se implementan en línea, es decir, en el punto de su aplicación.
Esto a veces puede mejorar la legibilidad del código y los compiladores pueden aplicar sus estrategias de optimización de manera aún
más eficiente. Por supuesto, las funciones lambda también pueden tratarse como datos, por ejemplo, almacenarse en variables o
pasarse como un argumento de función a una llamada función de orden superior (consulte la siguiente sección sobre este
tema).
La estructura básica de una expresión lambda es la siguiente:
[lista de captura] (lista de parámetros) > return_type_declaration {cuerpo lambda}
Dado que este libro no es una introducción al lenguaje C++, no explicaré aquí todos los conceptos básicos sobre las
expresiones lambda. Incluso si está viendo algo como esto por primera vez, debe quedar relativamente claro que el tipo de retorno,
la lista de parámetros y el cuerpo lambda son prácticamente los mismos que con las funciones ordinarias. Lo que puede parecer
inusual a primera vista son dos cosas. Por ejemplo, una expresión lambda no tiene nombre como una función ordinaria o un
objeto similar a una función. Esta es la razón por la que se habla en este contexto también de funciones anónimas. El otro elemento
llamativo es el corchete al principio, que también se denomina introductor lambda. Como sugiere el nombre, el introductor lambda
marca el comienzo de una expresión lambda. Además, el introductor también contiene opcionalmente algo que se denomina
lista de captura.
Lo que hace que esta lista de captura sea tan importante es que aquí se enumeran todas las variables del ámbito externo,
que deben estar disponibles dentro del cuerpo lambda, y si deben capturarse por valor (copia) o por referencia. En otras palabras,
estos son los cierres de la expresión lambda.
Un ejemplo de expresión lambda se define de la siguiente manera:
[](const doble multiplicando, const doble multiplicador) { return multiplicando * multiplicador; }
Esta es nuestra buena y antigua función de multiplicación como lambda. El introductor tiene una lista de captura en blanco, que
significa que no se utiliza nada del alcance circundante. Además, el tipo de retorno no se especifica en este caso, porque el
compilador puede deducirlo fácilmente.
Al asignar la expresión lambda a una variable, se crea un objeto de tiempo de ejecución correspondiente, el llamado cierre. Y esto
es realmente cierto: el compilador genera una clase de funtor de un tipo no especificado a partir de una expresión lambda, que se
instancia en tiempo de ejecución y se asigna a la variable. Las capturas en la lista de capturas se convierten en parámetros de
constructor y variables miembro del objeto funtor. Los parámetros en la lista de parámetros de lambda se convierten en parámetros
para el operador de paréntesis del funtor (operador()).
Listado 718. Usando la expresión lambda para multiplicar dos dobles
#incluir <iostream>
int main() { auto
multiplicar = [](const doble multiplicando, const doble multiplicador) { return multiplicando * multiplicador; };
std::cout << multiplicar(10.0, 50.0) << std::endl;
devolver 0; }
Sin embargo, todo se puede hacer más corto, porque una expresión lambda se puede llamar directamente en
el lugar de su definición agregando paréntesis con argumentos detrás del cuerpo lambda.
182
Machine Translated by Google
Capítulo 7 ■ Programación funcional
Listado 719. Definición y llamada de una expresión lambda de una sola vez
int main()
{ std::cout << []
(const doble multiplicando, const doble multiplicador) {
devuelve multiplicando * multiplicador; }
(50.0, 10.0) << estándar::endl;
devolver
0; }
El ejemplo anterior es, por supuesto, solo para fines de demostración, ya que el uso de una lambda en este
El estilo definitivamente no tiene sentido. El siguiente ejemplo utiliza dos expresiones lambda. Uno es usado por el
algoritmo std::transform para envolver las palabras en la comilla del vector de cadena con paréntesis angulares y
almacenarlas en otro vector llamado resultado. La otra expresión lambda es utilizada por std::for_each para generar el
contenido de resultado en la salida estándar.
Listado 720. Poner cada palabra en una lista entre paréntesis angulares
#include <algoritmo>
#include <iostream>
#include <cadena>
#include <vector>
int main()
{ std::vector<std::string> quote { "Eso es", "uno", "pequeño", "paso", "para", "un", "hombre", "uno",
"Un gran salto para la humanidad." };
std::vector<std::string> resultado;
std::transform(begin(quote), end(quote), back_inserter(resultado), [](const std::string&
word) { return "<" + word + ">"; }); std::for_each(begin(resultado),
end(resultado), [](const std::string& palabra) { std::cout
<< palabra << " "; });
devolver 0;
}
La salida de este pequeño programa es:
<Eso es> <uno> <pequeño> <paso> <para> <un> <hombre,> <uno> <gigante> <salto> <para> <la humanidad.>
Expresiones lambda genéricas (C++14)
Con la publicación de C++14, las expresiones lambda experimentaron algunas mejoras adicionales. Desde C++14 se
permite usar auto (consulte la sección sobre la deducción automática de tipos en el Capítulo 5) como el tipo de retorno de
una función, o una lambda. En otras palabras, el compilador deducirá el tipo. Estas expresiones lambda se denominan
expresiones lambda genéricas.
183
Machine Translated by Google
Capítulo 7 ■ Programación funcional
Aquí hay un ejemplo:
Listado 721. Aplicar una expresión lambda genérica en valores de diferentes tipos de datos
#include <complejo>
#include <iostream>
int main() { auto
cuadrado = [](const auto& value) noexcept { return value * value; };
const auto resultado1 = cuadrado (12.56); const
auto resultado2 = cuadrado(25u); const auto
resultado3 = cuadrado(6); const auto result4
= cuadrado(std::complejo<doble>(4.0, 2.5));
"
std::cout << "result1 es std::cout << resultado1 << "\n"; <<
"
<< "result2 es std::cout << "result3 resultado2 << "\n"; <<
"
es std::cout << "result4 es resultado3 << "\n"; <<
" resultado4 << std::endl;
devolver 0; }
El tipo de parámetro, así como el tipo de resultado, se derivan automáticamente según el tipo del parámetro concreto
(literal) cuando se compila la función (en el ejemplo anterior, double, unsigned int, int y un número complejo de tipo std::complex
<T>). Las lambdas generalizadas son extremadamente útiles en la interacción con los algoritmos de biblioteca estándar, porque
son de aplicación universal.
Funciones de orden superior
Un concepto central en la Programación Funcional son las llamadas funciones de orden superior. Son el colgante para las funciones
de primera clase. Una función de orden superior es una función que toma una o más funciones como argumentos, o pueden
devolver una función como resultado. En C++, cualquier objeto invocable, por ejemplo, una instancia del envoltorio std::function, un
puntero de función, un cierre creado a partir de una expresión lambda, un Functor hecho a mano y cualquier otra cosa que
implemente operator() se puede pasar como argumento. a una función de orden superior.
Podemos mantener esta introducción relativamente breve, porque ya hemos visto y usado varias funciones de orden superior.
Muchos de los algoritmos (consulte la sección sobre algoritmos en el Capítulo 5) en la biblioteca estándar de C++ son este tipo
de funciones. Dependiendo de su propósito, toman un Operador Unario, Predicado Unario u Operador Binario para aplicarlo
a un contenedor, o a un subrango de elementos en un contenedor.
Por supuesto, a pesar de que el encabezado <algoritmo> y también el encabezado <numérico> brindan una
selección de potentes funciones de orden superior para diferentes propósitos, también puede implementar funciones de orden
superior, respectivamente, o plantillas de funciones de orden superior por sí mismo, como en el siguiente ejemplo:
Listado 722. Un ejemplo de funciones de orden superior hechas a sí mismas
#include <funcional> #include
<iostream> #include <vector>
template<tipo de nombre CONTAINERTYPE, tipo de nombre UNARYFUNCTIONTYPE>
184
Machine Translated by Google
Capítulo 7 ■ Programación funcional
void myForEach(const CONTAINERTYPE& container, UNARYFUNCTIONTYPE unaryFunction) { for
(const auto& element : container)
{ unaryFunction(element); } }
template<typename CONTAINERTYPE, typename UNARYOPERATIONTYPE>
void myTransform(CONTAINERTYPE& container, UNARYOPERATIONTYPE unaryOperator) { for
(auto& element : container) {
elemento = OperadorUnario(elemento); }
template<typename NUMBERTYPE>
class ToSquare
{ public:
NUMBERTYPE operator()(const NUMBERTYPE& número) const noexcept {
número de retorno * número;
} };
template<typename TYPE>
void printOnStdOut(const TYPE& cosa) { std::cout
<< cosa << ", "; }
int main()
{ std::vector<int> números { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
myTransform(numbers, ToSquare<int>());
std::function<void(int)> printNumberOnStdOut = printOnStdOut<int>;
myForEach(numbers, printNumberOnStdOut);
devolver 0;
}
En este caso, nuestras dos plantillas de funciones de orden superior hechas por nosotros mismos, myTransform() y myForEach(),
solo son aplicables a contenedores completos porque, a diferencia de los algoritmos de biblioteca estándar, no tienen una interfaz de
iterador. Sin embargo, el punto crucial es que los desarrolladores pueden proporcionar funciones personalizadas de orden superior que
no existen en la biblioteca estándar de C++.
Ahora veremos tres de estas funciones de alto orden con mayor detalle, porque juegan un papel importante
papel en la programación funcional.
Mapear, filtrar y reducir Cada lenguaje
de programación funcional serio debe proporcionar al menos tres funciones útiles de orden superior: mapear,
filtrar y reducir (sinónimo: plegar). Incluso si a veces pueden tener nombres diferentes según el lenguaje de
programación, puede encontrar este triunvirato en Haskell, Erlang, Clojure, JavaScript, Scala y muchos otros
lenguajes con capacidades de programación funcional. Por lo tanto, podemos afirmar justificadamente que estas
tres funciones de orden superior forman un patrón de diseño de programación funcional muy común.
Por lo tanto, no debería sorprenderle que estas funciones de orden superior también estén contenidas en
la biblioteca estándar de C++. Y quizás tampoco te sorprenda que ya hayamos utilizado algunas de estas
funciones.
185
Machine Translated by Google
Capítulo 7 ■ Programación funcional
Echemos un vistazo consecutivo a cada una de estas funciones.
Mapa
El mapa podría ser el más fácil de entender de los tres. Con la ayuda de esta función de orden superior, podemos aplicar
una función de operador a cada elemento individual de una lista. En C++, esta función la proporciona el algoritmo de
biblioteca estándar std::transform (definido en el encabezado <algoritmo>) que ya ha visto en algunos ejemplos de
código anteriores.
Filtrar
También el filtro es fácil. Como sugiere el nombre, esta función de orden superior toma un Predicado (consulte la
sección sobre Predicados anteriormente en este capítulo) y una lista, y elimina cualquier elemento de la lista que no satisfaga
la condición del Predicado. En C++, esta función la proporciona el algoritmo de biblioteca estándar std::remove_if (definido
en el encabezado <algoritmo>) que ya ha visto en algunos ejemplos de código anteriores.
Sin embargo, aquí hay otro buen ejemplo para filtrar respectivamente std::remove_if. Si padece una enfermedad
llamada "aibofobia", que es un término humorístico para el miedo irracional a los palíndromos, debe filtrar los palíndromos
de las listas de palabras de la siguiente manera:
Listado 723. Eliminar todos los palíndromos de un vector de palabras
#include <algoritmo>
#include <iostream>
#include <cadena>
#include <vector>
class IsPalindrome { public:
bool
operator()(const std::string& word) const {
const auto middleOfWord = begin(word) + word.size() / 2; return
std::equal(begin(word), middleOfWord, rbegin(word)); } };
int main()
{ std::vector<std::string> someWords { "papá", "hola", "radar", "vector", "desanivelado", "foo", "bar", "carro de carreras", "
ROTOR", "", "C++", "aibofobia" };
algunasPalabras.erase(std::remove_if(begin(algunasPalabras), fin(algunasPalabras), EsPalindromo()),
end(algunasPalabras));
std::for_each(begin(someWords), end(someWords), [](const auto& word) {
std::cout << palabra << ","; });
devolver 0; }
La salida de este programa es:
hola,vector,foo,bar,C++,
186
Machine Translated by Google
Capítulo 7 ■ Programación funcional
Reducir (Doblar)
Reducir (sinónimos: Fold, Collapse, Aggregate) es la más poderosa de las tres funciones de orden superior y puede
ser un poco difícil de entender a primera vista. Reduce respectivamente fold es una función de orden superior para
obtener un valor de resultado único aplicando un operador binario en una lista de valores. En C++, esta función la
proporciona el algoritmo de biblioteca estándar std::accumulate (definido en el encabezado <numeric>).
Algunos dicen que std::accumulate es el algoritmo más poderoso de la biblioteca estándar.
Para comenzar con un ejemplo simple, puede obtener fácilmente la suma de todos los números enteros en un vector de esta manera:
Listado 724. Construyendo la suma de todos los valores en un vector usando std::accumulate
#include <numérico>
#include <iostream>
#include <vector>
int main()
{ std::vector<int> números { 12, 45, 102, 33, 78, 8, 100, 2017, 110 };
const int sum = std::accumulate(begin(numbers), end(numbers), 0); std::cout << "La
"
suma es: << sum << std::endl; return 0;
Aquí se usó la versión de std::accumulate que no espera un operador binario explícito en la lista de parámetros.
Usando esta versión de la función, simplemente se calcula la suma de todos los valores. Por supuesto, puede
proporcionar un operador binario propio, como en el siguiente ejemplo a través de una expresión lambda:
Listado 725. Encontrar el número más alto en un vector usando std::accumulate
int main()
{ std::vector<int> números { 12, 45, 102, 33, 78, 8, 100, 2017, 110 };
const int maxValue = std::accumulate(begin(numbers), end(numbers), 0,
[](const int value1, const int value2) { return value1
> value2 ? valor1 : valor2; }); std::cout << "El número
"
más alto es: return 0; } << maxValor << std::endl;
PLEGADO IZQUIERDO Y DERECHO
La programación funcional a menudo distingue entre dos formas de plegar una lista de elementos: un pliegue a la izquierda y un
pliegue a la derecha.
Si combinamos el primer elemento con el resultado de combinar recursivamente el resto, esto se llama un pliegue a la derecha. En
cambio, si combinamos el resultado de combinar recursivamente todos los elementos excepto el último, con el último elemento, esta
operación se denomina pliegue izquierdo.
187
Machine Translated by Google
Capítulo 7 ■ Programación funcional
Si, por ejemplo, tomamos una lista de valores que se van a doblar con un operador + a una suma, entonces los paréntesis son los
siguientes para una operación de doblado a la izquierda: ((A + B) + C) + D. En cambio, con un pliegue a la derecha, los paréntesis
quedarían así: A + (B + (C + D)). En el caso de una operación asociativa simple +, el resultado no hace ninguna diferencia si se forma
con un pliegue a la izquierda o un pliegue a la derecha.
Pero en el caso de funciones binarias no asociativas, el orden en que se combinan los elementos puede influir en el valor del resultado final.
También en C++, podemos distinguir entre un pliegue a la izquierda y un pliegue a la derecha. Si usamos std::accumulate con iteradores
normales, obtenemos un pliegue a la izquierda:
std::accumulate(begin, end, init_value, binary_operator)
En cambio, si usamos std::accumulate con un iterador inverso, obtenemos un pliegue a la derecha:
std::accumulate(rbegin, rend, init_value, binary_operator)
Expresiones de plegado en C++17
Comenzando con C++17, el lenguaje ha ganado una característica nueva e interesante llamada expresiones de pliegue. Las
expresiones de pliegue de C++17 se implementan como las denominadas plantillas variádicas (disponibles desde C++11), es decir,
como plantillas que pueden tomar un número variable de argumentos de forma segura. Este número arbitrario de argumentos se
mantiene en un llamado paquete de parámetros.
Lo que se ha agregado con C++17 es la posibilidad de reducir el paquete de parámetros directamente con la ayuda de
un operador binario, es decir, realizar un plegado. La sintaxis general de las expresiones de pliegue de C++17 es la siguiente:
( ... operator parampack ) // pliegue a la
( parampack operator ... ) ( initvalue izquierda // pliegue a la derecha
operator ... operator parampack ) // doblar a la izquierda con un valor inicial ( parampack operator ...
operator initvalue ) // doblar a la derecha con un valor inicial
Veamos un ejemplo, un pliegue a la izquierda con un valor inicial:
Listado 726. Un ejemplo de un pliegue a la izquierda
#incluir <iostream>
template<typename... PACK> int
subtractFold(int minuendo, PACK... sustraendos) { sustraendos);
retorno (minuendo } ...
int main() { const
int resultado = subtractFold(1000, 55, 12, 333, 1, 12); std::cout << "El resultado
"
es: << resultado << std::endl; return 0; }
Tenga en cuenta que en este caso no se puede utilizar un pliegue por la derecha debido a la falta de asociatividad del operador–. Doblar
Las expresiones son compatibles con 32 operadores, incluidos operadores lógicos como ==, && y ||.
188
Machine Translated by Google
Capítulo 7 ■ Programación funcional
Aquí hay otro ejemplo que prueba que un paquete de parámetros contiene al menos un número par:
Listado 727. Comprobar si un paquete de parámetros contiene un valor par
#incluir <iostream>
template <typename... TYPE> bool
containsEvenValue(const TYPE&... argumento) { return ((argumento
% 2 == 0) || ...); }
int main() { const
bool resultado1 = contieneEvenValue(10, 7, 11, 9, 33, 14); const bool result2 =
contieneEvenValue(17, 7, 11, 9, 33, 29);
estándar::cout << estándar::boolalpha;
"
std::cout << "resultado1 es << resultado1 << "\n"; std::cout <<
"
"resultado2 es << resultado2 << std::endl; devolver 0; }
La salida de este programa es:
resultado1 es
verdadero resultado2 es falso
Código limpio en programación funcional
Sin duda, el movimiento de la programación funcional no se ha detenido antes de C++, y eso es básicamente bueno.
Muchos conceptos útiles se han incorporado a nuestro lenguaje de programación algo antiguo.
Pero el código que está escrito en un estilo funcional no es automáticamente un código bueno o limpio. El aumento
La popularidad de los lenguajes de programación funcional durante los últimos años podría hacerle creer que el código
funcional es per se mejor mantenible, mejor legible, mejor comprobable y menos propenso a errores que, por ejemplo, el código
orientado a objetos. ¡Pero eso no es cierto! Por el contrario, el código funcional ingeniosamente elaborado que hace cosas no
triviales puede ser muy difícil de entender.
Tomemos, por ejemplo, una operación de plegado simple que es muy similar a uno de los ejemplos anteriores:
// Crea la suma de todos los precios de los productos
const Money sum = std::accumulate(begin(productPrices), end(productPrices), 0.0);
Si leyera esto sin el comentario explicativo del código fuente... ¿esta intención revela el código?
Recuerde lo que hemos aprendido en el Capítulo 4 Acerca de los comentarios: siempre que sienta la necesidad de escribir un
comentario sobre el código fuente, primero debe pensar en cómo mejorar el código para que el comentario se vuelva superfluo.
Entonces, lo que realmente queremos leer o escribir respectivamente es algo como esto:
const Dinero totalPrice = buildSumOfAllPrices(productPrices);
189
Machine Translated by Google
Capítulo 7 ■ Programación funcional
Entonces, primero hagamos una declaración fundamental:
¡Los principios de un buen diseño de software aún se aplican, independientemente del estilo de programación que utilice!
¿Prefiere el estilo de programación funcional sobre OO? Está bien, pero estoy seguro de que estarás de acuerdo en que KISS, DRY y
YAGNI (ver Capítulo 3) también son muy buenos principios en la programación funcional. ¿Cree que puede ignorar el principio de responsabilidad
única (consulte el capítulo 6) en la programación funcional? ¡Olvídalo! Si una función hace más de una cosa, conducirá a problemas similares a
los de la orientación a objetos. Y creo que no hace falta mencionar ese buen y expresivo naming (ver Capítulo 4 sobre buenos nombres)
también es enormemente importante para la comprensibilidad y mantenibilidad del código en un entorno funcional.
Siempre tenga en cuenta que los desarrolladores pasan mucho más tiempo leyendo código que escribiendo código.
Por lo tanto, podemos concluir que la mayoría de los principios de diseño utilizados por los diseñadores y programadores de software
orientado a objetos también pueden ser utilizados por programadores funcionales.
Personalmente, prefiero una combinación equilibrada de ambos estilos de programación. Hay muchos desafíos de diseño que se pueden
resolver perfectamente utilizando paradigmas orientados a objetos. El polimorfismo es un gran beneficio de OO. Puedo aprovechar el Principio
de Inversión de Dependencias (consulte la sección homónima en el Capítulo 6), que me permite invertir las dependencias del código fuente y del
tiempo de ejecución.
En cambio, los cálculos matemáticos complejos se pueden resolver mejor utilizando una programación funcional.
estilo. Y si se deben cumplir altos y ambiciosos requisitos de rendimiento y eficiencia, lo que inevitablemente requerirá una paralelización
de ciertas tareas, la programación funcional puede jugar su carta de triunfo.
Independientemente de si prefiere escribir software de forma orientada a objetos, en un estilo funcional o
en una mezcla apropiada de ambos, siempre debe recordar la siguiente cita:
Codifica siempre como si el tipo que acabará manteniendo tu código fuera un psicópata violento que sabe dónde vives.
—John F. Woods, 1991, en una publicación en el grupo de noticias comp.lang.c++
190
Machine Translated by Google
CAPÍTULO 8
Desarrollo basado en pruebas
El Proyecto Mercury se ejecutó con iteraciones muy cortas (medio día) que estaban limitadas en el
tiempo. El equipo de desarrollo realizó una revisión técnica de todos los cambios y, curiosamente,
aplicó la práctica de programación extrema de desarrollo de prueba primero, planificación y redacción
de pruebas antes de cada microincremento.
—Craig Larman y Victor R. Basili, Desarrollo iterativo e incremental: una breve historia.
IEEE, 2003
En la sección “Pruebas unitarias” (ver Capítulo 2) hemos aprendido que un buen conjunto de pruebas pequeñas y rápidas
puede asegurar que nuestro código funcione correctamente. Hasta ahora, todo bien. Pero, ¿qué tiene de especial el desarrollo
basado en pruebas (TDD) y por qué justifica un capítulo adicional en este libro?
Especialmente en los últimos años, la disciplina del desarrollo basado en pruebas ha ganado popularidad. TDD se ha
convertido en un ingrediente importante de la caja de herramientas de los artesanos de software. Eso es un poco sorprendente,
porque la idea básica de los enfoques Test First no es nada nuevo. El Proyecto Mercurio, que se menciona en la cita anterior,
fue el primer programa de vuelo espacial tripulado de los Estados Unidos y se llevó a cabo bajo la dirección de la NASA desde
1958 hasta 1963. Aunque lo que se practicaba hace unos 50 años como un enfoque de Prueba Primero ciertamente es no es
exactamente el tipo de TDD como lo conocemos hoy, podemos decir que la idea básica estuvo presente bastante temprano en
el desarrollo de software profesional.
Pero luego parece que este enfoque ha caído en el olvido durante décadas. En innumerables proyectos con miles de
millones de líneas de código, las pruebas se pospusieron al final del proceso de desarrollo. Las consecuencias a veces
devastadoras de este desplazamiento a la derecha de las pruebas importantes en los cronogramas del proyecto son conocidas:
si el tiempo se está acortando en el proyecto, lo primero que suele abandonar el equipo de desarrollo son las pruebas importantes.
Con la creciente popularidad de las prácticas ágiles en el desarrollo de software y la aparición de un nuevo método
llamado Programación eXtreme (XP) a principios de la década de 2000, se redescubrió el desarrollo basado en pruebas.
Kent Beck escribió su famoso libro TestDriven Development: By Example [Beck02], y los enfoques Test First como TDD
experimentaron un renacimiento y se convirtieron en herramientas cada vez más importantes en la caja de herramientas de los
creadores de software.
En este capítulo, no solo explicaré que, aunque el término "Prueba" se incluye en el desarrollo basado en
pruebas, no se trata principalmente de garantía de calidad. TDD ofrece muchos más beneficios que una simple validación de la
corrección del código. Más bien, explicaré las diferencias de TDD con lo que a veces se denomina Prueba unitaria simple
(POUT), seguido de la discusión detallada del flujo de trabajo de TDD, respaldado por un ejemplo práctico detallado que muestra
cómo hacerlo en C++.
© Stephan Roth 2017 191
S. Roth, C++ limpio, DOI 10.1007/9781484227930_8
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
Los inconvenientes de las pruebas unitarias simples (POUT)
Sin duda, como hemos visto en el Capítulo 2 un conjunto de pruebas unitarias es básicamente una situación mucho mejor que
no tener ninguna prueba. Pero en muchos proyectos, las pruebas unitarias se escriben de alguna manera en paralelo a la
implementación del código a probar, a veces incluso completamente después de la finalización del módulo a desarrollar. El
diagrama de actividad representado en la Figura 81 visualiza este proceso.
Figura 81. La secuencia típica en desarrollo con pruebas unitarias tradicionales
192
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
Este enfoque generalizado también se conoce ocasionalmente como prueba unitaria simple (POUT).
Básicamente, POUT significa que el software se desarrollará "Codificando primero" y no probando primero; por ejemplo, con este
enfoque, las pruebas unitarias se escriben siempre después de que se haya escrito el código que se va a probar. Y para muchos
desarrolladores, este orden parece ser la única secuencia lógica. Argumentan que para probar algo, obviamente la cosa a probar
necesita haber sido construida previamente. Y en algunas organizaciones de desarrollo, este enfoque incluso se denomina
erróneamente como "desarrollo basado en pruebas", lo cual es totalmente incorrecto.
Como dije, las pruebas unitarias simples son mejores que ninguna prueba unitaria. Sin embargo, este enfoque tiene algunas
desventajas:
•No hay obligación de escribir las pruebas unitarias después. Una vez que una función funciona
(... o parece funcionar), hay poca motivación para actualizar el código con pruebas unitarias. No es
divertido, y la tentación de pasar a lo siguiente es demasiado grande para muchos desarrolladores.
•El código resultante puede ser difícil de probar. A menudo, no es tan fácil adaptar el código existente con
pruebas unitarias, porque se le dio poca importancia a la capacidad de prueba del código que se originó.
Esto permitió que surgiera un código fuertemente acoplado.
• No es fácil alcanzar una cobertura de prueba bastante alta con pruebas unitarias adaptadas. La escritura
de pruebas unitarias después del código tiene la tendencia de que se escapen algunos problemas
o errores.
Desarrollo basado en pruebas como factor de cambio
TestDriven Development (TDD) cambia por completo el desarrollo tradicional. Para los desarrolladores que aún no se han ocupado
de TDD, este enfoque representa un cambio de paradigma.
Como un enfoque llamado Test First y en contraste con POUT, TDD no permite que ninguna producción
el código se escribe antes de que se haya escrito la prueba asociada. En otras palabras: TDD significa que escribimos la prueba
para una nueva característica o función siempre antes de escribir el código de producción correspondiente. Esto se hace estrictamente
paso a paso: después de cada prueba implementada, se escribe solo el código de producción suficiente para que la prueba pase. Y
se hace siempre y cuando aún existan requerimientos no realizados para el módulo a desarrollar.
A primera vista, parece paradójico, y también un poco absurdo, escribir una prueba unitaria para algo.
que aún no existe. ¿Cómo puede funcionar esto?
No te preocupes, funciona. Después de haber discutido el proceso detrás de TDD en detalle en la siguiente sección, todos
ojalá se eliminen las dudas.
El flujo de trabajo de TDD
Al realizar el desarrollo basado en pruebas, los pasos que se muestran en la figura 82 se ejecutan repetidamente hasta que se
cumplen todos los requisitos conocidos para que la unidad se desarrolle.
193
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
Figura 82. El flujo de trabajo detallado de TDD como un diagrama de actividad
En primer lugar, es notable que la primera acción después del nodo inicial que está etiquetado con "INICIO" es que el
desarrollador debe pensar en lo que quiere hacer. Y vemos un llamado Pin de entrada en la parte superior de esta acción que
acepta "requisitos". ¿A qué requisitos se refiere aquí?
Bueno, ante todo hay requisitos que debe cumplir un sistema de software. esto se aplica
tanto a los requisitos de los stakeholders del negocio en el nivel superior con respecto a todo el sistema, como a los requisitos
que residen en los niveles de abstracción inferiores, es decir, requisitos para componentes, clases y funciones, que se derivaron
de los requisitos de los stakeholders del negocio. Con TDD y su enfoque Test First, los requisitos se establecen firmemente
mediante pruebas unitarias, de hecho, antes de que se escriba el código de producción. En nuestro caso de un enfoque de
Prueba Primero para el desarrollo de unidades, es decir, en el nivel más bajo de la Pirámide de Prueba (ver Figura 21 en el Capítulo
2), por supuesto, los requisitos en el nivel más bajo se refieren aquí.
A continuación, se escribirá una prueba, mediante la cual se diseñará la interfaz pública (API). Esto puede resultar
sorprendente, porque en la primera ejecución de este ciclo todavía no hemos escrito ningún código de producción. Entonces,
¿qué interfaz se puede diseñar aquí si tenemos un papel en blanco?
Bueno, la respuesta simple es esta: ese "papel en blanco" es exactamente lo que queremos completar ahora, pero desde
una perspectiva diferente a la habitual. Tomamos ahora la perspectiva de un futuro cliente externo de la unidad a desarrollar.
Usamos una pequeña prueba para definir cómo queremos usar la unidad a desarrollar. En otras palabras, este es el paso que
debería conducir a unidades de software bien comprobables y, por lo tanto, también bien utilizables.
194
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
Después de haber escrito las líneas apropiadas en la prueba, también debemos, por supuesto, satisfacer al compilador y proporcionar la
interfaz solicitada por la prueba.
Luego, inmediatamente, la siguiente sorpresa: la prueba unitaria recién escrita debe fallar (inicialmente). ¿Por qué?
Respuesta simple: tenemos que asegurarnos de que la prueba pueda fallar en absoluto. Incluso una prueba unitaria
puede implementarse de forma incorrecta y, por ejemplo, pasar siempre, sin importar lo que estemos haciendo en el código de producción.
Así que tenemos que asegurarnos de que la prueba recién escrita esté armada.
Ahora estamos llegando al clímax de este pequeño flujo de trabajo: escribimos solo el código de producción suficiente, ¡y ni una sola
línea más! – ¡que se supere la nueva prueba unitaria (… y, en su caso, todas las pruebas existentes anteriormente)! Y es muy importante
ser disciplinado en este punto y no escribir más código del requerido (recuerde el principio KISS del Capítulo 3). Depende del desarrollador
decidir qué es apropiado aquí en cada situación. A veces, una sola línea de código, o incluso una sola declaración, es suficiente; en otros
casos, debe llamar a una función de biblioteca. Si esto último es el caso, ha llegado el momento de pensar en cómo integrar y usar esta
biblioteca, y especialmente cómo poder reemplazarla con un Test Double (ver la sección sobre Test Doubles (Mock Objects) en el Capítulo 2 ) .
Si ahora ejecutamos las pruebas unitarias y lo hemos hecho todo bien, las pruebas pasarán.
Ahora hemos llegado a un punto notable en el proceso. Si las pruebas pasan ahora, siempre tendremos una cobertura de prueba
unitaria del 100 % en este paso. ¡Siempre! No solo 100 % en el sentido de una métrica de cobertura de prueba técnica, como cobertura de
funciones, cobertura de sucursales o cobertura de extractos. ¡No, mucho más importante es que tenemos una cobertura de prueba unitaria
del 100% con respecto a los requisitos que ya se implementaron en este punto! Y sí, en este punto posiblemente aún queden algunos o
muchos requisitos no implementados para la unidad a desarrollar.
Esto está bien, porque pasaremos por el ciclo TDD una y otra vez hasta que se cumplan todos los requisitos. Pero para un subconjunto de
requisitos que ya se cumplen en este punto, tenemos una cobertura de prueba unitaria del 100 %.
¡Este hecho nos da un poder tremendo! Con esta red de seguridad sin interrupciones de pruebas unitarias, ahora podemos llevar
a cabo refactorizaciones sin miedo. Los olores de código (por ejemplo, código duplicado) o los problemas de diseño se pueden solucionar
ahora. No debemos tener miedo de romper la funcionalidad, porque las pruebas unitarias ejecutadas regularmente nos darán una respuesta
inmediata al respecto. Y lo agradable es esto: si una o más pruebas fallan durante la fase de refactorización, el cambio de código que condujo
a esto fue muy pequeño.
Después de que se haya completado la refactorización, ahora podemos implementar otro requisito que aún no se ha
cumplido al continuar el ciclo TDD. Si no hay más requisitos, estamos listos.
La Figura 82 muestra el ciclo TDD con muchos detalles. Resumido en sus tres pasos principales esenciales como
representado en la Figura 83, el ciclo TDD a menudo se denomina "ROJO VERDE REFACTOR".
Figura 83. El flujo de trabajo central de TDD
195
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
• ROJO: Escribimos una prueba unitaria fallida.
• VERDE: escribimos suficiente código de producción para que la nueva prueba y todas las pruebas escritas
anteriormente pasen.
• REFACTOR: Se eliminan la duplicación de código y otros olores de código, tanto del
código de producción, así como de las pruebas unitarias.
Los términos ROJO y VERDE se refieren a las integraciones típicas de Unit Test Framework que están disponibles para un
variedad de IDE, donde las pruebas que pasaron se muestran en verde y las pruebas que fallaron se muestran en rojo.
LAS TRES REGLAS DE TDD DEL TÍO BOB
En su gran libro The Clean Coder [Martin11], Robert C. Martin, también conocido como Uncle Bob, recomienda que sigamos las tres reglas de TDD:
• No se le permite escribir ningún código de producción hasta que primero haya escrito un error
prueba de unidad.
• No se le permite escribir más de una prueba unitaria de lo suficiente para fallar, y no
la compilación está fallando.
• No está permitido escribir más código de producción del suficiente para pasar el
actualmente fallando la prueba unitaria.
Martin argumenta que el cumplimiento estricto de estas tres reglas obliga al desarrollador a trabajar en ciclos muy cortos. Como resultado, el
desarrollador nunca estará a más de unos segundos o solo unos minutos de una situación cómoda en la que el código era correcto y todo
funcionaba.
Suficiente de teoría, ahora explicaré un desarrollo completo de una pieza de software usando TDD con un pequeño ejemplo.
TDD por ejemplo: el código de números romanos Kata
La idea básica de lo que hoy en día se llama Code Kata fue descrita por primera vez por Dave Thomas, uno de los dos autores
del notable libro The Pragmatic Programmer [Hunt99]. Dave era de la opinión de que los desarrolladores deberían practicar
repetidamente en una base de código pequeña, no relacionada con el trabajo, para que puedan dominar su profesión como un
músico. Dijo que los desarrolladores deben aprender y mejorar constantemente, y para ello necesitan sesiones de práctica para
aplicar la teoría una y otra vez, utilizando la retroalimentación para mejorar cada vez.
Un código kata es un pequeño ejercicio de programación, que sirve exactamente para este propósito. El término kata se
hereda de las artes marciales. En los deportes de combate del Lejano Oriente, usan katas para practicar sus movimientos básicos
una y otra vez. El objetivo es llevar el curso del movimiento a la perfección.
Este tipo de práctica se transfirió al desarrollo de software. Para mejorar sus habilidades de programación, los desarrolladores deben
practicar su oficio con la ayuda de pequeños ejercicios. Katas se convirtió en una faceta importante del movimiento Software Craftsmanship.
Pueden abordar diferentes habilidades que debe tener un desarrollador, por ejemplo, conocer los métodos abreviados de teclado del IDE,
aprender un nuevo lenguaje de programación, centrarse en ciertos principios de diseño o practicar TDD. En Internet existen varios catálogos
con katas adecuados para diferentes propósitos, por ejemplo, la colección de Dave Thomas en http://codekata.com.
Para nuestros primeros pasos con TDD usamos un código kata con un énfasis algorítmico: el conocido código kata de números
romanos.
196
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
TDD KATA: CONVERTIR NÚMEROS ÁRABES A NÚMEROS ROMANOS
Los romanos escribieron números usando letras. Por ejemplo, escribieron “V” para el número arábigo 5.
Su tarea es desarrollar una pieza de código utilizando el enfoque de desarrollo basado en pruebas (TDD) que traduce los números
arábigos entre 1 y 3999 en su respectiva representación romana.
Los números en el sistema romano se representan mediante combinaciones de letras del alfabeto latino.
Los números romanos, tal como se usan hoy en día, se basan en siete caracteres:
1 yo
5 V
10 X
50 L
100 C
500 D
1000 M
Los números se forman combinando caracteres y sumando los valores. Por ejemplo, el número arábigo 12 está representado por
“XII” (10 + 1 + 1). Y el número 2017 es “MMXVII” en su equivalente romano.
Las excepciones son 4, 9, 40, 90, 400 y 900. Para evitar eso, se deben repetir cuatro caracteres iguales en sucesión, el
número 4, por ejemplo, no se representa con “IIII”, sino con “IV”. Esto se conoce como notación sustractiva, es decir, el
número que está representado por el carácter anterior I se resta de V (5 1 = 4). Otro ejemplo es "CM", que es 900 (1000 100).
Por cierto: los romanos no tenían equivalente para el 0, además no conocían los números negativos.
Preparativos
Antes de que podamos escribir nuestra primera prueba, debemos hacer algunos preparativos y configurar el entorno
de prueba.
Como marco de prueba de unidad para este kata, uso Google Test (https://github.com/google/googletest), un marco de
prueba de unidad C++ independiente de la plataforma publicado bajo la nueva licencia BSD. Por supuesto, también se puede usar
cualquier otro marco de pruebas unitarias de C++ para este kata.
También se recomienda encarecidamente utilizar un sistema de control de versiones. Aparte de algunas excepciones,
realizaremos un compromiso con el sistema de control de versiones después de cada transferencia del ciclo TDD. Esto tiene la gran
ventaja de que somos capaces de retroceder y hacer retroceder decisiones posiblemente equivocadas.
Además, tenemos que pensar en cómo se van a organizar los archivos de código fuente. Mi sugerencia
ya que este kata debe comenzar inicialmente con un solo archivo, el archivo que ocupará todas las pruebas
unitarias futuras: ArabicToRomanNumeralsConverterTestCase.cpp. Dado que TDD nos guía de manera incremental a
través del proceso de formación de una unidad de software, es posible decidir más tarde si se requieren archivos adicionales.
Para una verificación de función fundamental, escribimos una función principal que inicializa Google Test y ejecuta todas
las pruebas, y escribimos una prueba unitaria simple (llamada Preparaciones Completadas) que siempre falla intencionalmente,
como se muestra en el siguiente ejemplo de código.
197
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
Listado 81. El contenido inicial de ArabicToRomanNumeralsConverterTestCase.cpp
#incluir <gtest/gtest.h>
int main(int argc, char** argv) {
pruebas::InitGoogleTest(&argc, argv); devolver
EJECUTAR_TODAS_PRUEBAS(); }
PRUEBA (Caso de prueba del convertidor de números árabes a números romanos, preparaciones completadas) {
GTEST_FAIL(); }
Después de compilar y vincular, ejecutamos el archivo binario resultante para ejecutar la prueba. La salida de nuestro
pequeño programa en la salida estándar (stdout) debería ser la siguiente:
Listado 82. El resultado de la prueba de funcionamiento
[==========] Ejecutando 1 prueba de 1 caso de prueba.
[] Configuración del entorno de prueba global. []
1 prueba de ArabicToRomanNumeralsConverterTestCase [EJECUTAR]
ArabicToRomanNumeralsConverterTestCase.PreparationsCompleted ../
ArabicToRomanNumeralsConverterTestCase.cpp:9: Error fallido [FALLO]
ArabicToRomanNumeralsConverterTestCase.PreparationsCompleted (0 ms) [ ] 1 prueba de
ArabicToRomanNumeralsConverterTestCase (2 ms en total)
[] Desmontaje del entorno de prueba global [==========]
Se ejecutó 1 prueba de 1 caso de prueba. (16 ms en total)
[PASADO] 0 pruebas.
[FALLIDO] 1 prueba, enumerada a continuación:
[FALLIDO] ArabicToRomanNumeralsConverterTestCase.PreparationsCompleted
1 PRUEBA FALLIDA
Como era de esperar, la prueba falla. La salida en stdout es bastante útil para imaginar qué salió mal. Especifica
el nombre de las pruebas fallidas, el nombre del archivo, el número de línea y la razón por la que falló la prueba. En este caso,
es una falla impuesta por una macro especial de prueba de Google.
Si ahora intercambiamos la macro GTEST_FAIL() con la macro GTEST_SUCCEED() dentro de la prueba, después de un
recompilación la prueba debe pasar:
Listado 83. El resultado de la ejecución de prueba exitosa
[==========] Ejecutando 1 prueba de 1 caso de prueba.
[] Configuración del entorno de prueba global. []
1 prueba de ArabicToRomanNumeralsConverterTestCase [ EJECUTAR [ [] 1
prueba ] ArabicToRomanNumeralsConverterTestCase.PreparationsCompleted OK ]
ArabicToRomanNumeralsConverterTestCase.PreparationsCompleted (0 ms)
de ArabicToRomanNumeralsConverterTestCase (0 ms en total)
[] Desmontaje del entorno de prueba global [==========]
Se ejecutó 1 prueba de 1 caso de prueba. (4 ms en total)
[PASADO] 1 prueba.
198
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
Eso es bueno, porque ahora sabemos que todo está preparado correctamente y podemos comenzar con nuestro kata.
la primera prueba
El primer paso es decidir qué primer pequeño requisito queremos implementar. Luego escribiremos una prueba fallida para
ello. Para nuestro ejemplo, hemos decidido comenzar convirtiendo un solo número arábigo en un número romano:
queremos convertir el número arábigo 1 en una "I".
Por lo tanto, tomamos la prueba ficticia ya existente y la convertimos en una prueba unitaria real, que puede probar el
cumplimiento de este pequeño requisito. Por lo tanto, también debemos considerar cómo debería ser la interfaz para la función
de conversión.
Listado 84. La primera prueba (se omitieron partes irrelevantes del código fuente)
PRUEBA (Caso de prueba del convertidor de números árabes a números romanos, 1_esConvertedTo_I) {
ASSERT_EQ("I", convertNumberArabicToRomanNumeral(1)); }
Como puede ver, nos hemos decidido por una función simple que toma un número arábigo como parámetro y tiene una
cadena como valor de retorno.
Pero el código no se puede compilar sin errores de compilación, porque la función
convertArabicNumberToRomanNumeral() aún no existe. Recordemos la segunda de las tres reglas de TDD del tío Bob: "No
se le permite escribir más de una prueba unitaria de lo suficiente para fallar, y no compilar es fallar".
Eso significa que ahora tenemos que dejar de escribir código de prueba para escribir suficiente código de producción
para que pueda compilarse sin errores. Así que vamos a crear la función de conversión ahora, e incluso escribiremos esa
función directamente en el archivo de código fuente, que también contiene la prueba. Por supuesto, somos conscientes de
que no puede quedar así.
Listado 85. El stub de la función satisface al compilador
#include <gtest/gtest.h> #include
<cadena>
int main(int argc, char** argv) {
pruebas::InitGoogleTest(&argc, argv); devolver
EJECUTAR_TODAS_PRUEBAS(); }
std::string convertArabicNumberToRomanNumeral(const unsigned int arabicNumber) {
devolver "";
}
PRUEBA (Caso de prueba del convertidor de números árabes a números romanos, 1_esConvertedTo_I) {
ASSERT_EQ("I", convertNumberArabicToRomanNumeral(1)); }
Ahora el código se puede compilar de nuevo sin errores. Y por el momento la función devuelve solo una cadena vacía.
Además, ahora tenemos nuestra primera prueba ejecutable, que debe fallar (ROJO), porque la prueba espera una "I", pero
la función devuelve una cadena vacía:
199
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
Listado 86. El resultado de Google Test después de ejecutar la prueba unitaria que falló deliberadamente (RED)
[==========] Ejecutando 1 prueba de 1 caso de prueba.
[] Configuración del entorno de prueba global. []
1 prueba de ArabicToRomanNumeralsConverterTestCase [ EJECUTAR ../
] ArabicToRomanNumeralsConverterTestCase.1_isConvertedTo_I
ArabicToRomanNumeralsConverterTestCase.cpp:14: Valor de error de:
convertArabicNumberToRomanNumeral(1)
""
Real:
Esperado: "Yo"
[FALLIDO] ArabicToRomanNumeralsConverterTestCase.1_isConvertedTo_I (0 ms) [] 1 prueba de
ArabicToRomanNumeralsConverterTestCase (0 ms en total)
[] Desmontaje del entorno de prueba global [==========]
Se ejecutó 1 prueba de 1 caso de prueba. (6 ms en total)
[PASADO] 0 pruebas.
[FALLIDO] 1 prueba, enumerada a continuación:
[FALLIDO] ArabicToRomanNumeralsConverterTestCase.1_isConvertedTo_I
1 PRUEBA FALLIDA
Bien, eso es lo que esperábamos.
■ Nota Según la versión de Google Test utilizada, el resultado del marco de prueba puede ser ligeramente
diferente al que se muestra aquí.
Ahora necesitamos cambiar la implementación de la función convertArabicNumberToRomanNumeral() para que pase la
prueba. La regla es esta: haz lo más simple que pueda funcionar. ¿Y qué podría ser más fácil que devolver una "I" de la función?
Listado 87. La función modificada (se omitieron partes irrelevantes del código fuente)
std::string convertArabicNumberToRomanNumeral(const unsigned int arabicNumber) {
devuelve "yo"; }
Probablemente dirás: “¡Espera un minuto! Eso no es un algoritmo para convertir números arábigos en su
equivalentes romanos. ¡Eso es hacer trampa!"
Por supuesto, el algoritmo aún no está listo. Tienes que cambiar de opinión. Las reglas de TDD establecen que debemos
escribir el código más simple que pase la prueba actual. Es un proceso incremental, y estamos apenas al principio.
[==========] Ejecutando 1 prueba de 1 caso de prueba.
[] Configuración del entorno de prueba global. []
1 prueba de ArabicToRomanNumeralsConverterTestCase [ EJECUTAR ]
ArabicToRomanNumeralsConverterTestCase.1_isConvertedTo_I [ OK ]
ArabicToRomanNumeralsConverterTestCase.1_isConvertedTo_I (0 ms) [] 1 prueba de
ArabicToRomanNumeralsConverterTestCase (0 ms total)
[] Desmontaje del entorno de prueba global [==========]
Se ejecutó 1 prueba de 1 caso de prueba. (1 ms en total)
[PASADO] 1 prueba.
200
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
¡Excelente! La prueba pasó (VERDE) y podemos ir al paso de refactorización. En realidad, todavía no es necesario refactorizar algo, por
lo que podemos continuar con la siguiente ejecución del ciclo TDD. Pero primero tenemos que confirmar nuestros cambios en el repositorio del
código fuente.
La segunda prueba
Para nuestra segunda prueba unitaria, tomaremos un 2, que debe convertirse en "II".
PRUEBA (Caso de prueba del convertidor de números árabes a números romanos, 2_isConvertedTo_II) {
ASSERT_EQ("II", convertNumberArabicToRomanNumeral(2)); }
Como era de esperar, esta prueba debe fallar (RED), porque nuestra función convertArabicNumberToRomanNumeral()
devuelve siempre un "I". Después de haber verificado que la prueba falla, complementamos la implementación para que la prueba pueda pasar.
Una vez más, hacemos lo más simple que podría funcionar.
Listado 88. Agregamos algo de código para pasar la nueva prueba
std::string convertArabicNumberToRomanNumeral(const unsigned int arabicNumber) { if (arabicNumber == 2) { return "II"; }
devuelve "yo"; }
Ambas pruebas pasan (VERDE).
¿Deberíamos refactorizar algo ahora? Tal vez todavía no, pero es posible que tenga la sospecha de que lo haremos.
necesita una refactorización pronto. De momento seguimos con nuestra tercera prueba…
La tercera prueba y el orden posterior
Como era de esperar, nuestra tercera prueba evaluará la conversión del número 3:
PRUEBA (Caso de prueba del convertidor de números árabes a números romanos, 3_isConvertedTo_III) {
ASSERT_EQ("III", convertNumberArabicToRomanNumeral(3)); }
Por supuesto, esta prueba fallará (RED). El código para pasar esta prueba, y todas las pruebas anteriores (VERDE), tiene el siguiente
aspecto:
std::string convertArabicNumberToRomanNumeral(const unsigned int arabicNumber) { if (arabicNumber == 3) { return "III"; } if
(número arábigo == 2) { return "II"; }
devuelve "yo"; }
201
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
El mal presentimiento sobre el diseño emergente, que ya tuvimos en la segunda prueba, no fue infundado. Al menos ahora
deberíamos estar completamente insatisfechos con la obvia duplicación de código. Es bastante evidente que no podemos continuar por
este camino. Una secuencia interminable de sentencias if no puede ser una solución, porque terminaremos con un diseño horrible.
Es hora de refactorizar, y podemos hacerlo sin miedo, porque la cobertura de prueba unitaria del 100 % crea una cómoda
sensación de seguridad.
Si echamos un vistazo al código dentro de la función convertArabicNumberToRomanNumeral(), se puede reconocer un
patrón. El número arábigo es como un contador de los caracteres I de su equivalente romano. En otras palabras: siempre que el
número a convertir se pueda disminuir en 1 antes de que llegue a 0, se agrega una "I" a la cadena de números romanos.
Bueno, esto se puede hacer de una manera elegante usando un ciclo while y una concatenación de cadenas, como esta:
Listado 89. La función de conversión después de la refactorización
std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
std::string númeroromano; while
(número arábigo >= 1) { númeroromano
+= "I"; número arábigo; }
return númeroromano; }
Eso se ve bastante bien. Eliminamos la duplicación de código y encontramos una solución compacta. También tuvimos que eliminar
la declaración const del parámetro arabicNumber porque tenemos que manipular el número arábigo en la función. Y aún se superan las
tres pruebas unitarias existentes.
Podemos pasar a la siguiente prueba. Por supuesto, también puede continuar con el 5, pero me decidí por "10isX".
Tengo la esperanza de que el grupo de diez revele un patrón similar al 1, 2 y 3. El número arábigo 5, por supuesto, será tratado más
adelante.
Listado 810. La prueba de la cuarta unidad.
PRUEBA (Caso de prueba del convertidor de números árabes a números romanos, 10_isConvertedTo_X) {
ASSERT_EQ("X", convertNumberArabicToRomanNumeral(10)); }
Bueno, no debería sorprender a nadie que esta prueba falle (RED). Esto es lo que Google Test escribe en stdout sobre esta
nueva prueba:
] ArabicToRomanNumeralsConverterTestCase.10_isConvertedTo_X
[ EJECUTAR ../ArabicToRomanNumeralsConverterTestCase.cpp:31: Valor de error
de: convertArabicNumberToRomanNumeral(10)
Real: "IIIIIIIIII"
Esperado: "X"
[FALLIDO] ArabicToRomanNumeralsConverterTestCase.10_isConvertedTo_X (0 ms)
La prueba falla porque 10 no es "IIIIIIIIII", sino "X". Sin embargo, si vemos la salida de Google Test, podríamos hacernos una
idea. ¿Quizás el mismo enfoque que hemos usado para los números arábigos 1, 2 y 3 podría usarse también para 10, 20 y 30?
¡DETENER! Bueno, eso es imaginable, pero aún no deberíamos crear algo para el futuro sin pruebas unitarias.
que nos llevan a tal solución. Ya no trabajaríamos basados en pruebas si implementamos el código de producción para 20 y 30 de
una sola vez con el código para 10. Entonces, hacemos nuevamente lo más simple que podría funcionar.
202
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
Listado 811. La función de conversión ahora también puede convertir 10
std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
if (número arábigo == 10) { return
"X"; } más
{ std::string
romanNumeral; while (número
arábigo >= 1) { númeroromano += "I";
número arábigo; } return
númeroromano; } }
OK, la prueba y todas las pruebas anteriores han sido aprobadas (VERDE). Podemos agregar paso a paso una prueba
para el número arábigo 20 y luego para el 30. Después de ejecutar el ciclo TDD para ambos casos, nuestra función de conversión se ve
de la siguiente manera:
Listado 812. El resultado durante el sexto ciclo TDD antes de la refactorización
std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
if (número arábigo == 10) { return
"X"; } else if
(número arábigo == 20) { return "XX"; } else if
(número arábigo
== 30) { return "XXX"; } más { std::string
romanNumeral;
while
(número arábigo >= 1) { númeroromano
+= "I"; número arábigo;
} return númeroromano;
}
}
Al menos ahora se requiere urgentemente una refactorización. El código surgido tiene algunos malos olores, como algunas
redundancias y una alta complejidad ciclomática. Sin embargo, también se ha confirmado nuestra sospecha de que el procesamiento
de los números 10, 20 y 30 sigue un patrón similar al procesamiento de los números 1, 2 y 3.
Vamos a intentarlo:
Listado 813. Después de la refactorización, todas las decisiones ifelse desaparecen
std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
std::string númeroromano; while
(número arábigo >= 10) { númeroromano
+= "X"; numero arabe = 10; }
while (número arábigo >= 1)
{
203
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
númeroromano += "yo"; número
arábigo;
}
return númeroromano;
}
Excelente, todas las pruebas pasaron de inmediato! Parece que vamos por el buen camino.
Sin embargo, debemos tener en mente el objetivo del paso de refactorización en el ciclo TDD. Más arriba en esta sección se puede leer
lo siguiente: Se elimina la duplicación de código y otros olores de código, tanto del código de producción como de las pruebas unitarias.
Deberíamos echar un vistazo crítico a nuestro código de prueba. Actualmente se ve así:
Listado 814. Las pruebas unitarias surgidas tienen muchas duplicaciones de código.
PRUEBA (Caso de prueba del convertidor de números árabes a números romanos, 1_esConvertedTo_I) {
ASSERT_EQ("I", convertNumberArabicToRomanNumeral(1)); }
PRUEBA (Caso de prueba del convertidor de números árabes a números romanos, 2_isConvertedTo_II) {
ASSERT_EQ("II", convertNumberArabicToRomanNumeral(2)); }
PRUEBA (Caso de prueba del convertidor de números árabes a números romanos, 3_isConvertedTo_III) {
ASSERT_EQ("III", convertNumberArabicToRomanNumeral(3)); }
PRUEBA (Caso de prueba del convertidor de números árabes a números romanos, 10_isConvertedTo_X) {
ASSERT_EQ("X", convertNumberArabicToRomanNumeral(10)); }
PRUEBA (caso de prueba del convertidor de números árabes a números romanos, 20_isConvertedTo_XX) {
ASSERT_EQ("XX", convertNumberArabicToRomanNumeral(20)); }
PRUEBA (caso de prueba del convertidor de números árabes a números romanos, 30_isConvertedTo_XXX) {
ASSERT_EQ("XXX", convertNumberArabicToRomanNumeral(30)); }
Recuerde lo que escribí sobre la calidad del código de prueba en el Capítulo 2: la calidad del código de prueba debe ser
tan alto como la calidad del código de producción. En otras palabras, nuestras pruebas deben refactorizarse porque contienen muchas
duplicaciones y deben diseñarse de manera más elegante. Además, queremos aumentar su legibilidad y mantenibilidad. ¿Pero que podemos
hacer?
Eche un vistazo a las seis pruebas anteriores. La verificación en las pruebas es siempre la misma y se podría leer más
generalmente como: "Afirmar que el número arábigo <x> se convierte al número romano <cadena>".
Una solución podría ser proporcionar una aserción dedicada (también conocida como aserción personalizada o aserción personalizada) .
matcher) para ese propósito, que se puede leer de la misma manera que la oración anterior:
afirmar que(x).isConvertedToRomanNumeral("cadena");
204
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
Pruebas más sofisticadas con una afirmación personalizada
Para implementar nuestra aserción personalizada, primero escribimos una prueba unitaria que falla, pero diferente a las pruebas unitarias
que hemos escrito antes:
PRUEBA (Caso de prueba del convertidor de números árabes a números romanos, 33_isConvertedTo_XXXIII)
{afirmar que (33).isConvertedToRomanNumeral ("XXXII"); }
La probabilidad es muy alta de que la conversión de 33 ya funcione. Por lo tanto, forzamos la prueba a fallar (RED) al especificar
un resultado incorrecto intencional como el valor esperado ("XXXII"). Pero esta nueva prueba también falla debido a otra razón: el compilador
no puede compilar la prueba unitaria sin errores. Una función llamada afirmar que aún no existe, igualmente no hay
isConvertedToRomanNumeral. Recuerda siempre a Robert C.
La segunda regla de TDD de Martin (ver arriba): "No se le permite escribir más de una prueba unitaria de lo suficiente para fallar, y no compilar
es fallar".
Entonces, primero debemos satisfacer al compilador escribiendo la aserción personalizada. Este constará de dos partes:
• Una función libre de afirmación de eso (<parámetro>), que devuelve una instancia de un
clase de afirmación.
•La clase de aserción personalizada que contiene el método de aserción real, verificando una o varias propiedades
del objeto ensayado.
Listado 815. Una afirmación personalizada para números romanos
clase RomanNumeralAssert { público:
RomanNumeralAssert() = eliminar; explícito
RomanNumeralAssert (const unsigned int arabicNumber):
NúmeroArabeParaConvertir(NúmeroArábigo) { }
void isConvertedToRomanNumeral(const std::string& ExpectedRomanNumeral) const {
ASSERT_EQ(número romano esperado, convertir número arábigo en número romano (número arábigo en conversión)); }
privado:
const unsigned int arabicNumberToConvert; };
RomanNumeralAssert afirmar que (const unsigned int arabicNumber) {
RomanNumeralAssert afirmar { arabicNumber }; volver afirmar; }
■ Nota En lugar de una función libre assertThat, también se puede utilizar un método de clase público y estático en
la clase de aserción. Esto puede ser necesario cuando enfrenta violaciones de espacio de nombres, por ejemplo,
conflictos de nombres de funciones idénticos. Por supuesto, el nombre del espacio de nombres debe anteponerse
al usar el método de clase: RomanNumeralAssert::assertThat(33).isConvertedToRomanNumeral("XXXIII");
205
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
Ahora el código se puede compilar sin errores, pero la nueva prueba fallará como se esperaba durante la ejecución.
Listado 816. Un extracto de la salida de GoogleTest en stdout
[ EJECUTAR ] ArabicToRomanNumeralsConverterTestCase.33_isConvertedTo_XXXIII ../
ArabicToRomanNumeralsConverterTestCase.cpp:30: Valor de error de:
convertArabicNumberToRomanNumeral(arabicNumberToConvert)
Real: "XXXIII"
Esperado: número romano esperado que es:
"XXXII"
[FALLIDO] ArabicToRomanNumeralsConverterTestCase.33_isConvertedTo_XXXIII (0 ms)
Entonces necesitamos modificar la prueba y corregir el número romano que esperamos como resultado.
Listado 817. Nuestro Custom Asserter permite una ortografía más compacta del código de prueba
PRUEBA (Caso de prueba del convertidor de números árabes a números romanos, 33_isConvertedTo_XXXIII)
{afirmar que (33).isConvertedToRomanNumeral ("XXXIII"); }
Ahora podemos resumir todas las pruebas anteriores en una sola.
Listado 818. Todos los controles se pueden agrupar elegantemente en una función de prueba
TEST(ArabicToRomanNumeralsConverterTestCase, conversionOfArabicNumeralsToRomanNumerals_Works)
{ assertThat(1).isConvertedToRomanNumeral("I"); afirmar que (2). se
convierte en número romano ("II"); afirmar que (3). se convierte en
número romano ("III"); afirmar que (10). se convierte en número romano
("X"); afirmar que (20). se convierte en número romano ("XX"); afirmar
que (30). se convierte en número romano ("XXX"); afirmar que (33). se
convierte en número romano ("XXXIII"); }
Eche un vistazo a nuestro código de prueba ahora: libre de redundancia, limpio y fácil de leer. La franqueza de nuestra afirmación
hecha por nosotros mismos es bastante elegante. Y es deslumbrantemente fácil agregar más pruebas ahora, porque solo tenemos que
escribir una sola línea de código para cada nueva prueba.
Puede quejarse de que esta refactorización también tiene una pequeña desventaja. El nombre del método de prueba ahora es
menos específico que el nombre de todos los métodos de prueba antes de la refactorización (consulte la sección Nombres de pruebas
unitarias en el Capítulo 2). ¿Podemos tolerar estos pequeños inconvenientes? Creo que sí. Hemos hecho un compromiso aquí: esta
pequeña desventaja se compensa con los beneficios en términos de mantenibilidad y extensibilidad de nuestras pruebas.
Ahora podemos continuar con el ciclo TDD e implementar el código de producción sucesivamente para las siguientes tres pruebas:
afirmar que (100). se convierte en número romano ("C"); afirmar que
(200). se convierte en número romano ("CC"); afirmar que (300). se
convierte en número romano ("CCC");
206
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
Después de tres iteraciones, el código se verá así antes del paso de refactorización:
Listado 819. Nuestra función de conversión en el noveno ciclo TDD antes de la refactorización
std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
std::string númeroromano; if
(número arábigo == 100)
{ númeroromano = "C"; }
else if (número arábigo == 200) { númeroromano
= "CC"; } else if (número
arábigo == 300) { númeroromano = "CCC"; }
else { while (número arábigo
>= 10)
{ númeroromano += "X"; numero arabe
= 10; } while (número
arábigo >= 1)
{ númeroromano += "I"; número
arábigo; } } return
númeroromano; }
Y de nuevo surge el mismo patrón que antes con 1, 2, 3; y 10, 20 y 30. También podemos usar un ciclo similar para las
centenas:
Listado 820. El patrón emergente, así como qué partes del código son variables y cuáles son idénticas, es claramente reconocible
std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
std::string númeroromano; while
(número arábigo >= 100) { númeroromano
+= "C"; numero arabe =
100; } while (número arábigo
>= 10) { númeroromano += "X"; numero
arabe = 10; } while (número
arábigo >= 1)
{ númeroromano += "I"; número
arábigo; } return
númeroromano; }
Es hora de limpiar de nuevo
En este punto deberíamos volver a echar un vistazo crítico a nuestro código. Si continuamos así, el código contendrá muchas
duplicaciones de código, porque las tres declaraciones while se ven muy similares. Sin embargo, podemos aprovechar estas
similitudes abstrayendo las partes del código que son iguales en los tres bucles while.
207
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
¡Es tiempo de refactorización! Las únicas partes del código que son diferentes en los tres bucles while son el número arábigo
y su correspondiente número romano. La idea es separar estas partes variables del resto del ciclo.
En un primer paso, presentamos una estructura que asigna números arábigos a su equivalente romano. Además,
necesitamos una matriz (aquí usaremos std::array de la biblioteca estándar de C++) de esa estructura. Inicialmente, solo agregaremos
un elemento a la matriz que asigna la letra "C" al número 100.
Listado 821. Presentamos una matriz que contiene asignaciones entre números arábigos y su equivalente romano
struct ArabicToRomanMapping { unsigned
int arabicNumber; std::string
númeroromano; };
const std::size_t numberOfMappings = 1; usando
ArabicToRomanMappings = std::array<ArabicToRomanMapping, numberOfMappings>;
const ArabicToRomanMappings arabicToRomanMappings = {
{ 100, "C" } };
Después de estos preparativos, modificamos el primer ciclo while en la función de conversión para verificar si la base
idea funcionará.
Listado 822. Reemplazar los literales con entradas de la nueva matriz
std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
std::string númeroromano; while
(número arábigo >= arabicToRomanMappings[0].rabicNumeral) { romanNumeral +=
arabicToRomanMappings[0].romanNumeral; arabicNumber =
arabicToRomanMappings[0].arabicNumber; } while (número arábigo >= 10)
{ númeroromano += "X"; numero arabe
= 10; } while (número
arábigo >= 1)
{ númeroromano += "I"; número
arábigo; } return
númeroromano; }
Todas las pruebas pasan. Entonces podemos continuar llenando la matriz con las asignaciones "10isX" y "1isI" (no olvides
para ajustar el tamaño de la matriz en consecuencia!).
Listado 823. Nuevamente surge un patrón: la redundancia de código obvia puede eliminarse mediante un bucle
const std::size_t numberOfMappings { 3 }; // ... const
ArabicToRomanMappings arabicToRomanMappings = { { { 100, "C" }, { 10,
"X" }, { 1, "I" } } };
208
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
std::string númeroromano; while
(número arábigo >= arabicToRomanMappings[0].rabicNumeral) { romanNumeral +=
arabicToRomanMappings[0].romanNumeral; arabicNumber =
arabicToRomanMappings[0].arabicNumber; } while (Número arábigo >=
Asignaciones árabes a romanos[1]. Número árabe) { Número romano += Asignaciones
árabes a romanos [1]. Número romano; arabicNumber =
arabicToRomanMappings[1].arabicNumber; } while (Número arábigo >=
Asignaciones árabes a romanos[2].Número árabe) { Número romano += Asignaciones
árabes a romanos[2]. Número romano; arabicNumber =
arabicToRomanMappings[2].arabicNumber; } return númeroromano;
Y de nuevo, se pasan todas las pruebas. ¡Excelente! Pero todavía hay mucho código duplicado, por lo que
debemos continuar con nuestra refactorización. La buena noticia es que ahora podemos ver que la única diferencia en los
tres bucles while es solo el índice de la matriz. Esto significa que podemos arreglárnoslas con solo un bucle while si
iteráramos a través de la matriz.
Listado 824. A través del bucle for basado en rango, el principio DRY ya no se viola
std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
std::string númeroromano; for
(const auto& mapeo: arabicToRomanMappings) { while (número
arábigo >= mapeo.número arábigo) { númeroromano +=
mapeo.númeroromano; numeroArabe =
mapeo.numeroArabe; } } return númeroromano; }
Todas las pruebas pasan. ¡Wow eso es genial! Solo eche un vistazo a este fragmento de código compacto y fácil de
leer. Ahora se pueden admitir más asignaciones de números arábigos a sus equivalentes romanos al agregarlos a la matriz.
Probaremos esto por 1,000, que debe convertirse en una "M". Aquí está nuestra próxima prueba:
afirmar que (1000). se convierte en número romano ("M");
La prueba falló como se esperaba. Al agregar otro elemento para "1000isM" a la matriz, la nueva prueba y, por supuesto,
todas las pruebas anteriores deberían pasar.
const ArabicToRomanMappings arabicToRomanMappings = { {
{1000, "M"},
{100, "C"}, {
10, "X" }, {
1, "yo" } } };
209
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
Una prueba exitosa después de este pequeño cambio confirma nuestra suposición: ¡funciona! Eso fue bastante fácil.
Podemos agregar más pruebas ahora, por ejemplo, para 2000 y 3000. E incluso 3333 debería funcionar inmediatamente:
afirmar que (2000). se convierte en número romano ("MM"); afirmar
que (3000). se convierte en número romano ("MMM"); afirmar que
(3333). se convierte en número romano ("MMMCCCXXXIII");
Bien. Nuestro código funciona incluso con estos casos. Sin embargo, hay algunos números romanos que aún no se han
implementado. Por ejemplo, el 5 que se tiene que convertir a “V”.
afirmar que (5). se convierte en número romano ("V");
Como era de esperar, esta prueba falla. La pregunta interesante es la siguiente: ¿qué debemos hacer ahora que la prueba
se pasa? Tal vez pienses en un tratamiento especial de este caso. Pero, ¿es este realmente un caso especial, o podemos
tratar esta conversión de la misma manera que las conversiones anteriores y ya implementadas?
Probablemente lo más simple que podría funcionar es simplemente agregar un nuevo elemento en el índice correcto para
nuestra matriz? Bueno, tal vez valga la pena probarlo...
const ArabicToRomanMappings arabicToRomanMappings = { {
{1000, "M"},
{100, "C"}, {
10, "X" }, {
5, "V" }, {
1, "yo" } } };
Nuestra suposición era cierta: ¡Se pasan todas las pruebas! Incluso los números arábigos como 6 y 37 deben convertirse
correctamente ahora a su equivalente romano. Verificamos eso agregando aserciones para estos casos:
afirmar que (6). se convierte en número romano
("VI"); //...afirmeEso(37).esConvertidoEnNúmeroRomano("XXXVII");
Acercándose a la línea de meta
Y no sorprende que podamos usar básicamente el mismo enfoque para "50isL" y "500isD".
A continuación, debemos ocuparnos de la implementación de la llamada notación de resta, por ejemplo, el número
arábigo 4 debe convertirse en el número romano "IV". ¿Cómo podríamos implementar estos casos especiales con elegancia?
Bueno, después de una breve consideración, se vuelve obvio que estos casos no son nada realmente especial.
En última instancia, por supuesto, no está prohibido agregar una regla de mapeo a nuestra matriz donde la cadena contiene
dos caracteres en lugar de uno. Por ejemplo, podemos simplemente agregar una nueva entrada "4isIV" a la matriz
arabicToRomanMappings. Tal vez dirás: "¿No es eso un truco?" No, no lo creo, es pragmático y fácil, sin complicar
innecesariamente las cosas.
Por lo tanto, primero agregamos una nueva prueba que fallará:
afirmar que (4). se convierte en número romano ("IV");
210
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
Para pasar la nueva prueba, agregamos la regla de mapeo correspondiente para 4 (vea la penúltima entrada
en la matriz):
const ArabicToRomanMappings arabicToRomanMappings = { {
{1000, "M"},
{500, "D"},
{100, "C"}, {
50, "L" }, {
10, "X" }, {
5, "V" }, {
4, "IV" }, {
1, "yo" } } };
Después de ejecutar todas las pruebas y verificar que pasaron, ¡podemos estar seguros de que nuestra solución
también funciona para 4! Por lo tanto, podemos repetir ese patrón para “9isIX”, “40isXL”, “90isXC”, y así sucesivamente. El
esquema es siempre el mismo, por lo que no muestro el código fuente resultante aquí (el resultado final con el código completo
se muestra a continuación), pero creo que no es difícil de comprender.
¡Hecho!
La pregunta interesante es esta: ¿Cuándo sabemos que hemos terminado? ¿Que el software que tenemos que
implementar está terminado? ¿Que podemos dejar de ejecutar el ciclo TDD? ¿Realmente tenemos que probar todos los
números del 1 al 3999 cada uno mediante una prueba unitaria para saber que hemos terminado?
La respuesta simple: si todos los requisitos de nuestro fragmento de código se han implementado con éxito,
y no encontramos una nueva prueba unitaria que conduzca a un nuevo código de producción, ¡hemos terminado!
Y ese es exactamente el caso en este momento para nuestro kata TDD. Todavía podríamos agregar muchas más
afirmaciones al método de prueba; la prueba se pasaría cada vez sin necesidad de cambiar el código de producción. Esta
es la forma en que TDD nos "habla": "¡Oye, amigo, ya terminaste!"
El resultado se parece a lo siguiente:
Listado 825. Esta versión se registró en GitHub (ver la URL a continuación) con el mensaje de confirmación "Listo".
#include <gtest/gtest.h>
#include <cadena>
#include <matriz>
int main(int argc, char** argv) {
pruebas::InitGoogleTest(&argc, argv); devolver
EJECUTAR_TODAS_PRUEBAS(); }
struct ArabicToRomanMapping
{ unsigned int arabicNumber;
std::string númeroromano; };
const std::size_t numberOfMappings { 13 }; usando
ArabicToRomanMappings = std::array<ArabicToRomanMapping, numberOfMappings>;
211
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
const ArabicToRomanMappings arabicToRomanMappings = { {
{1000, "M"},
{900, "CM"},
{500, "D"},
{ 400, "CD" },
{100, "C"}, {
90, "XC" }, {
50, "L" }, {
40, "XL" }, {
10, "X" }, {
9, "IX" }, {
5, "V" }, {
4, "IV" }, {
1, "yo" } } };
std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
std::string númeroromano; for
(const auto& mapeo: arabicToRomanMappings) { while (número
arábigo >= mapeo.número arábigo) { númeroromano +=
mapeo.númeroromano; numeroArabe =
mapeo.numeroArabe; }
} return númeroromano;
}
// El código de prueba comienza aquí...
clase RomanNumeralAssert
{ público:
RomanNumeralAssert() = eliminar;
explícito RomanNumeralAssert (const unsigned int arabicNumber):
NúmeroArabeParaConvertir(NúmeroArábigo) { }
void isConvertedToRomanNumeral(const std::string& ExpectedRomanNumeral) const {
ASSERT_EQ(número romano esperado, convertir número arábigo en número romano (número arábigo en conversión)); }
privado:
const unsigned int arabicNumberToConvert; };
RomanNumeralAssert afirmar que (const unsigned int arabicNumber) {
return RomanNumeralAssert { número arabe };
}
PRUEBA(ArabicToRomanNumeralsConverterTestCase, conversionOfArabicNumbersToRomanNumerals_Works)
{
afirmar que (1). se convierte en número romano ("I");
afirmar que (2). se convierte en número romano ("II");
afirmar que (3). se convierte en número romano ("III");
212
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
afirmar que (4). se convierte en número romano ("IV"); afirmar
que (5). se convierte en número romano ("V"); afirmar que
(6). se convierte en número romano ("VI"); afirmar que (9). se
convierte en número romano ("IX"); afirmar que (10). se
convierte en número romano ("X"); afirmar que (20). se
convierte en número romano ("XX"); afirmar que (30). se
convierte en número romano ("XXX"); afirmar que (33). se
convierte en número romano ("XXXIII"); afirmar que (37). se convierte
en número romano ("XXXVII"); afirmar que (50). se convierte en
número romano ("L"); afirmar que (99). se convierte en número
romano ("XCIX"); afirmar que (100). se convierte en número
romano ("C"); afirmar que (200). se convierte en número
romano ("CC"); afirmar que (300). se convierte en número
romano ("CCC"); afirmar que (499). se convierte en número
romano ("CDXCIX"); afirmar que (500). se convierte en número
romano ("D"); afirmar que (1000). se convierte en número
romano ("M"); afirmar que (2000). se convierte en número
romano ("MM"); assertThat(2017).isConvertedToRomanNumeral("MMXVII");
afirmar que (3000). se convierte en número romano ("MMM"); afirmar
que (3333). se convierte en número romano ("MMMCCCXXXIII");
assertThat(3999).isConvertedToRomanNumeral("MMMCMXCIX"); }
■ Información El código fuente de Roman Numerals Kata completo, incluido su historial de versiones, se puede
encontrar en GitHub en: https://github.com/cleancpp/booksamples/.
¡Esperar! Sin embargo, todavía queda un paso muy importante por dar: debemos separar el código de producción
del código de prueba. Usamos el archivo ArabicToRomanNumeralsConverterTestCase.cpp todo el tiempo como nuestro
banco de trabajo, pero ahora ha llegado el momento en que el creador de software tiene que quitar su trabajo terminado del
tornillo de banco. En otras palabras, el código de producción ahora debe moverse a un nuevo archivo diferente, aún por
crear; pero, por supuesto, las pruebas unitarias aún deberían poder probar el código.
Durante este último paso de refactorización, se pueden tomar algunas decisiones de diseño. Por ejemplo, ¿permanece
con una función de conversión independiente, o el método de conversión y la matriz deben envolverse en una nueva clase?
Claramente preferiría lo último (incrustar el código en una clase) porque tiene un diseño orientado a objetos y es más fácil
ocultar los detalles de implementación con la ayuda de la encapsulación.
No importa cómo se proporcione el código de producción y cómo se integre en su entorno de uso (esto depende del
propósito), nuestra cobertura de prueba de unidad sin interrupciones hace que sea poco probable que algo salga mal.
Las ventajas de TDD
El desarrollo basado en pruebas es principalmente una herramienta y una técnica para el diseño y desarrollo
incremental de un componente de software. Es por eso que el acrónimo TDD también se conoce como "Diseño basado
en pruebas". Es una forma, por supuesto no la única, de pensar en sus requisitos o diseñar antes de escribir el código
de producción.
213
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
Las ventajas significativas de TDD son las siguientes:
• TDD, si se hace bien, lo obliga a dar pequeños pasos al escribir software. El
El enfoque garantiza que siempre tenga que escribir solo unas pocas líneas de código de producción
para volver a alcanzar el estado cómodo en el que todo funciona. Esto también significa que, como
mucho, se encuentra a unas pocas líneas de código de una situación en la que todo ha funcionado.
Esta es la principal diferencia con el enfoque tradicional de producir y cambiar una gran cantidad de
código de producción de antemano, lo que va de la mano con el inconveniente de que el software a
veces no se puede compilar y ejecutar sin errores durante horas o días.
• TDD establece un ciclo de retroalimentación muy rápido. Los desarrolladores siempre deben saber si todavía
están trabajando en un sistema correcto. Por lo tanto, es importante para ellos que tengan un circuito de
retroalimentación rápido para saber en una fracción de segundo que todo funciona correctamente. Las
pruebas complejas de sistema e integración, especialmente si aún se realizan manualmente, no son
capaces de esto y son demasiado lentas (recuerde la Pirámide de prueba en el Capítulo 2).
• La creación de una prueba unitaria primero ayuda a un desarrollador a considerar realmente lo que debe
hacerse. En otras palabras, TDD garantiza que el código no se piratee simplemente desde el cerebro
hasta el teclado. Eso es bueno, porque el código que se escribió de esta manera a menudo es propenso
a errores, difícil de leer y, a veces, incluso superfluo. Muchos desarrolladores suelen ir más rápido que su
verdadera capacidad para ofrecer un buen trabajo. TDD es una forma de ralentizar a los desarrolladores
en un sentido positivo. No se preocupen, gerentes, es bueno que sus desarrolladores disminuyan la
velocidad, porque esto pronto se verá recompensado con un aumento notable en la calidad y velocidad
en el proceso de desarrollo cuando la alta cobertura de prueba muestre su efecto positivo.
• Con TDD surge una especificación sin espacios en forma de código ejecutable.
Las especificaciones escritas en lenguaje natural con un programa de procesamiento de texto de una suite
de Office, por ejemplo, no son ejecutables, son "artefactos muertos".
• El desarrollador trata mucho más consciente y responsablemente con las dependencias.
Si se requiere otro componente de software o incluso un sistema externo (por ejemplo, una base de
datos), esta dependencia puede definirse debido a una abstracción (interfaz) y reemplazarse por un
doble de prueba (también conocido como objeto simulado) para la prueba. Los módulos de software
resultantes (p. ej., clases) son más pequeños, tienen un acoplamiento flexible y contienen solo el
código necesario para pasar las pruebas.
• El código de producción emergente con TDD tendrá una cobertura de prueba unitaria del 100 % de
forma predeterminada. Si TDD se realizó correctamente, no debería haber una sola línea de código
de producción que no haya sido motivada por una prueba de unidad escrita previamente.
El desarrollo basado en pruebas puede ser un impulsor y un habilitador para un diseño de software bueno y sostenible. Como
ocurre con muchas otras herramientas y métodos, la práctica de TDD no puede garantizar un buen diseño. No es una panacea para
los problemas de diseño. Las decisiones de diseño todavía las toma el desarrollador y no la herramienta. Como mínimo, TDD es un
enfoque útil para evitar lo que podría percibirse como un mal diseño. Muchos desarrolladores que usan TDD en su trabajo diario
pueden confirmar que es extremadamente difícil producir o tolerar código malo y desordenado con este enfoque.
Y no hay duda sobre cuándo un desarrollador ha terminado de implementar todas las funcionalidades requeridas: si todas las
pruebas unitarias están en verde, significa que todos los requisitos en la unidad están satisfechos y ¡el trabajo está hecho! Y un efecto
secundario agradable es que está hecho en alta calidad.
214
Machine Translated by Google
Capítulo 8 ■ Desarrollo basado en pruebas
Además, el flujo de trabajo de TDD también impulsa el diseño de la unidad a desarrollar, especialmente su interfaz.
Con TDD y Test First, el diseño y la implementación de la API se guían por sus casos de prueba. Cualquiera que haya intentado escribir
pruebas unitarias para código heredado sabe lo difícil que puede ser. Estos sistemas generalmente se construyen "Código primero".
Muchas dependencias inconvenientes y un mal diseño de API complican las pruebas en tales sistemas. Y si una unidad de software
es difícil de probar, también es difícil de (re)utilizar. En otras palabras: TDD brinda una retroalimentación temprana sobre la usabilidad de
una unidad de software, es decir, qué tan simple puede integrarse y usarse esa pieza de software en su entorno de ejecución planificado.
Cuándo no debemos usar TDD
La pregunta final es esta: ¿deberíamos desarrollar cada pieza de código de un sistema utilizando un enfoque de prueba primero?
Mi respuesta clara es ¡ No!
Sin duda: el desarrollo basado en pruebas es una excelente práctica para guiar el diseño y la implementación de una pieza de
software. En teoría, incluso sería posible desarrollar casi todas las partes de un sistema de software de esta manera. Y como una especie
de efecto secundario positivo, el código emergente se prueba al 100 % de forma predeterminada.
Pero algunas partes de un proyecto son tan simples, pequeñas o menos complejas que no justifican este enfoque. Si puede escribir
su código rápidamente, porque la complejidad y los riesgos son bajos, entonces, por supuesto, puede hacerlo. Ejemplos de tales situaciones
son las clases de datos puras sin funcionalidad (lo cual es, por cierto, un olor, pero por otras razones; vea la sección sobre clases
anémicas en el Capítulo 6), o un simple código de unión que simplemente se acopla con dos módulos.
Además, la creación de prototipos puede ser una tarea muy difícil con TDD. Cuando ingresa a un nuevo territorio, o debe desarrollar
software en un entorno muy innovador sin experiencia en el dominio, a veces no está seguro de qué camino tomará para encontrar una
solución. Escribir pruebas unitarias primero en proyectos con requisitos muy volátiles y confusos puede ser una tarea extremadamente
desafiante. A veces, puede ser mejor escribir una primera solución rudimentaria de manera fácil y rápida, y garantizar su calidad en un
paso posterior con la ayuda de pruebas unitarias actualizadas.
Otro gran desafío, para el que TDD no ayudará, es conseguir una buena arquitectura. TDD no reemplaza la reflexión
necesaria sobre las estructuras de grano grueso (subsistemas, componentes, …) de su sistema de software. Si se enfrenta a
decisiones fundamentales sobre marcos, bibliotecas, tecnologías o patrones de arquitectura, TDD no le ayudará.
Para cualquier otra cosa, recomiendo encarecidamente TDD. Este enfoque puede ahorrar mucho tiempo, dolores de cabeza y falsos
comienza cuando debe desarrollar una unidad de software, como una clase, en C++.
Para cualquier cosa que sea más compleja que unas pocas líneas de código, los artesanos del software pueden
probar el código tan rápido como otros desarrolladores pueden escribir código sin pruebas, si no más rápido.
—Sandro Mancuso
■ Sugerencia Si desea profundizar más en el desarrollo controlado por pruebas con C++, le recomiendo el excelente libro
Programación moderna en C++ con desarrollo controlado por pruebas [Langr13] de Jeff Langr. El libro de Jeff ofrece
información mucho más profunda sobre TDD y le brinda lecciones prácticas sobre los desafíos y las recompensas de hacer TDD en C++.
215
Machine Translated by Google
CAPÍTULO 9
Patrones de diseño y modismos
Los buenos artesanos pueden aprovechar una gran cantidad de experiencia y conocimientos. Una vez que han encontrado una buena
solución para un determinado problema, toman esta solución en su repertorio para aplicarla en el futuro a un problema
similar. Idealmente, transforman su solución en algo que se conoce como forma canónica y la documentan, tanto para ellos
como para los demás.
FORMA CANÓNICA
El término Forma Canónica en este contexto describe una representación de algo que se reduce a la forma
más simple y significativa sin perder generalidad. Relacionado con los patrones de diseño, la forma canónica
de un patrón describe sus elementos más básicos: nombre, contexto, problema, fuerzas, solución, ejemplos,
inconvenientes, etc.
Esto también es cierto para los desarrolladores de software. Los desarrolladores experimentados pueden aprovechar una
gran cantidad de soluciones de muestra para problemas de diseño constantemente recurrentes en el software. Comparten su
conocimiento con otros y lo hacen reutilizable para problemas similares. El principio detrás de esto: ¡No reinventar la rueda!
En 1995, se publicó un libro muy conocido y ampliamente aclamado. Sus cuatro autores, a saber, Erich
Gamma, Richard Helm, Ralph Johnson y John Vlissides, también conocidos como Gang of Four (GoF), introdujeron el
principio de los patrones de diseño en el desarrollo de software y presentaron un catálogo de 23 patrones de diseño orientados
a objetos. Su título es Design Patterns: Elements of Reusable ObjectOriented Software [Gamma95] y puede considerarse
hasta el día de hoy como uno de los trabajos más importantes en el dominio del desarrollo de software.
Algunas personas creen que Gamma et al. había inventado todos los patrones de diseño que se describen en su libro.
Pero eso no es cierto. Los patrones de diseño no se inventan, pero se pueden encontrar. Los autores han examinado los sistemas
de software que estaban bien hechos en cuanto a flexibilidad, mantenibilidad y extensibilidad.
Encontraron la causa de estas características positivas y las describieron en forma canónica.
Después de que apareció el libro de la Banda de los Cuatro, se pensó que habría una avalancha de patrones
libros en los años siguientes. Pero esto no sucedió. De hecho, en los años siguientes hubo algunos otros libros importantes
sobre el tema patrón, como PatternOriented Software Architecture (también conocido bajo el acrónimo “POSA”) [Busch96] o Patterns
of Enterprise Application Architecture [Fowler02] sobre patrones arquitectónicos. , pero la gran masa esperada se quedó fuera.
Principios de diseño frente a patrones de diseño
En los capítulos anteriores, hemos discutido muchos principios de diseño. Pero, ¿cómo se relacionan estos principios con los patrones
de diseño? ¿Qué es más importante?
© Stephan Roth 2017 217
S. Roth, C++ limpio, DOI 10.1007/9781484227930_9
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Bueno, asumamos hipotéticamente que quizás algún día la orientación a objetos se vuelva totalmente impopular y la
Programación Funcional (vea el Capítulo 7) sea el paradigma de programación dominante.
¿Principios como KISS, DRY, YAGNI, Principio de responsabilidad única, Principio abiertocerrado, Ocultación de información, etc.,
se están volviendo inválidos y, por lo tanto, sin valor? ¡ La respuesta clara es no!
Un principio es una “verdad” o “ley” fundamental que sirve como base para las decisiones. Por lo tanto, un principio es en
la mayoría de los casos independiente de un determinado paradigma o tecnología de programación. El principio KISS (ver Capítulo
3), por ejemplo, es un principio muy universal. No importa si está programando en un estilo orientado a objetos o funcional, o si usa
diferentes lenguajes como C ++, C #, Java o Erlang, ¡tratar de hacer algo lo más simple posible siempre es una actitud que vale la
pena!
Por el contrario, un patrón de diseño es una solución para un problema de diseño concreto en un contexto determinado.
Especialmente aquellos que se describen en el famoso libro de patrones de diseño de Gang of Four están estrechamente asociados
con la orientación a objetos. Por lo tanto, los principios son más duraderos y más importantes. Puede encontrar un patrón de diseño
para un determinado problema de programación por sí mismo, si ha interiorizado los principios.
Las decisiones y los patrones dan soluciones a las personas; los principios les ayudan a diseñar los suyos propios.
—Eoin Woods en un discurso de apertura sobre el
Conferencia de trabajo conjunto IEEE/IFIP sobre arquitectura de software 2009 (WICSA2009)
Algunos patrones y cuándo usarlos
Además de los 23 patrones de diseño descritos en el libro de Gang of Four, hay, por supuesto, más patrones. Algunos patrones
se encuentran a menudo en los proyectos de desarrollo, mientras que otros son más o menos raros o exóticos. Las siguientes
secciones discuten algunos de los patrones de diseño más importantes en mi opinión. Aquellos que resuelven problemas de diseño
que ocurren con mucha frecuencia y que un desarrollador debería al menos haber escuchado antes.
Por cierto, ya hemos utilizado algunos patrones de diseño en los capítulos anteriores, algunos incluso relativamente
intenso, pero no lo hemos mencionado ni notado. Solo una pequeña pista: en el libro de Gang of Four [Gamma95] puedes
encontrar un patrón de diseño que se llama... ¡ Iterator!
Antes de continuar con la discusión de patrones de diseño individuales, se debe señalar aquí una advertencia:
■ Advertencia ¡No exagere con el uso de patrones de diseño! Sin duda, los patrones de diseño son geniales y, a veces, incluso
fascinantes. Pero un uso exagerado de ellos, especialmente si no hay buenas razones que lo justifiquen, puede tener consecuencias
catastróficas. Su diseño de software sufrirá un exceso de ingeniería inútil. Recuerda siempre KISS y YAGNI (ver Capítulo 3).
Pero ahora echemos un vistazo a algunos patrones.
Inyección de dependencia (DI)
La inyección de dependencia es un elemento clave de la arquitectura ágil.
—Ward Cunningham, parafraseado del
Panel de discusión sobre "Desarrollo ágil y tradicional" en la Conferencia de
calidad de software del noroeste del Pacífico (PNSQC) 2004
218
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
El hecho de que comience la sección sobre patrones de diseño específicos con uno que no se menciona en el famoso libro de
Gang of Four tiene razones de peso, por supuesto. Estoy convencido de que la inyección de dependencia es, con mucho, el
patrón más importante que puede ayudar a los desarrolladores de software a mejorar significativamente el diseño de un software.
Este patrón puede considerarse con razón como un cambio de juego.
Antes de profundizar en la Inyección de dependencia, primero quiero contar con otro patrón que es
perjudicial para el buen diseño de software: ¡el Singleton!
El antipatrón Singleton
Estoy bastante seguro de que conoce el patrón de diseño llamado Singleton. Es, a primera vista, un patrón simple y
extendido, no solo en el dominio de C++ (veremos pronto que su supuesta simplicidad puede ser engañosa). Algunas
bases de código incluso están llenas de Singletons. Este patrón, por ejemplo, se usa a menudo para los llamados registradores
(objetos con fines de registro), para conexiones de bases de datos, para la administración central de usuarios o para representar
cosas del mundo físico (por ejemplo, hardware, como USB o interfaces de impresora) . Además, las Fábricas y las denominadas
Clases de Utilidad a menudo se implementan como Singletons. Estos últimos son un olor a código por sí mismos, porque son un
signo de cohesión débil (ver Capítulo 3).
Los periodistas han preguntado regularmente a los autores de Design Patterns cuándo revisarían su libro y publicarían
una nueva edición. Y su respuesta habitual era que no verían ninguna razón para ello, porque el contenido del libro sigue
siendo válido en gran medida. Sin embargo, en una entrevista con la revista en línea InformIT , se permitieron dar una
respuesta más detallada. Aquí hay un pequeño extracto de toda la entrevista, que revela una interesante opinión de Gamma
sobre Singletons (Larry O'Brien fue el entrevistador, y Erich Gamma da la respuesta):
[…]
Larry: ¿Cómo refactorizarías "Patrones de diseño"?
Erich: Hicimos este ejercicio en 2005. Aquí hay algunas notas de nuestra sesión. Hemos descubierto que los
principios del diseño orientado a objetos y la mayoría de los patrones no han cambiado desde entonces. (…)
Al discutir qué patrones eliminar, descubrimos que todavía los amamos a todos. (En realidad no, estoy a favor de
eliminar Singleton. Su uso es casi siempre un olor a diseño).
—Patrones de diseño 15 años después: una entrevista con Erich Gamma,
Richard Helm y Ralph Johnson, 2009 [InformIT09]
Entonces, ¿por qué Erich Gamma dijo que el Patrón Singleton es casi siempre un olor a diseño? ¿Qué tiene de malo?
Para responder a esto, primero veamos qué objetivos se deben lograr mediante Singletons. ¿Qué requisitos
se pueden cumplir con este patrón? Aquí está la declaración de la misión del patrón Singleton del libro GoF:
Asegúrese de que una clase solo tenga una instancia y proporcione un punto de acceso global a ella.
—Erich Gamma et. al., Patrones de diseño [Gamma95]
Esta declaración contiene dos aspectos conspicuos. Por un lado, la misión de este patrón es controlar y gestionar todo el ciclo
de vida de su única instancia. De acuerdo con el principio de Separación de preocupaciones, la gestión del ciclo de vida de un
objeto debe ser independiente y separada de su lógica comercial específica de dominio. En un Singleton, estas dos
preocupaciones básicamente no están separadas.
219
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Por otro lado, se proporciona un acceso global a esta instancia, de modo que cualquier otro objeto en el
aplicación puede usarlo. Este discurso sobre un "punto de acceso global" en el contexto de la orientación a objetos
parece sospechoso y debería generar señales de alerta.
Veamos primero un estilo de implementación general de un Singleton en C++, el llamado Singleton de Meyers, llamado
así por Scott Meyers, el autor del libro Eficaz C++ [Meyers05]:
Listado 91. Una implementación de Singleton de Meyers en C++ moderno
#ifndef SINGLETON_H_
#define SINGLETON_H_
clase Singleton final { público:
Singleton
estático y getInstance() {
Singleton estático theInstance { }; devolver la
instancia; }
int hacerAlgo() { return
42; }
// ...más funciones miembro haciendo cosas más o menos útiles aquí...
privado:
Singleton() = predeterminado;
Singleton(const Singleton&) = eliminar;
Singleton(Singleton&&) = eliminar; Operador
Singleton& =(const Singleton&) = borrar; Singleton&
operator=(Singleton&&) = eliminar; // ...
};
#terminara si
Una de las principales ventajas de este estilo de implementación de Singleton es que, desde C++ 11, el
proceso de construcción de la única instancia que usa una variable estática dentro de getInstance() es seguro para
subprocesos por defecto (ver § 6.7 en [ ISO11]). ¡Tenga cuidado, porque eso no significa automáticamente que todas las
demás funciones miembro de Singleton también sean seguras para subprocesos! Esto último debe ser garantizado por el desarrollador.
En el código fuente, el uso de una instancia de singleton global de este tipo suele verse así:
Listado 92. Un extracto de la implementación de una clase arbitraria que usa Singleton
001 #include "CualquierUsuarioSingleton.h"
002 #include "Singleton.h" 003
#include <cadena> 004 ... // ...
024
025 void AnySingletonUser::aMemberFunction() { // ... std::string
... result =
040 Singleton::getInstance().doThis();
220
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
... // ...
050 }
051 ... // ...
089 090 void CualquierUsuarioSingleton::otraFunciónMiembro() {
... //...
098 resultado int = Singleton::getInstance().doThat(); //...
...
104 valor doble = Singleton::getInstance().doSomethingMore();
... //...
110 }
111 // ...
Creo que ahora queda claro cuál es uno de los principales problemas con Singletons. Debido a su
visibilidad y accesibilidad global, simplemente se usan en cualquier lugar dentro de la implementación de otras
clases. Eso significa que en el diseño del software, todas las dependencias de este Singleton están ocultas
dentro del código. No puede ver estas dependencias examinando las interfaces de sus clases, es decir, sus
atributos y métodos.
Y la clase AnySingletonUser ejemplificada anteriormente es solo representativa de quizás cientos de clases
dentro de una gran base de código, muchas de las cuales también usan Singleton en diferentes lugares. En otras
palabras: un Singleton en OO es como una variable global en la programación procedimental. Puede usar este
objeto global en todas partes, y no puede ver ese uso en la interfaz de la clase de uso, sino solo en su implementación.
Esto tiene un impacto negativo significativo en la situación de dependencia en un proyecto, como se muestra en
la Figura 91.
Figura 91. Amado por todos: ¡el Singleton!
221
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
■ Nota Quizás se esté preguntando al ver la Figura 91 que hay una instancia de variable miembro privada dentro
de la clase Singleton, que no se puede encontrar en este formulario en la implementación recomendada por
Meyers. Bueno, UML es un lenguaje de programación agnóstico, es decir, como lenguaje de modelado multipropósito
no conoce C++, Java u otros lenguajes orientados a objetos. De hecho, también en Singleton de Meyers hay una
variable que contiene la única instancia, pero no hay una notación gráfica para una variable con duración de
almacenamiento estático en UML, porque esta característica es propiedad de C++. Por lo tanto, elegí la forma de
representar esta variable como un miembro estático privado. Esto hace que la representación también sea
compatible con la implementación Singleton que ya no se recomienda y que se describe en el libro GoF [Gamma95].
Creo que es fácil imaginar que todas estas dependencias tendrán importantes inconvenientes con respecto a la reutilización,
la mantenibilidad y la capacidad de prueba. Todas esas clases de clientes anónimos de Singleton están estrechamente acopladas
a él (recuerde la buena propiedad del acoplamiento débil que hemos discutido en el Capítulo 3).
Como consecuencia, perdemos por completo la posibilidad de aprovechar el polimorfismo para proporcionar una implementación
alternativa. Solo piensa en las pruebas unitarias. ¿Cómo puede tener éxito implementar una prueba de unidad real, si se usa algo
dentro de la implementación de la clase que se va a probar que no puede ser reemplazado fácilmente por un Doble de prueba
(también conocido como Objeto simulado; consulte la sección sobre Dobles de prueba en el Capítulo 2) ?
Y recuerde todas las reglas para las buenas pruebas unitarias que hemos discutido en el Capítulo 2, especialmente
la independencia de las pruebas unitarias. Un objeto global como un Singleton tiene a veces un estado mutable. ¿Cómo se puede
asegurar la independencia de las pruebas, si muchas o casi todas las clases en un código base dependen de un solo objeto
que tiene un ciclo de vida que termina con la terminación del programa, y que posiblemente tenga un estado compartido entre
ellos? ?!
Otra desventaja de Singletons es que si tienen que cambiarse debido a requisitos nuevos o cambiantes, este
cambio podría desencadenar una cascada de cambios en todas las clases dependientes. Todas las dependencias visibles en la
Figura 91 y que apuntan al Singleton son posibles rutas de propagación de cambios.
Finalmente, también es muy difícil asegurar en un sistema distribuido, que es un caso común en la arquitectura de software
hoy en día, que exista exactamente una instancia de una clase. Solo imagine el patrón de microservicios, donde un sistema de
software complejo se compone de muchos procesos pequeños, independientes y distribuidos. En tal entorno, los Singletons no solo
son difíciles de proteger contra instanciaciones múltiples, sino que también son problemáticos debido al estrecho acoplamiento que
fomentan.
Entonces, tal vez te preguntes ahora: "Está bien, lo tengo, los Singleton son malos, pero ¿cuáles son las alternativas?" La
respuesta quizás sorprendentemente simple, que por supuesto requiere algunas explicaciones adicionales, es esta: ¡ simplemente
cree uno e inyéctelo donde sea necesario!
Inyección de dependencia al rescate
En la entrevista antes mencionada con Erich Gamma et al. los autores también hicieron una declaración sobre esos
patrones de diseño, que les gustaría incluir en una nueva revisión de su libro. Nominaron solo algunos patrones que posiblemente se
convertirían en su trabajo legendario y uno de ellos es Inyección de dependencia.
Básicamente, la inyección de dependencia (DI) es una técnica en la que los objetos de servicio independientes que necesita
un objeto de cliente dependiente se suministran desde el exterior. El objeto de cliente no tiene que preocuparse por sus objetos de
servicio requeridos por sí mismo, o solicitar activamente los objetos de servicio, por ejemplo, de una fábrica (consulte
el patrón de fábrica más adelante en este capítulo) o de un localizador de servicios.
222
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
La intención detrás de DI podría formularse de la siguiente manera:
Desacople los componentes de sus servicios requeridos de tal manera que los componentes
no tengan que saber los nombres de estos servicios, ni cómo deben adquirirse.
Veamos un ejemplo específico, el Logger ya mencionado anteriormente, por ejemplo, una clase de servicio, que ofrece
la posibilidad de escribir entradas de registro. Dichos registradores a menudo se han implementado como Singletons. Por
lo tanto, cada cliente del registrador depende de ese objeto Singleton global, como se muestra en la figura 92.
Figura 92. Tres clases específicas de dominio de una tienda web dependen del singleton Logger
Así es como podría verse la clase singleton Logger en el código fuente (solo se muestran las partes relevantes):
Listado 93. El Logger implementado como Singleton
#incluir <vista_cadena>
class Logger final
{ public:
static Logger& getInstance() { static
Logger theLogger { }; devuelve el
Registrador; }
void writeInfoEntry(std::string_view entrada) {
// ...
}
void writeWarnEntry(std::string_view entrada) {
// ...
}
void writeErrorEntry(std::string_view entrada) {
// ...
} };
223
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
std::string_view [C++17]
Desde C++17, hay una nueva clase disponible en el estándar de lenguaje C++: std :: string_view (definida en el
encabezado <string_view>). Los objetos de esta clase son proxies de gran rendimiento (Proxy es, por cierto, también
un patrón de diseño) de una cadena, que son baratos de construir (no hay asignación de memoria para datos de cadena
sin procesar) y, por lo tanto, también son baratos de copiar.
Y otra buena característica es: std::string_view también puede servir como un adaptador para cadenas de estilo C (char*),
matrices de caracteres e incluso para implementaciones de cadenas propietarias de diferentes marcos como
CString (MFC) o QString (Qt):
CString aString("Soy un objeto de cadena del tipo CString de MFC"); std::string_view
viewOnCString { (LPCTSTR)aString };
Por lo tanto, es la clase ideal para representar cadenas cuyos datos ya pertenecen a otra persona y si se requiere
acceso de solo lectura, por ejemplo, durante la ejecución de una función. Por ejemplo, en lugar de las referencias
constantes generalizadas a std::string, ahora se debe usar std::string_view como reemplazo de los parámetros de
función de cadena de solo lectura en un programa C++ moderno.
Ahora solo seleccionamos con fines de demostración una de esas muchas clases que usan el registrador
Singleton en su implementación para escribir entradas de registro, la clase CustomerRepository:
Listado 94. Un extracto de la clase CustomerRepository
#include "Cliente.h"
#include "Identificador.h"
#include "Registrador.h"
class CustomerRepository
{ público: //...
Cliente findCustomerById(const Identifier& customerId) {
Logger::getInstance().writeInfoEntry("Comenzando a buscar un cliente especificado por un
identificador único dado..."); // ...
} // ... };
Para deshacerse del Singleton y poder reemplazar el objeto Logger con un Test Double durante
pruebas unitarias, primero debemos aplicar el Principio de Inversión de Dependencia (DIP; consulte el Capítulo 6). Esto
significa que primero tenemos que introducir una abstracción (una interfaz) y hacer que tanto el CustomerRepository
como el Logger concreto dependan de esa interfaz, como se muestra en la Figura 93.
224
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Figura 93. Desacoplamiento a través del Principio de Inversión de Dependencia aplicado
Así es como se ve la nueva interfaz LoggingFacility en el código fuente:
Listado 95. La interfaz de LoggingFacility
#include <memoria>
#include <vista_cadena>
class LoggingFacility { público:
virtual
~LoggingFacility() = predeterminado; virtual void
writeInfoEntry(std::string_view entrada) = 0; virtual void writeWarnEntry(entrada
std::string_view) = 0; virtual void writeErrorEntry(entrada std::string_view) =
0; };
utilizando Logger = std::shared_ptr<LoggingFacility>;
El StandardOutputLogger es un ejemplo de una clase Logger específica que implementa el
interfaz LoggingFacility y escribe el registro en la salida estándar, como sugiere su nombre:
Listado 96. Una posible implementación de LoggingFacility: StandardOutputLogger
#incluye "LoggingFacility.h" #incluye
<iostream>
clase StandardOutputLogger: public LoggingFacility { public: virtual void
writeInfoEntry(std::string_view entry) override { std::cout << "[INFO] " << entrada <<
std::endl; }
virtual void writeWarnEntry(entrada std::string_view) override { std::cout <<
"[ADVERTENCIA] " << entrada << std::endl; }
225
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
virtual void writeErrorEntry(entrada std::string_view) override { std::cout << "[ERROR]
" << entrada << std::endl; } };
A continuación, debemos modificar la clase CustomerRepository. Primero, creamos una nueva variable miembro de
el tipo de puntero inteligente alias Logger. Esta instancia de puntero se pasa a la clase a través de un constructor
de inicialización. En otras palabras, permitimos que una instancia de una clase que implementa la interfaz
LoggingFacility se inyecte en el objeto CustomerRepository durante la construcción. También eliminamos el
constructor predeterminado, porque no queremos permitir que se cree un CustomerRepository sin un registrador.
Además, eliminamos la dependencia directa en la implementación de Singleton y, en su lugar, usamos el puntero
inteligente Logger para escribir entradas de registro.
Listado 97. La clase modificada Repositorio de clientes
#include "Cliente.h"
#include "Identificador.h"
#include "LoggingFacility.h"
clase CustomerRepository { público:
CustomerRepository() = eliminar;
explícito CustomerRepository(const Logger& loggingService) : logger { loggingService } { }
//...
Cliente findCustomerById(const Identifier& customerId) {
logger>writeInfoEntry("Comenzando a buscar un cliente especificado por un identificador único dado..."); // ...
} // ...
privado: //...
registrador registrador; };
Como consecuencia de esta refactorización, ahora hemos logrado que la clase CustomerRepository ya no
dependa de un registrador específico. En cambio, CustomerRepository simplemente tiene una dependencia de una
abstracción (interfaz) que ahora es explícitamente visible en la clase y su interfaz, porque está representada por una
variable de miembro y un parámetro de constructor. Eso significa que la clase CustomerRepository ahora acepta
objetos de servicio con fines de registro que se pasan desde el exterior, como este:
Listado 98. El objeto Logger se inyecta en la instancia de CustomerRepository
Registrador registrador = std::make_shared<StandardOutputLogger>();
CustomerRepository customerRepository { registrador };
Este cambio de diseño tiene efectos significativamente positivos. Se promueve un acoplamiento flojo, y el cliente
El objeto CustomerRepository ahora se puede configurar con varios objetos de servicio que brindan funcionalidad
de registro, como se puede ver en el siguiente diagrama de clases UML (Figura 94):
226
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Figura 94. Class CustomerRepository se puede proporcionar con implementaciones de registro específicas a través de su constructor
Además, la capacidad de prueba de la clase CustomerRepository se ha mejorado significativamente. Ya no hay
dependencias ocultas para Singletons. Ahora podemos reemplazar fácilmente un servicio de registro real por un objeto simulado
(consulte el Capítulo 2 sobre Pruebas Unitarias y Pruebas Dobles). Podemos equipar el objeto simulado con métodos de espionaje,
por ejemplo, para verificar dentro de la prueba unitaria qué datos dejarían nuestro objeto CustomerRepository a través de la
interfaz LoggingFacility.
Listado 99. Un doble de prueba (objeto simulado) para pruebas unitarias de clases que dependen de LoggingFacility
prueba de espacio de nombres {
#include "../src/LoggingFacility.h" #include <cadena>
class LoggingFacilityMock : public LoggingFacility { public: virtual void
writeInfoEntry(std::string_view entry) override {
entrada de registro recientemente escrita = entrada; }
virtual void writeWarnEntry(entrada std::string_view) override {
entrada de registro recientemente escrita = entrada; }
virtual void writeErrorEntry(entrada std::string_view) override {
entrada de registro recientemente escrita = entrada; }
std::string_view getRecentlyWrittenLogEntry() const {
volver recientementeWrittenLogEntry; }
privado:
estándar::cadena recientemente escrita en el
registro; };
usando MockLogger = std::shared_ptr<LoggingFacilityMock>;
227
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Y en esta prueba de unidad ejemplar, puede ver el objeto simulado en acción:
Listado 910. Una prueba unitaria de ejemplo usando el objeto simulado
#include "../src/CustomerRepository.h" #include
"LoggingFacilityMock.h" #include <gtest/gtest.h>
prueba de espacio de nombres {
PRUEBA (Caso de prueba del cliente, Entrada de registro escrita como se esperaba) {
Registrador de MockLogger = std::make_shared<LoggingFacilityMock>();
CustomerRepository customerRepositoryToTest { registrador };
Identificador Idcliente { 1234 };
customerRepositoryToTest.findCustomerById(customerId);
ASSERT_EQ("Comenzando a buscar un cliente especificado por un identificador único dado...", logger
>getRecentlyWrittenLogEntry());}
En el ejemplo anterior, presenté la inyección de dependencia como un patrón para eliminar los molestos Singleton, pero, por
supuesto, esta es solo una de muchas aplicaciones. Básicamente, un buen diseño de software orientado a objetos debe garantizar
que los módulos o componentes involucrados estén acoplados de la manera más flexible posible, y la inyección de dependencia
es la clave para este objetivo. Al aplicar este patrón de manera consistente, surgirá un diseño de software que tiene una
arquitectura de complemento muy flexible. Y como una especie de efecto secundario positivo, esta técnica da como resultado objetos
altamente comprobables.
La responsabilidad de la creación y vinculación de objetos se elimina de los propios objetos y se centraliza en un
componente de infraestructura, el llamado Ensamblador o Inyector. Este componente (vea la Figura 95) generalmente opera
al inicio del programa y procesa algo así como un "plan de construcción" (por ejemplo, un archivo de configuración) para todo el
sistema de software, es decir, instancia los objetos y servicios en el orden correcto y inyecta los servicios en los objetos que los
necesitan.
Figura 95. El Ensamblador es responsable de la creación e inyección de objetos.
228
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Por favor, preste atención a la agradable situación de dependencia. La dirección de las dependencias de creación.
(flechas discontinuas con el estereotipo «Crear») conduce desde el Ensamblador a los otros módulos (clases).
En otras palabras, ninguna clase en este diseño "sabe" que existe un elemento de infraestructura como un Ensamblador (Eso
no es completamente correcto, porque al menos otro elemento en el sistema de software sabe sobre la existencia de
este componente, porque el proceso de ensamblaje debe ser activado por alguien, generalmente al inicio del programa).
En algún lugar dentro del componente Ensamblador, posiblemente se podría encontrar algo como las siguientes líneas de
código:
Listado 911. Partes de la implementación del Ensamblador podrían verse así
// ...
Registrador loggingServiceToInject = std::make_shared<StandardOutputLogger>(); auto
customerRepository = std::make_shared<CustomerRepository>(loggingServiceToInject); // ...
Esta técnica DI se denomina inyección de constructor, porque el objeto de servicio que se va a inyectar se pasa como
argumento a un constructor de inicialización del objeto de cliente. La ventaja de la inyección del constructor es que el objeto
del cliente se inicializa por completo durante su construcción y se puede usar inmediatamente.
Pero, ¿qué hacemos si los objetos de servicio se van a inyectar en objetos de cliente mientras se ejecuta el programa, por
ejemplo, si un objeto de cliente solo se crea ocasionalmente durante la ejecución del programa, o si el registrador específico
debe intercambiarse en tiempo de ejecución? Luego, el objeto del cliente debe proporcionar un setter para el objeto del servicio,
como en el siguiente ejemplo:
Listado 912. La clase Customer proporciona un setter para inyectar un Logger
#include "Dirección.h"
#include "LoggingFacility.h"
clase Cliente { público:
Cliente() = predeterminado;
void setLoggingService(const Logger& loggingService) { logger =
loggingService; }
//...
privado:
Dirección Dirección;
registrador registrador; };
Esta técnica DI se llama inyección setter. Y, por supuesto, también es posible combinar la inyección de constructor y
la inyección de setter.
La Inyección de Dependencia es un patrón de diseño que hace que un diseño de software esté débilmente acoplado
y eminentemente configurable. Permite la creación de diferentes configuraciones de productos para diferentes clientes o
propósitos previstos de un producto de software. Aumenta enormemente la capacidad de prueba de un sistema de
software, ya que permite inyectar objetos simulados muy fácilmente. Por lo tanto, este patrón no debe ignorarse al diseñar
cualquier sistema de software serio. Si desea profundizar más en este patrón, le recomiendo leer el artículo del blog que marca
tendencia “La inversión de los contenedores de control y el patrón de inyección de dependencia” escrito por Martin Fowler [Fowler04].
229
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
En la práctica, a menudo se utilizan marcos de Inyección de Dependencia, que están disponibles comercialmente
y soluciones de código abierto.
Adaptador Estoy
seguro de que el Adaptador (sinónimo: Wrapper) es uno de los patrones de diseño más utilizados. La razón de esto es que la
adaptación de interfaces incompatibles es ciertamente un caso que a menudo es necesario en el desarrollo de software, por ejemplo, si
se debe integrar un módulo desarrollado por otro equipo, o cuando se utilizan bibliotecas de terceros.
Aquí está la declaración de la misión del patrón Adapter:
Convierta la interfaz de una clase en otra interfaz que esperan los clientes. El adaptador permite que las
clases trabajen juntas que de otro modo no podrían debido a las interfaces incompatibles.
—Erich Gamma et. al., Patrones de diseño [Gamma95]
Desarrollemos más el ejemplo de la sección anterior sobre Inyección de dependencia. Supongamos que queremos usar BoostLog v2
(ver http://www.boost.org) para fines de registro, pero queremos que el uso de esta biblioteca de terceros sea intercambiable con otros
enfoques y tecnologías de registro.
La solución es simple: solo tenemos que proporcionar otra implementación de la interfaz LoggingFacility, que adapta la
interfaz de BoostLog a la interfaz que queremos, como se muestra en la Figura 96.
Figura 96. Un adaptador para una solución de registro de Boost
En el código fuente, nuestra implementación adicional de la interfaz LoggingFacility BoostTrivialLog
El adaptador se ve de la siguiente manera:
Listado 913. El adaptador para Boost.Log es solo otra implementación de LoggingFacility
#incluye "LoggingFacility.h" #incluye
<boost/log/trivial.hpp>
clase BoostTrivialLogAdapter: public LoggingFacility { public: virtual void
writeInfoEntry(std::string_view entry) override {
BOOST_LOG_TRIVIAL(información) << entrada; }
230
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
virtual void writeWarnEntry(entrada std::string_view) override {
BOOST_LOG_TRIVIAL(aviso) << entrada; }
virtual void writeErrorEntry(entrada std::string_view) override {
BOOST_LOG_TRIVIAL(error) << entrada; } };
Las ventajas son obvias: a través del patrón Adapter, ahora hay exactamente una clase en todo mi sistema de software que
depende de la solución de registro de terceros. Esto también significa que nuestro código no está contaminado con declaraciones de
registro propietarias, como BOOST_LOG_TRIVIAL(). Y debido a que esta clase de adaptador es solo otra implementación de la interfaz
LoggingFacility, también puedo usar Inyección de dependencia (consulte la sección anterior) para inyectar instancias, o exactamente la
misma instancia, de esta clase en todos los objetos de cliente que quieran usarla.
Los adaptadores pueden facilitar una amplia gama de posibilidades de adaptación y conversión para interfaces incompatibles.
Esto va desde adaptaciones simples, como nombres de operaciones y conversiones de tipos de datos, hasta admitir un conjunto
completamente diferente de operaciones. En nuestro caso anterior, una llamada de una función miembro con un parámetro de cadena
se convierte en una llamada del operador de inserción para secuencias.
Las adaptaciones de interfaz son, por supuesto, más fáciles si las interfaces a adaptar son similares. Si las interfaces son
muy diferente, un adaptador también puede convertirse en una pieza de código muy compleja.
Estrategia Si
recordamos el Principio AbiertoCerrado (OCP) descrito en el Capítulo 6 como guía para un diseño extensible orientado a objetos, el
patrón de diseño de la estrategia puede considerarse como el "concierto de celebridad" de este importante principio. Aquí está la declaración
de la misión de este patrón:
Defina una familia de algoritmos, encapsule cada uno y hágalos intercambiables.
La estrategia permite que el algoritmo varíe independientemente de los clientes que lo utilicen.
—Erich Gamma et. al., Patrones de diseño [Gamma95]
Hacer las cosas de diferentes maneras es un requisito común en el diseño de software. Solo piense en ordenar algoritmos para
listas. Hay varios algoritmos de clasificación que tienen diferentes características con respecto a la complejidad del tiempo (número de
operaciones requeridas) y la complejidad del espacio (espacio de almacenamiento adicional requerido además de la lista de entrada).
Algunos ejemplos son BubbleSort, QuickSort, MergeSort, InsertSort y HeapSort.
Por ejemplo, BubbleSort es el menos complejo y es muy eficiente en cuanto al consumo de memoria, pero también
uno de los algoritmos de clasificación más lentos. Por el contrario, QuickSort es un algoritmo de clasificación rápido y eficiente que es
fácil de implementar a través de su estructura recursiva y no requiere memoria adicional, pero es muy ineficiente con listas preordenadas
e invertidas. Con la ayuda del patrón de estrategia, se puede implementar un simple intercambio del algoritmo de clasificación, por
ejemplo, dependiendo de las propiedades de la lista que se va a clasificar.
Consideremos otro ejemplo. Supongamos que queremos tener una representación textual de una instancia
de una clase Cliente en un sistema de TI comercial arbitrario. Un requisito de las partes interesadas establece que la representación
textual se formateará en varios formatos de salida: como texto sin formato, como XML (lenguaje de marcado extensible) y como
JSON (notación de objetos de JavaScript).
231
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Bien, antes que nada, presentemos una abstracción para nuestras diversas estrategias de formato, la clase abstracta
Formateador:
Listado 914. El formateador abstracto contiene todo lo que todas las clases específicas de formateador tienen en común
#include <memoria>
#include <cadena>
#include <vista_cadena>
#include <sstream>
formateador de clase
{ public:
virtual ~Formatter() = predeterminado;
Formateador y withCustomerId(std::string_view customerId) {
this>IdCliente = IdCliente; devolver
*esto; }
Formatter& withForename(std::string_view nombre) {
este>nombre = nombre; devolver
*esto; }
Formatter& withSurname(std::string_view apellido) { this>apellido
= apellido; devolver *esto; }
Formatter& withStreet(std::string_view street) { this>street =
street; devolver *esto; }
Formatter& withZipCode(std::string_view zipCode) { this>zipCode
= zipCode; devolver *esto; }
Formatter& withCity(std::string_view city) { this>city =
city; devolver *esto; }
virtual std::string format() const = 0;
protegido:
std::string customerId { "000000" }; std::string
nombre { "n/a" }; std::string apellido { "n/
a" }; std::string calle { "n/a" };
232
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
std::string código postal { "n/a" };
std::string city { "n/a" };n };
usando FormatterPtr = std::unique_ptr<Formatter>;
Los tres formateadores específicos que proporcionan los estilos de formato solicitados por las partes interesadas
son los siguientes:
Listado 915. Los tres formateadores específicos anulan la función miembro de formato virtual puro () de formateador
#include "Formatador.h"
clase PlainTextFormatter: Formateador público { público:
virtual
std::string format() const override { std::stringstream
formattedString { }; formattedString << "[" <<
IDcliente << "]: " << apellido << ", " << código postal <<
" "
<< nombre <<
" "
<< calle << ", " <<
ciudad << ".";
devuelve formattedString.str(); } };
clase XmlFormatter: Formateador público
{ público:
virtual std::string format() const override { std::stringstream
formattedString { }; formattedString << "<id del
cliente=\"" << ID del
cliente << "\">\n" <<
" <nombre>" << nombre << "</nombre>\n" << " <apellido>"
<< apellido << "</apellido>\n" << " <calle>" << calle <<
"</calle>\n" << " <código postal>" << código postal
<< "</código postal>\n" << " <ciudad>" << ciudad << "</
ciudad>\n" << "</cliente>\n"; devuelve
formattedString.str(); } };
clase JsonFormatter: Formateador público { público:
virtual
std::string format() const override { std::stringstream
formattedString { }; Cadena con formato << "{\n"
<<
" \"IdCliente : \"" << IdCliente << FIN_DE_PROPIEDAD <<
" \"Nombre: \"" << nombre << FIN_DE_PROPIEDAD <<
" \"Apellido: \"" << apellido << FIN_DE_PROPIEDAD <<
" \"Calle: \"" << calle << FIN_DE_PROPIEDAD <<
233
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
" \"Código postal: \"" << zipCode << FIN_DE_PROPIEDAD << "
\"Ciudad: \"" << ciudad << "\"\n" << "}\n";
return
formattedString.str ( ) ; }
privado:
static constexpr const char* const END_OF_PROPERTY { "\",\n" }; };
Como se puede ver claramente aquí, la OCP está particularmente bien apoyada. Tan pronto como se requiera
un nuevo formato de salida, solo se debe implementar otra especialización de la clase abstracta Formatter. No se
requieren modificaciones a los formateadores ya existentes.
Listado 916. Así es como se usa el objeto del formateador pasado dentro de la función miembro
getAsFormattedString()
#include "Dirección.h"
#include "Id. de Cliente.h"
#include "Formatador.h"
class Customer
{ public: // ... std::string getAsFormattedString(const FormatterPtr& formatter) const {
volver formateador>
withCustomerId(customerId.toString()).
withForename(nombre de
pila). con Apellido(apellido).
withStreet(dirección.getStreet()).
withZipCode(dirección.getZipCodeAsString()).
withCity(dirección.getCity()).
formato(); } // ...
privado:
ID de cliente ID de cliente;
std::string nombre;
std::string apellido;
Dirección Dirección; };
La función miembro Customer::getAsFormattedString() tiene un parámetro que espera un puntero único a
un objeto formateador. Este parámetro se puede usar para controlar el formato de la cadena que se puede
recuperar a través de esta función miembro o, en otras palabras: la función miembro Customer::getAsFormatted
String() se puede proporcionar con una estrategia de formato.
Por cierto: tal vez hayas notado el diseño especial de la interfaz pública del Formateador con sus numerosas
funciones encadenadas con...(). Aquí también se ha utilizado otro patrón de diseño, que se llama Fluent Interface. En
la programación orientada a objetos, una interfaz fluida es un estilo para diseñar API de manera que la legibilidad del
código sea similar a la de la prosa escrita ordinaria. En el capítulo anterior sobre Test Driven Development (Capítulo
8), ya vimos una interfaz de este tipo. Ahí hemos introducido una costumbre
234
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
aserción (consulte la sección "Pruebas más sofisticadas con una aserción personalizada") para escribir pruebas más elegantes y legibles. En
nuestro caso aquí, el truco es que cada función miembro with...() es autorreferencial, es decir, el nuevo contexto para llamar a una función
miembro en el Formateador es equivalente al contexto anterior, a menos que el formato final( ) se llama la función.
Como de costumbre, aquí también hay una visualización gráfica de la estructura de clases de nuestro ejemplo de código, un diagrama
de clases UML (Figura 97):
Figura 97. Una estrategia de formateo abstracta y sus tres estrategias de formateo concretas
Como es fácil de ver, el patrón de estrategia en este ejemplo asegura que la persona que llama a la función miembro Cust
omer::getAsFormattedString() pueda configurar el formato de salida como quiera. ¿Quieres admitir otro formato de salida? No hay problema:
gracias al excelente soporte del Principio AbiertoCerrado, se puede agregar fácilmente otra estrategia de formato concreta. Las otras estrategias
de formato, así como la clase Cliente, no se ven afectadas por esta extensión.
Dominio
Los sistemas de software generalmente tienen que realizar una variedad de acciones debido a la recepción de instrucciones. Los usuarios de
software de procesamiento de texto, por ejemplo, emiten una variedad de comandos al interactuar con la interfaz de usuario del software.
Quieren abrir un documento, guardar un documento, imprimir un documento, copiar un fragmento de texto, pegar un fragmento de texto
copiado, etc. Este patrón general también es observable en otros dominios. Por ejemplo, en el mundo financiero, podría haber órdenes de un
cliente a su corredor de valores para comprar acciones, vender acciones, etc. Y en un dominio más técnico como la fabricación, los comandos
se utilizan para controlar instalaciones y máquinas industriales.
Al implementar sistemas de software controlados por comandos, es importante asegurarse de que la solicitud de una acción esté
separada del objeto que realmente realiza la acción. El principio rector detrás de esto es el acoplamiento flexible (consulte el Capítulo 3) y la
separación de preocupaciones.
Una buena analogía es un restaurante. En un restaurante, el mesero acepta el pedido del cliente, pero no es responsable de
cocinar la comida. Esa es una tarea de la cocina del restaurante. De hecho, es incluso transparente para el cliente cómo se prepara la
comida. Tal vez el restaurante prepare la comida él mismo, pero la comida también puede ser entregada desde otro lugar.
235
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
En el desarrollo de software orientado a objetos existe un patrón de comportamiento llamado Comando (sinónimo:
Acción) que fomenta este tipo de desacoplamiento. Su declaración de misión es la siguiente:
Encapsule una solicitud como un objeto, lo que le permitirá parametrizar clientes con diferentes solicitudes,
poner en cola o registrar solicitudes y admitir operaciones que no se pueden deshacer.
—Erich Gamma et. al., Patrones de diseño [Gamma95]
Un buen ejemplo del patrón Comando es una arquitectura Cliente/Servidor, donde un cliente, el llamado Invocador , envía
comandos que deben ejecutarse en un servidor, al que se hace referencia como Receptor .
Comencemos con el Comando abstracto, que es una interfaz simple y pequeña que se ve de la siguiente manera:
Listado 917. La interfaz de comando
#include <memoria>
clase Comando
{ público:
virtual ~Comando() = predeterminado;
ejecución de vacío virtual () = 0; };
usando CommandPtr = std::shared_ptr<Comando>;
También introdujimos un alias de tipo (CommandPtr) para un puntero inteligente a los comandos.
Esta interfaz de comando abstracta ahora puede implementarse mediante varios comandos concretos. Déjanos primero
eche un vistazo a un comando muy simple, la salida de la cadena "¡Hola mundo!":
Listado 918. Una primera y muy sencilla implementación de un Comando concreto
#incluir <iostream>
class HelloWorldOutputCommand : public Command { public: virtual
void
execute() override {
std::cout << "¡Hola mundo!" << "\n";
} };
A continuación, necesitamos el elemento que acepta y ejecuta los comandos. Este elemento se llama Receptor en
la descripción general de este patrón de diseño. En nuestro caso es una clase llamada Servidor que cumple este rol:
Listado 919. El receptor de comandos
#include "Comando.h"
class Server
{ public:
void acceptCommand(const CommandPtr& command) { command
>execute(); } };
236
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Actualmente, esta clase contiene solo una función miembro pública simple que puede aceptar y ejecutar comandos.
Finalmente, necesitamos el llamado Invoker, que es la clase Cliente en nuestra arquitectura Cliente/Servidor:
Listado 920. El Cliente envía comandos al Servidor
clase Cliente
{ public:
void run()
{ Servidor theServer { };
CommandPtr helloWorldOutputCommand = std::make_shared<HelloWorldOutputCommand>();
elServidor.acceptCommand(helloWorldOutputCommand); } };
Dentro de la función main() encontramos el siguiente código simple:
Listado 921. La función principal()
#include "Cliente.h"
int main()
{ Cliente cliente { };
cliente.ejecutar();
devolver 0;
}
Si este programa ahora se está compilando y ejecutando, la salida "¡Hola mundo!" aparecerá en la salida estándar.
Bueno, a primera vista, esto puede parecer poco emocionante, pero lo que hemos logrado a través del patrón de comando es
que el origen y el envío del comando están desvinculados de su ejecución. Ahora podemos manejar objetos de comando así
como otros objetos.
Dado que este patrón de diseño admite muy bien el Principio AbiertoCerrado (OCP; consulte el Capítulo 6), también es
muy fácil de agregar nuevos comandos con modificaciones menores insignificantes del código existente. Por ejemplo, si
queremos forzar al servidor a esperar un cierto tiempo, podemos simplemente agregar el siguiente comando nuevo:
Listado 922. Otro comando concreto que le indica al servidor que espere
#include "Command.h"
#include <crono>
#include <hilo>
clase WaitCommand: comando público { público:
explícito
WaitCommand (const sin firmar int duración en milisegundos) no excepto :
duraciónEnMillisegundos{duraciónEnMillisegundos} { };
virtual void execute() override
{ std::chrono::milliseconds dur(durationInMilliseconds);
std::this_thread::sleep_for(dur); }
privado:
duración int sin firmar en milisegundos { 1000 }; };
237
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Ahora podemos usar el nuevo WaitCommand así:
Listado 923. Nuestro nuevo WaitCommand en uso
clase Cliente
{ public:
void run() {
Servidor elServidor { }; const
unsigned int SERVER_DELAY_TIMESPAN { 3000 };
CommandPtr waitCommand = std::make_shared<WaitCommand>(SERVER_DELAY_TIMESPAN);
elServidor.acceptCommand(waitCommand);
CommandPtr helloWorldOutputCommand = std::make_shared<HelloWorldOutputCommand>();
elServidor.acceptCommand(helloWorldOutputCommand); } };
Para obtener una visión general de la estructura que se ha originado hasta ahora, la Figura 98 muestra una
diagrama de clase UML correspondiente:
Figura 98. El servidor solo conoce la interfaz de comando, pero no ningún comando concreto.
Como se puede ver en este ejemplo, podemos parametrizar comandos con valores. Dado que la firma de la función
miembro de ejecución virtual pura () está especificada como sin parámetros por la interfaz de comando, la
parametrización se realiza con la ayuda de un constructor de inicialización. Además, no tuvimos que cambiar nada en el
servidor de clase, ya que pudo tratar y ejecutar el nuevo comando de inmediato.
El patrón Command ofrece múltiples posibilidades de aplicaciones. Por ejemplo, los comandos pueden
estar en cola Esto también admite una ejecución asíncrona de los comandos: el invocador envía el comando y luego
puede hacer otras cosas de inmediato, pero el receptor ejecuta el comando en un momento posterior.
Sin embargo, ¡falta algo! En la declaración de misión citada anteriormente del patrón de Comando, puede leer algo sobre
"...apoyar operaciones que no se pueden deshacer". Bueno, la siguiente sección está dedicada a ese tema.
238
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Procesador de comandos
En nuestro pequeño ejemplo de una arquitectura Cliente/Servidor de la sección anterior, hice un poco de trampa. En realidad, un
servidor no ejecutaría los comandos de esa manera como lo demostré anteriormente. El comando
los objetos que están llegando al servidor se distribuirían a las partes internas del servidor que son responsables de la
ejecución del comando. Esto se puede hacer, por ejemplo, con la ayuda de otro patrón que se llama Cadena de responsabilidad
(este patrón no se describe en este libro).
Consideremos otro ejemplo un poco más complejo. Supongamos que tenemos un programa de dibujo.
Los usuarios de este programa pueden dibujar muchas formas diferentes, por ejemplo, círculos y rectángulos. Para este propósito, los
menús correspondientes están disponibles en la interfaz de usuario del programa a través de los cuales se pueden invocar estas
operaciones de dibujo. Estoy bastante seguro de que lo ha adivinado: los desarrolladores de software bien calificados de este
programa implementaron el patrón de Comando para realizar estas operaciones de dibujo. Sin embargo, un requisito de las partes
interesadas establece que un usuario del programa también puede deshacer las operaciones de dibujo.
Para cumplir con este requisito, necesitamos, en primer lugar, comandos que se puedan deshacer.
Listado 924. La interfaz UndoableCommand se crea combinando Command y Revertable
#include <memoria>
clase Comando
{ público:
virtual ~Comando() = predeterminado;
ejecución de vacío virtual () = 0; };
class Reversible { public:
virtual
~Revertible() = predeterminado; deshacer vacío
virtual () = 0; };
class UndoableCommand : public Command, public Reversible { };
usando CommandPtr = std::shared_ptr<UndoableCommand>;
De acuerdo con el Principio de Segregación de la Interfaz (ISP; consulte el Capítulo 6), hemos agregado otra interfaz
Reversible que admite la función Deshacer. Esta nueva interfaz se puede combinar con la interfaz de Comando existente mediante la
herencia a un UndoableCommand.
Como ejemplo de muchos comandos de dibujo diferentes que se pueden deshacer, solo muestro el comando concreto para el
círculo aquí:
Listado 925. Un comando que se puede deshacer para dibujar círculos
#incluye "Comando.h"
#incluye "Procesador de dibujo.h" #incluye
"Punto.h"
clase DrawCircleCommand : public UndoableCommand { public:
DrawCircleCommand(DrawingProcessor& receptor, const Point& centerPoint, const doble radio) noexcept :
receptor { receptor }, centerPoint { centerPoint },
radio { radio } { }
239
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
virtual void execute() override
{ receptor.drawCircle(centerPoint, radius); }
virtual void undo() override
{ receptor.eraseCircle(centerPoint, radius); }
privado:
Procesador de dibujo y receptor;
const Punto centroPunto; const
radio doble; };
Es fácil imaginar que los comandos para dibujar un rectángulo y otras formas se parecen mucho.
El receptor de ejecución del comando es una clase llamada DrawingProcessor, que es el elemento que realiza las
operaciones de dibujo. Se pasa una referencia a este objeto junto con otros argumentos durante la construcción del
comando (ver constructor de inicialización). En este lugar solo muestro un pequeño extracto de la clase probablemente
compleja DrawingProcessor, porque no juega un papel importante para la comprensión del patrón:
Listado 926. El DrawingProcessor es el elemento que realizará las operaciones de dibujo.
class DrawingProcessor { public:
void
drawCircle(const Point& centerPoint, const doble radio) {
// Instrucciones para dibujar un círculo en la pantalla...
};
void eraseCircle(const Point& centerPoint, const doble radio) {
// Instrucciones para borrar un círculo de la pantalla...
};
// ...
};
Ahora llegamos a la pieza central de este patrón, el CommandProcessor:
Listado 927. La clase CommandProcessor administra una pila de objetos de comando que se pueden deshacer
#incluir <pila>
class CommandProcessor
{ public:
void ejecutar(const CommandPtr& comando) { comando
>ejecutar();
comandoHistorial.push(comando); }
240
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
void undoLastCommand() { if
(commandHistory.empty()) { return; }
commandHistory.top()>undo();
comandoHistorial.pop(); }
privado:
std::stack<std::shared_ptr<Revertible>> commandHistory; };
La clase CommandProcessor (que, por cierto, no es segura para subprocesos cuando se utiliza la
implementación anterior) contiene un std::stack<T> (definido en el encabezado <stack>), que es un tipo de datos abstracto que
funciona como LIFO (Last Entrada primero en salir). Después de que la función miembro CommandProcessor::execute() haya
desencadenado la ejecución de un comando, el objeto del comando se almacena en la pila commandHistory. Al llamar a la función
miembro CommandProcessor::undoLastCommand(), el último comando almacenado en la pila se deshace y luego se elimina
de la parte superior de la pila.
Además, la operación de deshacer ahora se puede modelar como un objeto de comando. En este caso, el receptor de comandos
es, por supuesto, el mismo CommandProcessor:
Listado 928. El UndoCommand solicita al CommandProcessor que realice una acción de deshacer
#include "Comando.h"
#incluir "CommandProcessor.h"
class UndoCommand : public UndoableCommand { public:
undoCommand explícito (CommandProcessor& receptor) noexcept : receptor { receptor }
{ }
virtual void execute() override
{ receptor.undoLastCommand(); }
deshacer vacío virtual () anular {
// Se dejó en blanco intencionalmente, porque no se debe deshacer una operación de deshacer. }
privado:
Procesador de comandos y receptor; };
241
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
¿Perdió la visión general? Bien, una vez más es hora de un “panorama general” en la forma de un diagrama de clases
UML (Figura 99).
Figura 99. El CommandProcessor (a la derecha) ejecuta los comandos que recibe y gestiona un historial de comandos
Cuando se utiliza el patrón de comando en la práctica, a menudo se enfrenta a la necesidad de poder componer un
comando más complejo a partir de varios comandos simples o grabar y reproducir comandos (secuencias de comandos). Para
poder implementar dichos requisitos de una manera elegante, el siguiente patrón de diseño es adecuado.
Compuesto
Una estructura de datos muy utilizada en Ciencias de la Computación es la de un árbol. Los árboles se pueden encontrar en todas
partes. Por ejemplo, la organización jerárquica de un sistema de archivos en un medio de datos (por ejemplo, un disco duro) se
ajusta a la de un árbol. El navegador de proyectos de un entorno de desarrollo integrado (IDE) suele tener una estructura de
árbol. En el diseño de compiladores, el árbol de sintaxis abstracta (AST) es, como sugiere su nombre, una representación en
árbol de la estructura sintáctica abstracta del código fuente que suele ser el resultado de la fase de análisis de sintaxis de un
compilador.
El modelo orientado a objetos para una estructura de datos en forma de árbol se denomina patrón compuesto . Este patrón tiene
la siguiente intención:
Componga objetos en estructuras de árbol para representar jerarquías de partetodo. Composite permite a
los clientes tratar objetos individuales y composiciones de objetos de manera uniforme.
—Erich Gamma et. al., Patrones de diseño [Gamma95]
242
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Nuestro ejemplo anterior de las secciones Comando y Procesador de comandos debe ampliarse con la posibilidad
de que podamos crear comandos compuestos y que los comandos se puedan grabar y reproducir. Entonces
agregamos una nueva clase al diseño anterior, un ComandoCompuesto:
Listado 929. Un nuevo UndoableCommand concreto que gestiona una lista de comandos
#include "Comando.h"
#include <vector>
class CompositeCommand : public UndoableCommand
{ public:
void addCommand(CommandPtr& command)
{ commands.push_back(command); }
virtual void ejecutar () anular {
for (const auto& command : commands) {
comando>ejecutar(); } }
virtual void undo() override { for (const
auto& command : commands) { command
>undo(); } }
privado:
comandos std::vector<CommandPtr>; };
El comando compuesto tiene una función miembro addCommand(), que le permite agregar comandos a una
instancia de CompositeCommand. Dado que la clase CompositeCommand también implementa la interfaz
UndoableCommand, sus instancias pueden tratarse como comandos ordinarios. En otras palabras, también es
posible ensamblar comandos compuestos con otros comandos compuestos jerárquicamente. A través de la
estructura recursiva del patrón Composite, puede generar árboles de comando.
243
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
El siguiente diagrama de clases UML (Figura 910) muestra el diseño extendido.
Figura 910. Con CompositeCommand agregado (a la izquierda), los comandos ahora se pueden programar
La clase CompositeCommand recién agregada ahora se puede usar, por ejemplo, como una grabadora de macros para
para grabar y reproducir secuencias de comandos:
Listado 930. Nuestro nuevo CompositeCommand en acción como grabador de macros
int principal() {
Procesador de comandos Procesador de comandos { };
Procesador de dibujo Procesador de dibujo { };
auto macroRecorder = std::make_shared<CompositeCommand>();
Punto circuloCentroPunto { 20, 20 }; CommandPtr
drawCircleCommand = std::make_shared<DrawCircleCommand>(drawingProcessor, circleCenterPoint, 10);
commandProcessor.execute(drawCircleCommand);
macroRecorder>addCommand(dibujarCircleCommand);
Punto rectánguloCentroPunto { 30, 10 }; CommandPtr
drawRectangleCommand = std::make_shared<DrawRectangleCommand>(drawingProcessor, rectánguloCenterPoint, 5, 8);
commandProcessor.execute(drawRectangleCommand);
macroRecorder>addCommand(dibujarRectangleCommand);
244
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
commandProcessor.execute(macroRecorder);
CommandPtr undoCommand = std::make_shared<UndoCommand>(commandProcessor);
commandProcessor.execute(deshacerComando);
devolver 0;
}
Con la ayuda del patrón Composite, ahora es muy fácil ensamblar secuencias de comandos complejas a
partir de comandos simples (estos últimos se denominan "hojas" en la forma canónica). Dado que CompositeCommand
también implementa la interfaz UndoableCommand, se pueden usar exactamente como los comandos simples. Esto simplifica
enormemente el uso a través del código del cliente.
En una inspección más cercana hay una pequeña desventaja. Es posible que haya notado que el acceso a la función
miembro CompositeCommand::addCommand() solo es posible si usa una instancia (macroRecorder) del tipo concreto
CompositeCommand (consulte el código fuente anterior). Esta función miembro no está disponible a través de la interfaz
UndoableCommand. En otras palabras, ¡aquí no se da el trato igualitario prometido (recuerde la intención del patrón) de
compuestos y hojas!
Si observa el patrón compuesto general en [Gamma95], verá que las funciones administrativas para
administrar elementos secundarios se declaran en la abstracción. En nuestro caso, sin embargo, esto significaría que
tendríamos que declarar un addCommand() en la interfaz UndoableCommand (lo que sería una violación del ISP, por cierto).
La consecuencia fatal sería que los elementos hoja tendrían que anular addCommand() y proporcionar una implementación
significativa para esta función miembro.
¡Esto no es posible! ¿Qué pasará, por favor, que no viole el Principio del Mínimo Asombro (ver Capítulo 3), si añadimos
un comando a una instancia de DrawCircleCommand?
Si hiciéramos eso, sería una violación del Principio de Sustitución de Liskov (LSP; consulte el Capítulo 6).
Por lo tanto, es mejor hacer una compensación en nuestro caso y prescindir del tratamiento igualitario de composites y
hojas.
Observador
Un patrón de arquitectura bien conocido para la estructuración de sistemas de software es ModelViewController (MVC).
Con la ayuda de este patrón de arquitectura, que se describe en detalle en el libro PatternOriented Software Architecture
[Busch96], normalmente se estructura la parte de presentación (interfaz de usuario) de una aplicación.
El principio detrás de esto es la Separación de preocupaciones (SoC). Entre otras cosas, los datos a mostrar, que se mantienen
en el llamado modelo, se separan de las múltiples representaciones visuales (las llamadas vistas) de estos datos.
En MVC, el acoplamiento entre las vistas y el modelo debe ser lo más flexible posible. Este acoplamiento suelto
generalmente se realiza con el patrón Observer . El observador es un patrón de comportamiento que se describe en [Gamma95]
y tiene la siguiente intención:
Defina una dependencia de uno a muchos entre objetos para que cuando un objeto cambie de estado,
todos sus dependientes sean notificados y actualizados automáticamente.
—Erich Gamma et. al., Patrones de diseño [Gamma95]
Como de costumbre, el patrón se puede explicar mejor con un ejemplo. Consideremos una aplicación de hoja de cálculo,
que es un componente natural de muchas suites de software de oficina. En una aplicación de este tipo, los datos se
pueden mostrar en una hoja de trabajo, en un gráfico circular y en muchas otras formas de presentación; las llamadas vistas.
Se pueden crear diferentes vistas de los datos y también cerrarlas de nuevo.
245
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
En primer lugar, necesitamos un elemento abstracto para las vistas que se llama Observer.
Listado 931. El observador abstracto
#include <memoria>
class Observer
{ público:
virtual ~Observer() = predeterminado;
getId int virtual () = 0;
actualización de vacío virtual () =
0; };
usando ObserverPtr = std::shared_ptr<Observador>;
Los Observadores observan a un llamado Sujeto. Para ello, podrán ser registrados en el Sujeto, y también
podrán ser dados de baja.
Listado 932. Los observadores se pueden agregar y eliminar de un llamado Sujeto
#include "Observer.h"
#include <algoritmo>
#include <vector>
class IsEqualTo final { público:
explícito
IsEqualTo(const ObserverPtr& Observer) :
observador { observador } { }
operador bool()(const ObserverPtr& observadorParaComparar) {
return observadorParaComparar>getId() == observador>getId(); }
privado:
observadorPtr observador; };
class Asunto
{ public:
void addObserver(ObserverPtr& observerToAdd) { auto iter
= std::find_if(begin(observadores), end(observadores),
IsEqualTo(observerToAdd)); if
(iter == end(observadores))
{ observadores.push_back(observerToAdd); }
void removeObserver(ObserverPtr& ObserverToRemove) {
observadores.erase(std::remove_if(begin(observadores), end(observadores),
IsEqualTo(observerToRemove)), end(observadores));
}
246
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
protegido:
void notificar a Todos los Observadores()
const { for (const auto& observador : observadores)
{ observador
>actualizar(); } }
privado:
std::vector<ObserverPtr> observadores; };
Además de la clase Sujeto, también se define un Functor llamado IsEqualTo (ver Capítulo 7 about Functors),
que se usa para comparaciones al agregar y eliminar observadores. El Functor compara los ID del Observer. También
sería concebible que compare las direcciones de memoria de las instancias de Observer. Entonces incluso sería
posible que varios observadores del mismo tipo se registraran en el Sujeto.
El núcleo es la función de miembro notificar a Todos los Observadores(). Está protegido ya que está destinado a
ser llamado por los Sujetos concretos que se heredan de éste. Esta función itera sobre todos los observadores
registrados y llama a su función miembro update().
Veamos un tema concreto, el modelo de hoja de cálculo.
Listado 933. El modelo de hoja de cálculo es un sujeto concreto
#incluir "Asunto.h" #incluir
<iostream>
#incluir <vista_cadena>
class SpreadsheetModel : public Subject { public:
void
changeCellValue(std::string_view column, const int row, const double value) { std::cout << "Celda [" <<
"
columna << ", " << fila << " ] = // Cambiar el valor de una celda de hoja << valor << std::endl;
de cálculo y luego... notificar a Todos los Observadores(); } };
Esto, por supuesto, es solo un mínimo absoluto de un modelo de hoja de cálculo. Solo sirve para explicar
el principio funcional del patrón. Lo único que puede hacer aquí es llamar a una función miembro que llama a la función
notificar a Todos los Observadores() heredada.
Los tres observadores concretos de nuestro ejemplo que implementan la función miembro update() del
La interfaz del observador son las tres vistas TableView, BarChartView y PieChartView.
Listado 934. Tres vistas concretas implementan la interfaz abstracta de Observer
#incluye "Observador.h"
#incluye "Modelo de hoja de cálculo.h"
class TableView: observador público { público:
TableView explícito (Modelo de hoja de cálculo y el modelo):
modelo { el modelo} {} virtual
int getId () invalidar { return 1;
247
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
anular la actualización virtual del vacío () {
std::cout << "Actualización de TableView". << estándar::endl; }
privado:
Modelo de hoja de cálculo y
modelo; };
class BarChartView: public Observer { public:
explicit
BarChartView (SpreadsheetModel& theModel) : model { theModel }
{ } virtual int getId() override
{ return 2;
virtual void update() override { std::cout
<< "Actualización de BarChartView". << estándar::endl; }
privado:
Modelo de hoja de cálculo y
modelo; };
clase PieChartView: public Observer { público:
explícito
PieChartView (SpreadsheetModel& theModel) : model { theModel }
{ } virtual int getId() override
{ return 3;
virtual void update() override { std::cout
<< "Actualización de PieChartView". << estándar::endl; }
privado:
Modelo de hoja de cálculo y
modelo; };
Creo que es hora de volver a mostrar una visión general en forma de diagrama de clases. La figura 911
muestra la estructura (clases y dependencias) que ha surgido.
248
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Figura 911. Cuando se cambia el modelo de hoja de cálculo, notifica a todos sus observadores
En la función main() ahora usamos el modelo de hoja de cálculo y las tres vistas de la siguiente manera:
Listado 935. Nuestro modelo de hoja de cálculo y las tres vistas ensambladas y en acción
#include "SpreadsheetModel.h" #include
"SpreadsheetViews.h"
int principal() {
Modelo de hoja de cálculo modelo de hoja de cálculo { };
ObserverPtr observador1 = std::make_shared<TableView>(spreadsheetModel); modelo de hoja de
cálculo.addObserver(observador1);
ObserverPtr observador2 = std::make_shared<BarChartView>(spreadsheetModel); modelo de hoja de
cálculo.addObserver(observador2);
modelo de hoja de cálculo.cambiarValorCelda("A", 1, 42);
modelo de hoja de cálculo.removeObserver(observador1);
modelo de hoja de cálculo.cambiarValorCelda("B", 2, 23.1);
ObserverPtr observador3 = std::make_shared<PieChartView>(spreadsheetModel); modelo de hoja de
cálculo.addObserver(observador3);
modelo de hoja de cálculo.cambiarValorCelda("C", 3, 3.1415926);
devolver 0; }
249
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Después de compilar y ejecutar el programa, vemos lo siguiente en la salida estándar:
Celda [A, 1] = 42
Actualización de TableView.
Actualización de BarChartView.
Celda [B, 2] = 23.1
Actualización de BarChartView.
Celda [C, 3] = 3.14153
Actualización de BarChartView.
Actualización de PieChartView.
Además de la característica positiva del acoplamiento débil (el Sujeto concreto no sabe nada acerca de los Observadores), este
patrón también apoya muy bien el Principio AbiertoCerrado. Se pueden agregar muy fácilmente nuevos observadores concretos (en nuestro
caso, nuevas vistas) ya que no es necesario ajustar ni cambiar nada en las clases existentes.
Fábricas
De acuerdo con el principio de Separación de preocupaciones (SoC), la creación o adquisición de objetos debe estar separada de
las tareas específicas del dominio que tiene un objeto. El patrón de inyección de dependencia discutido anteriormente sigue este principio
de una manera directa, porque todo el proceso de creación de objetos está centralizado en un elemento de infraestructura y los
objetos no tienen que preocuparse por eso.
Pero, ¿qué haremos si se requiere que un objeto se cree dinámicamente en algún punto en
tiempo de ejecución? Bueno, esta tarea puede ser asumida por una fábrica de objetos.
El patrón de diseño de fábrica es básicamente relativamente simple y aparece en las bases de código de muchas maneras diferentes.
formas y variedades. Además del principio SoC, también se admite en gran medida la ocultación de información (consulte el Capítulo 3),
porque el proceso de creación de una instancia debe ocultarse a sus usuarios.
Como ya se ha dicho, las fábricas se pueden encontrar en innumerables formas y variantes. Discutimos solo una variante simple.
Fábrica sencilla
La implementación probablemente más simple de una fábrica se ve así (tomamos el ejemplo de registro del
sección DI anterior):
Listado 936. Probablemente la fábrica de objetos más simple imaginable
#incluye "LoggingFacility.h" #incluye
"StandardOutputLogger.h"
class LoggerFactory { público:
registrador
estático crear () {
return std::make_shared<StandardOutputLogger>(); } };
250
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
El uso de esta fábrica muy simple se ve de la siguiente manera:
Listado 937. Uso de LoggerFactory para crear una instancia de Logger
#include "LoggerFactory.h"
int main()
{ Registrador registrador =
LoggerFactory::create(); // ...registrar
algo... return
0; }
Tal vez te preguntes ahora si vale la pena gastar una clase extra para una tarea tan insignificante. Bueno, tal vez no. Es
más sensato, si la fábrica pudiera crear varios registradores y decidiera de qué tipo será. Esto se puede hacer, por ejemplo,
leyendo y evaluando un archivo de configuración, o se lee una determinada clave de la base de datos del Registro de
Windows. También es imaginable que el tipo de objeto generado dependa de la hora del día. Las posibilidades son infinitas.
Es importante que esto sea completamente transparente para la clase de cliente. Entonces, aquí hay un LoggerFactory un
poco más sofisticado que lee un archivo de configuración (por ejemplo, desde el disco duro) y decide sobre la configuración
actual, qué registrador específico se crea:
Listado 938. Una fábrica más sofisticada que lee y evalúa un archivo de configuración
#include "LoggingFacility.h" #include
"StandardOutputLogger.h" #include
"FilesystemLogger.h"
#include <fstream>
#include <cadena>
#include <vista_cadena>
class LoggerFactory { privado:
enum
class OutputTarget: int {
SALIDA ESTÁNDAR,
ARCHIVO };
public:
explícito LoggerFactory(std::string_view nombre del archivo de configuración) : nombre
del archivo de configuración { nombre del archivo de configuración } { }
Registrador crear () const {
const std::string ConfigurationFileContent = readConfigurationFile();
OutputTarget outputTarget = evaluarConfiguración(configuraciónArchivoContenido); return
createLogger(objetivo de salida); }
privado:
std::string readConfigurationFile() const {
std::ifstream filestream(configurationFileName); return
std::string(std::istreambuf_iterator<char>(filestream), std::istreambuf_iterator<char>()); }
251
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
OutputTarget evaluarConfiguración(std::string_view configuraciónArchivoContenido) const {
// Evaluar el contenido del archivo de configuración... return
OutputTarget::STDOUT; }
Logger createLogger(OutputTarget outputTarget) const { switch
(outputTarget) { case
OutputTarget::FILE: return
std::make_shared<FilesystemLogger>(); case
OutputTarget::STDOUT:
predeterminado: return std::make_shared<StandardOutputLogger>(); }
const std::string nombre del archivo de configuración; };
El diagrama de clases UML de la figura 912 muestra la estructura que conocemos básicamente de la sección
sobre inyección de dependencias (figura 95), pero ahora con nuestra LoggerFactory simple en lugar de un ensamblador.
Figura 912. El Cliente utiliza una LoggerFactory para obtener Loggers concretos
Una comparación de este diagrama con la figura 95 muestra una diferencia significativa: mientras que
la clase CustomerRepository no depende del Ensamblador, el Cliente "conoce" la clase de fábrica cuando usa el patrón
Factory. Presumiblemente, esta dependencia no es un problema grave, pero deja claro una vez más que un
acoplamiento flojo se lleva al máximo con Dependency Injection.
252
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Fachada
El patrón de fachada es un patrón estructural que se usa a menudo a nivel arquitectónico y tiene la siguiente intención:
Proporcionar una interfaz unificada a un conjunto de interfaces en un subsistema. Facade define una interfaz
de nivel superior que hace que el subsistema sea más fácil de usar.
—Erich Gamma et. al., Patrones de diseño [Gamma95]
La estructuración de un gran sistema de software de acuerdo con los principios de Separación de preocupaciones, Principio de
responsabilidad única (consulte el Capítulo 6) y Ocultación de información (consulte el Capítulo 3) generalmente tiene como
resultado que se originen algún tipo de componentes o módulos más grandes. En general, estos componentes o módulos a veces
se pueden denominar "subsistemas". Incluso en una arquitectura en capas, las capas individuales se pueden considerar como subsistemas.
Para promover la encapsulación, la estructura interna de un componente o subsistema debe estar oculta para sus
clientes (consulte Ocultación de información en el Capítulo 3). La comunicación entre subsistemas y, por lo tanto, la cantidad
de dependencias entre ellos, debe minimizarse. Sería fatal que los clientes de un subsistema deban conocer detalles sobre su
estructura interna y la interacción de sus partes.
Una fachada regula el acceso a un subsistema complejo al proporcionar una interfaz simple y bien definida para los clientes.
Cualquier acceso al subsistema deberá realizarse únicamente por encima de la Fachada.
El siguiente diagrama UML (Figura 913) muestra un subsistema llamado Facturación para preparar facturas.
Su estructura interna consta de varias partes interconectadas. Los clientes del subsistema no pueden acceder a estas partes
directamente. Deben utilizar Facade BillingService, que está representado por un Puerto UML (estereotipo «fachada») en el borde del
subsistema.
Figura 913. El subsistema de facturación proporciona un servicio de facturación de fachada como un punto de acceso para los clientes
En C++, y también en otros lenguajes, una fachada no es nada especial. A menudo es solo una clase simple que
está recibiendo llamadas en su interfaz pública y las reenvía a la estructura interna del subsistema.
A veces es solo un simple reenvío de una llamada a uno de los elementos estructurales internos del subsistema, pero ocasionalmente
un Fachada también realiza conversiones de datos, entonces también es un Adaptador (ver la sección sobre Adaptador).
253
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
En nuestro ejemplo, la clase Facade BillingService implementa dos interfaces, representadas por UML
notación de bola. De acuerdo con el Principio de Segregación de Interfaz (ISP; ver Capítulo 6), la configuración del subsistema
Facturación (interfaz Configuración) está separada de la generación de facturas (interfaz InvoiceCreation). Por lo tanto, Facade
debe anular las operaciones que se declaran en ambas interfaces.
Money Class Si la alta
precisión es importante, debe evitar los valores de coma flotante. Las variables de punto flotante de tipo float, double o long double ya
fallan en adiciones simples, como lo demuestra este pequeño ejemplo:
Listado 939. Al sumar 10 números de coma flotante de esta manera, es posible que el resultado no sea lo suficientemente preciso
#incluye <afirmación.h>
#incluye <iostream>
int principal() {
doble suma = 0.0;
sumando doble = 0,3;
for (int i = 0; i < 10; i++) { suma = suma +
sumando; };
afirmar (suma == 3.0);
devolver 0; }
Si compila y ejecuta este pequeño programa, esto es lo que verá como resultado de la consola:
Aserción fallida: suma == 3.0, archivo ..\main.cpp, línea 13
Creo que la causa de esta desviación es generalmente conocida. Los números de coma flotante se almacenan
internamente en formato binario. Debido a esto es imposible almacenar un valor de 0.3 (y otros) precisamente en una variable de tipo
float, double o long double, porque no tiene una representación exacta de longitud finita en binario.
En decimal, tenemos un problema similar. No podemos representar el valor 1/3 (un tercio) usando solo notación decimal.
0.33333333 no es completamente exacto.
Hay varias soluciones para este problema. Para las monedas, puede ser un enfoque adecuado almacenar el valor del
dinero en un número entero con la precisión requerida, por ejemplo, $12,45 se almacenará como 1245. Si los requisitos no son
muy altos, un número entero puede ser una solución factible. Tenga en cuenta que el estándar C++ no especifica el tamaño de los
tipos integrales en bytes; por lo tanto, debe tener cuidado con cantidades muy grandes ya que puede ocurrir un desbordamiento de
enteros. En caso de duda, se debe utilizar un número entero de 64 bits, ya que puede contener grandes cantidades de dinero.
254
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
DETERMINACIÓN DEL RANGO DE UN TIPO ARITMÉTICO
Los rangos específicos de la implementación real para los tipos aritméticos (ya sea enteros o de punto flotante) se
pueden encontrar como plantillas de clase en el encabezado <límites>. Por ejemplo, así es como encontrará el
rango máximo para int:
#include <límites>
constexpr auto INT_LOWER_BOUND = std::numeric_limits<int>::min(); constexpr auto
INT_UPPER_BOUND = std::numeric_limits<int>::max();
Otro enfoque popular es proporcionar una clase especial para este propósito, la llamada Clase de Dinero:
Proporcione una clase para representar cantidades exactas de dinero. Una Clase de Dinero maneja diferentes
monedas y cambios entre ellas.
—Martin Fowler, Patrones de arquitectura de aplicaciones empresariales [Fowler02]
Figura 914. Una clase de dinero
El patrón Money Class es básicamente una clase que encapsula una cantidad financiera y su moneda, pero tratar
con dinero es solo un ejemplo de esta categoría de clases. Hay muchas otras propiedades, o dimensiones, que deben
ser representadas con precisión, por ejemplo, medidas precisas en física (Tiempo, Voltaje, Corriente, Distancia, Masa,
Frecuencia, Cantidad de sustancias…).
255
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
1991: MISIL PATRIOT DESCONTINUADO
MIM104 Patriot es un sistema de misiles tierraaire (SAM) que fue diseñado y fabricado por Raytheon Company de
los Estados Unidos. Su aplicación típica es contrarrestar misiles balísticos tácticos de gran altitud, misiles de crucero y
aeronaves avanzadas. Durante la primera Guerra del Golfo Pérsico (1990 1991), también conocida como operación
"Tormenta del Desierto", Patriot se utilizó para derribar misiles balísticos de corto alcance SCUD iraquíes o Al Hussein
entrantes.
El 25 de febrero de 1991, una batería en Dhahran, una ciudad ubicada en la provincia oriental de Arabia Saudita, no pudo
interceptar un SCUD. El misil impactó en un cuartel del Ejército y provocó 28 muertos y 98 heridos.
Un informe de investigación [GAOIMTEC92] reveló que la causa de esta falla fue un cálculo inexacto del tiempo
transcurrido desde que se encendió el sistema debido a errores aritméticos de la computadora. Para que los misiles de
Patriot puedan detectar y alcanzar el objetivo después del lanzamiento, deben aproximarse espacialmente al objetivo,
también conocido como "puerta de alcance". Para predecir dónde aparecerá el objetivo a continuación (el llamado
ángulo de deflexión), se deben realizar algunos cálculos con el tiempo del sistema y la velocidad de vuelo del
objetivo. El tiempo transcurrido desde el inicio del sistema se midió en décimas de segundo y se expresó como un número
entero. La velocidad del objetivo se midió en millas por segundo y se expresó como un valor decimal. Para calcular la
"puerta de rango", el valor del temporizador del sistema debe multiplicarse por 1/10 para obtener el tiempo en segundos.
Este cálculo se realizó utilizando registros que tienen solo 24 bits de longitud.
El problema era que el valor de 1/10 en decimal no se puede representar con precisión en un registro de 24 bits.
El valor se cortó en 24 bits después del punto de raíz. La consecuencia fue que la conversión de tiempo de un
número entero a un número real da como resultado una pequeña pérdida de precisión que provoca un cálculo de tiempo
menos preciso. Este error de precisión probablemente no habría sido un problema si el sistema solo hubiera estado en
funcionamiento durante unas pocas horas, de acuerdo con su concepto de funcionamiento como sistema móvil.
Pero en este caso, el sistema ha estado funcionando durante más de 100 horas. El número que representaba el tiempo
de actividad del sistema era bastante grande. Esto significó que el pequeño error de conversión de 1/10 en su
representación decimal de 24 bits resultó en un gran error de desviación de casi medio segundo. Un misil SCUD
iraquí viaja aprox. 800 metros en este lapso de tiempo, lo suficientemente lejos como para estar fuera de la "puerta de
alcance" de un misil Patriot que se aproxima.
Si bien el manejo preciso de cantidades de dinero es un caso muy común en muchos sistemas de TI
empresariales, tendrá que luchar en vano para encontrar una clase de dinero en la mayoría de las bibliotecas de
clases base de C++ convencionales. ¡Pero no reinventes la rueda! Hay multitud de implementaciones diferentes de
C++ Money Class, solo pregúntele al motor de búsqueda de su confianza y obtendrá miles de resultados. Como sucede
a menudo, una implementación no satisface todos los requisitos. La clave es comprender el dominio de su
problema. Al elegir (o diseñar) una Money Class, puede considerar varias limitaciones y requisitos. Aquí hay algunas
preguntas que quizás deba aclarar primero:
•¿Cuál es el rango completo de valores a manejar (mínimo, máximo)?
•¿Qué reglas de redondeo se aplican? Existen leyes o prácticas nacionales para los redondeos en
algunos paises.
•¿Existen requisitos legales para la precisión?
256
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
• ¿Qué normas se deben tener en cuenta (p. ej., la norma internacional ISO 4217 para códigos de
moneda)?
•¿Cómo se mostrarán los valores al usuario?
•¿Con qué frecuencia tendrá lugar la conversión?
Desde mi perspectiva, es absolutamente esencial tener una cobertura de prueba unitaria del 100 % (consulte el Capítulo
2 sobre pruebas unitarias) para una clase de dinero para verificar si la clase está funcionando como se esperaba en todas las
circunstancias. Por supuesto, Money Class tiene un pequeño inconveniente en comparación con la representación numérica
pura con un número entero: pierde una pizca de rendimiento. Esto podría ser un problema en algunos sistemas. Pero estoy
convencido de que en la mayoría de los casos predominarán las ventajas (siempre ten en cuenta que la optimización prematura es mala).
Objeto de caso especial (objeto nulo)
En la sección “Don't Pass or Return 0 (NULL, nullptr)” en el Capítulo 4 aprendimos que devolver un nullptr desde una función o
método es malo y debe evitarse. Allí también discutimos varias estrategias para evitar punteros regulares (en bruto) en un programa
C++ moderno. En la sección “Una excepción es una excepción, ¡literalmente!” en el Capítulo 5 aprendimos que las excepciones solo
deben usarse para casos excepcionales reales y no con el propósito de controlar el flujo normal del programa.
La pregunta abierta e interesante ahora es esta: ¿Cómo tratamos esos casos especiales, que no son excepciones reales
(por ejemplo, una asignación de memoria fallida), sin usar un nullptr no semántico u otros valores extraños?
Retomemos de nuevo nuestro ejemplo de código, que hemos visto varias veces antes: la consulta de un Cliente por su nombre.
Listado 940. Un método de búsqueda de clientes por nombre
Customer CustomerService::findCustomerByName(const std::string& name) { // Código que busca al
cliente por nombre... // ...pero ¿qué hacemos si no existe un
cliente con el nombre dado? }
Bueno, una posibilidad sería devolver listas siempre en lugar de una sola instancia. Si la lista devuelta es
vacío, el objeto comercial consultado no existe:
Listado 941. Una alternativa a nullptr: devolver una lista vacía si falla la búsqueda de un cliente
#include "Cliente.h" #include
<vector>
utilizando CustomerList = std::vector<Cliente>;
CustomerList CustomerService::findCustomerByName(const std::string& name) {
// Código que busca el cliente por nombre... // ...y si no existe un
cliente con el nombre dado: return CustomerList(); }
La lista devuelta ahora se puede consultar en la siguiente secuencia del programa si está vacía. Pero que
la semántica tiene una lista vacía? ¿Fue un error responsable del vacío de la lista? Bueno, la función miembro
std::vector<T>::empty() no puede responder esta pregunta. Estar vacío es un estado de una lista, pero este estado no tiene una
semántica específica de dominio.
257
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Amigos, sin duda, esta solución es mucho mejor que devolver un nullptr, pero tal vez no sea lo suficientemente bueno
en algunos casos. Lo que sería mucho más cómodo es un valor de retorno que se pueda consultar sobre su causa de
origen, y sobre qué se puede hacer con él. ¡ La respuesta es el patrón de Caso Especial !
Una subclase que proporciona un comportamiento especial para casos particulares.
—Martin Fowler, Patrones de arquitectura de aplicaciones empresariales [Fowler02]
La idea detrás del patrón de casos especiales es que aprovechamos el polimorfismo y proporcionamos clases que representan
los casos especiales, en lugar de devolver nullptr o algún otro valor impar. Estas clases de casos especiales tienen la misma
interfaz que la clase "normal" que esperan las personas que llaman. El diagrama de clases de la figura 915 representa tal
especialización.
Figura 915. Las clases que representan un caso especial se derivan de la clase Cliente
En el código fuente de C++, una implementación de la clase Customer y la clase NotFoundCustomer
representar el caso especial se parece a esto (solo se muestran las partes relevantes):
Listado 942. Un extracto del archivo Customer.h con las clases Customer y NotFoundCustomer
#ifndef CLIENTE_H_
#define CLIENTE_H_
#include "Address.h"
#include "CustomerId.h" #include
<memoria> #include
<cadena>
class Customer
{ public: // ...más funciones miembro aquí... virtual
~Customer() = default;
virtual bool isPersistable() const noexcept {
return (IDcliente.isValid() && ! nombre.empty() && ! apellido.empty() &&
dirección de facturación>es válida() && dirección de envío>es válida());
}
258
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
privado:
ID de cliente ID de cliente;
std::string nombre; std::string
apellido;
std::shared_ptr<Dirección> dirección de facturación;
std::shared_ptr<Dirección> dirección de envío; };
class NotFoundCustomer final : public Customer { public: virtual
bool
isPersistable() const noexcept override { return false;
} };
usando CustomerPtr = std::unique_ptr<Cliente>;
#endif /* CLIENTE_H_ */
Los objetos que representan el caso especial ahora se pueden usar en gran medida como si fueran instancias
válidas (normales) de la clase Cliente. Las comprobaciones nulas permanentes, incluso cuando el objeto se pasa entre
diferentes partes del programa, son superfluas, ya que siempre hay un objeto válido. Se pueden hacer muchas cosas con
el objeto NotFoundCustomer, como si fuera una instancia de Customer, por ejemplo, presentarlo en una interfaz de usuario.
El objeto puede incluso revelar si es persistente. Para el Cliente “real”, esto se hace analizando sus campos de datos. Sin
embargo, en el caso de NotFoundCustomer, esta comprobación siempre tiene un resultado negativo.
Y en comparación con las comprobaciones nulas sin sentido, una declaración como la siguiente hace significativamente
mas sentido:
if (cliente.esPersistente()) {
// ...escriba el cliente en una base de datos aquí...
}
estándar::opcional<T> [C++17]
Desde C++17, existe otra alternativa interesante que podría usarse para un posible resultado o valor faltante: std::opcional<T>
(definido en el encabezado <opcional>). Las instancias de esta plantilla de clase representan un "valor contenido opcional", es
decir, un valor que puede o no estar presente.
La clase Cliente se puede usar como un valor opcional usando std::opcional<T> introduciendo un alias de tipo de la
siguiente manera:
#incluye "Cliente.h" #incluye
<opcional> usando
OpcionalCliente = std::opcional<Cliente>;
Nuestra función de búsqueda CustomerService::findCustomerByName() ahora se puede implementar de la siguiente manera:
clase CustomerRepository { público:
OptionalCustomer findCustomerByName(const std::string& name) {
259
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
if ( /* la búsqueda fue exitosa */ ) { return Cliente(); } más
{ volver {}; } } };
En el sitio de llamada de la función, ahora tiene dos formas de manejar el valor de retorno, como se ilustra en
el siguiente ejemplo:
int main()
{ Repositorio CustomerRepository { }; auto
opcionalCliente = repository.findCustomerByName("John Doe");
// Opción 1: Detectar una excepción, si 'clienteopcional' está vacío intente { cliente automático
=
Clienteopcional.valor();
} catch (std::bad_opcional_acceso& ex) { std::cerr <<
ex.what() << std::endl; }
// Opción 2: Proporcione un sustituto para un objeto que posiblemente falte auto cliente =
opcionalCliente.valor_or(NoEncontradoCliente());
devolver 0;
}
En la segunda opción, por ejemplo, es posible proporcionar un cliente estándar (predeterminado) o, como en
este caso, una instancia de un objeto de caso especial, si el cliente opcional está vacío. Recomiendo elegir la
primera opción cuando la ausencia de un objeto es inesperada y es una pista de que se ha producido un error
grave. Para los demás casos, en los que la falta de un objeto no es nada inusual, recomiendo la opción 2.
¿Qué es un idioma?
Un idioma de programación es un tipo especial de patrón para resolver un problema en un lenguaje de programación o tecnología
específica. Es decir, a diferencia de los patrones de diseño más generales, las expresiones idiomáticas tienen una aplicabilidad limitada.
A menudo, su aplicabilidad se limita exactamente a un lenguaje de programación específico o una determinada tecnología, por ejemplo, un
marco.
Los modismos se utilizan normalmente durante el diseño y la implementación detallados, si los problemas de programación deben
resolverse con un bajo nivel de abstracción. Un modismo bien conocido en el dominio de C y C++ es el llamado Include Guard, a veces
también llamado Macro Guard o Header Guard, que se usa para evitar la doble inclusión del mismo archivo de encabezado:
#ifndef NOMBRE DE ARCHIVO_H_
#define NOMBRE DE ARCHIVO_H_
// ...contenido del archivo de cabecera...
#terminara si
260
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Una desventaja de esta expresión es que se debe garantizar un esquema de nomenclatura coherente para los nombres de archivos y,
por lo tanto, también para los nombres de macros de includeguard. Por lo tanto, la mayoría de los compiladores de C y C++ admiten una directiva
#pragma once no estándar en la actualidad. Esta directiva, insertada en la parte superior de un archivo de encabezado, garantizará que el
archivo de encabezado se incluya solo una vez.
Por cierto, ya conocemos algunos modismos. En el Capítulo 4 discutimos el recurso
Adquisición es inicialización (RAII), y en el Capítulo 7 hemos visto el modismo EraseRemove.
Algunos modismos útiles de C++
No es una broma, pero en realidad puede encontrar una colección exhaustiva de casi 100(!) expresiones idiomáticas de C++ en Internet (WikiBooks:
More C++ Idioms; URL: https://en.wikibooks.org/wiki/More_C++_Idioms ). El problema es que no todos estos modismos conducen a un programa
C++ moderno y limpio. A veces son muy complejos y apenas comprensibles (p. ej., jerarquía algebraica), incluso para desarrolladores de C++
bastante hábiles.
Además, algunos modismos se han vuelto obsoletos en gran medida con la publicación de C++ 11 y los estándares posteriores. Por lo
tanto, presento aquí solo una pequeña selección, que considero interesante y aún útil.
El poder de la inmutabilidad
A veces es una gran ventaja tener clases para objetos que no pueden cambiar su estado una vez que han sido creados, también conocidas
como clases inmutables (lo que realmente quiere decir con esto son de hecho objetos inmutables, porque propiamente hablando una clase
solo puede ser alterada por un desarrollador). Por ejemplo, los objetos inmutables se pueden usar como valores clave en una estructura de datos
hash, ya que el valor clave nunca debe cambiar después de la creación.
Otro ejemplo conocido de un inmutable es la clase String en varios otros lenguajes como C# o Java.
Los beneficios de las clases inmutables, respectivamente, y los objetos son los siguientes:
•Los objetos inmutables son seguros para subprocesos de forma predeterminada, por lo que no tendrá ningún
problemas de sincronización si varios subprocesos o procesos acceden a esos objetos de forma no
determinista. Por lo tanto, la inmutabilidad facilita la creación de un diseño de software paralelizable ya que no hay
conflictos entre los objetos.
• La inmutabilidad hace que sea más fácil escribir, usar y razonar sobre el código, porque una clase invariable, es
decir, un conjunto de restricciones que siempre deben cumplirse, se establece una vez en la creación del
objeto y se garantiza que permanecerá sin cambios durante la vida del objeto. toda la vida.
Para crear una clase inmutable en C++, se deben tomar las siguientes medidas:
•Todas las variables miembro de la clase deben ser inmutables, es decir, todas deben ser constantes (consulte la sección
sobre Corrección de constantes en el Capítulo 4). Esto significa que solo se pueden inicializar una vez en un
constructor, utilizando la lista de inicializadores de miembros del constructor.
•Los métodos de manipulación no cambian el objeto en el que se llaman, pero devuelven una nueva instancia de la clase
con un estado alterado. El objeto original no se modifica.
Para enfatizar esto, no debería haber setter, porque una función miembro cuyo nombre comienza con set…() es
engañosa. No hay nada que establecer en un objeto inmutable.
•La clase debe marcarse como final. Esta no es una regla estricta, pero si se puede heredar una nueva clase de una clase
supuestamente inmutable, podría ser posible eludir su inmutabilidad.
261
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Aquí hay un ejemplo de una clase inmutable en C++:
Listado 943. El empleado está diseñado como una clase inmutable.
#include "Identificador.h"
#include "Dinero.h"
#include <string>
#include <string_view>
clase Empleado final { público:
Empleado(std::string_view nombre,
std::string_view apellido, const
Identificador y número de personal,
const Dinero y salario) noexcept : nombre
{ nombre }, apellido { apellido },
número de personal
{ número de personal }, salario
{ salario } { }
Identifier getStaffNumber() const noexcept { return
staffNumber; }
Dinero getSalario() const noexcept { return
salario; }
Empleado changeSalary(const Money& newSalary) const noexcept { return
Employee(nombre, apellido, staffNumber, newSalary); }
privado:
const std::string nombre; const
std::string apellido; const
Identificador staffNumber; salario; const
Dinero };
El fallo de sustitución no es un error (SFINAE)
De hecho, la falla de sustitución no es un error (abreviado: SFINAE) no es un modismo real sino una característica
del compilador de C++. Ya ha sido parte del estándar C++98, pero con C++11 se han agregado varias características
nuevas. Sin embargo, todavía se lo conoce como modismo, también porque se usa en un estilo muy idiomático,
especialmente en bibliotecas de plantillas, como la biblioteca estándar de C++ o Boost.
262
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
El pasaje de texto definitorio en el estándar se puede encontrar en la sección 14.8.2 sobre la deducción de argumentos de
plantilla. Allí podemos leer en §8 la siguiente afirmación:
Si una sustitución da como resultado un tipo o una expresión no válidos, la deducción de tipo falla. Un tipo o
expresión inválida es aquella que estaría mal formada si se escribiera utilizando los argumentos sustituidos.
Solo los tipos y expresiones no válidos en el contexto inmediato del tipo de función y sus tipos de parámetros
de plantilla pueden generar un error de deducción.
—Estándar para el lenguaje de programación C++ [ISO11]
Los mensajes de error en caso de una instanciación defectuosa de las plantillas de C++, por ejemplo, con argumentos de
plantilla incorrectos, pueden ser muy detallados y crípticos. SFINAE es una técnica de programación que garantiza que una sustitución
fallida de los argumentos de la plantilla no genere un molesto error de compilación. En pocas palabras, significa que si falla la
sustitución de un argumento de plantilla, el compilador continúa con la búsqueda de una plantilla adecuada en lugar de abortar con
un error.
Aquí hay un ejemplo muy simple con dos plantillas de funciones sobrecargadas:
Listado 944. SFINAE por ejemplo de dos plantillas de funciones sobrecargadas
#incluir <iostream>
template <nombre de tipo T>
void print(nombre de tipo T::tipo) {
std::cout << "Llamando a print(typename T::type)" << std::endl; }
template <typename T> void
print(T) { std::cout <<
"Llamando a print(T)" << std::endl; }
estructura Aestructura {
usando tipo = int; };
int main()
{ imprimir<Astruct>(42);
imprimir<int>(42);
imprimir (42);
devolver 0; }
La salida de este pequeño ejemplo en stdout será:
Llamando a print(nombre de tipo T::tipo)
Llamando a imprimir (T)
Llamando a imprimir (T)
Como puede verse, el compilador usa la primera versión de print() para la primera llamada de función y la segunda
versión para las dos convocatorias posteriores. Y este código también funciona en C++98.
263
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Bueno, pero SFINAE anterior a C++11 tenía varios inconvenientes. El ejemplo muy simple anterior es un poco
engañoso con respecto al esfuerzo real de usar esta técnica en proyectos reales. La aplicación de SFINAE de esta manera
en las bibliotecas de plantillas ha dado lugar a un código muy detallado y complicado que es difícil de entender. Además,
está mal estandarizado y, a veces, es específico del compilador.
Con la llegada de C++11, se introdujo la llamada biblioteca Type Traits, que ya conocimos en el Capítulo 7.
Especialmente la metafunción std::enable_if() (definida en el encabezado <type_traits>), que está disponible desde C+
+11, ahora juega un papel central en SFINAE. Con esta función, obtenemos una "capacidad de eliminación de funciones"
condicional de la resolución de sobrecarga basada en rasgos de tipo. En otras palabras, podemos, por ejemplo, elegir
una función en su versión sobrecargada dependiendo del tipo de argumento como este:
Listado 945. SFINAE utilizando la plantilla de función std::enable_if<>
#include <iostream>
#include <tipo_rasgos>
template <typename T> void
print(T var, typename std::enable_if<std::is_enum<T>::value, T>::type* = 0) { std::cout << "Llamada sobrecargada
print() para enumeraciones". << estándar::endl; }
template <typename T> void
print(T var, typename std::enable_if<std::is_integral<T>::value, T>::type = 0) { std::cout << "Llamando a print()
sobrecargado para tipos integrales". << estándar::endl; }
template <typename T> void
print(T var, typename std::enable_if<std::is_floating_point<T>::value, T>::type = 0) { std::cout << "Llamando a print()
sobrecargado para tipos de coma flotante". << estándar::endl; }
template <typename T> void
print(const T& var, typename std::enable_if<std::is_class<T>::value, T>::type* = 0) {
std::cout << "Llamando a print() sobrecargado para clases". << estándar::endl; }
Las plantillas de funciones sobrecargadas se pueden usar simplemente llamándolas con argumentos de diferentes
tipos, como este:
Listado 946. Gracias a SFINAE, hay una función print() coincidente para argumentos de diferente tipo
enum Enumeración1 {
Literal1,
Literal2 };
enum clase Enumeración2: int {
Literal1,
Literal2 };
264
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
clase Clazz { };
int principal() {
Enumeración1 enumVar1 { };
imprimir(enumVar1);
Enumeración2 enumVar2 { };
imprimir(enumVar2);
imprimir (42);
instancia de Clazz { };
imprimir (instancia);
imprimir (42.0f);
imprimir (42.0);
devolver 0;
}
Después de compilar y ejecutar, vemos el siguiente resultado en la salida estándar:
Llamar a print() sobrecargado para enumeraciones.
Llamar a print() sobrecargado para enumeraciones.
Llamar a print() sobrecargado para tipos integrales.
Llamar a print() sobrecargado para clases.
Llamar a print() sobrecargado para tipos de punto flotante.
Llamar a print() sobrecargado para tipos de punto flotante.
Debido al hecho de que la versión C++11 de std::enable_if es un poco detallada, C++14 ha agregado una
alias denominado std::enable_if_t.
El idioma de copiar e intercambiar
En la sección "La prevención es mejor que el cuidado posterior" en el Capítulo 5, hemos aprendido los cuatro niveles de garantía
de seguridad de excepción: seguridad sin excepción, seguridad con excepción básica, seguridad con excepción fuerte y la
garantía de no tirar. Lo que las funciones miembro de una clase siempre deben garantizar es la seguridad de excepción básica,
porque este nivel de seguridad de excepción suele ser fácil de implementar.
En la sección “La Regla del Cero” en el Capítulo 5 hemos aprendido que debemos diseñar clases siempre de manera que
las funciones miembro especiales generadas automáticamente por el compilador (constructor de copia, operador de asignación de
copia, etc.) hagan automáticamente las cosas correctas. O dicho de otro modo: cuando nos vemos obligados a
proporcionar un destructor no trivial, estamos ante un caso excepcional que requiere un tratamiento especial durante la
destrucción del objeto. Como consecuencia, se deduce que las funciones miembro especiales generadas por el compilador no
son suficientes para hacer frente a esta situación, y tenemos que implementarlas nosotros mismos.
Sin embargo, ocasionalmente es inevitable que la Regla del Cero no se pueda cumplir, es decir, un desarrollador tiene que
implementar las funciones de miembros especiales por sí mismo. En este caso, puede ser una tarea desafiante crear una
implementación segura de excepción de un operador de asignación sobrecargado. En tal caso, el modismo Copiar e intercambiar
es una forma elegante de resolver este problema.
265
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Por lo tanto, la intención de este modismo es la siguiente:
Implemente el operador de asignación de copia con una fuerte seguridad de excepción.
La forma más sencilla de explicar el problema y su solución es un pequeño ejemplo. Considere la siguiente clase:
Listado 947. Una clase que administra un recurso que se asigna en el montón
#incluir <cstddef>
clase Clazz final { público:
Clazz(const std::size_t size) : resourceToManage { new char[size] }, size { size } { }
~Clazz()
{ eliminar [] resourceToManage; }
privado:
char* resourceToManage;
estándar::tamaño_t
tamaño; };
Esta clase es, por supuesto, solo para fines de demostración y no debe ser parte de un programa real.
Supongamos que queremos hacer lo siguiente con la clase Clazz:
int principal() {
instancia Clazz1 { 1000 };
Clazz instancia2 { instancia1 }; devolver
0; }
Ya lo sabemos por el capítulo 5 que la versión generada por el compilador de un constructor de copia hace el
algo incorrecto aquí: ¡solo crea una copia plana del puntero de carácter resourceToManage!
Por lo tanto, tenemos que proporcionar nuestro propio constructor de copias, así:
#incluye <algoritmo>
clase Clazz final
{ público: // ...
Clazz(const Clazz& other) : Clazz { other.size }
{ std::copy(other.resourceToManage, other.resourceToManage + other.size, resourceToManage); } // ...
};
Hasta ahora, todo bien. Ahora la construcción de la copia funcionará bien. Pero ahora también necesitaremos una tarea de copia
operador. Si no está familiarizado con el idioma de copiar e intercambiar, una implementación de un operador de
asignación podría verse así:
266
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
#incluye <algoritmo>
clase Clazz final
{ público: // ...
Operador Clazz& =(const Clazz& otro) {
if (&otro == esto) { return
*esto; } eliminar
[] recurso para administrar;
resourceToManage = new char[otro.tamaño];
std::copy(other.resourceToManage, other.resourceToManage + other.size, resourceToManage);
tamaño = otro.tamaño;
devolver *esto;
} // ... };
Básicamente, este operador de asignación funcionará, pero tiene varios inconvenientes. Por ejemplo, el
código constructor y destructor está duplicado en él, lo cual es una violación del principio DRY (ver Capítulo 3).
Además, hay una verificación de autoasignación al principio. Pero la mayor desventaja es que no podemos garantizar
la seguridad excepcional. Por ejemplo, si la declaración nueva provoca una excepción, el objeto puede quedar en un estado
extraño que viola las invariantes de clase elemental.
¡Ahora entra en juego el idioma de copiar e intercambiar, también conocido como "CrearTemporaleIntercambiar"!
Para una mejor comprensión, presento ahora toda la clase Clazz:
Listado 948. Una implementación mucho mejor de un operador de asignación usando el idioma de copiar e intercambiar
#incluir <algoritmo> #incluir
<cstddef>
clase Clazz final { público:
Clazz(const std::size_t size) : resourceToManage { new char[size] }, size { size } { }
~Clazz()
{ eliminar [] resourceToManage; }
Clazz(const Clazz& other) : Clazz { other.size }
{ std::copy(other.resourceToManage, other.resourceToManage + other.size, resourceToManage);
Operador Clazz& =( Otro Clazz)
{ swap(otro);
devolver *esto; }
267
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
privado:
void swap(Clazz y otros) noexcept { using
std::swap;
swap(recursoParaGestionar, otro.recursoParaGestionar);
swap(tamaño, otro.tamaño); }
char* resourceToManage;
estándar::tamaño_t
tamaño; };
¿Cuál es el truco aquí? Veamos el operador de asignación completamente diferente. Esto ya no tiene una referencia
constante (const Clazz y otro) como parámetro, sino un parámetro de valor ordinario (Clazz otro). Esto significa que cuando se
llama a este operador de asignación, primero se llama al constructor de copias de Clazz. El constructor de copia, a su vez,
llama al constructor predeterminado que asigna memoria para el recurso. Y eso es exactamente lo que queremos: ¡necesitamos
una copia temporal de otro!
Ahora llegamos al corazón del modismo: la llamada de la función miembro privada Clazz::swap().Dentro
esta función, el contenido de la instancia temporal other, es decir, sus variables miembro, se intercambia (“swapped”)
con el contenido de las mismas variables miembro de nuestro propio contexto de clase (this). Esto se hace usando la función
std::swap() que no lanza (definida en el encabezado <utility>). Después de las operaciones de intercambio, el objeto temporal
otro ahora posee los recursos que antes eran propiedad de este objeto, y viceversa.
viceversa
Además, la función de miembro Clazz::swap() ahora hace que sea muy fácil implementar un movimiento
constructor:
clase Clazz
{ público: // ...
Clazz(Clazz&& otro) noexcept
{ swap(otro); } // ... };
Por supuesto, el objetivo principal en un buen diseño de clase debe ser que no sea necesario implementar
constructores de copia y operadores de asignación explícitos (Regla del Cero). Pero cuando se vea obligado a hacerlo,
debe recordar el idioma de copiar e intercambiar.
Puntero a la implementación (PIMPL)
La última sección de este capítulo está dedicada a un modismo con el divertido acrónimo PIMPL. PIMPL significa
Puntero a la Implementación; y el idioma también se conoce como Handle Body, Compilation Firewall o técnica del gato
de Cheshire (el gato de Cheshire es un personaje ficticio, un gato sonriente, de la novela Alicia en el país de las
maravillas de Lewis Carroll). Y tiene, por cierto, algunas similitudes con el patrón Bridge descrito en [Gamma95].
La intención del PIMPL podría formularse de la siguiente manera:
Elimine las dependencias de compilación en los detalles de implementación de la clase interna reubicándolos
en una clase de implementación oculta y, por lo tanto, mejore los tiempos de compilación.
268
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
Echemos un vistazo a un extracto de nuestra clase Cliente, una clase que hemos visto en muchos ejemplos antes:
Listado 949. Un extracto del contenido del archivo de cabecera Customer.h
#ifndef CLIENTE_H_
#define CLIENTE_H_
#include "Dirección.h"
#include "Identificador.h" #include
<cadena>
clase Cliente { público:
Cliente();
virtual ~Cliente()
= predeterminado; std::string getFullName()
const; void setShippingAddress( dirección y
dirección const); // ...
privado:
Identificador Idcliente; std::string
nombre; std::string apellido;
dirección dirección de envío; };
#endif /* CLIENTE_H_ */
Supongamos que se trata de una entidad comercial central en nuestro sistema de software comercial y que muchas otras
clases la utilizan (#include "Customer.h"). Cuando este archivo de encabezado cambia, cualquier archivo que use ese archivo
deberá volver a compilarse, incluso si solo se agrega una variable miembro privada, se cambia el nombre, etc.
Para reducir estas recopilaciones al mínimo absoluto, entra en juego el lenguaje PIMPL.
Primero reconstruimos la interfaz de clase de la clase Cliente de la siguiente manera:
Listado 950. El archivo de encabezado alterado Customer.h
#ifndef CLIENTE_H_
#define CLIENTE_H_
#include <memoria>
#include <cadena>
dirección de clase ;
clase Cliente { público:
Cliente();
~Cliente virtual
(); std::string getFullName()
const; void setShippingAddress( dirección y
dirección const); // ...
269
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
privado:
clase Impl;
std::unique_ptr<Impl> impl; };
#endif /* CLIENTE_H_ */
Llama la atención que todas las variables miembro privadas anteriores, así como sus directivas de inclusión
asociadas, ahora han desaparecido. En su lugar, está presente una declaración de avance para una clase denominada
Impl, así como un std::unique_ptr<T> para esta clase declarada de avance.
Y ahora echemos un vistazo al archivo de implementación correspondiente:
Listado 951. El contenido del archivo Customer.cpp
#include "Cliente.h"
#include "Dirección.h"
#include "Identificador.h"
clase Cliente::Impl final { public:
std::string
getFullName() const; void
setShippingAddress( dirección y dirección const);
privado:
Identificador Idcliente;
std::string nombre;
std::string apellido;
dirección dirección de envío; };
std::string Cliente::Impl::getFullName() const {
" "
volver nombre + + apellido;
}
void Cliente::Impl::setShippingAddress( dirección y dirección const) {
dirección de envío = dirección; }
// La implementación de la clase Cliente comienza aquí...
Cliente::Cliente() : impl { std::make_unique<Cliente::Impl>() } { }
Cliente::~Cliente() = predeterminado;
std::string Cliente::getFullName() const { return impl
>getFullName(); }
void Customer::setShippingAddress(const Dirección y dirección) {
impl>setShippingAddress(dirección); }
270
Machine Translated by Google
Capítulo 9 ■ Patrones de diseño y modismos
En la parte superior del archivo de implementación (hasta el comentario del código fuente), podemos ver
la clase Customer::Impl. En esta clase, ahora se ha reubicado todo, lo anterior lo ha hecho directamente la
clase Cliente. Aquí también encontramos todas las variables miembro.
En la sección inferior (comenzando con el comentario), ahora encontramos la implementación de la
clase Cliente. El constructor crea una instancia de Customer::Impl y la mantiene en el puntero inteligente impl. Por
lo demás, cualquier llamada de la API de clase Cliente se delega al objeto de implementación interno.
Si ahora hay que cambiar algo en la implementación interna en Customer::Impl, el compilador
solo debe compilar Customer.h/Customer.cpp, y luego el enlazador puede comenzar su trabajo de inmediato.
Tal cambio no tiene ningún efecto en el exterior y se evita una compilación de casi todo el proyecto que
consume mucho tiempo.
271
Machine Translated by Google
APÉNDICE A
Pequeña guía UML
El OMG Unified Modeling Language™ (OMG UML) es un lenguaje gráfico estandarizado para crear modelos de software y
otros sistemas. Su objetivo principal es permitir que los desarrolladores, arquitectos de software y otras partes interesadas diseñen,
especifiquen, visualicen, construyan y documenten artefactos de un sistema de software.
Los modelos UML respaldan la discusión entre diferentes partes interesadas, sirven como ayuda para aclarar los requisitos y otras
cuestiones relacionadas con el sistema de interés, y pueden capturar decisiones de diseño.
Este apéndice proporciona una breve descripción general de ese subconjunto de notaciones UML que se utilizan en este libro. Cada
El elemento UML se ilustra (sintaxis) y se explica brevemente (semántica). La definición abreviada de un elemento se basa en la
especificación UML actual [OMG15] que se puede descargar de forma gratuita desde el sitio web de OMG.
Se debe realizar una introducción en profundidad al lenguaje de modelado unificado con la ayuda de la literatura adecuada o tomando
un curso en un proveedor de capacitación.
diagramas de clase
Entre otras aplicaciones variadas, los diagramas de clases se utilizan generalmente para representar estructuras de un diseño de software
orientado a objetos.
Clase
El elemento central en los diagramas de clases es la clase.
CLASE
Una clase describe un conjunto de objetos que comparten las mismas especificaciones de características, restricciones
y semántica.
Una instancia de una clase se conoce comúnmente como un objeto. Por lo tanto, las clases pueden ser consideradas como
planos de objetos. El símbolo UML para una clase es un rectángulo, como se muestra en la Figura A1.
© Stephan Roth 2017 273
S. Roth, C++ limpio, DOI 10.1007/9781484227930
Machine Translated by Google
APÉNDICE a ■ Guía UML pequeña
Figura A1. Una clase llamada Cliente
Una clase tiene un nombre (en este caso, "Cliente"), que se muestra centrado en el primer compartimento del
símbolo rectangular. Si una clase es abstracta, es decir, no se puede instanciar, su nombre generalmente se muestra en
letras cursivas. Las clases pueden tener atributos (datos, estructura) y operaciones (comportamiento), que se muestran en los
compartimentos segundo, respectivo y tercero. El tipo de un atributo se indica separado por dos puntos después del nombre
del atributo. Lo mismo se aplica al tipo del valor de retorno de una operación. Las operaciones pueden tener parámetros que
se especifican entre paréntesis (corchetes). Los atributos u operaciones estáticos están subrayados.
Las clases tienen un mecanismo para regular el acceso a atributos y operaciones. En UML se llaman
visibilidades El tipo de visibilidad se coloca delante del nombre del atributo o de la operación y puede ser uno de los
caracteres que se describen en la Tabla A1.
Tabla A1. Visibilidades
Tipo de visibilidad del personaje
+ public: Este atributo u operación es visible para todos los elementos que pueden acceder a la clase.
# protegido: este atributo u operación no solo es visible dentro de la clase en sí, sino que también es visible para
los elementos que se derivan de la clase que lo posee (consulte Relación de generalización).
~ paquete: este atributo u operación es visible para los elementos que están en el mismo paquete que su clase
propietaria. Este tipo de visibilidad no tiene una representación adecuada en C++ y no se usa en este libro.
privado: este atributo u operación solo es visible dentro de la clase, en ningún otro lugar.
Una definición de clase C++ correspondiente a la clase UML que se muestra en la Figura A1 anterior puede verse así:
Listado A1. La clase Cliente en C++
#include <string>
#include "DateTime.h"
#include "CustomerIdentifier.h"
clase Cliente { público:
Cliente();
~Cliente virtual ();
274
Machine Translated by Google
APÉNDICE a ■ Guía UML pequeña
std::string getFullName() const;
DateTime getCumpleaños() const;
std::string getPrintableIdentifier() const;
privado:
std::string nombre;
std::string apellido;
Identificación del identificador de
cliente; };
La representación gráfica de las instancias rara vez es necesaria, por lo que los llamados diagramas de objetos
de UML solo juegan un papel menor. El símbolo UML para representar una instancia creada (es decir, un objeto) de
una clase, la llamada Especificación de instancia, es muy similar al de una clase. La principal diferencia es que la
leyenda del primer compartimento está subrayada. Muestra el nombre de la instancia especificada, separado por dos
puntos de su tipo, por ejemplo, la clase (consulte la Figura A2). También puede faltar el nombre (instancia anónima).
Figura A2. La Especificación de instancia a la derecha representa una existencia posible o real de una instancia de
clase Cliente
Interfaz
Una interfaz define un tipo de contrato: una clase que realiza la interfaz debe cumplir ese contrato.
INTERFAZ
Una interfaz es una declaración de un conjunto de obligaciones públicas coherentes.
Las interfaces siempre son abstractas, es decir, no se pueden instanciar de forma predeterminada. El símbolo
UML para una interfaz es muy similar a una clase, con la palabra clave «interfaz» (entre comillas francesas que se
denominan «guillemets») que precede al nombre, como se muestra en la Figura A3.
275
Machine Translated by Google
APÉNDICE a ■ Guía UML pequeña
Figura A3. Class Customer implementa operaciones que se declaran en la interfaz Person
La flecha discontinua con la punta de flecha cerrada pero sin rellenar es la relación de realización de la interfaz .
Esta relación expresa que la clase se ajusta al contrato especificado por la interfaz, es decir, la clase implementa aquellas
operaciones que son declaradas por la interfaz. Por supuesto, está permitido que una clase implemente múltiples interfaces.
A diferencia de otros lenguajes orientados a objetos, como Java o C#, no existe una palabra clave de interfaz en C++.
Por lo tanto, las interfaces generalmente se emulan con la ayuda de clases abstractas que consisten únicamente en funciones de
miembros virtuales puros, como se muestra en los siguientes ejemplos de código.
Listado A2. La interfaz de persona en C++
#include <cadena>
#include "FechaHora.h"
class Person { public:
virtual
~Person() { } virtual std::string
getFullName() const = 0; virtual DateTime getbirthday() const =
0; };
276
Machine Translated by Google
APÉNDICE a ■ Guía UML pequeña
Listado A3. La clase Customer realizando la interfaz Person
#include "Persona.h"
#include "IdentificadorCliente.h"
clase Cliente : persona pública { público:
Cliente();
~Cliente virtual ();
virtual std::string getFullName() const override; virtual
DateTime getbirthday() const override; std::string
getPrintableIdentifier() const;
privado:
std::string nombre;
std::string apellido;
Identificación del identificador de
cliente; };
Para mostrar que una clase o componente (consulte la sección Componentes a continuación) proporciona o requiere
interfaces, puede utilizar la denominada notación de bola y cavidad. Una interfaz proporcionada se representa con una bola
(también conocida como "piruleta"), una interfaz requerida se representa con un zócalo. Estrictamente hablando, esta es una notación
alternativa, como lo aclara la Figura A4.
Figura A4. La notación de rótula para las interfaces proporcionadas y requeridas
La flecha entre la clase Cliente y la interfaz Cuenta es una asociación navegable, que se explica
en la siguiente sección sobre asociaciones UML.
277
Machine Translated by Google
APÉNDICE a ■ Guía UML pequeña
Asociación
Las clases suelen tener relaciones estáticas con otras clases. La asociación UML especifica este tipo de relación.
ASOCIACIÓN
Una relación de asociación permite que una instancia de un clasificador (por ejemplo, una clase o un componente)
acceda a otra.
En su forma más simple, la sintaxis UML para una asociación es una línea sólida entre dos clases, como se muestra en
la Figura A5.
Figura A5. Una relación de asociación simple entre dos clases.
Esta simple asociación muchas veces no es suficiente para especificar adecuadamente la relación entre ambas clases.
Por ejemplo, la dirección de navegación a través de una asociación tan simple, es decir, quién puede acceder a quién, no
está definida de forma predeterminada. Sin embargo, la navegabilidad en este caso suele interpretarse como bidireccional por
convención, es decir, el Cliente tiene un atributo para acceder a ShoppingCart y viceversa. Por lo tanto, se puede proporcionar
más información a una asociación. La Figura A6 ilustra algunas de las posibilidades.
278
Machine Translated by Google
APÉNDICE a ■ Guía UML pequeña
Figura A6. Algunos ejemplos de asociaciones entre clases
1. Este ejemplo muestra una asociación con un extremo navegable (representado por un
punta de flecha) y el otro de navegabilidad no especificada. La semántica es: la clase A puede
navegar a la clase B. En la otra dirección no se especifica, es decir, la clase B podría navegar a la
clase A.
■ Nota Se recomienda enfáticamente definir la interpretación de la navegabilidad de tal extremo de asociación
no especificado en su proyecto. Mi recomendación es considerarlos como no navegables. Esta
interpretación también se utiliza en este libro.
2. Esta asociación navegable tiene un nombre (“has”). El triángulo sólido indica la dirección de lectura.
Aparte de eso, la semántica de esta asociación es completamente idéntica al ejemplo 1.
3. En este ejemplo, ambos extremos de la asociación tienen etiquetas (nombres) y multiplicidades.
Las etiquetas se utilizan normalmente para especificar los roles de las clases en una asociación.
Una multiplicidad especifica la cantidad permitida de instancias de las clases que están
involucradas en una asociación. Es un intervalo inclusivo de enteros no negativos que
comienza con un límite inferior y termina con un límite superior (posiblemente infinito).
En este caso, cualquier A tiene de cero a cualquier número de B, mientras que cualquier B tiene exactamente una A.
La Tabla A2 muestra algunos ejemplos de multiplicidades válidas.
4. Esta es una asociación especial llamada agregación. Representa un todoparte
relación, es decir, una clase (la parte) está subordinada jerárquicamente a la otra clase (el
todo). El diamante poco profundo es solo un marcador en este tipo de asociación e identifica el
todo. De lo contrario, todo lo que se aplica a las asociaciones también se aplica a una
agregación.
279
Machine Translated by Google
APÉNDICE a ■ Guía UML pequeña
5. Esta es una agregación compuesta, que es una forma fuerte de agregación.
Expresa que el todo es dueño de las partes, y por lo tanto también responsable de las
partes. Si se elimina una instancia del todo, todas sus instancias parciales normalmente
se eliminan con ella.
■ Nota Tenga en cuenta que una parte puede (donde esté permitido) eliminarse de un compuesto antes de que se elimine el todo
y, por lo tanto, no se eliminará como parte del todo. Esto puede ser posible mediante una multiplicidad de 0..1 en el extremo de la
asociación que está conectado al todo, es decir, el extremo con el rombo lleno. Las únicas multiplicidades permitidas en este extremo
son 1 o 0..1; todas las demás multiplicidades están prohibidas.
Tabla A2. Ejemplos de multiplicidad
Multiplicidad Significado
1 Exactamente uno Si no se muestra una multiplicidad en un extremo de la asociación, este es el valor predeterminado.
1..10 Un intervalo inclusivo entre 1 y 10.
0..* Un intervalo inclusivo entre 0 y cualquier número (de cero a muchos). El carácter de estrella (*) se
utiliza para representar el límite superior ilimitado (o infinito).
* Forma abreviada de 0..*.
1..* Un intervalo inclusivo entre 1 y cualquier número (uno a muchos).
En los lenguajes de programación, las asociaciones y el mecanismo de navegación de una clase a otra se
pueden implementar de varias formas. En C++, las asociaciones suelen implementarse mediante miembros que tienen
la otra clase como su tipo, por ejemplo, como una referencia o un puntero, como se muestra en el siguiente ejemplo.
Listado A4. Ejemplo de implementación de una asociación navegable entre las clases A y B
clase B; // Declaración de reenvío
clase A
{ privada:
B*
b; // ...
};
clase B { //
¡No hay puntero ni ninguna otra referencia a la clase A aquí! };
Generalización
Un concepto central en el desarrollo de software orientado a objetos es la llamada herencia. Lo que se quiere decir
con esto es la generalización de la respectiva especialización de clases.
280
Machine Translated by Google
APÉNDICE a ■ Guía UML pequeña
GENERALIZACIÓN
Una generalización es una relación taxonómica entre una clase general y una clase más específica.
La relación de generalización se utiliza para representar el concepto de herencia: la clase específica
(subclase) hereda atributos y operaciones de la clase más general (clase base). La sintaxis UML de la relación de
generalización es una flecha continua con una punta de flecha cerrada pero sin relleno, como se muestra en la Figura A7.
Figura A7. Una clase base abstracta Forma y tres clases concretas que son especializaciones de ella.
En la dirección de la flecha, esta relación se lee de la siguiente manera: “<Subclase> es un tipo de
<Clase base>”, por ejemplo, “Rectángulo es un tipo de Forma”.
Dependencia Además
de las asociaciones ya mencionadas, las clases (y componentes) pueden tener más relaciones con otras clases (y
componentes). Por ejemplo, si una clase se usa como un tipo para un parámetro de una función miembro, esto no
es una asociación, pero es un tipo de dependencia de esa clase usada.
DEPENDENCIA
Una dependencia es una relación que significa que un solo elemento o un conjunto de elementos requiere de otros
elementos para su especificación o implementación.
Como se muestra en la Figura A8, una dependencia se muestra como una flecha discontinua entre dos
elementos, por ejemplo, entre dos clases o componentes. Implica que el elemento en la punta de flecha es requerido
por el elemento en la cola de la flecha, por ejemplo, para propósitos de implementación. En otras palabras: el
elemento dependiente está incompleto sin el elemento independiente.
281
Machine Translated by Google
APÉNDICE a ■ Guía UML pequeña
Figura A8. Dependencias misceláneas
Además de su forma simple (ver el primer ejemplo en la Figura A8), se pueden distinguir dos tipos especiales de dependencia:
1. La dependencia de uso («uso») es una relación en la que un elemento requiere de otro elemento (o
conjunto de elementos) para su plena implementación u operación.
2. La dependencia de creación ("Crear") es un tipo especial de dependencia de uso
indicando que el elemento en la cola de la flecha crea instancias del tipo en la punta de flecha.
Componentes
El componente del elemento UML representa una parte modular de un sistema que generalmente se encuentra en un nivel de
abstracción más alto que una sola clase. Un componente sirve como una especie de “cápsula” o “envoltura” para un conjunto
de clases que en conjunto cumplen con cierta funcionalidad. La sintaxis UML para un componente se muestra en la Figura A9.
Figura A9. La notación UML para un componente
Debido al hecho de que un componente encapsula su contenido, define su comportamiento en términos de los llamados
interfaces proporcionadas y requeridas. Solo estas interfaces están disponibles para el entorno para el uso de un componente.
Esto significa que un componente puede ser reemplazado por otro si y solo si sus interfaces proporcionadas y requeridas son
idénticas. La sintaxis concreta para las interfaces (notación de bola y cavidad) es exactamente la misma que se muestra en la
Figura A4 y se describe en la sección sobre Interfaces.
282
Machine Translated by Google
APÉNDICE a ■ Guía UML pequeña
estereotipos
Entre otras formas, el vocabulario de UML se puede ampliar con la ayuda de los llamados estereotipos. Este mecanismo liviano permite
la introducción de extensiones específicas de plataforma o dominio de elementos UML estándar. Por ejemplo, mediante la aplicación del
estereotipo «Fábrica» en el elemento Clase estándar de UML, los diseñadores pueden expresar que esas clases específicas son fábricas
de objetos.
El nombre de un estereotipo aplicado se muestra entre un par de guillemets (comillas francesas) encima o antes del nombre del elemento
del modelo. Algunos estereotipos también introducen un nuevo símbolo gráfico, un icono.
La Tabla A3 contiene una lista de los estereotipos utilizados en este libro.
Tabla A3. Estereotipos utilizados en este libro
Estereotipo Significado
"Fábrica" Una clase que crea objetos sin exponer la lógica de creación de instancias al cliente.
"Fachada" Una clase que proporciona una interfaz unificada a un conjunto de interfaces en un componente o subsistema
complejo.
«SUT» El sistema bajo prueba. Las clases o componentes con este estereotipo son las entidades a probar, por ejemplo, con
la ayuda de Unit Tests.
«TestContext» Un contexto de prueba es una entidad de software, por ejemplo, una clase que actúa como un mecanismo de agrupación
para un conjunto de casos de prueba (ver estereotipo «TestCase»).
«TestCase» Un caso de prueba es una operación que interactúa con el «SUT» para verificar su corrección. Los casos de prueba se agrupan
en un «TestContext».
283
Machine Translated by Google
Bibliografía
[Beck01] Kent Beck, Mike Beedle, Arie van Bennekum, et al. Manifiesto para el desarrollo ágil de software.
2001. http://agilemanifesto.org, consultado el 24 de septiembre de 2016.
[Beck02] Kent Beck. Desarrollo basado en pruebas: con el ejemplo. AddisonWesley Professional, 2002.
[Busch96] Frank Buschmann, Regine Meunier, Hans Rohnert y Peter Sommerlad. Arquitectura de software orientada a patrones
Volumen 1: un sistema de patrones. Wiley, 1996.
[Cohn09] Mike Cohn. Tener éxito con Agile: desarrollo de software usando Scrum (1.ª edición).
AddisonWesley, 2009.
[Evans04] Eric J. Evans. Diseño basado en dominios: abordar la complejidad en el corazón del software
(1ª Edición). AddisonWesley, 2004.
[Fernandes12] R. Martinho Fernandes: Regla del Cero. https://rmf.io/cxx11/regladecero, recuperado
642017.
[Fowler02] Martín Fowler. Patrones de Arquitectura de Aplicaciones Empresariales. AddisonWesley, 2002.
[Fowler03] Martín Fowler. Modelo de dominio anémico. Noviembre de 2003. URL: https://martinfowler.com/
bliki/AnemicDomainModel.html, consultado el 512017.
[Fowler04] Martín Fowler. Inversión de Contenedores de Control y el patrón de Inyección de Dependencia. Enero
2004. URL: https://martinfowler.com/articles/injection.html, consultado el 1972017.
[Gamma95] Erich Gamma, Richard Helm, Ralph Johnson y John Vlissides. Patrones de diseño: Elementos de
Software reutilizable orientado a objetos. AddisonWesley, 1995.
[GAOIMTEC92] Oficina General de Contabilidad de los Estados Unidos. GAO/IMTEC9226: Defensa antimisiles Patriot:
Problema de software llevó a falla del sistema en Dhahran, Arabia Saudita, 1992. http://
www.fas.org/spp/starwars/gao/im92026.htm, consultado el 26122013.
[Hunt99] Andrew Hunt, David Thomas. El programador pragmático: de oficial a maestro.
AddisonWesley, 1999.
[ISO11] Organización Internacional de Normalización (ISO), JTC1/SC22/WG21 (Los estándares de C++
Comité). ISO/IEC 14882:2011, Estándar para el lenguaje de programación C++.
[Jeffries98] Ron Jeffries. ¡NO lo vas a necesitar! http://ronjeffries.com/xprog/articles/practices/
pracnotneed/, consultado el 24 de septiembre de 2016.
[JPL99] Laboratorio de Propulsión a Chorro de la NASA (JPL). El equipo de Mars Climate Orbiter encuentra la causa probable de la pérdida.
Septiembre de 1999. URL: http://mars.jpl.nasa.gov/msp98/news/mco990930.html, consultado el 772013.
[Knuth74] Donald E. Knuth. Programación estructurada con sentencias Ir a, ACM Journal Computing Surveys, vol. 6, No. 4,
diciembre de 1974. http://cs.sjsu.edu/~mak/CS185C/KnuthStructuredProgrammingGoTo.pdf , consultado el
532014.
[Koenig01] Andrew Koenig y Barbara E. Moo. C++ simplificado: la regla de tres. Junio de 2001. http://www.drdobbs.com/c
madeeasiertheruleofthree/184401400, consultado el 1652017.
[Langr13] Jeff Langr. Programación moderna en C++ con desarrollo basado en pruebas: Codifique mejor, duerma mejor.
Estantería pragmática, 2013.
[Liskov94] Barbara H. Liskov y Jeanette M. Wing: una noción conductual de la subtipificación. Transacciones ACM
sobre lenguajes y sistemas de programación (TOPLAS) 16 (6): 1811–1841. Noviembre de 1994. http://dl.acm.org/
citation.cfm?doid=197320.197383, consultado el 30122014.
© Stephan Roth 2017 285
S. Roth, C++ limpio, DOI 10.1007/9781484227930
Machine Translated by Google
■ Bibliografía
[Martin96] Robert C. Martín. El principio de sustitución de Liskov. ObjectMentor, marzo de 1996. http://
www.objectmentor.com/resources/articles/lsp.pdf, consultado el 30122014.
[Martin03] Robert C. Martín. Desarrollo ágil de software: principios, patrones y prácticas. Prentice Hall, 2003.
[Martin09] Robert C. Martín. Código limpio: un manual de artesanía ágil de software. Prentice Hall, 2009.
[Martín11] Robert C. Martín. The Clean Coder: un código de conducta para programadores profesionales.
Prentice Hall, 2011.
[Meyers05] Scott Meyers. C++ eficaz: 55 formas específicas de mejorar sus programas y diseños
(Tercera edicion). AddisonWesley, 2005.
[OMG15] Grupo de gestión de objetos. OMG Unified Modeling Language™ (OMG UML), versión 2.5.
Número de documento OMG: formal/20150301. http://www.omg.org/spec/UML/2.5, consultado el 1152016.
[Parnas07] Grupo de interés especial de ACM en ingeniería de software: perfil de miembro de ACM de David Lorge Parnas.
http://www.sigsoft.org/SEN/parnas.html, consultado el 24 de septiembre de 2016.
[Ram03] Stefan Ram. Página de inicio: Dr. Alan Kay sobre el significado de "Programación orientada a objetos".
http://www.purl.org/stefan_ram/pub/doc_kay_oop_en), consultado el 1132013.
[Sommerlad13] Peter Sommerlad. Reunión C++ 2013: C++ más simple con C++11/14. Noviembre de 2013.
http://wiki.hsr.ch/PeterSommerlad/files/MeetingCPP2013_SimpleC++.pdf, consultado el 122014.
[Thought08] ThoughtWorks, Inc. (múltiples autores). La antología de ThoughtWorks®: ensayos sobre software
Tecnología e Innovación. Estantería pragmática, 2008.
[Wipo1886] Organización Mundial de la Propiedad Intelectual (OMPI): Convenio de Berna para la Protección de las Obras Literarias
y Artísticas. http://www.wipo.int/treaties/en/ip/berne/index.html, consultado el 392014.
286
Machine Translated by Google
Índice
A argumento de bandera,
64 nombres de funciones,
Árbol de sintaxis abstracta (AST), 242 60 indicaciones,
Principio de dependencia acíclica, 93, 150–153 59 indicadores de demasiadas responsabilidades, 59
Manifiesto Ágil, 27 nombres reveladores de intenciones,
clases anémicas, 163 61 optimización de aceleración local y global, 60
Funciones anónimas, 181 NULL/nullptr, 68
número de argumentos, 62
B punteros regulares, 70
parámetro de resultado,
Teoría de la ventana rota, 2 66 código fuente, 56–57
GetInfo(), 42
C descomposición jerárquica, 45
notación húngara, 47–48 operador
Limpiar C++
de inserción, 77 macros, 83
niveles de abstracción, 45 nombre
código fuente OpenOffice 3.4.1 de Apache, 42 significativo y expresivo, 48 printf(), 76
comentarios
redundancia,
bloque de comentarios, 50–52 46 código
función deshabilitada, 50 autodocumentado, 44 código
generador de documentación, 54–56 fuente, 43
fórmula/algoritmo matemático, 53 código fuente, TDD (consulte Desarrollo basado en pruebas (TDD)
49, 54–56 control de versión Propiedad de código colectivo, 40
sustituta, 52 texto descripción, 49 Arquitectura de agente de solicitud de objetos comunes
historias de usuarios, 49 (CORBA), 134
abreviaturas Agregación compuesta, 280
crípticas, 46 cadenas de C++, patrón compuesto, 245
74 arreglos de Interruptores electrónicos operados por computadora (4ESS), 11
estilo C, 80 cast de Inyección de constructor, 229
estilo C, 81 DDD,
44–45
definición, 3 D
patrones de diseño (consulte Patrones de Objeto de acceso a datos, 20
diseño) programación funcional (consulte Inyección de dependencia (DI), 25, 219 objeto
Programación funcional ( FP) de cliente, 222
lenguaje) funciones
clase CustomerRepository, 224, 227–229
Código fuente OpenOffice 3.4.1 de Apache, desacoplamiento,
56–57 código 225 clases específicas de dominio, 223–
comentado, 58 corrección 224 entradas de
constante, 70–72 complejidad registro, 226 diseño de software, 229
ciclomática, 58 Registrador de salida estándar, 225–226
© Stephan Roth 2017 287
S. Roth, C++ limpio, DOI 10.1007/9781484227930
Machine Translated by Google
■ ÍNDICE
Principio de inversión de dependencia (DIP) clase Lenguaje de programación funcional (FP)
Cuenta, 155 Código limpio, 189–190
componentes, 156 composición, 168
Clase de cliente, 158 definición, 168
módulos de alto nivel, 157 fácil de probar, 169
Propietario de interfaz, 154 funciones de orden superior, 168, 188
módulos de bajo nivel, 157 datos inmutables, 168
Diagrama de clase UML, 155 funciones impuras, 171
Patrones de diseño, 25 parámetros de entrada y salida, 169
adaptador, 230–231 Cálculo lambda, 168
forma canónica, 217 Lisp, 167
comando, 235–238 funciones miembro, 169 C++
Procesador de comandos, 239–242 moderno
Compuesto, 242–245 Binary Functor, 178
inyección de dependencia, 219 carpetas y función
objeto de cliente, 222 envoltorios, 181
clase CustomerRepository, 224, 226, 227 Objetos similares a funciones, 173
desacoplamiento, Generador, 174
225 clases específicas de dominio, expresiones lambda genéricas, 183
223 entradas de expresiones lambda, 181
registro, 226 diseño de Predicados, 178
software, 229 StandardOutputLogger, plantillas, 173
225–226 frente a principios de función unaria, 176 sin
diseño, 217 Fachada, efectos secundarios,
253–254 Fábrica, 168 paralelización, 169
250–252 Interfaz fluida, Lisp pasado, 167
234 modismos, función pura, 170
260 RAII (consulte Adquisición de recursos transparencia referencial, 170
es inicialización (RAII)) Funtores, 173
copia e intercambio, 265–268
inmutabilidad, 261– 262
Incluir guardia, 260 GRAMO
PIMPL, 268–271 Banda de los cuatro (GoF), 217
SFINAE, 262–265 Convenciones generales de nomenclatura, 48
iterador, 218 Licencia pública general (GPLv2), 54 función
Clase de dinero, 254–257 miembro get(), 90
objeto nulo, 257–260 proyectos greenfield, 5
Observador, 245–250
fábrica simple, 250–252
singleton, 222 H
Patrón de objeto de caso especial, 66 Mango, 93
Estrategia, 231–235 Guardia de cabecera, 260
Diseño impulsado por el dominio (DDD), 44–45 Informática de alto rendimiento (HPC), 106
mi yo, j
Unidad de control del motor (ECU), 135 Especificación de instancia, 273
Calidad externa, 1 Desarrollo Integrado
Programación eXtreme (XP), 28, 191 Medio Ambiente (IDE), 242
Principio de segregación de interfaz (ISP), 149–150
calidad interna, 1
F
Patrón de fachada, 253
Falta de transparencia, 126. k
Objetos falsos, 22 Estilo Kernighan y Ritchie (K&R), 6
288
Machine Translated by Google
■ ÍNDICE
L DIP (ver Principio de inversión de dependencia
(ADEREZO))
Introductor lambda, 182 Motor, 163–164
Aplicación de la ley de Demeter Bomba de
(LOD), 162 combustible, 165
descomposición del automóvil, Clases de Dios, 136 ISP (ver Principio de segregación
158 conductor de clase, de interfaz (ISP)),
159–160 149–150 LOD (ver Ley de Demeter (LOD))
reglas, 161 diseño de software, 162 LSP (ver Principio de sustitución de Liskov (LSP))
Sistemas heredados, 17
Código de línea numerada, 6 OCP ( consulte Principio abiertocerrado (OCP)),
Herencia del principio de sustitución de Liskov 137 SRP
(LSP), 147–148 dilema (consulte Principio de responsabilidad única (SRP)),
cuadradorectángulo clase abstracta, 137 Funciones
140 biblioteca de clases, de miembros estáticos, 165–166 CORBA,
138–139 clase explícita 134 Simula67,
Cuadrado, 146–147 implementación, 133 Xerox PARC,
140–142 instancia de, 143 134 Una vez y solo
una vez ( OAOO), 29 Principio abiertocerrado
RTTI, 145–146 (OCP), 137, 231 OpenOffice.org (OOo), 42 Anular
reglas, 144–145 especificador, 64
setEdges(), setWidth() y setHeight(), 143–144
Registradores,
219 referencia de valor l, 95 P, Q Plain
Old Unit Testing (POUT), 192–193 pruebas de
aceptación, 12 AT&T
METRO
crash, 11 Cup Cake
Macroguardia, 260 AntiPattern, 12 bases de datos, 19
Singleton de Meyers, 220 nombres
Maquetas, 22 expresivos y descriptivos, 15–16 sistemas externos, 19
ModeloVistaControlador (MVC), 245 subsistema FlybyWire ,
10 getters y setters, 18 Ice Cream
Cone AntiPattern, 12
norte
inicialización, 18 pruebas de sistemas
Centro de Computación de Noruega (NCC), 133 grandes, 12 nave
Síndrome de No inventado aquí (NIH) espacial Mariner 1, 9 una
<algoritmo>, 118 afirmación por prueba, 17–18
biblioteca Boost, 122 código de producción, 19–22
comparación de dos secuencias, 122 departamento de control de
estructuras de datos concurrentes, 123 calidad, 14–15 seguridad
utilidades de fecha y hora, 123 funciones críticas, 10 errores de
biblioteca de sistema de software, 10 calidad
archivos, 123 del software, 22 calidad
paralelización, 120 del código de prueba, 15
biblioteca de rango, 123 biblioteca de dobles de prueba, 25
expresiones regulares, 123 clasificación y salida, 123 pirámide de prueba, 11–12
Therac25, 10
O código de terceros, 18
semáforo, 22
Clases de orientación a objetos pruebas basadas en UI,
(OO) 12 prueba unitaria , 13,
Principio de dependencia acíclica, 150–153 clases 16–17 xUnit,
anémicas, 163 definición, 14 Puntero a implementación (PIMPL), 267 POUT.
135 Consulte Pruebas unitarias simples (POUT)
289
Machine Translated by Google
■ ÍNDICE
Preprocesador, 82 semántica, 95
Principio del menor asombro (POLA/PLA), 39 desarrolladores de software, 85
Principio de la menor sorpresa (POLS), 39 pila, 86 std,
Optimizaciones guiadas por perfiles, 60 99
comportamiento indefinido, 110
R Información de tipo de tiempo de ejecución (RTTI), 145–146
Algoritmo inverso, 118
La adquisición de recursos es instancia de inicialización
S
(RAII), 87 Principio de separación de preocupaciones (SoC), 245, 250 método
nuevos y borrados explícitos, 93 setLoyaltyDiscountInPercent(), 49
recursos propietarios, 94 punteros Setter inyección, 229
inteligentes, 87 estándar, Cirugía de escopeta, 35
88–90, 93 tipos, Barras laterales, 5
87 Simula67, 133
Deducción de tipo automático del Principio de Responsabilidad Única (SRP), 35, 137
compilador Desarrollo de software
de administración de recursos, 105 Boy scouts, 40
cálculos, 107 principios, ventajas de ocultar
102 plantillas información, 29
variables, 109 preocupaciones dirección automática de puertas, 30–32
transversales excepción básica tipos de enumeraciones, 31
seguridad, 125 cláusula atrapada, pieza de código, 29
131 referencia dependiente del lenguaje de programación, 30
constante, 131 no excepción principio KISS, 28
seguridad, 125 cliente no acoplamiento débil, 35–38
encontrado, 129 no garantía de optimizaciones, 39
tiro, 127 POLA/PLA, 39
Programador pragmático, 129 cohesión fuerte, 32–35 cohesión
prevención, 125 débil, 35
seguridad de excepción fuerte, 125–126 Escopeta antipatrón, 34
manejo de transacciones, 124 tipos YAGNI, 28
de excepción específicos del usuario, 130 Entropía del software, 2
montón, 86 Big Ball Of Mud, 1 código
lvalue y rvalue, 96 de olor, 48, 58
Orbitador climático de Marte, 116 Patrón de objeto de caso especial, 66
Síndrome NIH Funciones especiales de los miembros, 21–22
<algoritmo>, 118 El fallo de sustitución no es un error (SFINAE),
biblioteca Boost, 122 262–265
comparación de dos secuencias, 122 Sistema bajo prueba (SUT), 15
estructuras de datos concurrentes, 123
utilidades de fecha y hora, 123
biblioteca de sistema de
T
archivos, 123 paralelización, Metaprogramación de plantillas (TMP), 3, 171
120 biblioteca de rango, composición y funciones de orden superior, 168 definición,
123 biblioteca de expresiones regulares, 171, 172 fácil de probar,
123 clasificación y salida, 123 169 datos inmutables,
168 sin efectos secundarios,
instancia RAII, 87 168 paralelización, 169
nuevos y eliminar explícitos, 93 plantilla variádica, 66
recursos propietarios, 94 punteros
inteligentes, 87 estándar, Dobles de prueba, 22
88–90, 93 tipos, Ventajas del desarrollo basado en pruebas
87 (TDD), 213–214 definición,
Regla de cero, 102 191
referencias de valor de r, 97 POUT, 192–193
290
Machine Translated by Google
■ ÍNDICE
creación de prototipos, 215 asociación, 278–280
código de números romanos kata agregación, 279
Convertidor de números árabes a romanos agregación compuesta, 280
TestCase.cpp, 198 creación, 278, 279
función modificada, 200 uso, 279
caracteres, 197 notación esférica, 277 clase, 273–
limpieza, 207–210 275 componentes,
duplicaciones de código, 282 dependencia,
204 función de conversión, 281–282 agregación
199 función convertArabicNumberToRoman compuesta creación, 282
Numeral, 201 aserción uso, 282
personalizada, 205–207
ejecución, 198–199 generalización , 280–281
prueba unitaria fallida, Especificación de instancia, 280
200 GitHub, 211 – interfaz, 275–277
213 decisiones ifelse, realización de interfaz, 276
203 regla de mapeo, estereotipos, 283
210 movimiento de artesanía de software, 196 multiplicidad, 279, 280
concatenación de cadenas, Lenguaje de modelado unificado (UML), 7
202 notación de resta, 210 Clase UserOfConversionService, 25
bucle while, 202 Clases de utilidad, 219
flujo de trabajo de, 193–
196 pirámide de
prueba, 11 W
acoplamiento apretado, 36 TMP. Ver Metaprogramación de plantillas Sitio
(TMP) web y repositorio de código fuente, 7
Función Win32 CreateWindowEx(), 62
U, V
Agregación de lenguaje de modelado X, Y, Z
unificado (UML), 279 Xerox PARC, 134
291