Machine Translated by Google PIENSA COMO UN PROGRAMADOR UNA INTRODUCCIÓN A SOLUCIÓN CREATIVA DE PROBLEMAS t l S YYPAG oh C A DY Y V. ANTON SPRAUL Machine Translated by Google Machine Translated by Google PIENSA COMO UN PROGRAMADOR Machine Translated by Google Machine Translated by Google PIENSA COMO UN PROGRAMADOR Una introducción a Solución creativa de problemas por V. Anton Spraul San Francisco Machine Translated by Google PIENSA COMO UN PROGRAMADOR. Copyright © 2012 por V. Anton Spraul. Reservados todos los derechos. Ninguna parte de este trabajo puede reproducirse o transmitirse de ninguna forma ni por ningún medio, electrónico o mecánico, incluidas fotocopias, grabaciones o cualquier sistema de almacenamiento o recuperación de información, sin el permiso previo por escrito del propietario de los derechos de autor y del editor. Primera impresión 15 14 13 12 123456789 ISBN­10: 1­59327­424­6 ISBN­13: 978­1­59327­424­5 Editorial: William Pollock Editor de producción: Alison Law Diseño de portada: Charlie Wylie Diseño de interiores: Estudios Octopod Editor de desarrollo: Keith Fancher Revisor técnico: Dan Randall Editora: Julianne Jigour Compositor: Susan Glinert Stevens Corrector: Ward Webber Para obtener información sobre distribuidores de libros o traducciones, comuníquese directamente con No Starch Press, Inc.: Sin almidón Press, Inc. 38 Ringold Street, San Francisco, CA 94103 teléfono: 415.863.9900; fax: 415.863.9950; info@nostarch.com; www.nostarch.com Datos de catalogación en publicación de la Biblioteca del Congreso Un registro de catálogo de este libro está disponible en la Biblioteca del Congreso. No Starch Press y el logotipo de No Starch Press son marcas comerciales registradas de No Starch Press, Inc. Otros nombres de productos y empresas mencionados aquí pueden ser marcas comerciales de sus respectivos propietarios. En lugar de utilizar un símbolo de marca registrada cada vez que aparece un nombre de marca registrada, utilizamos los nombres solo de manera editorial y para beneficio del propietario de la marca, sin intención de infringir la marca. La información contenida en este libro se distribuye "tal cual", sin garantía. Si bien se han tomado todas las precauciones en la preparación de este trabajo, ni el autor ni No Starch Press, Inc. tendrán responsabilidad alguna ante ninguna persona o entidad con respecto a cualquier pérdida o daño causado o presuntamente causado directa o indirectamente por el información contenida en el mismo. Machine Translated by Google CONTENIDOS BREVES Agradecimientos ................................................ ................................................. ....... xi Introducción ................................................. ................................................. ................ xiii Capítulo 1: Estrategias para la resolución de problemas ................................. ................................1 Capítulo 2: Puros rompecabezas................................................ ................................................. ..25 Capítulo 3: Resolución de problemas con matrices ................................. ................................55 Capítulo 4: Resolución de problemas con punteros y memoria dinámica ................................81 Capítulo 5: Resolver problemas con las clases ........................................... ..........................111 Capítulo 6: Resolución de problemas con recursividad ................................. ........................143 Capítulo 7: Resolución de problemas con la reutilización de código................................ ........................171 Capítulo 8: Pensar como un programador ................................................ ................................195 Índice................................................. ................................................. ........................227 Machine Translated by Google Machine Translated by Google CONTENIDOS EN DETALLE EXPRESIONES DE GRATITUD INTRODUCCIÓN xi xiii Sobre este libro ............................................... ................................................. ..... xv Requisitos previos ................................................. ................................................ xvi Temas elegidos ................................................ .......................................... xv Estilo de programación................................................ ................................. xvi Ejercicios ................................................. ................................................ xvi ¿Por qué C++? ................................................. ........................................xvii 1 ESTRATEGIAS PARA LA RESOLUCIÓN DE PROBLEMAS 1 Rompecabezas clásicos ................................................. ................................................. ...... 2 La zorra, la oca y el maíz ................................................ ........................ 3 Problema: ¿Cómo cruzar el río? ................................................. ........................ 3 Rompecabezas de fichas deslizantes ................................. ........................................ 7 Problema: El ocho deslizante ................................................ ........................................ 7 Problema: Los cinco deslizantes ......................................... ........................................ 8 Sudoku................................................. ................................................. . Problema: completar un cuadrado de Sudoku ................................. ................. La esclusa de Quarrasi ................................................ ........................................ 13 11 11 Problema: Abrir la cerradura alienígena ................................. ........................ 13 Técnicas generales de resolución de problemas ................................. ........................... 15 Tenga siempre un plan................................................ .................................... dieciséis Replantear el problema................................................. ................................ 17 Dividir el problema................................................. ........................................ 17 Comience con lo que sabe ................................................ ................................ 18 Reducir el problema................................................. ................................. 19 Busque analogías ................................................. ........................................ 20 Experimento ................................................. ................................................ 20 No te frustres ......................................... ........................................ 21 Ejercicios ................................................. ................................................. ............ 22 2 PUZLES PUROS 25 Revisión de C++ utilizado en este capítulo ................................. ................................ 26 Patrones de salida ................................................. ................................................. .... 26 Problema: la mitad de un cuadrado ................................................ ................................ 26 Problema: Un cuadrado (reducción de la mitad de un cuadrado) ................................. ........ 27 Problema: Una línea (reducción adicional de la mitad de un cuadrado) ........................ ... 27 Problema: Cuenta atrás contando hacia arriba ................................. ................... 28 Problema: un triángulo lateral ................................................ ................................ 29 Procesamiento de entrada ................................................ ................................................. ... 31 Problema: Validación de la suma de comprobación de Luhn ................................. ................... 31 Analizando el problema................................................ ........................ 33 Machine Translated by Google Problema: convertir un dígito de carácter en un número entero ........................... ........... 35 Problema: Validación de suma de comprobación de Luhn, longitud fija ................. ................ 36 Problema: Validación de suma de comprobación simple, longitud fija ................. ................ 36 Problema: Positivo o Negativo ................................ ................................................. 39 Uniendo las piezas . ................................................. ........................ 39 Estado de seguimiento ......................... ................................................. ................................. 41 Problema: Decodificar un mensaje ................. ................................................. ........... 41 Problema: Leer un número de tres o cuatro dígitos ................. .......... 45 Problema: Leer un número con tres o cuatro dígitos, aún más simplificado ........ 46 Conclusión ................ ................................................. ................................................ 53 Ejercicios .... ................................................. ................................................. ...... 53 3 SOLUCIONAR PROBLEMAS CON ARRAYS 55 Repaso de los fundamentos de los arreglos ................................................ ................................ 56 Tienda ................. ................................................. ........................................ 56 Copiar ........ ................................................. ................................................. 57 Recuperación y búsqueda . ................................................. ................................. 57 Ordenar ................. ................................................. ................................... 59 Computar estadísticas ........... ................................................. ........................ 61 Resolver problemas con matrices ................. ................................................. ............ 62 Problema: Encontrar el modo ................................ ................................................ 62 Refactorización .. ................................................. .......................................... 65 Matrices de datos fijos .. ................................................. .......................................... 67 Matrices no escalares ... ................................................. ................................................. 69 Matrices multidimensionales .. ................................................. ........................................ 71 Decidir cuándo utilizar matrices ......... ................................................. ......................... 74 Ejercicios ......................... ................................................. ........................................ 78 4 RESOLVIENDO PROBLEMAS CON PUNTEROS Y MEMORIA DINÁMICA 81 Repaso de los fundamentos del puntero ................................................ ................................. 82 Beneficios de los punteros .................. ................................................. ................................. 83 Estructuras de datos del tamaño de tiempo de ejecución ........... ................................................. ......... 83 Estructuras de datos redimensionables .................................. ........................................ 83 Compartir memoria ......... ................................................. ................................. 84 Cuándo utilizar punteros ................. ................................................. .......................... 84 La memoria importa .................... ................................................. ................................. 85 La pila y el montón ................. ................................................. ................. 86 Tamaño de la memoria ................................. ................................................. ........ 88 Vida útil ........................................ ................................................. .......... 90 Resolver problemas de punteros ................................. ................................................. ... 91 Cadenas de longitud variable ................................. ................................... 91 Problema: Manipulación de cadenas de longitud variable ..... ................................................. 91 Listas enlazadas .. ................................................. ........................................ 101 Problema: Seguimiento de una cantidad desconocida de estudiantes Registros ................................ 101 Conclusión y próximos pasos ................. ................................................. ................ 108 Ejercicios ................................ ................................................. .......................... 109 viii Contenido en detalle Machine Translated by Google 5 RESOLVER PROBLEMAS CON CLASES 111 Repaso de los fundamentos de la clase ......................................... ................................ 112 Objetivos del uso de la clase .................................. ................................................ 113 Encapsulación................................................. ........................................ 114 Reutilización de código ................................................ ........................................ 114 Dividiendo el problema................................................. ................................ 115 Ocultación de información ................................................ ................................ 115 Legibilidad ................................................. ........................................ 117 Expresividad ................................................. ........................................ 117 Construyendo una clase sencilla ................................................ .......................................... 118 Problema: Lista de clases ................................................ ........................................ 118 El marco básico de la clase ......................................... ........................ 119 Métodos de soporte ................................................. ........................................ 122 Clases con datos dinámicos ................................................ ........................................ 125 Problema: Seguimiento de una cantidad desconocida de registros de estudiantes ......... 126 Agregar un nodo ................................................. ........................................ 128 Reorganizar la lista................................................. ................................ 130 Destructor................................................ ................................................ 133 Copia profunda ................................................. ........................................ 134 El panorama general de las clases con memoria dinámica ................................. 139 Errores a evitar................................................ ................................................ 140 La clase falsa................................................ ........................................ 140 Personas con una sola tarea .................................. .......................................... 141 Ejercicios ................................................. ................................................. .......... 141 6 RESOLVER PROBLEMAS CON RECURSIÓN 143 Revisión de los fundamentos de la recursividad ................................................ .......................... 144 Recursión de cabeza y cola ................................................ ........................................ 144 Problema: ¿Cuántos loros? ................................................. ........................ 144 Enfoque 1 ................................................. ........................................ 145 Enfoque 2 ................................................ ........................................ 146 Problema: ¿Quién es nuestro mejor cliente? ................................................. ............ 148 Enfoque 1 ................................................. ........................................ 149 Enfoque 2 ................................................ ........................................ 151 La gran idea recursiva ................................................ ........................................ 152 Problema: calcular la suma de una matriz de números enteros ................................. 153 Errores comunes ................................................ ................................................ 155 Demasiados parámetros ............................................... ................................ 155 Variables globales ................................................ ........................................ 156 Aplicar la recursividad a estructuras de datos dinámicas ................................. .......... 158 Recursividad y listas enlazadas ................................................ ........................ 158 Problema: contar números negativos en una lista enlazada individualmente ................. 159 Recursividad y árboles binarios ................................................ ........................ 160 Problema: encontrar el valor más grande en un árbol binario ........................................ ..... 162 Funciones de contenedor ................................................ ................................................ 163 Problema: encontrar el número de hojas en un árbol binario ................................. 163 Cuándo elegir la recursividad................................................ ........................................ 165 Argumentos en contra de la recursividad ................................................ ................. 166 Contenido en detalle ix Machine Translated by Google Problema: Mostrar una lista enlazada en orden ................................. ................ 168 Problema: Mostrar una lista enlazada en orden inverso ........................ ........................ 168 Ejercicios ........................ ................................................. ................................ 170 7 SOLUCIONAR PROBLEMAS CON LA REUTILIZACIÓN DE CÓDIGO 171 Buena reutilización y mala reutilización ................................. ................................... 172 Revisión de los fundamentos de los componentes ........ ................................................. ............ 173 Bloque de código ................................. ................................................. ....... 173 Algoritmos ......................................... ................................................. ... 173 Patrones ................................................ ................................................. ... 174 Tipos de datos abstractos ................................. ................................... 175 Bibliotecas ........... ................................................. .................................... 175 Conocimiento de los componentes del edificio ........... ................................................. ................ 176 Aprendizaje exploratorio ................................. ................................................ 176 Problema: El primer estudiante ................................................ ................................. 177 Aprendizaje según sea necesario ............ ................................................. .................. 180 Problema: Recorrido eficiente ........................ ................................................ 180 Elección de un tipo de componente ................................................ ................................. 188 Elección de componentes en acción ........... ................................................. ....... 189 Problema: Clasificar algunos y dejar otros solos ................................. ................ 189 Comparación de los resultados ................................ ................................................. 193 Ejercicios ... ................................................. ................................................. ...... 193 8 PENSAR COMO UN PROGRAMADOR 195 Creando tu propio plan maestro ................................................ ................................. 196 Aprovechando tus fortalezas y debilidades ............. ................................. 196 Armando el Plan Maestro ......... ................................................. ..... 202 Abordar cualquier problema ......................................... ................................................. 203 Problema: hacer trampa en el ahorcado ................................. ......................... 204 Encontrar una manera de hacer trampa ................... ................................................. ..... 205 Operaciones requeridas para hacer trampa en el ahorcado ................................. .... 206 Diseño inicial ................................................ ................................................ 208 Codificación inicial ................................................. ......................................... 210 Análisis de resultados iniciales..... ................................................. ................... 217 El arte de resolver problemas ......................... ................................................ 218 Aprender nueva programación Habilidades ................................................. ......................... 219 Nuevos idiomas ......................... ................................................. ................ 219 Nuevas habilidades 222 para un idioma que ya conoce .................. ................. Nuevas bibliotecas ................................................. ........................................ 223 Toma una clase ..... ................................................. ................................. 223 Conclusión ............ ................................................. ................................................ 224 Ejercicios .... ................................................. ................................................. ..... 225 ÍNDICE x Contenido en detalle 227 Machine Translated by Google EXPRESIONES DE GRATITUD Ningún libro es realmente obra de un solo autor y he recibido mucha ayuda sobre Think Like a Programmer. Estoy agradecido a todos en No Starch Press, especialmente a Keith Fancher y Alison Law, quien editó, dio forma y guió el libro durante toda su producción. También debo agradecer a Bill Pollock por su decisión de inscribirme en primer lugar; espero que esté tan satisfecho con el resultado como yo. La gente de No Starch ha sido siempre amable y servicial en su correspondencia conmigo. Espero algún día conocerlos en persona y ver hasta qué punto se parecen a sus avatares de dibujos animados en el sitio web de la empresa. Dan Randall hizo un trabajo maravilloso como editor técnico. Sus numerosas sugerencias más allá de la revisión técnica me ayudaron a fortalecer el manuscrito en muchas áreas. En el frente interno, las personas más importantes de mi vida, Mary Beth y Madeline, me brindaron amor, apoyo y entusiasmo y, lo que es más importante, tiempo para escribir. Finalmente, a todos los estudiantes de programación que he tenido a lo largo de los años: Gracias por dejarme ser su maestro. Las técnicas y estrategias descritas en este libro se desarrollaron gracias a nuestros esfuerzos conjuntos. Espero que hayamos facilitado el viaje a la próxima generación de programadores. Machine Translated by Google Machine Translated by Google t l ¿Tiene dificultades para escribir programas, aunque cree que comprende los lenguajes de programación? ¿Eres capaz de leer ¿Lee un capítulo en un libro de programación, asiente con la cabeza todo el tiempo, pero no puede aplicar lo que ha leído a sus propios programas? Eres capaz de S YYPAG oh C A DY Y INTRODUCCIÓN comprender un ejemplo de programa que ha leído en línea, incluso hasta el punto de poder explicarle a otra persona lo que hace cada línea del código, pero siente que su cerebro se paraliza cuando se enfrenta a una tarea de programación y una pantalla en blanco en su texto. ¿editor? No estás solo. He enseñado programación durante más de 15 años y la mayoría de mis alumnos habrían encajado en esta descripción en algún momento de su instrucción. Llamaremos a la habilidad faltante resolución de problemas, la capacidad de tomar una descripción dada del problema y escribir un programa original para resolverlo. No toda la programación requiere una resolución exhaustiva de problemas. Si sólo está realizando modificaciones menores a un programa existente, depurando o agregando código de prueba, el Machine Translated by Google La programación puede ser de naturaleza tan mecánica que su creatividad nunca se pone a prueba. Pero todos los programas requieren la resolución de problemas en algún momento, y todos los buenos programadores pueden resolver problemas. Resolver problemas es difícil. Es cierto que algunas personas hacen que parezca fácil. los “naturales”, el equivalente en el mundo de la programación de un atleta talentoso, como Michael Jordan. Para estos pocos elegidos, las ideas de alto nivel se traducen sin esfuerzo al código fuente. Para hacer una metáfora de Java, es como si sus cerebros ejecutaran Java de forma nativa, mientras que el resto de nosotros tuviéramos que ejecutar una máquina virtual, interpretando sobre la marcha. No tener talento natural no es fatal para convertirse en programador; si lo fuera, el mundo tendría pocos programadores. Sin embargo, he visto a muchos estudiantes valiosos luchar durante demasiado tiempo ante la frustración. En el peor de los casos, abandonan por completo la programación, convencidos de que nunca podrán ser programadores, de que los únicos buenos programadores son los que nacen con un don innato. ¿Por qué es tan difícil aprender a resolver problemas de programación? En parte, se debe a que la resolución de problemas es una actividad diferente de aprender la sintaxis de programación y, por lo tanto, utiliza un conjunto diferente de "músculos" mentales. Aprender la sintaxis de programación, leer programas, memorizar elementos de una interfaz de programación de aplicaciones: estas son en su mayoría actividades analíticas del “cerebro izquierdo”. Escribir un programa original utilizando herramientas y habilidades previamente aprendidas es una actividad creativa del "lado derecho del cerebro". Supongamos que necesita quitar una rama que cayó en una de las canaletas de lluvia de su casa, pero su escalera no es lo suficientemente larga para alcanzar la rama. Te diriges a tu garaje y buscas algo, o una combinación de cosas, que te permita quitar la rama de la canaleta. ¿Hay alguna forma de extender la escalera? ¿Hay algo que puedas sostener en la parte superior de la escalera para agarrar o desalojar la rama? Tal vez podrías simplemente subir al techo desde otro lugar y tomar la rama desde arriba. Eso es resolución de problemas y es una actividad creativa. Lo creas o no, cuando diseñas un programa original, tu proceso mental es bastante similar al de la persona que descubre cómo quitar la rama del canal y bastante diferente al de una persona que depura un bucle for existente . Sin embargo, la mayoría de los libros de programación centran su atención en la sintaxis y la semántica. Aprender la sintaxis y la semántica de un lenguaje de programación es esencial, pero es sólo el primer paso para aprender a programar en ese lenguaje. En esencia, la mayoría de los libros de programación para principiantes enseñan cómo leer un programa, no cómo escribirlo. Los libros que sí se centran en la escritura a menudo son efectivamente “libros de cocina” en el sentido de que enseñan “recetas” específicas para usar en situaciones particulares. Estos libros pueden ser muy valiosos para ahorrar tiempo, pero no como un camino para aprender a escribir código original. Piense en los libros de cocina en el sentido original. Aunque los grandes cocineros poseen libros de cocina, nadie que dependa de ellos puede ser un gran cocinero. Un gran cocinero comprende los ingredientes, los métodos de preparación y de cocción, y sabe cómo combinarlos para preparar excelentes comidas. Todo lo que un gran cocinero necesita para preparar una comida sabrosa es una cocina completamente equipada. De la misma manera, un gran programador comprende la sintaxis del lenguaje, los marcos de aplicación, los algoritmos y los principios de la ingeniería de software y sabe cómo combinarlos para crear excelentes programas. Dale a un gran programador una lista de especificaciones, déjalo libre con un entorno de programación completamente equipado y sucederán grandes cosas. xiv Introducción Machine Translated by Google En general, la educación actual en programación no ofrece mucha orientación en el área de resolución de problemas. En cambio, se supone que si a los programadores se les da acceso a todas las herramientas de programación y se les pide que escriban suficientes programas, eventualmente aprenderán a escribir dichos programas y los escribirán bien. Hay algo de verdad en esto, pero “eventualmente” puede llevar mucho tiempo. El viaje desde la iniciación hasta la iluminación puede estar lleno de frustración, y muchos de los que inician el viaje nunca llegan al destino. En lugar de aprender por prueba y error, puede aprender a resolver problemas de forma sistemática. De eso se trata este libro. Puede aprender técnicas para organizar sus pensamientos, procedimientos para descubrir soluciones y estrategias para aplicar a ciertas clases de problemas. Al estudiar estos enfoques, puedes desbloquear tu creatividad. No se equivoque: la programación, y especialmente la resolución de problemas, es una actividad creativa. La creatividad es misteriosa y nadie puede decir exactamente cómo funciona la mente creativa. Sin embargo, si podemos aprender a componer música, recibir consejos sobre escritura creativa o que nos enseñen a pintar, entonces también podremos aprender a resolver problemas de programación de forma creativa. Este libro no le dirá exactamente qué hacer; le ayudará a desarrollar sus habilidades latentes de resolución de problemas para que sepa qué debe hacer. Este libro trata de ayudarte a convertirte en el programador que debes ser. Mi objetivo es que usted y todos los demás lectores de este libro aprendan a abordar sistemáticamente cada tarea de programación y tengan la confianza de que, en última instancia, resolverán un problema determinado. Cuando completes este libro, quiero que pienses como un programador y creas que eres un programador. Sobre este libro Habiendo explicado la necesidad de este libro, necesito hacer algunos comentarios sobre qué es y qué no es este libro. Requisitos previos Este libro asume que usted ya está familiarizado con la sintaxis y semántica básica del lenguaje C++ y que ha comenzado a escribir programas. La mayoría de los capítulos esperarán que usted conozca los fundamentos específicos de C++; Estos capítulos comenzarán con una revisión de esos fundamentos. Si todavía estás absorbiendo los conceptos básicos del idioma, no te preocupes. Hay muchos libros excelentes sobre la sintaxis de C++ y puedes aprender a resolver problemas en paralelo al aprendizaje de la sintaxis. Sólo asegúrese de haber estudiado la sintaxis relevante antes de intentar abordar los problemas de un capítulo. Temas elegidos Los temas tratados en este libro representan áreas en las que con mayor frecuencia he visto luchar a los nuevos programadores. También presentan una amplia muestra representativa de diferentes áreas en la programación temprana e intermedia. Sin embargo, debo enfatizar que este no es un “libro de cocina” de algoritmos. o patrones para resolver problemas específicos. Aunque en capítulos posteriores se analiza cómo emplear algoritmos o patrones conocidos, no debería utilizar este Introducción xvi Machine Translated by Google Reserve como una “hoja de cuna” para superar problemas particulares o concéntrese solo en los capítulos que se relacionan directamente con sus luchas actuales. En lugar de ello, trabajaría durante todo el libro, omitiendo material sólo si carece de los requisitos previos necesarios para seguir la discusión. Estilo de programación Una nota rápida sobre el estilo de programación empleado en este libro: este libro no trata sobre programación de alto rendimiento ni sobre la ejecución del código más compacto y eficiente. El estilo que he elegido para los ejemplos de código fuente pretende ser legible por encima de cualquier otra consideración. En algunos casos, tomo varios pasos para lograr algo que podría hacerse en un solo paso, solo para que quede claro el principio que estoy tratando de demostrar. En este libro se cubrirán algunos aspectos del estilo de programación, pero cuestiones más importantes, como qué se debe o no incluir en una clase, no cuestiones pequeñas, como cómo se debe sangrar el código. Como programador en desarrollo, por supuesto querrás emplear un estilo coherente y legible en todo el trabajo que realices. Ejercicios El libro incluye una serie de ejercicios de programación. Este no es un libro de texto y no encontrará respuestas a ninguno de los ejercicios al final. Los ejercicios le brindan oportunidades para aplicar los conceptos descritos en los capítulos. Por supuesto, depende de usted decidir probar cualquiera de los ejercicios, pero es esencial que ponga estos conceptos en práctica. Simplemente leer el libro no logrará nada. Recuerde que este libro no le dirá exactamente qué hacer en cada situación. Al aplicar las técnicas que se muestran en este libro, desarrollará su propia capacidad para descubrir qué hacer. Además, aumentar su confianza, otro objetivo principal de este libro, requiere éxito. De hecho, esa es una buena manera de saber cuándo ha realizado suficientes ejercicios en un área problemática determinada: cuando está seguro de que puede abordar otros problemas en el área. Por último, los ejercicios de programación deberían ser divertidos. Si bien puede haber momentos en los que preferirías hacer otra cosa, resolver un problema de programación debería ser un desafío gratificante. Deberías pensar en este libro como una carrera de obstáculos para tu cerebro. Las carreras de obstáculos desarrollan fuerza, resistencia y agilidad y dan confianza al entrenador. Al leer los capítulos y aplicar los conceptos a tantos ejercicios como pueda, generará confianza y desarrollará habilidades de resolución de problemas que pueden usarse en cualquier situación de programación. En el futuro, cuando se enfrente a un problema difícil, sabrá si debe intentar superarlo, superarlo o superarlo. xvi Introducción Machine Translated by Google ¿Por qué C++? Los ejemplos de programación de este texto están codificados en C++. Dicho esto, este libro trata sobre la resolución de problemas con programas, no específicamente sobre C++. Aquí no encontrará muchos consejos y trucos específicos de C++, y los conceptos generales que se enseñan a lo largo de este libro se pueden emplear en cualquier lenguaje de programación. Sin embargo, no se puede hablar de programación sin hablar de programas, y hubo que elegir un lenguaje específico. Se seleccionó C++ por varias razones. Primero, es popular en una variedad de áreas problemáticas. En segundo lugar, debido a sus orígenes en el lenguaje C estrictamente procedimental, el código C++ puede escribirse utilizando tanto el paradigma procedimental como el orientado a objetos. La programación orientada a objetos es tan común ahora que no se puede omitir en una discusión sobre resolución de problemas, pero muchos conceptos fundamentales de resolución de problemas se pueden discutir en términos de programación estrictamente procedimental, y hacerlo simplifica tanto el código como la discusión. sión. En tercer lugar, como lenguaje de bajo nivel con bibliotecas de alto nivel, C++ nos permite analizar ambos niveles de programación. Los mejores programadores pueden “conectar manualmente” soluciones cuando sea necesario y utilizar bibliotecas de alto nivel e interfaces de programación de aplicaciones para reducir el tiempo de desarrollo. Por último, y en parte como función de las otras razones enumeradas, C++ es una excelente opción porque una vez que aprendes a resolver problemas en C++, aprendes a resolver problemas en cualquier lenguaje de programación. Muchos programadores han descubierto cómo las habilidades aprendidas en un lenguaje se aplican fácilmente a otros lenguajes, pero esto es especialmente cierto para C++ debido a su enfoque de paradigmas cruzados y, francamente, a su dificultad. C++ es el verdadero negocio: es programación sin ruedas de apo Esto es desalentador al principio, pero una vez que empieces a tener éxito en C++, sabrás que no serás alguien que pueda codificar un poco: serás un programador. Introducción xvii Machine Translated by Google Machine Translated by Google t l S YYPAG oh C A DY Y ESTRATEGIAS PARA RESOLUCIÓN DE PROBLEMAS Este libro trata sobre la resolución de problemas, pero ¿qué es exactamente la resolución de problemas? Cuando la gente usa el término en una conversación ordinaria, a menudo significa algo muy diferente de lo que queremos decir aquí. Si su Honda Civic 1997 tiene humo azul saliendo del tubo de escape, está funcionando bruscamente en ha perdido eficiencia de combustible, este es un problema que se puede resolver con conocimiento automotriz, diagnóstico, equipos de reemplazo y herramientas comunes de taller. Sin embargo, si les cuentas tu problema a tus amigos, uno de ellos podría decir: “Oye, deberías cambiar ese viejo Honda por algo nuevo. Problema resuelto." Pero la sugerencia de su amigo no sería realmente una solución al problema: sería una forma de evitarlo . Los problemas incluyen restricciones, reglas inquebrantables sobre el problema o la forma en que se debe resolver el problema. Con el Civic averiado, una de las limitaciones es que deseas reparar el auto actual, no comprar uno nuevo. Las limitaciones también pueden incluir el costo total de las reparaciones, cuánto tiempo llevará la reparación o el requisito de que no se puedan comprar herramientas nuevas solo para esta reparación. Machine Translated by Google Al resolver un problema con un programa, también existen restricciones. Las limitaciones comunes incluyen el lenguaje de programación, la plataforma (¿se ejecuta en una PC, un iPhone o qué?), el rendimiento (un programa de juego puede requerir que los gráficos se actualicen al menos 30 veces por segundo, una aplicación empresarial puede requerir que los gráficos se actualicen al menos 30 veces por segundo). un tiempo máximo de respuesta a la entrada del usuario) o huella de memoria. A veces, la restricción implica qué otro código puede hacer referencia: tal vez el programa no pueda incluir cierto código de fuente abierta, o tal vez lo contrario. tal vez solo pueda usar código abierto. Entonces, para los programadores podemos definir la resolución de problemas como escribir un programa original que realice un conjunto particular de tareas y cumpla con todas las restricciones establecidas. Los programadores principiantes a menudo están tan ansiosos por cumplir la primera parte de esa definición (escribir un programa para realizar una determinada tarea) que fallan en la segunda parte de la definición, cumpliendo las restricciones establecidas. A un programa como ese, uno que parece producir resultados correctos pero que infringe una o más de las reglas establecidas, lo llamo Kobayashi Maru. Si ese nombre no le resulta familiar, significa que no está suficientemente familiarizado con una de las piedras angulares de la cultura geek, la película Star Trek II: La ira de Khan. La película contiene una trama secundaria sobre un ejercicio para aspirantes a oficiales en la Academia de la Flota Estelar. Los cadetes son colocados a bordo de un puente de nave estelar simulado y obligados a actuar como capitanes en una misión que implica una elección imposible. Personas inocentes morirán en un barco herido, el Kobayashi Maru, pero para llegar hasta ellos es necesario iniciar una batalla con los klingon, una batalla que sólo puede terminar con la destrucción del barco del capitán. El ejercicio tiene como objetivo poner a prueba el coraje de un cadete bajo fuego. No hay forma de ganar y todas las decisiones conducen a malos resultados. Hacia el final de la película, descubrimos que el Capitán Kirk modificó la simulación para que realmente se pudiera ganar. Kirk fue inteligente, pero no resolvió el dilema del Kobayashi Maru; lo evitó. Afortunadamente, los problemas que enfrentará como programador tienen solución, pero muchos programadores aún recurren al enfoque de Kirk. En algunos casos, lo hacen de forma accidental. (“¡Oh, vaya! Mi solución sólo funciona si hay cien elementos de datos o menos. Se supone que funciona para un conjunto de datos ilimitado. Tendré que reconsiderar esto”). En otros casos, la eliminación de restricciones es deliberada. , una estratagema para cumplir un plazo impuesto por un jefe o un instructor. En otros casos, el programador simplemente no sabe cómo cumplir con todas las restricciones. En los peores casos que he visto, el estudiante de programación le ha pagado a otra persona para que escriba el programa. Independientemente de las motivaciones, siempre debemos ser diligentes para evitar al Kobayashi Maru. Rompecabezas clásicos A medida que avance en este libro, notará que aunque los detalles del código fuente cambian de un área problemática a otra, surgirán ciertos patrones en los enfoques que adoptemos. Esta es una gran noticia porque es lo que finalmente nos permite abordar cualquier problema con confianza, tengamos o no una amplia experiencia en esa área problemática. problema experto 2 Capítulo 1 Machine Translated by Google Los solucionadores reconocen rápidamente una analogía, una similitud explotable entre un problema resuelto y un problema no resuelto. Si reconocemos que una característica del problema A es análoga a una característica del problema B y ya hemos resuelto el problema B, tenemos una idea valiosa para resolver el problema A. En esta sección, discutiremos problemas clásicos fuera del mundo de programación que tienen lecciones que podemos aplicar a problemas de programación. El zorro, la oca y el maíz El primer problema clásico que discutiremos es un acertijo sobre un granjero que necesita cruzar un río. Probablemente lo hayas encontrado anteriormente de una forma u otra. PROBLEMA: ¿CÓMO CRUZAR EL RÍO? Un granjero con un zorro, un ganso y un saco de maíz necesita cruzar un río. El granjero tiene un bote de remos, pero sólo hay espacio para él y uno de sus tres objetos. Desafortunadamente, tanto el zorro como el ganso tienen hambre. No se puede dejar al zorro solo con el ganso, o el zorro se comerá al ganso. Asimismo, no se puede dejar sola a la oca con el saco de maíz, o la oca se comerá el maíz. ¿Cómo consigue el granjero que todo cruce el río? La configuración para este problema se muestra en la Figura 1­1. Si nunca antes te has encontrado con este problema, detente aquí y dedica unos minutos a intentar resolverlo. Si has escuchado este acertijo antes, intenta recordar la solución y si pudiste resolverlo por tu cuenta. Costa lejana BOLSA Oh' MAÍZ Cerca de la costa Figura 1­1: El zorro, el ganso y el saco de maíz. El barco puede transportar un artículo a la vez. No se puede dejar al zorro en la misma orilla que al ganso, ni al ganso en la misma orilla que al saco de maíz. Estrategias para la resolución de problemas 3 Machine Translated by Google Pocas personas son capaces de resolver este enigma, al menos sin una pista. Sé que no lo era. Así es como suele ser el razonamiento. Como el granjero sólo puede llevar una cosa a la vez, necesitará varios viajes para llevar todo a la otra orilla. En el primer viaje, si el granjero lleva al zorro, el ganso se quedaría con el saco de maíz y el ganso se comería el maíz. Asimismo, si el granjero se llevara el saco de maíz en el primer viaje, el zorro se quedaría con el ganso y el zorro se comería el ganso. Por lo tanto, el granjero debe llevar el ganso en el primer viaje, lo que da como resultado la configuración que se muestra en la Figura 1­2. Costa lejana BOLSA Oh' MAÍZ Cerca de la costa Figura 1­2: El primer paso necesario para resolver el problema del zorro, la oca y el saco de maíz. Sin embargo, a partir de este paso, todos los pasos siguientes parecen terminar en un fracaso. Hasta ahora, todo bien. Pero en el segundo viaje, el granjero debe llevarse el zorro o el maíz. Sin embargo, todo lo que el granjero tome debe dejarse en la orilla opuesta con el ganso mientras el granjero regresa a la orilla más cercana por el artículo restante. Esto significa que o el zorro y el ganso quedarán juntos o el ganso y el maíz quedarán juntos. Como ninguna de estas situaciones es aceptable, el problema parece irresoluble. Nuevamente, si ha visto este problema antes, probablemente recuerde el elemento clave de la solución. El granjero tiene que llevar el ganso en el primer viaje, como se explicó anteriormente. En el segundo viaje, supongamos que el granjero lleva al zorro. Sin embargo, en lugar de dejar al zorro con el ganso, el granjero lleva el ganso de regreso a la orilla cercana. Luego, el granjero cruza el saco de maíz, dejando al zorro y el maíz en la otra orilla, mientras regresa para un cuarto viaje con el ganso. La solución completa se muestra en la Figura 1­3. Este acertijo es difícil porque la mayoría de las personas nunca consideran llevar uno de los objetos de la orilla lejana a la orilla cercana. Algunas personas incluso sugerirán que el problema es injusto y dirán algo como: "¡No dijiste que podía retirar algo!". Esto es cierto, pero también es cierto que nada en la descripción del problema sugiere que esté prohibido recuperar algo. 4 Capítulo 1 Machine Translated by Google 2 1 3 El paso "truco" 4 5 6 7 8 Figura 1­3: Solución paso a paso del rompecabezas del zorro, el ganso y el maíz Piense en lo fácil que sería resolver el rompecabezas si se hiciera explícita la posibilidad de llevar uno de los objetos a la orilla cercana: el granjero tiene un bote de remos que puede usarse para transferir objetos en cualquier dirección, pero hay Sólo hay espacio para el granjero y uno de sus tres objetos. Con esa sugerencia a la vista, más personas resolverían el problema. Esto ilustra un principio importante de la resolución de problemas: si desconoce todas las acciones posibles que podría realizar, es posible que no pueda resolver el problema. Podemos referirnos a estas acciones como operaciones. Al enumerar todas las operaciones posibles, podemos resolver muchos problemas probando cada combinación de operaciones hasta que encontremos una que funcione. En términos más generales, al reformular un problema en términos más formales, a menudo podemos descubrir soluciones que de otro modo se nos habrían escapado. Estrategias para la resolución de problemas 5 Machine Translated by Google Olvidemos que ya conocemos la solución e intentemos plantear este enigma en particular de manera más formal. Primero, enumeraremos nuestras restricciones. Las limitaciones clave aquí son: 1. El agricultor sólo podrá llevar un artículo a la vez en la embarcación. 2. No se puede dejar al zorro y al ganso solos en la misma orilla. 3. La oca y el maíz no pueden quedar solos en la misma orilla. Este problema es un buen ejemplo de la importancia de las restricciones. Si eliminamos cualquiera de estas limitaciones, el rompecabezas es fácil. Si eliminamos la primera restricción, podemos simplemente cruzar los tres elementos en un solo viaje. Incluso si solo podemos llevar dos artículos en el bote, podemos cruzar con el zorro y el maíz y luego regresar por el ganso. Si eliminamos la segunda restricción (pero dejamos las otras restricciones en su lugar), solo tenemos que tener cuidado, cruzando primero el ganso, luego el zorro y finalmente el maíz. Por lo tanto, si olvidamos o ignoramos alguna de las limitaciones, terminaremos con un Kobayashi Maru. A continuación, enumeremos las operaciones. Hay varias formas de expresar las operaciones de este rompecabezas. Podríamos hacer una lista específica de las acciones que creemos que podemos realizar: 1. Operación: Lleva al zorro al otro lado del río. 2. Operación: Llevar el ganso al otro lado del río. 3. Operación: Llevar el maíz al otro lado del río. Recuerde, sin embargo, que el objetivo de reformular formalmente el problema es obtener información para una solución. A menos que ya hayamos resuelto el problema y descubierto la posible operación “oculta”, llevando el ganso de regreso al lado cercano del río, no la descubriremos al hacer nuestra lista de acciones. En lugar de ello, deberíamos intentar que las operaciones sean genéricas o parametrizadas. 1. Operación: Remar el bote de una orilla a la otra. 2. Operación: Si el barco está vacío, cargar un artículo desde la orilla. 3. Operación: Si el barco no está vacío, descargue el artículo a la orilla. Pensando en el problema en los términos más generales, esta segunda lista de operaciones nos permitirá resolver el problema sin necesidad de un “¡ajá!” momento relativo al viaje de regreso a la orilla cercana con el ganso. Si generamos todas las secuencias posibles de movimientos, terminando cada secuencia una vez que viola una de nuestras restricciones o alcanza una configuración que hemos visto antes, eventualmente encontraremos la secuencia de la Figura 1­3 y resolveremos el rompecabezas. La dificultad inherente del rompecabezas se habrá evitado mediante la reformulación formal de restricciones y operaciones. 6 Capítulo 1 Machine Translated by Google Lecciones aprendidas ¿Qué podemos aprender del zorro, el ganso y el maíz? Replantear el problema de una manera más formal es una excelente técnica para comprender mejor un problema. Muchos programadores buscan a otros programadores para discutir un problema, no sólo porque otros programadores pueden tener la respuesta sino también porque articular el problema en voz alta a menudo desencadena pensamientos nuevos y útiles. Replantear un problema es como tener esa discusión con otro programador, excepto que estás desempeñando ambas partes. La lección más amplia es que pensar en el problema puede ser tan productivo, o en algunos casos más productivo, que pensar en la solución. En muchos casos, el enfoque correcto para la solución es la solución. Rompecabezas de azulejos deslizantes El rompecabezas de fichas deslizantes viene en diferentes tamaños, lo que, como veremos más adelante, ofrece un mecanismo de resolución particular. La siguiente descripción es para una versión 3×3 del rompecabezas. PROBLEMA: EL OCHO DESLIZANTE Una cuadrícula de 3×3 se llena con ocho fichas, numeradas del 1 al 8, y un espacio vacío. Inicialmente, la red está en una configuración confusa. Una ficha se puede deslizar hacia un espacio vacío adyacente, dejando vacía la ubicación anterior de la ficha. El objetivo es deslizar los mosaicos para colocar la cuadrícula en una configuración ordenada, desde el mosaico 1 en la esquina superior izquierda. El objetivo de este problema se muestra en la Figura 1­4. Si nunca antes has intentado un rompecabezas como este, tómate el tiempo para hacerlo ahora. Se pueden encontrar muchos simuladores de rompecabezas deslizantes en la Web, pero para nuestros propósitos es mejor si usa naipes o fichas para crear su propio juego en una mesa. En la Figura 1­5 se muestra una configuración inicial sugerida. 123 472 456 861 78 35 Figura 1­4: La configuración de Figura 1­5: Una configuración la meta en la versión de ocho inicial particular para el fichas del rompecabezas de rompecabezas de fichas deslizantes fichas deslizantes. El cuadrado vacío representa el espacio vacío en el que puede deslizarse una ficha adyacente. Estrategias para la resolución de problemas 7 Machine Translated by Google Este rompecabezas es bastante diferente al del granjero con su zorro, su ganso y su maíz. La dificultad en ese problema vino por pasar por alto una de las posibles operaciones. En este problema, eso no sucede. Desde cualquier configuración dada, hasta cuatro fichas pueden estar adyacentes al espacio vacío, y cualquiera de esas fichas se puede deslizar hacia el espacio vacío. Eso enumera completamente todas las operaciones posibles. La dificultad de este problema surge más bien de la larga cadena de operaciones que requiere la solución. Una serie de operaciones de deslizamiento puede mover algunas fichas a sus posiciones finales correctas mientras mueve otras fichas fuera de su posición, o puede acercar algunas fichas a sus posiciones correctas mientras aleja otras. Debido a esto, es difícil decir si alguna operación en particular lograría avances hacia el objetivo final. Sin poder medir el progreso, es difícil formular una estrategia. Muchas personas que intentan resolver un rompecabezas de fichas deslizantes simplemente mueven las fichas al azar, con la esperanza de encontrar una configuración desde la cual se pueda ver un camino hacia la configuración objetivo. Sin embargo, existen estrategias para los rompecabezas de fichas deslizantes. Para ilustrar un enfoque, consideremos el rompecabezas de una cuadrícula más pequeña que es rectangular pero no cuadrada. PROBLEMA: LOS CINCO DESLIZANTES Una cuadrícula de 2×3 se llena con cinco fichas, numeradas del 4 al 8, y un espacio vacío. Inicialmente, la red está en una configuración confusa. Una ficha se puede deslizar hacia un espacio vacío adyacente, dejando vacía la ubicación anterior de la ficha. El objetivo es deslizar los mosaicos para colocar la cuadrícula en una configuración ordenada, desde el mosaico 4 en la parte superior izquierda. Es posible que hayas notado que nuestras cinco fichas están numeradas del 4 al 8. del 1 al 5. La razón de esto quedará clara en breve. Aunque este es el mismo problema básico que el ocho deslizante, es mucho más más fácil con sólo cinco fichas. Pruebe la configuración que se muestra en la Figura 1­6. Si juegas con estos mosaicos durante unos minutos, probablemente encontrarás una solución. Al jugar con rompecabezas de fichas pequeñas, he desarrollado una habilidad particular. Es 68 esta habilidad, junto con una observación que discutiremos en breve, la que utilizo para resolver todos los acertijos de fichas deslizantes. A mi técnica la llamo tren. Se basa en la observación de que un circuito de posiciones de mosaicos que incluye el 547 Figura 1­6: Una configuración inicial particular para un espacio vacío forma un tren de mosaicos que se puede rotar en rompecabezas de cualquier parte del circuito preservando al mismo tiempo el orden mosaicos deslizantes reducido de 2×3 relativo de los mosaicos. La Figura 1­7 ilustra el tren más pequeño posible de cuatro posiciones. Desde la primera configuración, el 1 puede deslizarse hacia el cuadrado vacío, el 2 puede deslizarse hacia el espacio que dejó el 1 y finalmente el 3 puede deslizarse hacia el espacio que dejó el 2. Esto deja el espacio vacío adyacente al 1, lo que permite que el tren continúe y, por lo tanto, que las fichas giren efectivamente en cualquier lugar a lo largo del recorrido del tren. 8 Capítulo 1 Machine Translated by Google 12 3 23 3 1 21 1 32 Figura 1­7: Un “tren”, un camino de fichas que comienza junto al cuadrado vacío y puede deslizarse como un tren de vagones a través del rompecabezas. Utilizando un tren podremos mover una serie de fichas manteniendo su relación. relación positiva. Ahora volvamos a la configuración anterior de cuadrícula de 2×3. Aunque ninguno de los mosaicos en esta cuadrícula está en su posición final correcta, algunos mosaicos están adyacentes a los mosaicos que deben bordear en la configuración final. Por ejemplo, en la configuración final, el 4 estará encima del 7 y actualmente esos mosaicos son adyacentes. Como se muestra en la Figura 1­8, podemos usar un tren de seis posiciones para llevar el 4 y el 7 a sus posiciones finales correctas. Cuando hacemos eso, los mosaicos restantes son casi correctos; sólo tenemos que deslizar el 8. 68 45 645 547 7 8 1 2 6 78 3 Figura 1­8: Desde la configuración 1, dos rotaciones a lo largo del “tren” delineado nos llevan a la configuración 2. A partir de ahí, un solo deslizamiento de mosaico da como resultado el objetivo, la configuración 3. Entonces, ¿cómo nos permite esta técnica resolver cualquier rompecabezas de fichas deslizantes? Considere nuestra configuración original de 3×3. Podemos usar un tren de seis posiciones para mover las fichas 1 y 2 adyacentes de modo que 2 y 3 sean adyacentes, como se muestra en la Figura 1­9. 472 4 861 81 35 327 1 5 6 2 Figura 1­9: Desde la configuración 1, las fichas se giran a lo largo del "tren" delineado para llegar a la configuración 2. Esto coloca 1, 2 y 3 en cuadrados adyacentes. Con un tren de ocho posiciones, podemos mover las fichas 1, 2 y 3 a sus posiciones finales correctas, como se muestra en la Figura 1­10. Estrategias para la resolución de problemas 9 Machine Translated by Google 4 5 6 123 81 68 327 547 1 2 Figura 1­10: Desde la configuración 1, los mosaicos se giran para llegar a la configuración 2, en la que los mosaicos 1, 2 y 3 están en sus posiciones finales correctas. Observe las posiciones de los mosaicos 4 a 8. Los mosaicos están en la configuración que di para la cuadrícula de 2×3. Esta es la observación clave. Habiendo colocado las fichas 1 a 3 en sus posiciones correctas, podemos resolver el resto de la cuadrícula como un rompecabezas separado, más pequeño y más fácil. Tenga en cuenta que tenemos que resolver una fila o columna completa para que este método funcione; Si colocamos los mosaicos 1 y 2 en las posiciones correctas pero el mosaico 3 todavía está fuera de lugar, no hay forma de mover algo a la esquina superior derecha sin mover uno o ambos mosaicos de la fila superior fuera de su lugar. . Esta misma técnica se puede utilizar para resolver acertijos de fichas deslizantes aún más grandes. El tamaño común más grande es un rompecabezas de 15 fichas, una cuadrícula de 4×4. Esto se puede resolver poco a poco moviendo primero los mosaicos 1 a 4 a su posición correcta, dejando una cuadrícula de 3×4, y luego moviendo los mosaicos de la columna más a la izquierda, dejando una cuadrícula de 3×3. En ese punto, el problema se ha reducido a un rompecabezas de 8 fichas. Lecciones aprendidas ¿Qué lecciones podemos aprender de los rompecabezas de fichas deslizantes? El número de movimientos de los mosaicos es tan grande que es difícil o imposible planificar una solución completa para un rompecabezas de mosaicos deslizantes desde la configuración inicial. Sin embargo, nuestra incapacidad para planificar una solución completa no nos impide elaborar estrategias o emplear técnicas para resolver sistemáticamente el rompecabezas. Al resolver problemas de programación, a veces nos enfrentamos a situaciones en las que no podemos ver un camino claro para codificar la solución, pero nunca debemos permitir que esto sea una excusa para renunciar a la planificación y los enfoques sistemáticos. Es mejor desarrollar una estrategia que atacar el problema mediante prueba y error. Desarrollé mi técnica de “tren” jugueteando con un pequeño rompecabezas. A menudo utilizo una técnica similar en programación. Cuando me enfrento a un problema oneroso, experimento con una versión reducida del problema. Estos experimentos frecuentemente producen ideas valiosas. La otra lección es que a veces los problemas son divisibles de maneras que no son inmediatamente obvias. Debido a que mover una ficha afecta no solo a esa ficha sino también a los posibles movimientos que se pueden realizar a continuación, uno podría pensar que un rompecabezas de fichas deslizantes debe resolverse en un solo paso, no en etapas. Buscar una forma de dividir un problema suele ser un tiempo bien empleado. Incluso si no puedes encontrar un 10 Capítulo 1 Machine Translated by Google división limpia, es posible que aprenda algo sobre el problema que le ayude a resolverlo. Al resolver problemas, trabajar con un objetivo específico en mente siempre es mejor que un esfuerzo aleatorio, ya sea que logres ese objetivo específico o no. Sudokus El juego de sudoku se ha vuelto enormemente popular gracias a sus apariciones en periódicos y revistas y también como juego basado en la web y por teléfono. Existen variaciones, pero discutiremos brevemente la versión tradicional. PROBLEMA: COMPLETAR UN CUADRADO SUDOKU Una cuadrícula de 9 × 9 está parcialmente llena con un solo dígito (del 1 al 9), y el jugador debe completar los cuadrados vacíos cumpliendo ciertas restricciones: en cada fila y columna, cada dígito debe aparecer exactamente una vez, y además, en cada área marcada de 3 × 3, cada dígito debe aparecer exactamente una vez. Si has jugado a este juego antes, probablemente ya tengas un conjunto de estrategias para completar un cuadrado en el tiempo mínimo. Centrémonos en la estrategia inicial clave observando el cuadrado de muestra que se muestra en la Figura 1­11. Figura 1­11: Un sencillo sudoku cuadrado Los sudokus varían en dificultad y su dificultad está determinada por el número de cuadrados que quedan por llenar. Según esta medida, este es un rompecabezas muy fácil. Como ya hay 36 cuadrados numerados, solo quedan 45 que deben llenarse para completar el rompecabezas. La pregunta es: ¿qué cuadrados deberíamos intentar rellenar primero? Estrategias para la resolución de problemas 11 Machine Translated by Google Recuerde las limitaciones del rompecabezas. Cada uno de los nueve dígitos debe aparecer una vez en cada fila, en cada columna y en cada área de 3 × 3 marcada por bordes gruesos. Estas reglas dictan dónde debemos comenzar nuestros esfuerzos. El área de 3×3 en el medio del rompecabezas ya tiene números en ocho de sus nueve cuadrados. Por lo tanto, el cuadrado en el centro sólo puede tener un valor posible, el valor que aún no está representado en otro cuadrado en esa área de 3×3. Ahí es donde deberíamos empezar a resolver este rompecabezas. El número que falta en esa área es 7, así que lo colocaríamos en el cuadrado del medio. Con ese valor en su lugar, observe que la columna más central ahora tiene valores en siete de sus nueve cuadrados, lo que deja sólo dos cuadrados restantes, cada uno de los cuales debe tener un valor que no esté ya en la columna: Los dos números que faltan son 3 y 9. La restricción en esta columna nos permitiría colocar cualquier número en cualquier lugar, pero observe que 3 ya está presente en la tercera fila y 9 ya está presente en la séptima fila. Por lo tanto, las restricciones de fila dictan que 9 vayan en la tercera fila de la columna del medio y 3 vayan en la séptima fila de la columna del medio. Estos pasos se resumen en la Figura 1­12. 6 91 7 82 5 913 14 5 6 14 825 5 9 67 5 69 1 6 15 5 69 2 62 4 6 8 825 9 7 67 4 2 913 14 5 39 9 3 2 8 825 67 4 5 62 7 9 7 15 4 7 82 2 913 2 8 6 91 39 3 62 4 7 82 2 3 2 6 91 39 3 5 4 69 7 15 5 3 Figura 1­12: Los primeros pasos para resolver el sudoku de muestra No resolveremos todo el rompecabezas aquí, pero estos primeros pasos resaltan la importancia de buscar cuadrados que tengan el menor número de valores posibles; idealmente, solo uno. Lecciones aprendidas La principal lección del sudoku es que debemos buscar la parte más limitada del problema. Si bien las restricciones son a menudo las que hacen que un problema sea difícil para empezar (recordemos el zorro, el ganso y el maíz), también pueden simplificar nuestro pensamiento sobre la solución porque eliminan opciones. Aunque no analizaremos la inteligencia artificial específicamente en este libro, existe una regla para resolver ciertos tipos de problemas en inteligencia artificial llamada la "variable más restringida". Significa que en un problema en el que intentas asignar diferentes valores a diferentes variables para cumplir con las restricciones, debes comenzar con la variable que tiene la mayor cantidad de restricciones, o dicho de otra manera, la variable que tiene el menor número de valores posibles. 12 Capítulo 1 Machine Translated by Google He aquí un ejemplo de este tipo de pensamiento. Supongamos que un grupo de compañeros de trabajo quiere ir a almorzar juntos y le han pedido que busque un restaurante que guste a todos. El problema es que cada uno de los compañeros de trabajo impone algún tipo de restricción a la decisión del grupo: Pam es vegetariana, a Todd no le gusta la comida china, etc. Si su objetivo es minimizar la cantidad de tiempo que lleva encontrar un restaurante, debe comenzar hablando con el compañero de trabajo que tiene las restricciones más onerosas. Si Bob tiene una serie de alergias alimentarias generales, por ejemplo, tendría sentido comenzar buscando una lista de restaurantes donde sepa que puede comer, en lugar de comenzar con Todd, cuyo disgusto por la comida china puede mitigarse fácilmente. A menudo se puede aplicar la misma técnica a problemas de programación. Si una parte del problema está muy limitada, ese es un excelente lugar para comenzar porque puede progresar sin preocuparse de perder tiempo en un trabajo que luego se deshará. Un corolario relacionado es que debes comenzar con la parte que es obvia. Si puedes resolver parte del problema, sigue adelante y haz lo que puedas. Puedes aprender algo al ver tu propio código que estimulará tu imaginación para resolver el resto. La cerradura Quarrasi Es posible que hayas visto cada uno de los acertijos anteriores antes, pero no deberías haber visto el último de este capítulo a menos que hayas leído este libro anteriormente, porque este lo inventé yo mismo. Lea atentamente porque la redacción de este problema es un poco complicada. PROBLEMA: ABRIR LA CERRADURA EXTRANJERA Una raza alienígena hostil, los Quarrasi, ha aterrizado en la Tierra y tú has sido capturado. Has logrado dominar a tus guardias, a pesar de que son enormes y tienen diez tácticas, pero para escapar de la nave espacial (aún en tierra), debes abrir la enorme puerta. Las instrucciones para abrir la puerta, curiosamente, están impresas en inglés, pero aún así no es pan comido. Para abrir la puerta, debes deslizar el Kratzz con forma de tres barras a lo largo de las vías que conducen desde el receptor derecho al receptor izquierdo, que se encuentra al final de la puerta, a 10 pies de distancia. Esto es bastante fácil, pero hay que evitar que se activen las alarmas, que funcionan de la siguiente manera. En cada Kratzz hay una o más gemas de poder de cristal en forma de estrella conocidas como Quinicrys. Cada receptor tiene cuatro sensores que se iluminan si el número de Quinicrys en la columna de arriba es par. Una alarma suena si el número de sensores encendidos es exactamente uno. Tenga en cuenta que la alarma de cada receptor es independiente: nunca puede tener exactamente un sensor encendido para el receptor izquierdo o para el derecho. La buena noticia es que cada alarma está equipada con un supresor, que evita que la alarma suene mientras se presiona el botón. Si pudieras presionar ambos supresores a la vez, el problema sería fácil, pero no puedes porque tienes brazos humanos cortos en lugar de largos tentáculos Quarassi. Teniendo en cuenta todo esto, ¿cómo se desliza el Kratzz para abrir la puerta sin activar ninguna de las alarmas? Estrategias para la resolución de problemas 13 Machine Translated by Google La configuración inicial se muestra en la Figura 1­13, con los tres Kratzz en el receptor derecho. Para mayor claridad, la Figura 1­14 muestra una mala idea: deslizar el Kratzz superior hacia el receptor izquierdo provoca un estado de alarma en el receptor derecho. Podrías pensar que podríamos evitar la alarma con el supresor, pero recuerda que acabamos de mover el Kratzz superior al receptor izquierdo, por lo que estamos a 10 pies de distancia del supresor del receptor derecho. Receptor derecho Receptor izquierdo 10 pies Cada Kratzz puede deslizarse de un receptor al otro. otro. Alarma Supresor Alarma Alarma Alarma Supresor Figura 1­13: Configuración inicial para el problema de la cerradura Quarrasi. Debes deslizar las tres barras Kratzz, actualmente en el receptor derecho, hacia el receptor izquierdo sin activar ninguna de las alarmas. Un sensor se enciende cuando aparece un número par de Quinicrys en forma de estrella en la columna de arriba, y suena una alarma si se enciende exactamente un sensor conectado. Los supresores pueden evitar que suene una alarma, pero sólo para el receptor en el que se encuentra. Receptor derecho Receptor izquierdo 10 pies Alarma Alarma Supresor Alarma Alarma Supresor Figura 1­14: La cerradura Quarrasi en estado de alarma. Simplemente deslizó el Kratzz superior hacia el receptor izquierdo, por lo que el receptor derecho queda fuera de su alcance. El segundo sensor de la alarma derecha está encendido porque aparece un número par de Quinicrys en la columna de arriba y suena una alarma cuando exactamente uno de sus sensores está encendido. Antes de continuar, tómate un tiempo para estudiar este problema e intenta desarrollar una solución. Dependiendo de su punto de vista, este problema no es tan difícil como parece. En serio, ¡piénsalo antes de continuar! 14 Capítulo 1 Machine Translated by Google Has pensado sobre eso? ¿Pudiste encontrar una solución? Hay dos caminos posibles hacia una respuesta aquí. El primer camino es prueba y error: intentar varios movimientos de Kratzz de forma metódica y retroceder a los pasos anteriores cuando llegues a un estado de alarma hasta encontrar una serie de movimientos que tengan éxito. El segundo camino es darse cuenta de que el rompecabezas es un truco. Si no has visto El truco todavía está aquí: esto es sólo el problema del zorro, el ganso y el maíz disfrazado de forma elaborada. Aunque las reglas para la alarma están escritas de forma general, hay un número limitado de combinaciones para esta cerradura específica. Con sólo tres Kratzz, sólo nos queda saber qué combinaciones de Kratzz en un receptor son aceptables. Si etiquetamos los tres Kratzz como superior, medio e inferior, entonces las combinaciones que crean alarmas son “arriba y medio” y “medio e abajo”. Si cambiamos el nombre de arriba a zorro, del medio a ganso y de abajo a maíz, entonces las combinaciones problemáticas son las mismas que en el otro problema, “zorro y ganso” y “ganso y maíz”. Por tanto, este problema se resuelve de la misma manera que el problema del zorro, el ganso y el maíz. Deslizamos el Kratzz (ganso) del medio hacia el receptáculo izquierdo. Luego, deslizamos la parte superior (zorro) hacia la izquierda, sujetando el supresor de la alarma izquierda mientras colocamos la parte superior (zorro) en su lugar. A continuación, comenzamos a deslizar el medio (ganso) de regreso al receptáculo derecho. Luego, deslizamos la parte inferior (maíz) hacia la izquierda, y finalmente, deslizamos la del medio (ganso) hacia la izquierda una vez más, abriendo la cerradura. Lecciones aprendidas La principal lección aquí es la importancia de reconocer analogías. Aquí podemos ver que el problema de la cerradura de Quarrasi es análogo al problema del zorro, el ganso y el maíz. Si descubrimos esa analogía lo suficientemente temprano, podemos evitar la mayor parte del trabajo del problema traduciendo nuestra solución del primer problema en lugar de crear una nueva solución. La mayoría de las analogías en la resolución de problemas no serán tan directas, pero ocurrirán con una frecuencia cada vez mayor. Si tuvo problemas para ver la conexión entre este problema y el problema del zorro, el ganso y el maíz, es porque deliberadamente incluí tantos detalles superfluos como fue posible. La historia que plantea el problema de los Quarrasi es irrelevante, al igual que los nombres de toda la tecnología alienígena, que sirven para aumentar la sensación de desconocimiento. Además, el mecanismo par/ impar de la alarma hace que el problema parezca más complicado de lo que es. Si observas la posición real de los Quinicrys, puedes ver que el Kratzz superior y el Kratzz inferior son opuestos, por lo que no interactúan en el sistema de alarma. El Kratzz del medio, sin embargo, interactúa con los otros dos. Nuevamente, si no vio la analogía, no se preocupe. Comenzarás a reconocerlos más después de que te pongas en alerta para detectarlos. Técnicas generales de resolución de problemas Los ejemplos que hemos analizado demuestran muchas de las técnicas clave que se emplean en la resolución de problemas. En el resto de este libro, veremos problemas de programación específicos y descubriremos formas de resolverlos, pero primero necesitamos un conjunto general de técnicas y principios. Algunas áreas problemáticas Estrategias para la resolución de problemas 15 Machine Translated by Google Tenemos técnicas específicas, como veremos, pero las reglas siguientes se aplican a casi cualquier situación. Si los convierte en una parte habitual de su enfoque de resolución de problemas, siempre tendrá un método para atacar un problema. Siempre tenga un plan Esta es quizás la regla más importante. Siempre debes tener un plan, en lugar de involucrarte en actividades sin dirección. A estas alturas, debes comprender que tener un plan siempre es posible. Es cierto que si aún no has resuelto el problema en tu cabeza, entonces no puedes tener un plan para implementar una solución en código. Eso vendrá más tarde. Sin embargo, incluso al principio, debes tener un plan sobre cómo vas a encontrar la solución. Para ser justos, es posible que el plan requiera modificaciones en algún momento del viaje, o que tengas que abandonar tu plan original y elaborar otro. Entonces, ¿por qué es tan importante esta regla? El general Dwight D. Eisenhower era famoso por decir: “Siempre he pensado que los planes son inútiles, pero la planificación es indispensable”. Quería decir que las batallas son tan caóticas que es imposible predecir todo lo que podría suceder y tener una respuesta predeterminada para cada resultado. Entonces, en ese sentido, los planes son inútiles en el campo de batalla (otro líder militar, el prusiano Helmuth von Moltke, dijo la famosa frase que “ningún plan sobrevive al primer contacto con el enemigo”). Pero ningún ejército puede tener éxito sin planificación y organización. A través de la planificación, un general aprende cuáles son las capacidades de su ejército, cómo trabajan juntas las diferentes partes del ejército, etc. De la misma manera, siempre debes tener un plan para resolver un problema. Puede que no sobreviva al primer contacto con el enemigo (puede que lo descarten tan pronto como empieces a escribir código en tu editor de código fuente), pero debes tener un plan. Sin un plan, simplemente estás esperando un golpe de suerte, el equivalente al mono que escribe al azar produciendo una de las obras de Shakespeare. Los golpes de suerte son poco comunes y los que ocurren aún pueden requerir un plan. Mucha gente ha oído la historia del descubrimiento de la penicilina: un investigador llamado Alexander Fleming olvidó cerrar una placa de Petri una noche y por la mañana descubrió que el moho había inhibido el crecimiento de las bacterias en la placa. Pero Fleming no estaba sentado esperando un golpe de suerte; había estado experimentando de forma exhaustiva y controlada y así reconoció la importancia de lo que vio en la placa de Petri. (Si encontrara moho creciendo en algo que dejé afuera la noche anterior, esto no resultaría en una contribución importante a la ciencia). La planificación también permite fijar objetivos intermedios y alcanzarlos. Sin un plan, sólo tienes un objetivo: resolver todo el problema. Hasta que no hayas resuelto el problema, no sentirás que has logrado nada. Como probablemente habrá experimentado, muchos programas no hacen nada útil hasta que están a punto de finalizar. Por lo tanto, trabajar sólo hacia el objetivo principal conduce inevitablemente a la frustración, ya que no hay ningún refuerzo positivo de tus esfuerzos hasta el final. Si, en cambio, crea un plan con una serie de objetivos menores, incluso si algunos parecen tangenciales al problema principal, logrará un progreso mensurable hacia una solución y sentirá que su tiempo se ha acabado. 16 Capítulo 1 Machine Translated by Google sido gastado útilmente. Al final de cada sesión de trabajo, podrá marcar elementos de su plan y ganar confianza en que encontrará una solución en lugar de sentirse cada vez más frustrado. Replantear el problema Como lo demostró especialmente el problema del zorro, el ganso y el maíz, reformular un problema puede producir resultados valiosos. En algunos casos, un problema que parece muy difícil puede parecer fácil cuando se plantea de otra manera o se utilizan términos diferentes. Replantear un problema es como rodear la base de una colina que debes escalar; Antes de comenzar a subir, ¿por qué no observas la colina desde todos los ángulos para ver si hay un camino más fácil para subir? La reformulación a veces nos muestra que el objetivo no era lo que pensábamos que era. Una vez leí sobre una abuela que cuidaba a su nieta bebé mientras tejía. Para poder tejer, la abuela puso al bebé a su lado en un corralito portátil, pero al bebé no le gustaba estar en el corral y seguía llorando. La abuela probó todo tipo de juguetes para que el corral fuera más divertido para el bebé, hasta que se dio cuenta de que mantener al bebé en el corral era sólo un medio para lograr un fin. El objetivo era que la abuela pudiera tejer en paz. La solución: dejar que el bebé juegue felizmente en la alfombra, mientras la abuela teje dentro del corral. La reformulación puede ser una técnica poderosa, pero muchos programadores la omitirán porque no implica directamente escribir código o incluso diseñar una solución. Ésta es otra razón por la que tener un plan es esencial. Sin un plan, su único objetivo es tener un código que funcione, y la reformulación le quita tiempo a la escritura del código. Con un plan, puede plantear “replantear formalmente el problema” como primer paso; por lo tanto, completar la reexpresión oficialmente cuenta como avance. Incluso si una reformulación no conduce a ninguna información inmediata, puede ayudar De otras maneras. Por ejemplo, si un supervisor o un instructor le ha asignado un problema, puede llevar su reformulación a la persona que le asignó el problema y confirmar su comprensión. Además, reformular el problema puede ser un paso previo necesario para utilizar otras técnicas comunes, como reducir o dividir el problema. En términos más generales, la reformulación puede transformar áreas problemáticas enteras. La técnica que empleo para soluciones recursivas, que compartiré en un capítulo posterior, es un método para reformular problemas recursivos de modo que pueda tratarlos igual que problemas iterativos. Divida el problema Encontrar una manera de dividir un problema en pasos o fases puede facilitarlo mucho. Si puedes dividir un problema en dos partes, podrías pensar que cada parte sería la mitad de difícil de resolver que el conjunto original, pero normalmente es incluso más fácil que eso. He aquí una analogía que le resultará familiar si ya ha visto algoritmos de clasificación comunes. Supongamos que tiene 100 archivos que necesita colocar en un cuadro en orden alfabético, y su método básico de alfabetización es efectivamente lo que llamamos ordenación por inserción: toma uno de los archivos al azar, lo coloca en el cuadro, Estrategias para la resolución de problemas 17 Machine Translated by Google luego coloque el siguiente archivo en el cuadro en la relación correcta con el primer archivo, y luego continúe, colocando siempre el nuevo archivo en su posición correcta con respecto a los otros archivos, de modo que en un momento dado, los archivos en el cuadro estén alfabéticos. ­izado. Supongamos que alguien inicialmente separa los archivos en 4 grupos de aproximadamente el mismo tamaño, A–F, G–M, N–S y T–Z, y le dice que ordene alfabéticamente los 4 grupos individualmente y luego los suelte uno tras otro en el caja. Si cada uno de los grupos contuviera alrededor de 25 archivos, entonces uno podría pensar que alfabetizar 4 grupos de 25 es aproximadamente la misma cantidad de trabajo que alfabetizar un solo grupo de 100. Pero en realidad es mucho menos trabajo porque el trabajo que implica insertar un un solo archivo crece a medida que crece el número de archivos ya archivados; debe mirar cada archivo en el cuadro para saber dónde debe colocarse el nuevo archivo. (Si dudas de esto, piensa en una versión más extrema— compare la idea de ordenar 50 grupos de 2 archivos, lo que probablemente podría hacer en menos de un minuto, con ordenar un solo grupo de 100 archivos). De la misma manera, dividir un problema a menudo puede reducir la dificultad en un orden de magnitud. Combinar técnicas de programación es mucho más complicado que utilizar técnicas únicamente. Por ejemplo, una sección de código que emplea una serie de sentencias if dentro de un bucle while que a su vez está dentro de un bucle for será más difícil de escribir (y leer) que una sección de código que emplea todas esas mismas sentencias de control secuencialmente. Discutiremos formas específicas de dividir los problemas en los capítulos siguientes, pero siempre debes estar alerta a la posibilidad. Recuerde que algunos problemas, como nuestro rompecabezas de fichas deslizantes, a menudo ocultan su posible subdivisión. A veces, la forma de encontrar las divisiones de un problema es reducir el problema, como veremos en breve. Comience con lo que sabe A los novelistas primerizos a menudo se les aconseja "escribe lo que sabes". Esto no significa que los novelistas deban intentar elaborar obras únicamente en torno a incidentes y personas que hayan observado directamente en sus propias vidas; si este fuera el caso, nunca podríamos tener novelas fantásticas, ficción histórica o muchos otros géneros populares. Pero significa que cuanto más se aleja un escritor de su propia experiencia, más difícil puede resultarle escribir. De la misma manera, al programar, debes intentar comenzar con lo que ya sabes hacer y trabajar desde ahí. Una vez que haya dividido el problema en partes, por ejemplo, continúe y complete las partes que ya sepa codificar. Tener una solución parcial que funcione puede generar ideas sobre el resto del problema. Además, como habrás notado, un tema común en la resolución de problemas es lograr avances útiles para generar confianza en que finalmente completarás la tarea. Al comenzar con lo que sabe, genera confianza e impulso hacia la meta. La máxima de “empieza con lo que sabes” también se aplica en los casos en los que no has dividido el problema. Imagine que alguien hiciera una lista completa de todas las habilidades en programación: escribir una clase de C++, ordenar una lista de números, encontrar el valor más grande en una lista vinculada, etc. En cada punto de tu desarrollo como programador, habrá muchas habilidades en esta lista que puedes desarrollar bien, otras habilidades que puedes usar con esfuerzo y luego otras habilidades que puedes usar con esfuerzo. 18 Capítulo 1 Machine Translated by Google Aún no lo sé. Un problema en particular puede resolverse por completo con las habilidades que ya tienes o no, pero debes investigar a fondo el problema utilizando las habilidades que ya tienes en tu cabeza antes de buscar en otra parte. Si pensamos en las habilidades de programación como herramientas y en un problema de programación como un proyecto de reparación del hogar, debería intentar realizar la reparación utilizando las herramientas que ya tiene en su garaje antes de dirigirse a la ferretería. Esta técnica sigue los principios que ya hemos comentado. Sigue un plan y da orden a nuestros esfuerzos. Cuando comenzamos nuestra investigación de un problema aplicando las habilidades que ya tenemos, podemos aprender más sobre el problema y su solución final. Reducir el problema Con esta técnica, cuando te enfrentas a un problema que no puedes resolver, reduces el alcance del problema, ya sea agregando o eliminando restricciones, para producir un problema que sí sabes cómo resolver. Veremos esta técnica en acción en capítulos posteriores, pero aquí hay un ejemplo básico. Suponga que le dan una serie de coordenadas en un espacio tridimensional y debe encontrar las coordenadas más cercanas entre sí. Si no sabes inmediatamente cómo solucionar esto, existen diferentes formas de reducir el problema para buscar una solución. Por ejemplo, ¿qué pasa si las coordenadas están en un espacio bidimensional, en lugar de un espacio tridimensional? Si eso no ayuda, ¿qué pasa si los puntos se encuentran a lo largo de una sola línea, de modo que las coordenadas son solo números individuales (C++ se duplica, digamos)? Ahora la pregunta esencialmente es, en una lista de números, encontrar los dos números con la mínima diferencia absoluta. O podría reducir el problema manteniendo las coordenadas en un espacio tridimensional pero con solo tres valores, en lugar de una serie de tamaño arbitrario. Entonces, en lugar de un algoritmo para encontrar la distancia más pequeña entre dos coordenadas cualesquiera, es solo una cuestión de comparar la coordenada A con la coordenada B, luego B con C y luego A con C. Estas reducciones simplifican el problema de diferentes maneras. La primera reducción elimina la necesidad de calcular la distancia entre puntos tridimensionales. Tal vez no sepamos cómo hacerlo todavía, pero hasta que lo descubramos, todavía podemos avanzar hacia una solución. La segunda reducción, por el contrario, se centra casi por completo en calcular la distancia entre puntos tridimensionales, pero elimina el problema de encontrar un valor mínimo en una serie de valores de tamaño arbitrario. Por supuesto, para resolver el problema original, eventualmente necesitaremos las habilidades involucradas en ambas reducciones. Aun así, la reducción nos permite trabajar en un problema más simple incluso cuando no podemos encontrar una manera de dividir el problema en pasos. En efecto, es como un Kobayashi Maru deliberado, pero temporal. Sabemos que no estamos trabajando en el problema completo, pero el problema reducido tiene suficiente en común con el problema completo como para avanzar hacia la solución definitiva. Muchas veces, los programadores descubren que tienen todas las habilidades individuales necesarias para resolver el problema y, al escribir código para resolver cada aspecto individual del problema, ven cómo combinar las distintas piezas de código en un todo unificado. Estrategias para la resolución de problemas 19 Machine Translated by Google Reducir el problema también nos permite identificar exactamente dónde radica la dificultad restante. Los programadores principiantes a menudo necesitan buscar ayuda de programadores experimentados, pero esto puede ser una experiencia frustrante para todos los involucrados si el programador con dificultades no puede describir con precisión la ayuda que necesita. Uno nunca quiere verse reducido a decir: “Aquí está mi programa y no funciona. ¿Por qué no?" Usando la técnica de reducción de problemas, uno puede identificar la ayuda necesaria, diciendo algo como: “Aquí tienes un código que escribí. Como puedes ver, sé cómo encontrar la distancia entre dos coordenadas tridimensionales y sé cómo comprobar si una distancia es menor que otra. Pero parece que no puedo encontrar una solución general para encontrar el par de coordenadas con la distancia mínima”. Busque analogías Una analogía, para nuestros propósitos, es una similitud entre un problema actual y un problema ya resuelto que puede aprovecharse para ayudar a resolver el problema actual. La similitud puede adoptar muchas formas. A veces significa que los dos problemas son en realidad el mismo problema. Esta es la situación que tuvimos con el problema del zorro, la oca y el maíz y el problema de la esclusa de Quarrasi. La mayoría de las analogías no son tan directas. A veces la similitud afecta sólo a una parte de los problemas. Por ejemplo, dos problemas de procesamiento de números pueden ser diferentes en todos los aspectos, excepto que ambos trabajan con números que requieren más precisión que la proporcionada por los tipos de datos de punto flotante integrados; No podrás usar esta analogía para resolver todo el problema, pero si ya has descubierto una manera de manejar el problema de la precisión adicional, puedes manejar el mismo problema de la misma manera nuevamente. Aunque reconocer analogías es la forma más importante de mejorar su velocidad y habilidad para resolver problemas, también es la habilidad más difícil de desarrollar. La razón por la que es tan difícil al principio es que no puedes buscar analogías hasta que tengas un almacén de soluciones anteriores a las que puedas hacer referencia. Aquí es donde los programadores en desarrollo a menudo intentan tomar un atajo, encontrar código que sea similar al código necesario y modificarlo desde allí. Sin embargo, por varias razones esto es un error. En primer lugar, si no completa una solución usted mismo, no la habrá comprendido ni internalizado por completo. En pocas palabras, es muy difícil modificar correctamente un programa que no comprendes completamente. No es necesario haber escrito el código para comprenderlo completamente, pero si no hubiera podido escribir el código, su comprensión será necesariamente limitada. En segundo lugar, cada programa exitoso que usted escriba es más que una solución a un problema actual; es una fuente potencial de analogías para resolver problemas futuros. Cuanto más confíe ahora en el código de otros programadores, más tendrá que depender de él en el futuro. Hablaremos en profundidad sobre la “buena reutilización” y la “mala reutilización” en el Capítulo 7. Experimento A veces la mejor manera de progresar es probar cosas y observar los resultados. Tenga en cuenta que experimentar no es lo mismo que adivinar. Cuando adivinas, escribes un código y esperas que funcione, sin tener una fe firme. 20 Capítulo 1 Machine Translated by Google que así será. Un experimento es un proceso controlado. Plantea una hipótesis sobre lo que sucederá cuando se ejecute cierto código, lo prueba y comprueba si su hipótesis es correcta. A partir de estas observaciones, obtienes información que te ayudará a resolver el problema original. La experimentación puede resultar especialmente útil cuando se trata de interfaces de programación de aplicaciones o bibliotecas de clases. Supongamos que está escribiendo un programa que usa una clase de biblioteca que representa un vector (en este contexto, una matriz unidimensional que crece automáticamente a medida que se agregan más elementos), pero nunca antes ha usado esta clase de vector y no está Estoy seguro de qué sucede cuando se elimina un elemento del vector. En lugar de seguir adelante resolviendo el problema original mientras las incertidumbres se arremolinan en su cabeza, podría crear un programa corto e independiente sólo para jugar con la clase de vector y probar específicamente las situaciones que le preocupan. Si dedica un poco de tiempo al programa “demostrante vectorial”, podría convertirse en una referencia para el trabajo futuro con la clase. Otras formas de experimentación son similares a la depuración. Supongamos que cierto programa produce un producto que está por detrás de las expectativas: por ejemplo, si la salida es numérica, los números son los esperados, pero en orden inverso. Si no ve por qué ocurre esto después de revisar su código, como experimento, puede intentar modificar el código para hacer que la salida sea al revés deliberadamente (ejecutar un bucle en la dirección inversa, tal vez). El cambio resultante, o la falta de cambio, en la salida puede revelar el problema en su código fuente original o puede revelar una brecha en su comprensión. De cualquier manera, estás más cerca de una solución. No te frustres La técnica final no es tanto una técnica, sino una máxima: no te frustres. Cuando esté frustrado, no pensará con tanta claridad, no trabajará con tanta eficiencia y todo le llevará más tiempo y le parecerá más difícil. Peor aún, la frustración tiende a alimentarse de sí misma, de modo que lo que comienza como una leve irritación termina como una ira absoluta. Cuando doy este consejo a nuevos programadores, a menudo me responden que, si bien en principio están de acuerdo con mi punto, no tienen control sobre sus frustraciones. ¿No es pedirle a un programador que no se frustre por la falta de éxito como pedirle a un niño que no grite si pisa una tachuela? La respuesta es no. Cuando alguien pisa una tachuela, se envía inmediatamente una señal fuerte a través del sistema nervioso central, donde responden las profundidades inferiores del cerebro. A menos que sepas que estás a punto de dar el paso, es imposible reaccionar a tiempo para contrarrestar la respuesta automática del cerebro. Así que dejaremos que el niño se salga con la suya por gritar. El programador no está en el mismo barco. A riesgo de parecer un gurú de la autoayuda, un programador frustrado no responde a un estímulo externo. El programador frustrado no está enojado con el código fuente en el monitor, aunque puede expresar su frustración en esos términos. En cambio, el programador frustrado está enojado consigo mismo. La fuente de la frustración es también el destino, la mente del programador. Estrategias para la resolución de problemas 21 Machine Translated by Google Cuando te permites frustrarte (y uso la palabra "permitir" deliberadamente), en realidad te estás dando una excusa para seguir fallando. Suponga que está trabajando en un problema difícil y siente que aumenta su frustración. Horas más tarde, recuerdas una tarde de dientes apretados y lápices rotos con ira y te dices a ti mismo que habrías logrado un progreso real si hubieras podido calmarte. En verdad, es posible que haya decidido que ceder a su enojo era más fácil que enfrentar el problema difícil. Entonces, en última instancia, evitar la frustración es una decisión que debes tomar. Sin embargo, hay algunas ideas que puedes emplear y que te ayudarán. En primer lugar, nunca olvide la primera regla: siempre debe tener un plan y que, si bien escribir código que resuelva el problema original es el objetivo de ese plan, no es el único paso de ese plan. Por lo tanto, si tienes un plan y lo estás siguiendo, entonces estás progresando y debes creerlo. Si ha seguido todos los pasos de su plan original y aún no está listo para comenzar a codificar, entonces es hora de hacer otro plan. Además, cuando se trata de frustrarse o tomar un descanso, debes tomarte un descanso. Un truco consiste en tener más de un problema en el que trabajar, de modo que si este problema te bloquea, puedas dirigir tus esfuerzos a otra parte. Tenga en cuenta que si divide el problema con éxito, puede utilizar esta técnica en un solo problema; simplemente bloquea la parte del problema que te tiene estancado y trabaja en otra cosa. Si no tienes otro problema que puedas abordar, levántate de la silla y haz otra cosa, algo que mantenga tu sangre fluyendo pero que no te haga daño al cerebro: sal a caminar, lava la ropa, sigue tu rutina de estiramiento. (Si te registras para ser programador y estás sentado frente a una computadora todo el día, ¡te recomiendo que desarrolles una rutina de estiramiento!). No pienses en el problema hasta que termine tu descanso. Ejercicios Recuerda, para aprender algo de verdad hay que ponerlo en práctica, así que trabaja tantos ejercicios como puedas. En este primer capítulo, por supuesto, todavía no estamos hablando de programación, pero aun así, te animo a que pruebes algunos ejercicios. Piensa en estas preguntas como calentamiento para tus dedos antes de empezar a tocar música real. 1­1. Pruebe un sudoku de dificultad media (puede encontrarlos en todo el Web y probablemente en tu periódico local), experimentando con diferentes estrategias y tomando nota de los resultados. ¿Puedes escribir un plan general para resolver un sudoku? 1­2. Considere una variante de rompecabezas de fichas deslizantes en la que las fichas están cubiertas con una imagen en lugar de números. ¿Cuánto aumenta esto la dificultad y por qué? 1­3. Encuentra una estrategia para rompecabezas de fichas deslizantes diferente a la mía. 22 Capítulo 1 Machine Translated by Google 1­4. Busca acertijos antiguos del tipo del zorro, el ganso y el maíz e intenta resolverlos. Muchos de los grandes acertijos fueron originados o popularizados por Sam Loyd, por lo que puedes buscar su nombre. Además, una vez que descubras (o te rindas y leas) la solución, piensa en cómo podrías hacer una versión más sencilla del rompecabezas. ¿Qué tendrías que cambiar? ¿Las limitaciones o simplemente la redacción? 1­5. Intente escribir algunas estrategias explícitas para otros juegos tradicionales de lápiz y papel, como los crucigramas. ¿Por dónde deberías empezar? ¿Qué debes hacer cuando estás estancado? Incluso los juegos de periódicos sencillos, como “Jumble”, son útiles para reflexionar sobre la estrategia. Estrategias para la resolución de problemas 23 Machine Translated by Google Machine Translated by Google t l S YYPAG oh C A DY Y PUZLES PUROS En este capítulo, comenzaremos a tratar con el código real. Si bien se necesitarán conocimientos intermedios de programación para capítulos posteriores, las habilidades de programación requeridas en este capítulo son tan simples como pueden ser. Eso no significa que todos estos acertijos serán fáciles, solo que debes poder concentrarte en res y no la sintaxis de programación. Esto es resolución de problemas en su forma más pura. Una vez que sepas lo que quieres hacer, traducir tus pensamientos al código C++ será sencillo. Recuerde que la lectura de este libro, en sí misma, proporciona un beneficio limitado. Debe resolver cualquier problema que le parezca no trivial mientras lo analizamos, intentando resolverlo usted mismo antes de leer sobre mi enfoque. Al final del capítulo, pruebe algunos de los ejercicios, muchos de los cuales serán extensiones de los problemas que analizamos. Machine Translated by Google Revisión de C++ utilizado en este capítulo Este capítulo utiliza el C++ básico con el que ya debería estar familiarizado, incluidas las declaraciones de control if, for, while , do­ while y switch. Es posible que todavía no se sienta cómodo escribiendo código para resolver problemas originales con estas declaraciones; después de todo, de eso se trata este libro. Sin embargo, debe comprender la sintaxis de cómo se escriben estas declaraciones o tener a mano una buena referencia de C++. También debes saber cómo escribir y llamar funciones. Para simplificar las cosas, usaremos los flujos estándar cin y cout para entrada y salida. Para usar estas secuencias, incluya el archivo de encabezado necesario, iostream, en su código y agregue instrucciones de uso para los dos objetos de secuencia estándar: #incluir <iostream> usando std::cin; usando std::cout; Por motivos de brevedad, estas declaraciones no se mostrarán en los listados de códigos. Se supone su inclusión en cualquier programa que los utilice. Patrones de salida En este capítulo, resolveremos tres problemas principales. Debido a que haremos un uso extensivo de las técnicas de división y reducción de problemas, cada uno de estos problemas principales generará varios subproblemas. En esta primera sección, probaremos una serie de programas que producen resultados con patrones en una forma regular. Programas como estos desarrollan habilidades para escribir bucles. PROBLEMA: MEDIO CUADRADO Escriba un programa que utilice sólo dos declaraciones de salida, cout << "#" y cout << "\n", para producir un patrón de símbolos hash con la forma de la mitad de un cuadrado perfecto de 5 x 5 (o un triángulo rectángulo): ##### #### ### ## # Aquí hay otro gran ejemplo de la importancia de las restricciones. Si ignoramos el requisito de que solo podemos usar dos sentencias de salida, una que produzca un único símbolo hash y otra que produzca un final de línea, podemos escribir un Kobayashi Maru y resolver este problema de manera trivial. Sin embargo, con esa restricción implementada, tendremos que usar bucles para resolver este problema. Puede que ya veas la solución en tu cabeza, pero supongamos que no es así. Una buena primera arma es la reducción. ¿Cómo podemos reducir este problema a un punto en el que sea fácil de resolver? ¿Qué pasaría si el patrón fuera un cuadrado entero en lugar de la mitad de un cuadrado? 26 Capítulo 2 Machine Translated by Google PROBLEMA: UN CUADRADO (MITAD DE REDUCCIÓN CUADRADA) Escriba un programa que utilice sólo dos declaraciones de salida, cout << "#" y cout << "\n", para producir un patrón de símbolos hash con forma de cuadrado perfecto de 5x5: ##### ##### ##### ##### ##### Esto puede ser suficiente para ponernos en marcha, pero supongamos que tampoco supiéramos cómo abordarlo. Podríamos reducir aún más el problema, creando una sola línea de símbolos hash en lugar del cuadrado. PROBLEMA: UNA LÍNEA (MEDIO CUADRADO DE REDUCCIÓN ADICIONAL) Escriba un programa que utilice sólo dos declaraciones de salida, cout << "#" y cout << "\n", para producir una línea de cinco símbolos hash: ##### Ahora tenemos un problema trivial que se puede resolver con un bucle for : para (int número hash = 1; número hash <= 5; número hash ++) { cout << "#"; } cout << "\n"; A partir de aquí, volvemos a la reducción anterior, la forma cuadrada completa. El cuadrado completo son simplemente cinco repeticiones de la línea de cinco símbolos hash. Sabemos cómo hacer código repetido; simplemente escribimos un bucle. Entonces podemos convertir nuestro bucle simple en un bucle doble: para (int fila = 1; fila <= 5; fila++) { para (int número hash = 1; número hash <= 5; número hash ++) { cout << "#"; } cout << "\n"; } Hemos colocado todo el código del listado anterior en un nuevo bucle para que se repita cinco veces, produciendo cinco filas, cada fila una línea de cinco símbolos hash. Nos estamos acercando a la solución definitiva. ¿Cómo modificamos el código para que produzca el patrón de medio cuadrado? Si miramos el último listado y lo comparamos con nuestra salida de medio cuadrado deseada, podemos ver que el problema está en la expresión condicional hashNum <= 5. Este condicional produce Rompecabezas puros 27 Machine Translated by Google la misma línea de cinco símbolos hash en cada fila. Lo que necesitamos es un mecanismo para ajustar el número de símbolos producidos en cada fila de modo que la primera fila tenga cinco símbolos, la segunda fila tenga cuatro, y así sucesivamente. Para ver cómo hacer esto, hagamos otro experimento de programa reducido. Una vez más, siempre es más fácil trabajar en la parte problemática de un problema de forma aislada. Por un momento, olvidémonos de los símbolos hash y hablemos sólo de números. PROBLEMA: CUENTA ATRÁS CONTANDO ARRIBA Escriba una línea de código que vaya en la posición designada en el bucle en el listado a continuación. El programa muestra los números del 5 al 1, en ese orden, con cada número en una línea separada. para (int fila = 1; fila <= 5; fila++) { cout << expresión << "\n"; } Debemos encontrar una expresión que sea 5 cuando la fila es 1, 4 cuando la fila es 2, y así sucesivamente. Si queremos una expresión que disminuya a medida que aumenta la fila , nuestro primer pensamiento podría ser colocar un signo menos delante de los valores de la fila multiplicando la fila por –1. Esto produce números que bajan, pero no los números deseados. Sin embargo, es posible que estemos más cerca de lo que pensamos. ¿Cuál es la diferencia entre el valor deseado y el valor obtenido al multiplicar la fila por –1? La Tabla 2­1 resume este análisis. Tabla 2­1: Cálculo del valor deseado a partir de la variable de fila Fila Valor Fila * –1 diferencia de Valor deseado 1 5 –1 6 2 4 –2 6 3 3 –3 6 4 2 –4 6 5 1 –5 6 Deseado La diferencia es un valor fijo, 6. Esto significa que la expresión que necesitamos es * ­1 fila + 6. Usando un poco de álgebra, podemos simplificar esto a 6 filas. Probémoslo: para (int fila = 1; fila <= 5; fila++) { cout << 6 ­ fila << "\n"; } Genial, ¡funciona! Si esto no hubiera funcionado, nuestro error probablemente habría sido menor, debido a los cuidadosos pasos que hemos tomado. De nuevo es muy fácil 28 Capítulo 2 Machine Translated by Google experimentar con un bloque de código así de pequeño y simple. Ahora tomemos esta expresión y usémosla para limitar el bucle interno: para (int fila = 1; fila <= 5; fila++) { for (int hashNum = 1; hashNum <= 6 ­ fila; hashNum++) { cout << "#"; } cout << "\n"; } Usar la técnica de reducción requiere más pasos para pasar de la descripción al programa completo, pero cada paso es más fácil. Piense en usar una serie de poleas para levantar un objeto pesado: debe tirar de la cuerda más lejos para lograr la misma cantidad de elevación, pero cada tirón es mucho más suave para sus músculos. Abordemos otro problema de forma antes de continuar. PROBLEMA: UN TRIÁNGULO LATERAL Escriba un programa que utilice sólo dos declaraciones de salida, cout << "#" y cout << "\n", para producir un patrón de símbolos hash con forma de triángulo lateral: # ## ### #### ### ## # No vamos a seguir todos los pasos que utilizamos en el problema anterior porque no es necesario. Este problema del “triángulo lateral” es análogo al problema de la “mitad de un cuadrado”, por lo que podemos usar lo que hemos aprendido del último en el primero. ¿Recuerdas la máxima de “empieza con lo que sabes”? Comencemos enumerando habilidades y técnicas del problema “La mitad de un cuadrado” que se pueden aplicar a este problema. Sabemos cómo: Mostrar una fila de símbolos de una longitud particular usando un bucle Mostrar una serie de filas usando bucles anidados Cree un número variable de símbolos en cada fila usando un método algebraico. expresión en lugar de un valor fijo Descubra la expresión algebraica correcta a través de la experimentación y análisis La Figura 2­1 resume nuestra posición actual. La primera fila muestra el problema anterior "La mitad de un cuadrado". Vemos el patrón deseado de símbolos hash (a), el patrón de líneas (b), el patrón de cuadrados (c) y la secuencia numérica (d) que transformará el patrón cuadrado en el patrón de medio cuadrado. La segunda fila muestra el problema actual del "Triángulo lateral". Nuevamente vemos el patrón deseado (e), la línea (f), un patrón de rectángulo (g) y una secuencia numérica (h). Puros rompecabezas 29 Machine Translated by Google En este punto, no tendremos problemas para producir (f) porque es casi lo mismo que (b). Y deberíamos poder producir (g) porque es simplemente (c) con más filas y un símbolo menos por fila. Finalmente, si alguien nos diera la expresión algebraica que produciría la secuencia numérica (h), no tendríamos dificultades para crear el patrón deseado (e). Por tanto, la mayor parte del trabajo mental necesario para crear una solución al problema del “triángulo lateral” ya se ha realizado. Además, sabemos exactamente qué trabajo mental queda: descubrir una expresión para producir la secuencia numérica (h). Entonces es ahí donde debemos dirigir nuestra atención. Podríamos tomar el ##### ##### 5 #### ##### 4 ### ##### 3 ## ##### 2 # ##### 1 (C) (d) # #### 1 ## #### 2 ### #### 3 (a) #### ##### (b) #### 4 ### #### 3 ## #### 2 # #### 1 (gramo) (h) (Es) #### (F) Figura 2­1: Varios componentes necesarios para resolver los problemas de forma. código terminado para el problema “La mitad de un cuadrado” y experimentar hasta que podamos producir la secuencia numérica deseada o adivinar y hacer una tabla como la Tabla 2­1 para ver si eso estimula nuestra creatividad. Intentemos experimentar esta vez. En el problema “La mitad de un cuadrado”, restar la fila de un número mayor funcionó bien, así que veamos qué números obtenemos al ejecutar la fila en un bucle del 1 al 7 y restar la fila de 8. El resultado se muestra en Figura 2­2 (b). Eso no es lo que queremos. ¿A dónde vamos desde aquí? En el problema anterior, necesitábamos un número que bajara en lugar de subir, así que restamos nuestra variable de bucle de un número mayor. En este problema, primero debemos subir y luego bajar. ¿Tendría sentido restarle a un número del medio? Si reemplazamos la fila 8 en el código anterior con la fila 4, obtenemos el resultado en la Figura 2­2 (c). Eso tampoco está bien, pero parece que podría ser un patrón útil si no miramos los signos menos de los últimos tres números. ¿Qué pasaría si usáramos la función de valor absoluto para eliminar esos signos menos? La expresión abs(4 ­ fila) produce los resultados de la Figura 2­2 (d). Estamos tan cerca ahora que ¡casi puedo saborearlo! Es sólo que primero bajamos y luego subimos cuando necesitamos subir primero y luego bajar. Pero, ¿cómo pasamos de la secuencia numérica que tenemos a la secuencia numérica que necesitamos? Intentemos observar los números de la Figura 2­2 (d) de una manera diferente. ¿Qué pasa si contamos los espacios vacíos en lugar de las marcas, como se muestra en la Figura 2­2 (e)? La columna (d) es el patrón correcto de valores si contamos los espacios vacíos. Para obtener el número correcto de marcas de almohadilla, piense que cada fila tiene cuatro cuadros y luego reste el número de espacios vacíos. Si cada fila tiene cuatro cuadros de los cuales abs(4 ­ fila) son espacios vacíos, entonces el número de cuadros con marcas de almohadilla vendrá dado por 4 ­ abs(4 ­ fila). Eso funciona. Conéctelo y pruébelo. 30 Capítulo 2 Machine Translated by Google 1 7 3 3 # 2 6 2 2 ## 3 5 1 1 ### 4 4 0 0 #### 3 3 ­1 1 ### 2 2 ­2 2 ## 1 1 ­3 3 # (a) (b) (C) (d) (Es) Figura 2­2: Varios componentes necesarios para resolver el problema Problema del “triángulo lateral” Hemos evitado la mayor parte del trabajo para este problema mediante analogía y hemos resuelto el resto mediante experimentación. Este doble golpe es un gran enfoque cuando un nuevo problema es muy similar a otro que ya puedes resolver. Procesamiento de entrada Los programas anteriores sólo produjeron resultados. Cambiemos las cosas y probemos programas que se centren exclusivamente en procesar la entrada. Cada uno de estos programas comparte una restricción: la entrada se leerá carácter por carácter y el programa debe procesar cada carácter antes de leer el siguiente. En otras palabras, los programas no almacenarán los caracteres en una estructura de datos para su posterior procesamiento, sino que los procesarán sobre la marcha. En este primer problema, realizaremos la validación del número de identificación. En el mundo moderno, casi todo tiene un número de identificación, como un ISBN o un número de cliente. A veces, estos números deben ingresarse a mano, lo que introduce la posibilidad de error. Si un número ingresado por error no coincide con ningún número de identificación válido, el sistema puede rechazarlo fácilmente. Pero ¿qué pasa si el número es incorrecto pero aún es válido? Por ejemplo, ¿qué pasa si un cajero, al intentar acreditar su cuenta por la devolución de un producto, ingresa el número de cuenta de otro cliente? El otro cliente recibiría su crédito. Para evitar esta situación se han desarrollado sistemas para detectar errores en los números de identificación. Funcionan ejecutando el número de identificación a través de una fórmula que genera uno o más dígitos adicionales, que pasan a formar parte de un número de identificación ampliado. Si se cambia alguno de los dígitos, la parte original del número y los dígitos adicionales ya no coincidirán y el número puede rechazarse. PROBLEMA: VALIDACIÓN DE LA SUMA DE VERIFICACIÓN DE LUHN La fórmula de Luhn es un sistema ampliamente utilizado para validar números de identificación. Usando el número original, duplica el valor de cada dos dígitos. Luego sume los valores de los dígitos individuales (si un valor duplicado ahora tiene dos dígitos, sume los dígitos individualmente). El número de identificación es válido si la suma es divisible por 10. Escriba un programa que tome un número de identificación de longitud arbitraria y determine si el número es válido según la fórmula de Luhn. El programa debe procesar cada carácter antes de leer el siguiente. Rompecabezas puros 31 Machine Translated by Google El proceso suena un poco complicado, pero un ejemplo aclarará todo. Nuestro programa solo validará un número de identificación, no creará el dígito de control. Repasemos ambos extremos del proceso: calcular un dígito de control y validar el resultado. Este proceso se demuestra en la Figura 2­3. En el inciso (a), calculamos el dígito de control. El número de identificación original, 176248, se muestra en el cuadro de línea discontinua. Cada dos dígitos, comenzando desde el dígito más a la derecha del número original (que, después de la suma del dígito de control, será el segundo más a la derecha), se duplica. Luego se suma cada dígito. Tenga en cuenta que cuando al duplicar un dígito se obtiene un número de dos dígitos, cada uno de esos dígitos se considera por separado. Por ejemplo, cuando se duplica 7 para producir 14, no se suma 14 a la suma de comprobación, sino 1 y 4 individualmente. En este caso, la suma de verificación es 27, por lo que el dígito de verificación es 3 porque ese es el valor del dígito que haría que la suma total sea 30. Recuerde, la suma de verificación del número final debe ser divisible por 10; en otras palabras, debería terminar en 0. 1 7 6 2 ×2 3 ×2 4 +4 8 ×2 14 1+1+ 4 dieciséis 4 6 ++++ 4 6 = 27 + 1 = 30 3 (a) 1 7 6 2 ×2 ×2 14 1+1+ 4 4 6 ++++ 4 3 ×2 4 +4 8 dieciséis 1 6 + 3 = 30 (b) Figura 2­3: Fórmula de suma de comprobación de Luhn En la parte (b), validamos el número 1762483, que ahora incluye el dígito de control. Este es el proceso que usaremos para este problema. Como antes, duplicamos cada segundo dígito, comenzando con el dígito a la derecha del dígito de control, y sumamos los valores de todos los dígitos, incluido el dígito de control, para determinar la suma de control. Debido a que la suma de verificación es divisible por 10, este número se valida. 32 Capítulo 2 Machine Translated by Google Rompiendo el problema El programa que resolverá este problema tiene varios problemas distintos que tendremos que abordar. Un problema es la duplicación de dígitos, lo cual es complicado porque los dígitos duplicados se determinan desde el extremo derecho del número de identificación. Recuerde, no vamos a leer y almacenar todos los dígitos y luego proceso. Vamos a procesar sobre la marcha. El problema es que obtendremos los dígitos de izquierda a derecha, pero realmente los necesitamos de derecha a izquierda para saber qué dígitos duplicar. Sabríamos qué dígitos duplicar si supiéramos cuántos dígitos hay en el número de identificación, pero no lo sabemos porque el problema establece que el número de identificación tiene una longitud arbitraria. Otro problema es que los números duplicados del 10 en adelante deben tratarse según sus dígitos individuales. Además, debemos determinar cuándo hemos leído el número de identificación completo. Finalmente, tenemos que descubrir cómo leer el número dígito por dígito. En otras palabras, el usuario va a ingresar un número largo, pero queremos leerlo como si los dígitos se ingresaran como números separados. Como siempre queremos tener un plan, debemos hacer una lista de estos problemas y abordarlos uno por uno: Saber qué dígitos duplicar Tratar los números duplicados 10 y mayores según sus características individuales. dígitos Saber que hemos llegado al final del número. Leer cada dígito por separado Para resolver problemas, trabajaremos en piezas individuales antes de escribir una solución final. Por lo tanto, no es necesario trabajar en estos temas en ningún orden en particular. Comience con el tema que parezca más fácil o, si quiere un desafío, el que parezca más difícil. O simplemente comience con el que sea más interesante. Comencemos abordando los dígitos duplicados que son 10 o más. Ésta es una situación en la que las limitaciones del problema hacen que las cosas sean más fáciles en lugar de dificultarlas. Calcular la suma de los dígitos de un número entero arbitrario podría suponer una buena cantidad de trabajo por sí solo. Pero ¿cuál es el rango de valores posibles aquí? Si comenzamos con dígitos individuales del 0 al 9 y los duplicamos, el valor máximo es 18. Por lo tanto, sólo hay dos posibilidades. Si el valor duplicado es de un solo dígito, entonces no hay nada más que hacer. Si el valor duplicado es 10 o mayor, entonces debe estar en el rango de 10 a 18 y, por lo tanto, el primer dígito es siempre 1. Hagamos un experimento de código rápido para confirmar este enfoque: dígito entero; cout << "Ingrese un número de un solo dígito, 0­9: "; cin >> dígito; int dígito duplicado = dígito * 2; suma interna; if (Dígito duplicado >= 10) suma = 1 + Dígito duplicado % 10; else suma = dígito duplicado; " << soy << "\n"; cout << "Suma de dígitos en un número duplicado: Rompecabezas puros 33 Machine Translated by Google NOTA El operador % se denomina operador de módulo. Para números enteros positivos, devuelve el Resto de la división de números enteros. Por ejemplo, 12 % 10 sería 2 porque después de dividir 10 entre 12 sobra el 2. Este es un código sencillo: el programa lee el dígito, lo duplica número duplicado y genera la suma un número duplicado mayor que 10 , suma los dígitos del . El corazón del experimento es el cálculo de la suma de . Al igual que con el cálculo del número de marcas necesarias para una fila particular en nuestros problemas de formas, aislar este cálculo en un programa corto propio facilita la experimentación. Incluso si al principio no encontramos la fórmula correcta, seguramente la encontraremos rápidamente. Antes de eliminar este problema de nuestra lista, conviertamos este código en una función breve que podamos usar para simplificar futuros listados de códigos: int doubleDigitValue(int dígito) { int dígito duplicado = dígito * 2; suma interna; si (dobleDígito > 10) suma = 1 + dobledoDígito % 10; else suma = dígito duplicado; suma de devolución; } Ahora trabajemos en la lectura de los dígitos individuales del número de identificación. ber. Nuevamente, podríamos abordar un problema diferente a continuación si quisiéramos, pero creo que este problema es una buena opción porque nos permitirá escribir el número de identificación de forma natural al probar las otras partes del problema. Si leemos el número de identificación como un tipo numérico (int, por ejemplo), obtendremos un número largo y tendremos mucho trabajo por delante. Además, hay un límite en cuanto al tamaño de un número entero que podemos leer, y la pregunta dice que el número de identificación tiene una longitud arbitraria. Por tanto, tendremos que leer carácter por carácter. Esto significa que debemos asegurarnos de saber cómo leer un carácter que representa un dígito y convertirlo en un tipo de número entero con el que podamos trabajar matemáticamente. Para ver qué pasaría si tomáramos el valor del carácter y lo usáramos directamente en una expresión entera, eche un vistazo a la siguiente lista, que incluye resultados de muestra. dígito de carácter; cout << "Ingrese un número de un dígito: "; dígito = cin.get(); int suma = dígito; " << soy << "? \n"; cout << "Es la suma de dígitos Ingrese un número de un dígito: 7 ¿La suma de los dígitos es 55? Tenga en cuenta que utilizamos el método get porque el operador de extracción básico (como en cin >> dígito) omite espacios en blanco. Eso no es un problema aquí, pero como verá, causaría problemas más adelante. En el ejemplo de entrada y salida , verá el problema. Todos los datos informáticos son esencialmente numéricos, por lo que los caracteres individuales se representan mediante códigos de caracteres enteros. Diferentes sistemas operativos 34 Capítulo 2 Machine Translated by Google Puede utilizar diferentes sistemas de códigos de caracteres, pero en este texto nos centraremos en el sistema ASCII común. En este sistema, el carácter 7 se almacena como el valor del código de carácter 55, por lo que cuando tratamos el valor como un número entero, lo que obtenemos es 55. Necesitamos un mecanismo para convertir el carácter 7 en el número entero 7. PROBLEMA: CONVERTIR DÍGITOS DE CARÁCTER A ENTERO Escriba un programa que lea un carácter del usuario que represente un dígito, del 0 al 9. Convierta el carácter al entero equivalente en el rango del 0 al 9 y luego genere el número entero para demostrar el resultado. En los problemas de forma de la sección anterior, teníamos una variable con un rango de valores que queríamos convertir a otro rango de valores. Hicimos una tabla con columnas para los valores originales y los valores deseados y luego verificamos la diferencia entre los dos. Este es un problema análogo y podemos usar nuevamente la idea de la tabla, como en la Tabla 2­2. Tabla 2­2: Códigos de caracteres y valores enteros deseados Carácter Código de carácter Entero deseado Diferencia 0 48 0 48 1 49 1 48 2 50 2 48 3 51 3 48 4 52 4 48 5 53 5 48 6 54 6 48 7 55 7 48 8 56 8 48 9 57 9 48 La diferencia entre el código de carácter y el número entero deseado es siempre 48, por lo que lo único que debemos hacer es restar ese valor. Es posible que hayas notado que este es el valor del código de caracteres para el carácter cero, 0. Esto siempre será cierto porque los sistemas de códigos de caracteres siempre almacenan los caracteres de los dígitos en orden, comenzando desde 0. Por lo tanto, podemos crear un código más general y más legible. , solución restando el carácter 0 en lugar de usar un valor predeterminado, como 48: dígito de carácter; cout << "Ingrese un número de un dígito: "; cin >> dígito; int suma = dígito ­ '0'; " << soy << "? \n"; cout << "Es la suma de dígitos Rompecabezas puros 35 Machine Translated by Google Ahora podemos pasar a determinar qué dígitos duplicar. Es posible que se requieran varios pasos para resolver esta parte del problema, así que intentemos reducirlo. ¿Qué pasaría si inicialmente nos limitáramos a un número de longitud fija? Esto confirmaría nuestra comprensión de la fórmula general y al mismo tiempo avanzaría hacia el objetivo final. Intentemos limitar la longitud a seis; esto es lo suficientemente largo como para ser una buena representación del desafío general. PROBLEMA: VALIDACIÓN DE LA SUMA DE VERIFICACIÓN DE LUHN, LONGITUD FIJA Escriba un programa que tome un número de identificación (incluido su dígito de control) de longitud seis y determine si el número es válido según la fórmula de Luhn. El programa debe procesar cada carácter antes de leer el siguiente. Como antes, podemos reducir aún más para que comenzar sea lo más fácil posible. ¿Qué pasaría si cambiáramos la fórmula para que ninguno de los dígitos se duplique? Entonces el programa sólo tiene que leer los dígitos y sumarlos. PROBLEMA: VALIDACIÓN SIMPLE DE SUMA DE VERIFICACIÓN, LONGITUD FIJA Escriba un programa que tome un número de identificación (incluido su dígito de control) de longitud seis y determine si el número es válido mediante una fórmula simple donde se suman los valores de cada dígito y se verifica el resultado para ver si es divisible por 10. El programa debe procesar cada carácter antes de leer el siguiente. Como sabemos cómo leer un dígito individual como un carácter, podemos resolver este problema de suma de comprobación simple y de longitud fija con bastante facilidad. Sólo necesitamos leer seis dígitos, sumarlos y determinar si la suma es divisible por 10. dígito de carácter; suma de comprobación int = 0; cout << "Ingrese un número de seis dígitos: "; for (int posición = 1; posición <= 6; posición ++) { cin >> dígito; suma de comprobación += dígito ­ '0'; } cout << "La suma de comprobación es " << suma de comprobación << ". \n"; si (suma de comprobación % 10 == 0) { cout << "La suma de comprobación es divisible por 10. Válido. \n"; } demás { cout << "La suma de comprobación no es divisible por 10. No válida. \n"; } 36 Capítulo 2 Machine Translated by Google A partir de aquí, necesitamos agregar la lógica para la fórmula de validación de Luhn real, lo que significa duplicar cada dos dígitos comenzando desde el segundo dígito de la derecha. Como actualmente nos limitamos a números de seis dígitos, debemos duplicar los dígitos en las posiciones uno, tres y cinco, contando desde la izquierda. En otras palabras, duplicamos el dígito si la posición es impar. Podemos identificar posiciones pares e impares usando el operador de módulo (%) porque la definición de un número par es que es divisible por dos. Entonces, si el resultado de la expresión posición % 2 es 1, la posición es impar y debemos duplicarla. Es importante recordar que aquí duplicar significa duplicar el dígito individual y también sumar los dígitos del número duplicado si la duplicación da como resultado un número 10 o mayor. Aquí es donde nuestra función anterior realmente ayuda. Cuando necesitamos duplicar un dígito según la fórmula de Luhn, simplemente lo enviamos a nuestra función y usamos el resultado. Juntando esto, simplemente cambie el código dentro del bucle for del listado anterior: for (int posición = 1; posición <= 6; posición++) { cin >> dígito; if (posición % 2 == 0) suma de comprobación += dígito ­ '0'; else suma de comprobación += doubleDigitValue(dígito ­ '0'); } Hemos logrado mucho en este problema hasta ahora, pero aún quedan un par de pasos por recorrer antes de que podamos escribir el código para números de identificación de longitud arbitraria. Para resolver en última instancia este problema, necesitamos dividir y conquistar. Supongamos que te pido que modifiques el código anterior para números de 10 o 16 dígitos. Eso sería trivial: sólo tendrías que cambiar el 6 usado como límite superior del bucle por otro valor. Pero supongamos que le pido que valide números de siete dígitos. Eso requeriría una pequeña modificación adicional porque si el número de dígitos es impar y duplicamos cada dígito a partir del segundo de la derecha, el primer dígito de la izquierda ya no se duplica. En este caso, necesitas duplicar las posiciones pares: 2, 4, 6, etc. Dejando de lado esa cuestión por el momento, descubramos cómo manejar cualquier número de longitud par. El primer problema al que nos enfrentamos es determinar cuándo hemos llegado al final del número. Si el usuario ingresa un número de varios dígitos y presiona ENTER y leemos la entrada carácter por carácter, ¿qué carácter se lee después del último dígito? En realidad, esto varía según el sistema operativo, pero simplemente escribiremos un experimento: cout << "Ingrese un número: "; dígito de carácter; mientras (verdadero) { dígito = cin.get(); cout << int(dígito) << " "; } Puros rompecabezas 37 Machine Translated by Google Este bucle se ejecuta para siempre, pero cumple su función. Escribí el número 1234 y presioné ENTER. El resultado fue 49 50 51 52 10 (basado en ASCII; esto variará según el sistema operativo). Por lo tanto, 10 es lo que estoy buscando. Con esa información en mano, podemos reemplazar el bucle for en nuestro código anterior con un bucle while : dígito de carácter; suma de comprobación int = 0; posición interna = 1; cout << "Ingrese un número con un número par de dígitos: "; dígito = cin.get(); mientras (dígito!= 10) { if (posición % 2 == 0) suma de comprobación += dígito ­ '0'; else suma de comprobación += 2 * (dígito ­ '0'); dígito = cin.get(); posición++; } " cout << "La suma de comprobación << suma de comprobación << ". \n"; es si (suma de comprobación % 10 == 0) { cout << "La suma de comprobación es divisible por 10. Válido. \n"; } demás { cout << "La suma de comprobación no es divisible por 10. No válida. \n"; } En este código, la posición ya no es la variable de control en un bucle for , por lo que debemos inicializar e incrementarla por separado . El bucle ahora está controlado por la expresión condicional , que busca el valor del código de carácter que señala el final de la línea. Debido a que necesitamos un valor para verificar la primera vez que recorremos el ciclo, leemos el primer valor antes de que comience el ciclo y luego leemos cada valor subsiguiente dentro del ciclo , después del código de procesamiento. Nuevamente, este código manejará un número de cualquier longitud par. Para manejar un número de cualquier longitud impar, solo necesitaríamos modificar el código de procesamiento, invirtiendo la lógica de la condición de declaración if para duplicar los números en las posiciones pares, en lugar de las posiciones impares. Esto, al menos, agota todas las posibilidades. La longitud del número de identificación debe ser par o impar. Si supiéramos la longitud de antemano, sabríamos si duplicar las posiciones pares o impares del número. Sin embargo, no tenemos esa información hasta que hayamos llegado al final del número. ¿Es imposible una solución dadas estas limitaciones? Si sabemos cómo resolver el problema para un número impar de dígitos y para un número par de dígitos pero no sabemos cuántos dígitos hay en el número hasta que lo hayamos leído por completo, ¿cómo podemos resolver este problema? Es posible que ya vea la respuesta a este problema. Si no lo hace, no es porque la respuesta sea difícil sino porque está oculta en los detalles. Lo que podríamos usar aquí es una analogía, pero hasta ahora no hemos visto una situación análoga. En lugar de eso, haremos nuestra propia analogía. Hagamos un problema que se refiera explícitamente a esta misma situación y veamos si mirarlo cara a cara nos ayuda a encontrar una solución. Despeja tu mente de ideas preconcebidas basadas en el trabajo hasta el momento y lee el siguiente problema. 38 Capítulo 2 Machine Translated by Google PROBLEMA: POSITIVO O NEGATIVO Escriba un programa que lea 10 números enteros del usuario. Una vez ingresados todos los números, el usuario puede solicitar mostrar el recuento de números positivos o el recuento de números negativos. Este es un problema simple, que no parece tener ninguna complicación. Sólo necesitamos una variable que cuente los números positivos y otra variable que cuente los números negativos. Cuando el usuario especifica la solicitud al final del programa, solo necesitamos consultar la variable adecuada para la respuesta: número entero; int recuentopositivo = 0; int número negativo = 0; para (int i = 1; i <= 10; i++) { cin >> número; if (número > 0) positivCount++; if (número < 0) negativoCount++; } respuesta de carácter; cout << "¿Quieres el recuento (p)positivo o (n)negativo?"; cin >> respuesta; if (respuesta == 'p') cout " << "El recuento positivo es if << positivoCount << "\n"; (respuesta == 'n') " out << "El recuento negativo es << númeronegativo << "\n"; Esto muestra el método que necesitamos utilizar para el problema de la suma de comprobación de Luhn: realice un seguimiento de la suma de comprobación en ejecución en ambos sentidos, como si el número de identificación tuviera una longitud impar y nuevamente como si fuera una longitud par. Cuando lleguemos al final del número y descubramos la longitud real, tendremos la suma de verificación correcta en una variable u otra. Juntando las piezas Ahora hemos marcado todo lo que estaba en nuestra lista original de “tareas pendientes”. Es hora de juntar todo y resolver este problema. Debido a que hemos resuelto todos los subproblemas por separado, sabemos exactamente lo que debemos hacer y podemos usar nuestros programas anteriores como referencia para producir el resultado final rápidamente: dígito de carácter; int longitudimparSuma de verificación = 0; int inclusoLongitudChecksum = 0; posición interna = 1; cout << "Ingrese un número: "; dígito = cin.get(); mientras (dígito! = 10) { Puros rompecabezas 39 Machine Translated by Google si (posición % 2 == 0) { oddLengthChecksum += doubleDigitValue(dígito ­ '0'); EvenLengthChecksum += dígito ­ '0'; } demás { oddLengthChecksum += dígito ­ '0'; evenLengthChecksum += doubleDigitValue(dígito ­ '0'); } dígito = cin.get(); posición++; } suma de comprobación interna; if ((posición ­ 1) % 2 == 0) suma de comprobación = suma de comprobación de longitud uniforme; else suma de comprobación = oddLengthChecksum; " cout << "La suma de comprobación es << suma de comprobación <<". \norte"; si (suma de comprobación % 10 == 0) { cout << "La suma de comprobación es divisible por 10. Válido. \n"; } demás { cout << "La suma de comprobación no es divisible por 10. No válida. \n"; } Tenga en cuenta que cuando comprobamos si la longitud del número de entrada es par o impar , restamos 1 de la posición. Hacemos esto porque el último carácter que leemos en el bucle será el final de la línea final, no el último dígito del número. También podríamos haber escrito la expresión de prueba como (posición % 2 == 1), pero eso es más confuso de leer. En otras palabras, es mejor decir "si la posición ­ 1 es par, use la suma de verificación par" que "si la posición es impar, use la suma de verificación par" y debe recordar por qué eso tiene sentido. Esta es la lista de códigos más larga que hemos visto hasta ahora, pero no necesito anotar todo el código y describir cómo funciona cada parte porque ya has visto cada parte de forma aislada. Este es el poder de tener un plan. Sin embargo, es importante señalar que mi plan no es necesariamente el suyo . Es probable que los problemas que vi en la descripción original del problema y los pasos que tomé para solucionarlos difieran de lo que usted habría visto y hecho. Su experiencia como programador y los problemas que ha completado con éxito determinan qué partes del problema son triviales o difíciles y, por lo tanto, qué pasos debe seguir para resolver el problema. Es posible que haya habido un punto en la sección anterior en el que tomé lo que parecía un desvío innecesario para descubrir algo que ya era obvio para usted. Por el contrario, es posible que haya habido un punto en el que me salté ágilmente algo que era complicado para ti. Además, si hubiera trabajado en esto usted mismo, podría haber creado un programa igualmente exitoso que se veía bastante diferente al mío. No existe una única solución "correcta" para un problema, ya que cualquier programa que cumpla todas las restricciones cuenta como una solución, y para cualquier solución, no existe una única manera "correcta" de alcanzarla. Al ver todos los pasos que tomamos para llegar a la solución, junto con la relativa brevedad del código final, es posible que se sienta tentado a intentar recortar pasos en su propio proceso de resolución de problemas. Yo advertiría contra este impulso. Siempre es mejor dar más pasos que intentar hacer demasiado a la vez, incluso si algunos pasos parecen triviales. Recuerde cuáles son los objetivos en la resolución de problemas. El objetivo principal es, por supuesto, encontrar un programa que resuelva el problema planteado. 40 Capítulo 2 Machine Translated by Google y cumple con todas las limitaciones. El objetivo secundario es encontrar ese programa en el mínimo de tiempo. Minimizar la cantidad de pasos no es un objetivo y nadie tiene que saber cuántos pasos diste. Considere intentar llegar a la cima de una colina empinada que tiene un camino poco profundo pero largo y sinuoso. Ignorar el camino y subir la colina directamente desde la base hasta la cima ciertamente requerirá menos pasos que seguir el camino, pero ¿es más rápido? El resultado más probable de un ascenso directo es que te rindas y colapses. Recuerde también la última de mis reglas generales para la resolución de problemas: evite la frustración. Cuanto más trabajo intentes hacer en cada paso, más generarás frustración potencial. Incluso si retrocedes en un paso difícil y lo divides en subpasos, el daño ya estará hecho porque psicológicamente sentirás que estás retrocediendo en lugar de progresar. Cuando entreno a programadores principiantes en un enfoque paso a paso, a veces un estudiante se queja: "Oye, ese paso fue demasiado fácil". A lo que respondo: “¿De qué te quejas?” Si ha tomado un problema que inicialmente parecía difícil y lo ha dividido en pedazos tan pequeños que cada uno de ellos es trivial de resolver, le digo: ¡Felicitaciones! Eso es justo lo que deberías esperar. Estado de seguimiento El último problema que resolveremos en este capítulo es también el más difícil. Este problema tiene muchas piezas diferentes y una descripción complicada, que ilustrará la importancia de analizar un problema complejo. PROBLEMA: DECODIFICAR UN MENSAJE Un mensaje se ha codificado como una secuencia de texto que debe leerse carácter por carácter. La secuencia contiene una serie de números enteros delimitados por comas, cada uno de los cuales es un número positivo que puede representarse mediante un int de C++. Sin embargo, el carácter representado por un número entero particular depende del modo de decodificación actual. Hay tres modos: mayúsculas, minúsculas y puntuación. En modo mayúscula, cada número entero representa una letra mayúscula: el número entero módulo 27 indica la letra del alfabeto (donde 1 = A y así sucesivamente). Entonces, un valor de entrada de 143 en modo mayúscula produciría la letra H porque 143 módulo 27 es 8 y H es la octava letra del alfabeto. El modo minúsculas funciona igual pero con letras minúsculas; el resto de dividir el número entero por 27 representa la letra minúscula (1 = a y así sucesivamente). Entonces, un valor de entrada de 56 en modo minúscula produciría la letra b porque 57 módulo 27 es 2 y b es la segunda letra del alfabeto. En el modo de puntuación, el número entero se considera módulo 9, con la interpretación dada en la Tabla 2­3 a continuación. Entonces 19 produciría un signo de exclamación porque 19 módulo 9 es 1. Al comienzo de cada mensaje, el modo de decodificación es letras mayúsculas. Cada vez que la operación del módulo (por 27 o 9, dependiendo del modo) resulta en 0, el modo de decodificación cambia. Si el modo actual es mayúscula, el modo cambia a letras minúsculas. Si el modo actual es minúscula, el modo cambia a puntuación, y si es puntuación, vuelve a estar en mayúsculas. Rompecabezas puros 41 Machine Translated by Google Tabla 2­3: Modo de decodificación de puntuación Número Símbolo 1 ! 2 ? 3 , 4 . 5 6 7 8 (espacio) ; " ' Al igual que con la fórmula de validación de Luhn, analizaremos un ejemplo concreto para asegurarnos de que tenemos todos los pasos en orden. La Figura 2­4 muestra un ejemplo de decodificación. El flujo de entrada original se muestra en la parte superior. Los pasos del procesamiento proceden de arriba hacia abajo. La columna (a) muestra el número actual en la entrada. La columna (b) es el modo actual, pasando de mayúsculas (U) a minúsculas (L) y puntuación (P). La columna (c) muestra el divisor del modo actual. La columna (d) es el resto de dividir el divisor actual en la columna (c) entre la entrada actual de la columna (a). El resultado se muestra en la columna (e), ya sea un carácter o, si el resultado en (d) es 0, un cambio al siguiente modo en el ciclo. Al igual que con el problema anterior, podemos comenzar considerando explícitamente las habilidades que necesitaremos para diseñar una solución. Necesitamos leer una cadena de caracteres hasta llegar al final de la línea. Los caracteres representan una serie de números enteros, por lo que necesitamos leer los caracteres numéricos y convertirlos en números enteros para su posterior procesamiento. Una vez que tenemos los números enteros, necesitamos convertir el número entero en un solo carácter para la salida. Finalmente, necesitamos alguna forma de rastrear el modo de decodificación para saber si el número entero actual debe decodificarse en una letra minúscula, mayúscula o puntuación. Convirtamos esto en una lista formal: Leer carácter por carácter hasta llegar al final de la línea. Convierte una serie de caracteres que representan un número en un número entero. Convierta un número entero del 1 al 26 en una letra mayúscula. Convierta un número entero del 1 al 26 en una letra minúscula. Convierta un número entero del 1 al 8 en un símbolo de puntuación según la Tabla 2­3. Seguimiento de un modo de decodificación. El primer elemento es algo que ya sabemos hacer del problema anterior. Además, aunque sólo tratamos dígitos individuales en la fórmula de validación de Luhn, sospecho que algo de lo que hicimos allí también será útil en el segundo elemento de nuestra lista. El código terminado para el algoritmo de Luhn probablemente todavía esté fresco en su mente, pero si deja el libro entre ese problema y este, querrá volver atrás y revisar ese código. En general, cuando la descripción de un problema actual "le suena", querrá extraer cualquier código similar de sus archivos para estudiarlo. 42 Capítulo 2 Machine Translated by Google Entrada original: 18,12312,171,763,98423,1208,216,11,500,18,241,0,32,20620,27,10 (a) (b) 18 En 27 18 12312 En 27 0 171 L 27 6 763 L 27 7 (C) (d) (Es) R l Ciclo de modos: EN i l gramo PAG 98423 L 27 8 h 1208 L 27 20 t 216 L 27 0 Mensaje decodificado: ¿Bien? ¡Sí! 11 500 18 241 PAG PAG PAG PAG 9 2 9 5 9 0 ? EN U 27 25 A l 0 En 27 0 32 L 27 5 Es 20620 L 27 19 s 27 L 27 0 10 PAG PAG 9 1 ! Figura 2­4: Procesamiento de muestra para el problema "Decodificar un mensaje" Vayamos al grano con los puntos que quedan. Es posible que hayas notado que he convertido cada una de las conversiones en un elemento separado. Sospecho que convertir un número en una letra minúscula será muy similar a convertir un número en una letra mayúscula, pero tal vez convertirlo a un símbolo de puntuación requiera algo diferente. En cualquier caso, no hay ninguna desventaja real en cortar la lista demasiado finamente; simplemente significa que podrás tachar cosas de la lista con más frecuencia. Comencemos con esas conversiones de entero a carácter. A partir del programa de fórmulas de Luhn, conocemos el código necesario para leer un dígito de carácter del 0 al 9 y convertirlo en un número entero en el rango del 0 al 9. ¿Cómo podemos ampliar este método para tratar con números de varios dígitos? Consideremos la posibilidad más simple: números de dos dígitos. Esto parece sencillo. En un número de dos cifras, la primera Rompecabezas puros 43 Machine Translated by Google ALMACENAMIENTO DEL CÓDIGO PARA SU REUTILIZACIÓN POSTERIOR Las similitudes entre los elementos del problema actual y el problema anterior muestran la importancia de guardar el código fuente de una manera que facilite su revisión posterior. Los desarrolladores de software hablan mucho sobre la reutilización de código, que ocurre cada vez que se utilizan piezas de software antiguo para crear software nuevo. A menudo, esto implica utilizar un componente encapsulado o reutilizar el código fuente palabra por palabra. Sin embargo, es igualmente importante tener fácil acceso a las soluciones anteriores que haya escrito. Incluso si no estás copiando código antiguo directamente, esto te permite reutilizar habilidades y técnicas aprendidas previamente sin tener que volver a aprenderlas. Para maximizar este beneficio, esfuércese por conservar todo el código fuente que escriba (teniendo en cuenta cualquier acuerdo de propiedad intelectual que pueda tener con clientes o empleadores, por supuesto). Sin embargo, el hecho de que usted reciba el máximo beneficio de los programas escritos previamente depende en gran medida del cuidado que tenga al guardarlos; El código que no puedes encontrar es un código que no puedes usar. Si emplea un enfoque paso a paso y escribe programas individuales para probar ideas por separado antes de integrarlas en el todo, asegúrese de guardar también esos programas intermedios. Puede que le resulte muy conveniente tenerlos disponibles más adelante, cuando la similitud entre su programa actual y el antiguo se encuentre en una de las áreas para las cuales escribió un programa de prueba. El dígito es el dígito de las decenas, por lo que debemos multiplicar este dígito individual por 10 y luego sumar el valor del segundo dígito. Por ejemplo, si el número fuera 35, después de leer los dígitos individuales como caracteres 3 y 5, y convertirlos a números enteros 3 y 5, obtendríamos el número entero general que necesitamos mediante la expresión 3 * 10 + 5. Confirmemos esto. con código: cout << "Ingrese un número de dos dígitos: "; char digitChar1 = cin.get(); char digitChar2 = cin.get(); int dígito1 = dígitoChar1 ­ '0'; int dígito2 = dígitoChar2 ­ '0'; int número total = dígito1 * 10 + dígito2; " cout << "Ese número como un número entero: << número total << "\n"; Eso funciona: el programa genera el mismo número de dos dígitos que ingresamos. Sin embargo, encontramos un problema cuando intentamos ampliar este método. Este programa utiliza dos variables diferentes para contener las dos entradas de caracteres y, si bien eso no causa problemas aquí, ciertamente no queremos extenderlo como una solución general. Si lo hiciéramos, necesitaríamos tantas variables como dígitos tengamos. Eso sería complicado y sería difícil de modificar si variara el rango de números posibles en el flujo de entrada. Necesitamos una solución más general para este subproblema de convertir caracteres a números enteros. El primer paso para encontrar esa solución general es reducir el código anterior a sólo dos variables: una char y una int: cout << "Ingrese un número de dos dígitos: "; char dígitoChar = cin.get(); int número global = (digitChar ­ '0') * 10; 44 Capítulo 2 Machine Translated by Google dígitoChar = cin.get(); número global += (digitChar ­ '0'); cout << "Ese número como un número entero: " << número total << "\n"; Logramos esto haciendo todos los cálculos en el primer dígito antes de leer el segundo dígito. Después de leer el primer dígito del carácter en un paso, lo convertimos a un número entero, lo multiplicamos por 10 y almacenamos el resultado . Después de leer el segundo dígito , sumamos su valor entero al total acumulado . Esto es equivalente al código anterior usando solo dos variables, una para el último carácter leído y otra para el valor general del número entero. El siguiente paso es considerar ampliar este método a números de tres dígitos. Una vez que hagamos eso, es probable que veamos un patrón que nos permitirá crear una solución general para cualquier número de dígitos. Sin embargo, cuando intentamos esto, encontramos un problema. Con el número de dos dígitos, multiplicamos el dígito izquierdo por 10 porque el dígito izquierdo estaba en la posición de las decenas. El dígito más a la izquierda en un número de tres dígitos estaría en la posición de las centenas, por lo que necesitaríamos multiplicar ese dígito por 100. Luego podríamos leer el dígito del medio, multiplicarlo por 10, sumarlo al total acumulado, y luego lea el último dígito y agréguelo también. Eso debería funcionar, pero no nos lleva hacia una solución general. ¿Ves el problema? Considere la afirmación anterior: el dígito más a la izquierda en un número de tres dígitos estaría en la posición de las centenas. Para una solución general, no sabremos cuántos dígitos hay en cada número hasta que lleguemos a la siguiente coma. El dígito más a la izquierda de un número con una cantidad desconocida de dígitos no se puede etiquetar en la posición de las centenas ni en ninguna otra posición. Entonces, ¿cómo sabemos qué multiplicador usar para cada dígito antes de sumarlo al total acumulado? ¿O necesitamos otro enfoque completamente diferente? Como siempre, cuando se atasca, es una buena idea crear un problema simplificado en el que trabajar. El problema aquí es no saber cuántos dígitos tendrá el número. El problema más simple que se ocupa de este tema sería uno que tenga sólo dos posibles recuentos de dígitos. PROBLEMA: LEER UN NÚMERO CON TRES O CUATRO DÍGITOS Escriba un programa para leer un número carácter por carácter y convertirlo a un número entero, usando solo una variable char y una variable int . El número tendrá tres o cuatro dígitos. Esta cuestión de no saber el número de caracteres hasta el final pero necesitar contarlos desde el principio es análoga a la cuestión de la fórmula de Luhn. En la fórmula de Luhn, no sabíamos si el número de identificación tenía una longitud par o impar. En ese caso, nuestra solución fue calcular los resultados de dos formas diferentes y elegir la adecuada al final. ¿Podríamos hacer algo así aquí? Si el número tiene tres o cuatro dígitos, sólo hay dos posibilidades. Si el número tiene tres dígitos, el dígito más a la izquierda es el dígito de las centenas. Si el número tiene cuatro dígitos, el más a la izquierda Rompecabezas puros 45 Machine Translated by Google El dígito es el dígito de los millares. Podríamos calcular como si tuviéramos un número de tres dígitos y como si tuviéramos un número de cuatro dígitos y luego elegir el número correcto al final, pero la descripción del problema nos permite tener solo una variable numérica. Relajemos esa restricción para lograr algún progreso. PROBLEMA: LEER UN NÚMERO CON TRES O CUATRO DÍGITOS, MÁS SIMPLIFICADO Escriba un programa para leer un número carácter por carácter y convertirlo a un número entero, usando solo una variable char y dos variables int . El número tendrá tres o cuatro dígitos. Ahora podemos poner en práctica el método de “calcularlo en ambos sentidos”. Procesaremos los primeros tres dígitos de dos maneras diferentes y luego veremos si hay un cuarto dígito: cout << "Ingrese un número de tres o cuatro dígitos: "; char digitChar = cin.get(); int número de tres dígitos = (carácter de dígitos ­ '0') * 100; int cuatroNúmeroDeDígitos = (digitChar ­ '0') * 1000; digitChar = cin.get(); tresNúmeroDeDígitos += (digitChar ­ '0') * 10; cuatroNúmeroDeDígitos += (digitChar ­ '0') * 100; digitChar = cin.get(); tresNúmeroDigit += (digitChar ­ '0'); cuatroNúmeroDeDígitos += (digitChar ­ '0') * 10; digitChar = cin.get(); si (digitChar == 10) { " cout << "Numerado ingresado: } << número de tres dígitos << "\n"; else { cuatroNúmeroDigit += (digitChar ­ '0'); " cout << "Numerado ingresado: << número de cuatro dígitos << "\n"; } Después de leer el dígito más a la izquierda, multiplicamos su valor entero por 100 y guárdelo en nuestra variable de tres dígitos . También multiplicamos el valor entero por 1000 y lo almacenamos en nuestra variable de cuatro dígitos . Este patrón continúa durante los siguientes dos dígitos. El segundo dígito se trata como un dígito de decenas en un número de tres dígitos y como un dígito de centenas en un número de cuatro dígitos. El tercer dígito se trata como un dígito de unidades y decenas. Después de leer el cuarto carácter, verificamos si es un final de línea comparándolo con el número 10 (al igual que en el problema anterior, este valor puede variar según el sistema operativo). Si es un final de línea, la entrada fue un número de tres dígitos. Si no, aún necesitamos sumar el dígito de las unidades al total . Ahora necesitamos descubrir cómo deshacernos de una de las variables enteras. Supongamos que eliminamos por completo la variable fourDigitNumber . El valor de threeDigitNumber aún se asignaría correctamente, pero cuando llegáramos a un punto en el que necesitáramos fourDigitNumber, no lo tendríamos. Usando el valor en threeDigitNumber, ¿hay alguna manera de determinar el valor que tendría? 46 Capítulo 2 Machine Translated by Google estado en fourDigitNumber? Supongamos que la entrada original fue 1234. Después de leer los primeros tres dígitos, el valor en threeDigitNumber sería 123; el valor que habría estado en fourDigitNumber es 1230. En general, dado que los multiplicadores de fourDigitNumber son 10 veces los de threeDigitNumber, el primero siempre sería 10 veces el segundo. Por lo tanto, sólo se necesita una variable entera porque la otra variable puede simplemente multiplicarse por 10 si es necesario: cout << "Ingrese un número de tres o cuatro dígitos: "; char digitChar = cin.get(); número int = (digitChar ­ '0') * 100; digitChar = cin.get(); número += (digitChar ­ '0') * 10; digitChar = cin.get(); número += (digitChar ­ '0'); digitChar = cin.get(); si (digitChar == 10) { " cout << "Numerado ingresado: << número << "\n"; } demás { número = número * 10 + (digitChar ­ '0'); " cout << "Numerado ingresado: << número << "\n"; } Ahora tenemos un patrón explotable. Considere expandir este código para manejar números de cinco dígitos. Después de calcular el valor correcto para los primeros cuatro dígitos, repetiríamos el mismo proceso que seguimos para leer el cuarto carácter en lugar de mostrar el resultado inmediatamente: leer un quinto carácter, verificar si es un final de línea, mostrar el número calculado previamente si lo es; de lo contrario, multiplique por 10 y agregue el valor del dígito del carácter actual: cout << "Ingrese un número de tres, cuatro o cinco dígitos: "; char digitChar = cin.get(); número int = (digitChar ­ '0') * 100; digitChar = cin.get(); número += (digitChar ­ '0') * 10; digitChar = cin.get(); número += (digitChar ­ '0'); digitChar = cin.get(); si (digitChar == 10) { " cout << "Numerado ingresado: } << número << "\n"; else { número = número * 10 + (digitChar ­ '0'); digitChar = cin.get(); si (digitChar == 10) { " cout << "Numerado ingresado: << número << "\n"; } demás { número = número * 10 + (digitChar ­ '0'); " cout << "Numerado ingresado: << número << "\n"; } } Rompecabezas puros 47 Machine Translated by Google En este punto, podríamos expandir fácilmente el código para manejar números de seis dígitos o números con menos dígitos. El patrón es claro: si el siguiente carácter es otro dígito, multiplique el total acumulado por 10 antes de sumar el valor del dígito entero del carácter. Con este entendimiento, podemos escribir un bucle para manejar un número de cualquier longitud: cout << "Ingrese un número con tantos dígitos como desee: "; char dígitoChar = cin.get(); int número = (digitChar ­ '0'); dígitoChar = cin.get(); mientras (digitChar != 10) { número = número * 10 + (digitChar ­ '0'); digitChar = cin.get(); } " cout << "Numerado ingresado: << número << "\n"; Aquí, leemos el primer carácter Luego leemos el segundo carácter y determinamos su valor de dígito . y llegamos al bucle, donde comprobamos si el carácter leído más recientemente es un final de línea . De lo contrario, multiplicamos el total acumulado en el bucle por 10 y sumamos el valor del dígito del carácter actual antes de leer el siguiente carácter . Una vez que llegamos al final de la línea, el número de la variable total acumulado contiene el valor entero para que generemos . Eso maneja la conversión de una serie de caracteres a su equivalente entero. En el programa final, leeremos una serie de números, separados por comas. Cada número deberá leerse y procesarse por separado. Como siempre, es mejor empezar pensando en una situación sencilla que demuestre el problema. Consideremos la entrada 101,22[EOL], donde [EOL] marca explícitamente el final de la línea para mayor claridad. Sería suficiente modificar la condición de prueba del bucle para comprobar si hay un carácter de fin de línea o una coma. Luego necesitaríamos colocar todo el código que procesa un número dentro de un bucle más grande que continúa hasta que se hayan leído todos los números. Entonces, el bucle interno debe detenerse durante [EOL] o una coma, pero el bucle externo debe detenerse solo durante [EOL]: carácter dígitoChar; hacer { digitChar = cin.get(); número int = (digitChar ­ '0'); digitChar = cin.get(); mientras ((digitChar != 10) && (digitChar != ',')) { número = número * 10 + (digitChar ­ '0'); digitChar = cin.get(); } " cout << "Numerado ingresado: << número << "\n"; } mientras (digitChar!= 10); Este es otro gran ejemplo de la importancia de los pequeños pasos. Aunque se trata de un programa corto, la naturaleza de ruedas dentro de ruedas del doble bucle habría generado un código complicado si hubiéramos intentado escribirlo desde cero. Es 48 Capítulo 2 Machine Translated by Google Sin embargo, es sencillo cuando llegamos a este código siguiendo un paso del programa anterior. La declaración de digitChar se mueve a una línea separada para que la declaración esté dentro del alcance de todo el código. El resto del código es el mismo que el listado anterior, excepto que se coloca dentro de un bucle do­ while que continúa hasta llegar al final de la línea . Una vez implementada esa parte de la solución, podemos centrarnos en procesar los números individuales. El siguiente elemento de nuestra lista es convertir un número del 1 al 26 en una letra de la A a la Z. Si lo piensas bien, esto es en realidad una inversión del proceso que usamos para convertir los caracteres de dígitos individuales a sus equivalentes enteros. Si restamos el código de caracteres para que 0 se traduzca del rango de códigos de caracteres de 0 a 9 al rango de enteros de 0 a 9, deberíamos poder agregar un código de caracteres para traducir de 1 a 26 a A­Z. ¿Y si añadimos 'A'? Aquí hay un intento junto con una entrada y salida de muestra: cout << "Ingrese un número del 1 al 26: "; número entero; cin >> número; carácter de salida de caracteres; carácter de salida = número + 'A'; cout << "Símbolo equivalente: " << carácter de salida << "\n"; Ingrese un número del 1 al 26: 5 Letra equivalente: F Eso no es del todo bien. La quinta letra del alfabeto es E, no F. El problema ocurre porque estamos sumando un número en el rango que comienza en 1. Cuando estábamos convirtiendo en la otra dirección, de un dígito de carácter a su equivalente entero, estábamos tratando con un rango que comenzaba desde 0. Podemos solucionar este problema cambiando el cálculo de número + 'A' a número + 'A' ­ 1. Tenga en cuenta que podemos buscar el valor del código de carácter para la letra A (es 65 en ASCII) y simplemente use uno menos que ese valor (por ejemplo, número + 64 en ASCII). Sin embargo, prefiero la primera versión porque es más legible. En otras palabras, si vuelves a mirar este código más tarde, podrás recordar más rápidamente qué número + 'A' ­ 1 hace que lo que hace el número + 64 porque la aparición de 'A' en el primero le recordará la conversión a letras mayúsculas. Una vez resuelto esto, podemos adaptar fácilmente esta idea para convertirla a letras minúsculas cambiando el cálculo a número + 'a' ­ 1. La conversión de la tabla de puntuación no es tan concisa porque los símbolos de puntuación en la tabla sí lo hacen. no aparecen en ese orden en ASCII ni en ningún otro sistema de código de caracteres. Como tal, vamos a tener que manejar esto mediante la fuerza bruta: cout << "Ingrese un número del 1 al 8: "; número entero; cin >> número; carácter de salida de caracteres; cambiar (número) { caso 1: carácter de salida = '!'; romper; caso 2: carácter de salida = '?'; romper; caso 3: carácter de salida = ','; romper; Rompecabezas puros 49 Machine Translated by Google caso 4: carácter de salida = '.'; romper; caso 5: carácter de salida = ' '; romper; caso 6: carácter de salida = ';'; romper; caso 7: carácter de salida = '"'; descanso; caso 8: carácter de salida = '\''; romper; } " cout << "Símbolo equivalente: << carácter de salida << "\n"; Aquí, hemos utilizado una instrucción de cambio para generar el carácter de puntuación correcto. Tenga en cuenta que se ha empleado una barra invertida como "escape" para mostrar la comilla simple . Tenemos un último subproblema que abordar antes de armar todo: cambiar de modo a modo cada vez que el valor más reciente se decodifica a 0. Recuerde que la descripción del problema requiere que modulemos cada valor entero por 27 (si actualmente estamos en el modo mayúsculas o minúsculas) o 9 (si estamos en modo de puntuación). Cuando el resultado es 0, cambiamos al siguiente modo. Lo que necesitamos es una variable para almacenar el modo actual y la lógica dentro de nuestro bucle "leer y procesar el siguiente valor" para cambiar de modo si es necesario. La variable que rastrea el modo actual podría ser un número entero simple, pero es más legible usar una enumeración. Una buena regla general: si una variable solo rastrea un estado y ningún valor en particular tiene un significado inherente, una enumeración es una buena idea. En este caso, podríamos tener una variable en modo int , diciendo arbitrariamente que el valor de 1 significa mayúsculas, 2 significa minúsculas y 3 significa puntuación. Sin embargo, no existe una razón inherente por la que se elijan esos valores. Cuando volvamos a ver el código más adelante, tendremos que volver a familiarizarnos con el sistema para entender una declaración como if (mode == 2). Si usamos una enumeración, como en la declaración (modo == MINÚSCULAS), no hay nada que recordar porque está todo explicado. Aquí está el código que resulta de esta idea, junto con una interacción de muestra: enum modeType {MAYÚSCULAS, MINÚSCULAS, PUNTUACIÓN}; número entero; modo tipo modo = MAYÚSCULAS; cout << "Ingrese algunos números que terminan en ­1: "; hacer { cin >> número; cout << "Número leído: " << número; cambiar (modo) { caso MAYÚSCULAS: número = número % 27; cout << ". Módulo 27: if " << número << ". "; (número == 0) { cout << "Cambiar a MINÚSCULAS"; modo = MINÚSCULAS; } romper; caso MINÚSCULAS: número = número % 27; cout << ". Módulo 27: if (número == 0) { 50 Capítulo 2 " << número << ". "; Machine Translated by Google cout << "Cambiar a PUNTUACIÓN"; modo = PUNTUACIÓN; } romper; caso PUNTUACIÓN: número = número % 9; cout << ". Módulo 9: if " << número << ". "; (número == 0) { cout << "Cambiar a MAYÚSCULAS"; modo = MAYÚSCULAS; } romper; } cout << "\n"; } mientras (número! = ­1); Ingrese algunos números que terminan en ­1: 2 1 0 52 53 54 55 6 7 8 9 10 ­1 Número leído: 2. Módulo 27: 2. Número leído: 1. Módulo 27: 1. Número leído: 0. Módulo 27: 0. Cambiar a MINÚSCULAS Número leído: 52. Módulo 27: 25. Número leído: 53. Módulo 27: 26. Número leído: 54. Módulo 27: 0. Cambiar a PUNTUACIÓN Número leído: 55. Módulo 9: 1. Número leído: 6. Módulo 9: 6. Número leído: 7. Módulo 9: 7. Número leído: 8. Módulo 9: 8. Número leído: 9. Módulo 9: 0. Cambiar a MAYÚSCULAS Número leído: 10. Módulo 27: 10. Número leído: ­1. Módulo 27: ­1. Hemos tachado todo lo que está en nuestra lista, por lo que ahora es el momento de integrar estos listados de códigos individuales para crear una solución para el programa general. Podríamos abordar esta integración de diferentes maneras. Podríamos juntar sólo dos piezas y construir a partir de ahí. Por ejemplo, podríamos combinar el código para leer y convertir los números separados por comas con el modo cambiando desde la lista más reciente. Luego podríamos probar esa integración y agregar el código para convertir cada número a la letra o símbolo de puntuación apropiado. O podríamos construir en la otra dirección, tomando la lista de números a caracteres y convirtiéndola en una serie de funciones que se llamarán desde el programa principal. En este punto, hemos ido más allá de la resolución de problemas hacia la ingeniería de software, que es un tema diferente. Hicimos una serie de bloques... Esa fue la parte difícil y ahora sólo tenemos que ensamblarlos, como se muestra en la Figura 2­5. Casi todas las líneas de este programa se extrajeron del código anterior de esta sección. La mayor parte del código proviene del programa de cambio de modo. El bucle de procesamiento central proviene de nuestro código para leer una serie de números delimitados por comas carácter por carácter. Finalmente, reconocerás el código que convierte los números enteros en letras mayúsculas, minúsculas y puntuación . La pequeña cantidad de código nuevo está marcada con . El Rompecabezas puros 51 Machine Translated by Google Las declaraciones de continuación nos llevan a la siguiente iteración del bucle cuando la última entrada fue un comando de cambio de modo, omitiendo el cout <<outputCharacter al final del bucle. carácter de salida de caracteres; enum modeType {MAYÚSCULAS, MINÚSCULAS, PUNTUACIÓN}; modo tipo modo = MAYÚSCULAS; dígito de carácter Char; hacer { digitChar = cin.get(); número int = (digitChar ­ '0'); digitChar = cin.get(); while ((digitChar!= 10) && (digitChar!= ',')) { número = número * 10 + (digitChar ­ '0'); digitChar = cin.get(); } modo interruptor) { caso MAYÚSCULAS: número = número % 27; carácter de salida = número + 'A' ­ 1; si (número == 0) { modo = MINÚSCULAS; continuar; } romper; caso MINÚSCULAS: número = número % 27; carácter de salida = número + 'a' ­ 1; si (número == 0) { modo = PUNTUACIÓN; continuar; } romper; case PUNTUACIÓN: número = número % 9; cambiar (número) { caso 1: carácter de salida = '!'; romper; caso 2: carácter de salida = '?'; romper; caso 3: carácter de salida = ','; romper; caso 4: carácter de salida = '.'; romper; caso 5: carácter de salida = caso 6: ' '; romper; carácter de salida = ';'; romper; caso 7: carácter de salida = '"'; descanso; caso 8: carácter de salida = '\''; romper; } si (número == 0) { modo = MAYÚSCULAS; continuar; } romper; } cout << carácter de salida; } mientras (digitChar! = 10); cout << "\n"; Figura 2­5: La solución ensamblada para el problema "Decodificar un mensaje" 52 Capítulo 2 Machine Translated by Google Si bien este es un trabajo de cortar y pegar, este es el buen tipo de trabajo de cortar y pegar, donde reutilizas el código que acabas de escribir y, por lo tanto, lo comprendes por completo. Como antes, piense en lo fácil que fue cada paso en este proceso, en lugar de intentar escribir el listado final desde cero. Sin duda, un buen programador podría producir el listado final sin pasar por los pasos intermedios, pero habría pasos en falso, ocasiones en las que el código se vería feo y líneas de código comentadas y luego reintroducidas. Al tomar pasos más pequeños, todo el trabajo sucio se hace temprano y el código nunca se vuelve demasiado feo porque el código con el que estamos trabajando actualmente nunca se vuelve largo ni complicado. Conclusión En este capítulo, analizamos tres problemas diferentes. En cierto sentido, tuvimos que tomar tres caminos diferentes para resolverlos. En otro sentido, tomamos el mismo camino cada vez porque utilizamos la misma técnica básica de dividir el problema en componentes; escribir código para resolver esos componentes individualmente; y luego usar el conocimiento adquirido al escribir los programas, o incluso usar directamente líneas de código de los programas, para resolver el problema original. En los capítulos que siguen, no usaremos este método explícitamente para cada problema, pero la idea fundamental siempre está ahí: dividir el problema en partes manejables. Dependiendo de sus antecedentes, estos problemas pueden haber parecido inicialmente encontrarse en cualquier parte del espectro de dificultad, desde diabólicos hasta triviales. Independientemente de lo difícil que parezca inicialmente un problema, recomendaría utilizar esta técnica en cada nuevo problema que enfrente. No querrás esperar hasta llegar a un problema frustrantemente difícil antes de probar una nueva técnica. Recuerda que uno de los objetivos de este texto es que desarrolles confianza en tu capacidad para resolver problemas. Practica el uso de las técnicas en problemas “fáciles” y tendrás mucho impulso para cuando resuelvas los difíciles. Ejercicios Como antes, te insto a que pruebes tantos ejercicios como puedas soportar. Ahora que estamos de lleno en la programación real, realizar ejercicios es esencial para que pueda desarrollar sus habilidades de resolución de problemas. 2­1. Utilizando la misma regla que los programas de formas vistos anteriormente en este capítulo (solo dos declaraciones de salida, una que genera la marca hash y otra que genera un final de línea), escriba un programa que produzca la siguiente forma: ######## ###### #### ## Rompecabezas puros 53 Machine Translated by Google 2­2. O qué tal: ## #### ###### ######## ######## ###### #### ## 2­3. Aquí hay uno especialmente complicado: ## ## ## ### ### ######## ######## ### ### ## ## ## 2­4. Diseñe el suyo propio: piense en su propio patrón simétrico de marcas de almohadilla y vea si puede escribir un programa para producirlo que siga la regla de las formas. 2­5. Si le gusta el problema de la fórmula de Luhn, intente escribir un programa para un sistema de dígitos de control diferente, como el sistema ISBN de 13 dígitos. El programa podría tomar un número de identificación y verificarlo o tomar un número sin su dígito de control y generar el dígito de control. 2­6. Si ha aprendido sobre números binarios y cómo convertir de decimal a binario y viceversa, intente escribir programas para realizar esas conversiones con números de longitud ilimitada (pero puede asumir que los números son lo suficientemente pequeños como para almacenarlos en un int estándar de C++) . . 2­7. ¿Has aprendido sobre hexadecimal? Intente escribir un programa que permita El usuario especifica una entrada en binario, decimal o hexadecimal y una salida en cualquiera de los tres. 2­8. ¿Quieres un desafío adicional? Generalice el código del ejercicio anterior para cree un programa que convierta de cualquier número de base 16 o menos a cualquier otra base numérica. Entonces, por ejemplo, el programa podría convertir de base 9 a base 4. 2­9. Escribe un programa que lea una línea de texto, cuente el número de palabras, identifique la longitud de la palabra más larga, el mayor número de vocales en una palabra y/o cualquier otra estadística que se te ocurra. 54 Capítulo 2 Machine Translated by Google t l S YYPAG oh C En el capítulo anterior, nos limitamos a variables escalares, es decir, variables que pueden contener sólo un valor a la vez. En este capítulo, veremos problemas que utilizan la estructura de datos agregados más común, la matriz. Aunque los arrays son estructuras simples con limitaciones fundamentales, su uso magnifica enormemente el poder de nuestros programas. A DY Y RESOLVIENDO PROBLEMAS CON ARRAYS En este capítulo, nos ocuparemos principalmente de matrices reales, es decir, aquellas declaradas con la sintaxis incorporada de C++, como por ejemplo: int tenIntegerArray[10]; Sin embargo, las técnicas que analizamos se aplican igualmente a estructuras de datos con atributos similares. La más común de estas estructuras es un vector. El término vector se usa a menudo como sinónimo de cualquier matriz de una sola dimensión, pero lo usaremos aquí en el sentido más específico de una estructura que tiene los atributos de una matriz sin un número máximo especificado de elementos. Entonces, para nuestras discusiones, una matriz tiene un tamaño fijo, mientras que un vector puede crecer o reducirse. Machine Translated by Google automáticamente según sea necesario. Cada uno de los problemas que analizamos en este capítulo incluye alguna restricción que nos permite utilizar una estructura con un número fijo de elementos. Sin embargo, los problemas sin tales restricciones podrían adaptarse utilizar un vector. Además, las técnicas utilizadas con matrices a menudo se pueden utilizar con estructuras de datos que no tienen todos los atributos enumerados anteriormente. Algunas técnicas, por ejemplo, no requieren acceso aleatorio, por lo que pueden usarse con estructuras como listas enlazadas. Debido a que los arreglos son tan comunes en la programación y debido a que las técnicas de arreglos se usan con frecuencia en contextos sin arreglos, los arreglos son un excelente campo de entrenamiento para el estudio de la resolución de problemas con estructuras de datos. Revisión de los fundamentos de las matrices Ya deberías saber qué es una matriz, pero repasemos algunos de sus atributos para mayor claridad. Una matriz es una colección de variables del mismo tipo organizadas bajo un nombre, donde las variables individuales se indican con un número. A las variables individuales las llamamos elementos de la matriz. En C++ y la mayoría de los demás lenguajes, el primer elemento tiene el número 0, pero en algunos lenguajes esto variará. Los atributos principales de la matriz se derivan directamente de la definición. Cada valor almacenado en una matriz es del mismo tipo, mientras que otras estructuras de datos agregados pueden almacenar valores de tipos mixtos. Un elemento individual está referenciado por un número llamado subíndice ; en otras estructuras de datos, se puede hacer referencia a elementos individuales por nombre o por un valor clave. De estos atributos primarios podemos derivar varios atributos secundarios. Como cada uno de los elementos está designado por un número en una secuencia que comienza en 0, podemos examinar fácilmente cada valor de una matriz. En otras estructuras de datos, esto puede resultar difícil, ineficiente o incluso imposible. Además, mientras que a algunas estructuras de datos, como las listas enlazadas, solo se puede acceder de forma secuencial, una matriz ofrece acceso aleatorio, lo que significa que podemos acceder a cualquier elemento de la matriz en cualquier momento. Estos atributos primarios y secundarios determinan cómo podemos usar las matrices. Cuando se trata de cualquier estructura de datos agregados, es bueno tener en mente un conjunto de operaciones básicas al considerar los problemas. Piense en estas operaciones básicas como herramientas comunes: los martillos, destornilladores y llaves inglesas de la estructura de datos. No todos los problemas mecánicos se pueden resolver con herramientas comunes, pero siempre debe considerar si un problema se puede resolver con herramientas comunes antes de ir a la ferretería. Aquí está mi lista de operaciones básicas para matrices. Almacenar Esta es la más básica de las operaciones. Una matriz es una colección de variables y podemos asignar un valor a cada una de esas variables. Para asignar el número entero 5 al primer elemento (elemento 0) del array previamente declarado, simplemente decimos: tenIntegerArray[0] = 5; 56 Capítulo 3 Machine Translated by Google Como ocurre con cualquier variable, los valores de los elementos dentro de nuestra matriz serán “basura” aleatoria hasta que se asignen valores particulares, por lo que las matrices deben inicializarse antes de usarse. En algunos casos, especialmente para pruebas, querremos asignar un valor particular a cada elemento de la matriz. Podemos hacer eso con un inicializador cuando se declara la matriz. int tenIntegerArray[10] = {4, 5, 9, 12, ­4, 0, ­57, 30987, ­287, 1}; En breve veremos un buen uso de un inicializador de matriz. A veces, en lugar de asignar un valor diferente a cada elemento, solo queremos que cada elemento de la matriz se inicialice con el mismo valor. Hay algunos atajos para asignar un cero a cada elemento de la matriz, dependiendo de la situación o del compilador utilizado (el compilador de C++ en Microsoft Visual Studio, por ejemplo, inicializa cada valor de cualquier matriz a cero a menos que se especifique lo contrario). En esta etapa, sin embargo, siempre inicializaría explícitamente una matriz siempre que sea necesaria la inicialización para mejorar la legibilidad, como en este código, que establece cada elemento en una matriz de 10 elementos en –1: int tenIntegerArray[10]; for (int i = 0; i < 10; i++) tenIntegerArray[i] = ­1; Copiar Podemos hacer una copia de la matriz. Hay dos situaciones comunes en las que esto podría resultar útil. En primer lugar, es posible que queramos manipular mucho la matriz pero aún así necesitemos la matriz en su forma original para su posterior procesamiento. Devolver la matriz a su forma original después de la manipulación puede ser difícil, o incluso imposible, si hemos cambiado alguno de los valores. Al copiar toda la matriz, podemos manipular la copia sin alterar el original. Todo lo que necesitamos para copiar una matriz completa es un bucle y una declaración de asignación, al igual que el código de inicialización: int tenIntegerArray[10] = {4, 5, 9, 12, ­4, 0, ­57, 30987, ­287, 1}; int segundoArray[10]; for (int i = 0; i < 10; i++) secondArray[i] = tenIntegerArray[i]; Esa operación está disponible para la mayoría de las estructuras de datos agregados. El segundo La situación es más específica de las matrices. A veces queremos copiar parte de los datos de una matriz a una segunda matriz, o queremos copiar los elementos de una matriz a una segunda matriz como método para reorganizar el orden de los elementos. Si ha estudiado el algoritmo de ordenación por combinación, habrá visto esta idea en acción. Veremos ejemplos de copia más adelante en este capítulo. Recuperación y búsqueda Con la capacidad de poner valores en la matriz, también necesitamos la capacidad de sacarlos de la matriz. Recuperar el valor de una ubicación particular es sencillo: int num = tenIntegerArray[0]; Resolver problemas con matrices 57 Machine Translated by Google Buscando un valor específico Normalmente la situación no es tan sencilla. A menudo no sabemos la ubicación que necesitamos y, en cambio, tenemos que buscar en la matriz para encontrar la ubicación de un valor específico. Si los elementos de la matriz no están en ningún orden en particular, lo mejor que podemos hacer es una búsqueda secuencial, donde miramos cada elemento de la matriz de un extremo al otro hasta encontrar el valor deseado. Aquí tienes una versión básica. constante int ARRAY_SIZE = 10; int intArray[ARRAY_SIZE] = {4, 5, 9, 12, ­4, 0, ­57, 30987, ­287, 1}; int valordestino = 12; int TargetPos = 0; while ((intArray[targetPos] != targetValue) && (targetPos < ARRAY_SIZE)) Pos.objetivo++; En este código, tenemos una constante que almacena el tamaño de la matriz , la matriz en sí , una variable para almacenar el valor que buscamos en la matriz y una variable para almacenar la ubicación donde se encuentra el valor . En este ejemplo, usamos nuestra constante ARRAY_SIZE para limitar el número de iteraciones sobre nuestra matriz , de modo que no pasemos del final de la matriz cuando targetValue no se encuentre entre los elementos de la matriz. Podrías “cablear” el número 10 en lugar de la constante, pero usar la constante hace que el código sea más general, lo que facilita su modificación y reutilización. Usaremos una constante ARRAY_SIZE en la mayor parte del código de este capítulo. Tenga en cuenta que si targetValue no se encuentra en intArray, targetPos será igual a ARRAY_SIZE después del ciclo. Esto es suficiente para indicar el evento porque ARRAY_SIZE no es un número de elemento válido. Sin embargo, dependerá del código que sigue comprobarlo. Tenga en cuenta también que el código no hace ningún esfuerzo por gestionar la posibilidad de que el valor objetivo aparezca más de una vez. La primera vez que aparece el valor objetivo, el ciclo termina. Búsqueda basada en criterios A veces el valor que buscamos no es un valor fijo sino un valor basado en la relación con otros valores de la matriz. Por ejemplo, es posible que deseemos encontrar el valor más alto de la matriz. El mecanismo para hacerlo es lo que yo llamo “Rey de la colina”, en referencia al juego del patio de recreo. Tener una variable que represente el valor más alto visto hasta ahora en la matriz. Recorre todos los elementos de la matriz con un bucle, y cada vez que encuentres un valor mayor que el valor más alto anterior, el nuevo valor derriba al rey anterior de la colina y ocupa su lugar: constante int ARRAY_SIZE = 10; int intArray[ARRAY_SIZE] = {4, 5, 9, 12, ­4, 0, ­57, 30987, ­287, 1}; int valormás alto = intArray[0]; para (int i = 1; i < ARRAY_SIZE; i++) { if (intArray[i] > valor más alto) Valor más alto = intArray[i]; } La variable valormás alto almacena el valor más grande encontrado en la matriz hasta el momento. En su declaración, se le asigna el valor del primer elemento del array bucle en el segundo elemento del array (permite 58 Capítulo 3 , lo que nos permite iniciar el Machine Translated by Google comencemos con i en 1 en lugar de 0) . Dentro del ciclo, comparamos el valor en la posición actual con el valor más alto, reemplazando el valor más alto si es apropiado . Tenga en cuenta que encontrar el valor más bajo, en lugar del más alto, es sólo una cuestión de cambiar la comparación “mayor que” a una comparación “menor que” (y cambiar el nombre de la variable para no confundir nosotros mismos). Esta estructura básica se puede aplicar a todo tipo de situaciones en las que queremos observar cada elemento de la matriz para encontrar el valor que mejor ejemplifique una cualidad particular. Clasificar Ordenar significa poner los datos en un orden específico. Probablemente ya haya encontrado algoritmos de clasificación para matrices. Esta es un área clásica para el análisis de desempeño porque hay muchos algoritmos de clasificación en competencia, cada uno con características de desempeño que varían dependiendo de las características de los datos subyacentes. El estudio de diferentes algoritmos de clasificación podría ser el tema de un libro completo por sí solo, por lo que no vamos a explorar esta área en toda su profundidad. En cambio, nos centraremos en lo que es práctico. Para la mayoría de las situaciones, puede conformarse con dos clasificaciones en su caja de herramientas: una clasificación rápida y fácil de usar y una clasificación decente y fácil de entender que puede modificar con confianza cuando surja la situación. Para hacerlo más rápido y fácil, usaremos la función de biblioteca estándar qsort, y cuando necesitemos modificar algo, usaremos una ordenación por inserción. Clasificación rápida y sencilla con qsort La clasificación rápida predeterminada para los programadores de C/C++ es la función qsort en la biblioteca estándar (el nombre sugiere que la clasificación subyacente emplea una clasificación rápida, pero el implementador de la biblioteca no está obligado a usar ese algoritmo). Para usar qsort, tenemos que escribir una función de comparación. qsort llamará a esta función lo que sea necesario para comparar dos elementos en la matriz y ver cuál debería aparecer antes en orden. La función se pasa dos vacíos. punteros. No hemos discutido los punteros todavía en este libro, pero todo lo que necesitas saber aquí es que debes convertir esos punteros vacíos en punteros al tipo de elemento en tu matriz. Entonces la función debería devolver un int, ya sea positivo, negativo o cero, según si el primer elemento es mayor, menor o igual que el segundo elemento. El valor exacto devuelto no importa, sólo si es positivo, negativo o cero. Aclaremos esta discusión con un ejemplo rápido de cómo ordenar una matriz de 10 números enteros usando qsort. Nuestra función de comparación: int compareFunc( const void * voidA, const void * voidB) { int * intA = (int *)(voidA); int * intB = (int *)(voidB); return *intA ­ *intB; } La lista de parámetros consta de dos punteros nulos constantes . Nuevamente, este es siempre el caso del comparador. El código dentro de la función comienza declarando dos punteros int y lanzando los dos punteros void al int Resolver problemas con matrices 59 Machine Translated by Google tipo de puntero. Podríamos escribir la función sin las dos variables temporales; Los incluyo aquí para mayor claridad. El punto es que, una vez que hayamos terminado con esas declaraciones, intA e intB apuntarán a dos elementos de nuestra matriz, y *intA e *intB serán dos números enteros que deben compararse. Finalmente, devolvemos el resultado de restar el segundo número entero del primer . Esto produce el resultado que queremos. Si intA > intB, por ejemplo, queremos devolver un número positivo, e intA – intB será positivo si intA > intB. Asimismo, intA – intB será negativo si intB > intA y será cero cuando los dos números enteros sean iguales. Con la función de comparación implementada, un ejemplo de uso de qsort se ve así: constante int ARRAY_SIZE = 10; int intArray[ARRAY_SIZE] = {87, 28, 100, 78, 84, 98, 75, 70, 81, 68}; qsort( intArray, ARRAY_SIZE, sizeof(int), compareFunc); Como puede ver, la llamada a qsort toma cuatro parámetros: la matriz a ordenar número de elementos en esa matriz ; el ; el tamaño de un elemento en la matriz, generalmente determinado, como aquí, por el operador sizeof ; y finalmente, la función comparadora . Si no ha tenido mucha experiencia pasando funciones como parámetros a otras funciones, observe la sintaxis utilizada para el último parámetro. Estamos pasando la función en sí, no llamando a la función y pasando el resultado de la llamada. Por lo tanto, simplemente indicamos el nombre de la función, sin lista de parámetros ni paréntesis. Clasificación fácil de modificar con clasificación por inserción En algunos casos, necesitarás escribir tu propio código de clasificación. A veces, la clasificación incorporada simplemente no funciona para su situación. Por ejemplo, supongamos que tiene una matriz de datos que desea ordenar en función de los datos de otra matriz. Cuando tenga que escribir su propia clasificación, querrá una rutina de clasificación sencilla en la que crea y que pueda implementar cuando lo solicite. Una sugerencia razonable para una clasificación de acceso es una clasificación por inserción. La clasificación por inserción funciona de la misma manera que muchas personas clasificarían las cartas cuando juegan al bridge: recogen las cartas una a la vez y las insertan en el lugar apropiado de sus manos para mantener el orden general, moviendo las otras cartas hacia abajo para hacer espacio. Aquí hay una implementación básica para nuestra matriz de enteros: int inicio = 0; int fin = ARRAY_SIZE ­ 1; for (int i = inicio + 1; i <= fin; i++) { for ( int j = i; j > inicio && intArray[j­1] > intArray[j]; j­­) { int temp = intArray[j­1]; intArray[j­1] = intArray[j]; intArray[j] = temporal; } } Comenzamos declarando dos variables, inicio y fin , indicando el subíndice del primer y último elemento de la matriz. Esto mejora la capacidad de lectura del código y también permite que el código se modifique fácilmente para ordenar solo una parte de la matriz, si se desea. El bucle exterior selecciona la siguiente “tarjeta” que se 60 Capítulo 3 Machine Translated by Google insertado en nuestra mano cada vez más ordenada . Observe que el bucle inicializa i para comenzar + 1. Recuerde que en el código "encontrar el valor más grande", inicializamos nuestra variable de valor más alto en el primer elemento de la matriz y comenzamos nuestro bucle con el segundo elemento de la matriz. formación. Esta es la misma idea. Si solo tenemos un valor (o “tarjeta”), entonces por definición está “en orden” y podemos comenzar considerando si el segundo valor debe ir antes o después del primero. El bucle interno coloca el valor actual en su posición correcta intercambiando repetidamente el valor actual con su predecesor hasta que alcanza la ubicación correcta. El contador de bucle j comienza en i , y el bucle disminuye j siempre que no hayamos alcanzado el extremo inferior de la matriz correcto para este nuevo valor y aún no hayamos encontrado el punto de parada . Hasta entonces, usamos tres declaraciones de asignación para intercambiar el valor actual una posición hacia abajo en la matriz . En otras palabras, si tuvieras una mano de 13 cartas y ya hubieras ordenado las 4 cartas más a la izquierda, podrías colocar la quinta carta de la izquierda en la posición correcta moviéndola repetidamente hacia abajo una carta hasta que ya no fuera de una carta más baja. valor que la carta a su izquierda. Eso es lo que hace el bucle interior. El bucle exterior hace esto para cada tarjeta comenzando desde el extremo izquierdo. Entonces, cuando terminemos, toda la matriz estará ordenada. Una ordenación por inserción no es la forma más eficiente en la mayoría de las circunstancias y, a decir verdad, el código anterior ni siquiera es la forma más eficiente de realizar una ordenación por inserción. Sin embargo, es razonablemente eficiente para arreglos de tamaño pequeño a moderado y es lo suficientemente simple como para memorizarlo. Piense en ello como una macro mental. Ya sea que elija este tipo u otro, debe tener una rutina de clasificación decente o mejor que pueda codificar usted mismo con confianza. No basta con tener acceso al código de clasificación de otra persona que no comprendes del todo. No querrás jugar con la maquinaria si no estás seguro de cómo funciona todo. Calcular estadísticas La operación final es similar a la operación de recuperación, en el sentido de que debemos observar cada elemento de la matriz antes de devolver un valor. Se diferencia de la operación de recuperación en que el valor no es simplemente uno de los elementos de la matriz, sino una estadística calculada a partir de todos los valores de la matriz. Por ejemplo, podríamos calcular el promedio, la mediana o la moda, y realizaremos todos estos cálculos más adelante en este capítulo. Una estadística básica que podríamos calcular podría ser el promedio de un conjunto de calificaciones de estudiantes: constante int ARRAY_SIZE = 10; int gradeArray[ARRAY_SIZE] = {87, 76, 100, 97, 64, 83, 88, 92, 74, 95}; doble suma = 0; para (int i = 0; i < ARRAY_SIZE; i++) { suma += arreglodegrado[i]; } media doble = suma/ARRAY_SIZE; Como otro ejemplo sencillo, considere la validación de datos. Supongamos que una matriz de valores dobles llamada sellerPayments representa pagos a proveedores. Sólo los valores positivos son válidos y, por lo tanto, los valores negativos indican integridad de los datos. Resolver problemas con matrices 61 Machine Translated by Google problemas. Como parte de un informe de validación, podríamos escribir un bucle para contar el número de valores negativos en la matriz: constante int ARRAY_SIZE = 10; int recuentoNegativo = 0; para (int i = 0; i < ARRAY_SIZE; i++) { if (vendorPayments[i] < 0) countNegative++; } Resolver problemas con matrices Una vez que haya comprendido las operaciones comunes, resolver un problema con matrices no es muy diferente a resolver problemas con datos simples, como lo hicimos en el capítulo anterior. Tomemos un ejemplo y repasémoslo utilizando las técnicas del capítulo anterior y cualquiera de las operaciones comunes para matrices que podamos necesitar. PROBLEMA: ENCONTRAR EL MODO En estadística, la moda de un conjunto de valores es el valor que aparece con más frecuencia. Escriba código que procese una serie de datos de encuestas, donde los encuestados hayan respondido a una pregunta con un número en el rango del 1 al 10, para determinar la moda del conjunto de datos. Para nuestro propósito, si existen múltiples modos, se puede elegir cualquiera. En este problema, se nos pide que recuperemos uno de los valores de una matriz. Usando las técnicas de búsqueda de analogías y comenzando con lo que sabemos, podemos esperar poder aplicar alguna variación de la técnica de recuperación que ya hemos visto: encontrar el valor más grande en una matriz. Ese código funciona almacenando el valor más grande visto hasta ahora en una variable. Luego, el código compara cada valor posterior con esta variable y lo reemplaza si es necesario. El método análogo aquí sería decir que almacenaríamos el valor visto con más frecuencia hasta el momento en una variable y luego reemplazaríamos el valor en la variable cada vez que descubriéramos un valor más común en la matriz. Cuando lo decimos así, en inglés, casi suena como si pudiera funcionar, pero cuando pensamos en el código real, descubrimos el problema. Echemos un vistazo a una matriz de muestra y una constante de tamaño para este problema: constante int ARRAY_SIZE = 12; int encuestaData[ARRAY_SIZE] = {4, 7, 3, 8, 9, 7, 3, 9, 9, 3, 3, 10}; La moda de estos datos es 3 porque 3 aparece cuatro veces, que es más frecuente que cualquier otro valor. Pero si procesamos esta matriz secuencialmente, como lo hacemos para el problema del “valor más alto”, ¿en qué punto decidimos que 3 es nuestra moda? ¿Cómo sabemos, cuando nos hemos encontrado con la cuarta y última aparición de 3 en la matriz, que de hecho es la cuarta y última aparición? No parece haber ninguna manera de descubrir esta información con un procesamiento único y secuencial de los datos de la matriz. 62 Capítulo 3 Machine Translated by Google Así que pasemos a una de nuestras otras técnicas: simplificar el problema. ¿Qué pasaría si nos facilitáramos las cosas juntando todas las apariciones del mismo número? Entonces, por ejemplo, ¿qué pasaría si los datos de nuestra encuesta de matriz de muestra se vieran así? int encuestaData[ARRAY_SIZE] = {4, 7, 7, 9, 9, 9, 8, 3, 3, 3, 3, 10}; Ahora los dos 7 están juntos, los 9 están juntos y los 3 están juntos. Con los datos agrupados de esta manera, parece que deberíamos poder procesar secuencialmente la matriz para encontrar el modo. Al procesar la matriz a mano, es fácil contar las apariciones de cada valor, porque simplemente sigues contando la matriz hasta encontrar el primer número que es diferente. Sin embargo, convertir lo que podemos hacer en nuestra cabeza en declaraciones de programación puede ser complicado. Entonces, antes de intentar escribir el código para este problema simplificado, escribamos un pseudocódigo, que son declaraciones similares a la programación que no son completamente en inglés o C++, sino algo intermedio. Esto nos recordará lo que intentamos hacer con cada declaración que necesitamos escribir. int más frecuente = ?; int frecuencia más alta = ?; int frecuenciaactual = 0; para (int i = 0; i < ARRAY_SIZE; i++) { FrecuenciaActual++; if (surveyData[i] ES LA ÚLTIMA OCURRENCIA DE UN VALOR) { if (frecuencia actual > frecuencia más alta) { frecuencia más alta = frecuencia actual; más frecuente = datos de encuesta[i]; } FrecuenciaActual = 0; } } No existe una forma correcta o incorrecta de escribir pseudocódigo y, si utiliza esta técnica, debe adoptar su propio estilo. Cuando escribo pseudocódigo, tiendo a escribir C++ legal para cualquier declaración en la que ya estoy seguro y luego detallo en inglés los lugares en los que todavía tengo que pensar. Aquí sabemos que necesitaremos una variable (mostFrequent) para contener el valor encontrado con más frecuencia hasta el momento, que al final del ciclo será el modo una vez que hayamos escrito todo correctamente. También necesitamos una variable para almacenar la frecuencia con la que ocurre ese valor (frecuencia más alta) para tener algo con qué comparar. Finalmente, necesitamos una variable que podamos usar para contar el número de apariciones del valor que estamos rastreando actualmente mientras procesamos secuencialmente la matriz (currentFrequency). Sabemos que necesitamos inicializar nuestras variables. Para currentFrequency, lógicamente tiene que comenzar en 0, pero aún no está claro cómo debemos inicializar las otras variables, sin el otro código implementado. Así que simplemente coloquemos signos de interrogación para recordarnos que debemos mirar eso nuevamente más tarde. El bucle en sí es el mismo bucle de procesamiento de matrices que ya hemos visto, por lo que ya está en su forma final . Dentro del ciclo, incrementamos la variable que cuenta las apariciones del valor actual y luego llegamos a la declaración fundamental. Sabemos que debemos comprobar si hemos llegado al último Resolver problemas con matrices 63 Machine Translated by Google aparición de un valor particular . El pseudocódigo nos permite saltarnos la lógica por ahora y esbozar el resto del código. Sin embargo, si esta es la última aparición del valor, sabemos qué hacer porque es como el código de "valor más alto": Necesitamos ver si el recuento de este valor es mayor que el más alto visto hasta ahora. Si es así, este valor se convierte en el nuevo valor más frecuente . Luego, debido a que el siguiente valor leído será la primera aparición de un nuevo valor, reiniciamos nuestro contador . Volvamos a la lógica de la declaración if que nos saltamos. ¿Cómo sabemos si ¿Esta es la última aparición de un valor en la matriz? Debido a que los valores de la matriz están agrupados, sabemos si un valor es la última aparición cuando el siguiente valor de la matriz es algo diferente: en términos de C++, cuando SurveyData[i] y SurveyData[i + 1] no son iguales. Además, el último valor de la matriz también es la última aparición de algún valor, aunque no haya un valor siguiente. Podemos comprobar esto comprobando si i == ARRAY_SIZE ­ 1, en cuyo caso este es el último valor de la matriz. Con todo eso resuelto, pensemos en esos valores iniciales para nuestras variables. Recuerde que con el código de procesamiento de matrices de “valor más alto”, inicializamos nuestra variable “más alta hasta ahora” con el primer valor de la matriz. Aquí, el valor "visto con más frecuencia" está representado por dos variables, mostFrequent para el valor en sí y mosthighFrequency para el número de apariciones. Sería fantástico si pudiéramos inicializar mostFrequent con el primer valor que aparece en la matriz y mosthighFrequency con su recuento de frecuencia, pero no hay forma de determinar la frecuencia del primer valor hasta que entremos en el bucle y comencemos a contar. Llegados a este punto, se nos podría ocurrir que la frecuencia del primer valor, sea cual sea, sería mayor que cero. Por lo tanto, si establecemos la frecuencia más alta en cero, una vez que lleguemos a la última aparición del primer valor, nuestro código reemplazará la frecuencia más frecuente y la frecuencia más alta con los números del primer valor de todos modos. El código completo se ve así: int más frecuente; int frecuencia más alta = 0; int frecuenciaactual = 0; para (int i = 0; i < ARRAY_SIZE; i++) { frecuenciaactual++; // if (surveyData[i] ES LA ÚLTIMA OCURRENCIA DE UN VALOR) if (i == ARRAY_SIZE ­ 1 || datosencuesta[i] != Datosencuesta[i + 1]) { if (frecuencia actual > frecuencia más alta) { frecuencia más alta = frecuencia actual; más frecuente = datos de encuesta[i]; } frecuencia actual = 0; } } En este libro, no hablaremos mucho sobre temas de estilo puro, como el estilo de documentación (comentarios), pero como estamos usando pseudocódigo en este problema, quiero mencionar un consejo. He notado que las líneas que dejo en “inglés simple” en el pseudocódigo son las que más se benefician de un comentario. 64 Capítulo 3 Machine Translated by Google en el código final, y el inglés simple es un gran comentario. Lo he demostrado en el código aquí. Es posible que olvides el significado exacto detrás de la expresión condicional en la declaración if pero el comentario en la línea anterior , aclara las cosas muy bien. En cuanto al código en sí, hace el trabajo, pero recuerda que requiere que los datos de nuestra encuesta estén agrupados. Agrupar los datos puede ser un trabajo en sí mismo, excepto... ¿Qué pasa si ordenamos la matriz? En realidad, no necesitamos que los datos estén ordenados, pero la clasificación logrará la agrupación que necesitamos. Como no pretendemos realizar ningún tipo especial de clasificación, simplemente agreguemos esta llamada a qsort al principio de nuestro código: qsort(surveyData, ARRAY_SIZE, sizeof(int), compareFunc); Tenga en cuenta que estamos usando la misma compareFunc que escribimos anteriormente para usar con qsort. Con el paso de clasificación implementado, tenemos una solución completa al problema original. Entonces nuestro trabajo está hecho. ¿O es eso? Refactorización Algunos programadores hablan de código que desprende "malos olores". Están hablando de un código funcional que esté libre de errores pero que aún sea problemático de alguna manera. A veces esto significa que el código es demasiado complicado o tiene demasiados casos especiales, lo que hace que al programador le resulte difícil modificar y mantener el programa. En otros casos, el código no es tan eficiente como podría ser y, si bien funciona para casos de prueba, al programador le preocupa que el rendimiento se vea afectado en casos más grandes. Esa es mi preocupación aquí. El paso de clasificación es casi instantáneo para nuestro pequeño caso de prueba, pero ¿qué pasa si la matriz es enorme? Además, sé que el algoritmo de clasificación rápida, que puede estar usando qsort , tiene su rendimiento más bajo cuando hay muchos valores duplicados en la matriz, y el objetivo de este problema es que todos nuestros valores están en el rango de 1 a 10. . Por tanto, propongo refactorizar el código. Refactorizar significa mejorar el código de trabajo, no cambiar lo que hace sino cómo lo hace. Quiero una solución que sea muy eficiente incluso para matrices grandes, suponiendo que los valores estén en el rango de 1 a 10. Pensemos nuevamente en las operaciones que sabemos hacer con matrices. Ya hemos explorado varias versiones del código "encontrar el más alto". Sabemos que aplicar el código "encontrar el más alto" directamente a nuestros datos de encuesta La matriz no producirá resultados útiles. ¿Existe una matriz a la que podamos aplicar la versión "stock" de "encontrar el más alto" y obtener la moda de los datos de la encuesta? La respuesta es sí. La matriz que necesitamos es el histograma de la matriz SurveyData . Un histograma es un gráfico que muestra con qué frecuencia aparecen valores diferentes en un conjunto de datos subyacente; nuestra matriz serán los datos para dicho histograma. En otras palabras, almacenaremos, en una matriz de 10 elementos, la frecuencia con la que aparece cada uno de los valores del 1 al 10 en SurveyData. Aquí está el código para crear nuestro histograma: constanteint MAX_RESPONSE = 10; int histograma[MAX_RESPONSE]; Resolver problemas con matrices 65 Machine Translated by Google para (int i = 0; i < MAX_RESPONSE; i++) { histograma[i] = 0; } para (int i = 0; i < ARRAY_SIZE; i++) { histograma[datosencuesta[i] ­ 1]++; } En la primera línea, declaramos la matriz que contendrá los datos de nuestro histograma . Notará que declaramos la matriz con 10 elementos, pero el rango de las respuestas de nuestra encuesta es de 1 a 10 y el rango de subíndices para esta matriz es de 0 a 9. Por lo tanto, tendremos que hacer ajustes, poniendo la cuenta de 1 en el histograma [0] y así sucesivamente. (Algunos programadores elegirían declarar la matriz con 11 elementos, dejando la ubicación [0] sin usar, para permitir que cada conteo entre en su posición natural). Inicializamos explícitamente los valores de la matriz a cero con un bucle , y luego estamos listos para contar las apariciones de cada valor en SurveyData con otro bucle . La declaración dentro del bucle debe leerse con atención; Estamos usando el valor en la ubicación actual de SurveyData para decirnos qué posición en el histograma. para incrementar. Para que esto quede claro, tomemos un ejemplo. Supongamos que i es 42. Inspeccionamos SurveyData[42] y encontramos (digamos) el valor 7. Por lo tanto, necesitamos incrementar nuestro contador de 7. Restamos 1 de 7 para obtener 6 porque el contador de 7 está en la posición [6] en el histograma y el histograma [6] se incrementa. Con los datos del histograma en su lugar, podemos escribir el resto del código. Tenga en cuenta que el código del histograma se escribió por separado para poder probarlo por separado. No se ahorra tiempo escribiendo todo el código a la vez en una situación en la que el problema se separa fácilmente en partes que se pueden escribir y probar individualmente. Habiendo probado el código anterior, ahora buscamos el valor más grande en la matriz del histograma : int más frecuente = 0; para (int i = 1; i < MAX_RESPONSE; i++) { if (histograma[i] > histograma[másfrecuente]) } más frecuente++; másfrecuente = i; Aunque se trata de una adaptación del código "encontrar el más alto", hay una diferencia. Aunque estamos buscando el valor más alto en la matriz del histograma, en última instancia, no queremos el valor en sí, sino la posición. En otras palabras, con nuestra matriz de muestra, queremos saber que 3 ocurre con más frecuencia que cualquier otro valor en los datos de la encuesta, pero el número real de veces que ocurre 3 no es importante. Por lo tanto, mostFrequent será la posición del valor más alto en el histograma, no el valor más alto en sí. Por lo tanto, lo inicializamos a 0 y no al valor en la ubicación [0]. Esto también significa que en la declaración if , comparamos con el histograma [más frecuente] y no con el más frecuente , y asignamos i, no con el histograma [i], a más frecuente cuando se encuentra un valor mayor. Finalmente, incrementamos mostFrequent . Esto es lo contrario de lo que hicimos en el ciclo anterior, restando 1 para obtener la posición correcta de la matriz. Si mostFrequent nos dice que la posición más alta de la matriz es 5, por ejemplo, significa que la entrada más frecuente en los datos de la encuesta fue 6. 66 Capítulo 3 Machine Translated by Google La solución de histograma escala linealmente con la cantidad de elementos en nuestra matriz SurveyData , que es lo mejor que podríamos esperar. Por lo tanto, es una mejor solución que nuestro enfoque original. Esto no significa que el primer acercamiento haya sido un error o una pérdida de tiempo. Por supuesto, es posible haber escrito este código sin pasar por la versión anterior, y se nos puede perdonar que deseemos haber conducido directamente a nuestro destino en lugar de tomar la ruta más larga. Sin embargo, le advierto que no se dé una palmada en la frente en aquellas ocasiones en las que la primera solución resulta no ser la solución final. Escribir un programa original (y recuerde que esto significa original para el programador que lo escribe) es un proceso de aprendizaje y no se puede esperar que progrese siempre en línea recta. Además, suele ocurrir que tomar un camino más largo en un problema nos ayuda a tomar un camino más corto en un problema posterior. En este caso particular, tenga en cuenta que nuestra solución original (si bien no se adapta bien a nuestro problema particular) podría ser la solución correcta si las respuestas de la encuesta no se limitaran estrictamente al pequeño rango de 1 a 10. O supongamos que luego se le pide que escriba un código que encuentre la mediana de un conjunto de valores enteros (la mediana es el valor en el medio, de modo que la mitad de los otros valores en el conjunto son mayores y la mitad de los otros valores son más bajos). El enfoque del histograma no te lleva a ninguna parte con la mediana, pero nuestro primer enfoque para la moda sí. La lección aquí es que un viaje largo no es una pérdida de tiempo si aprendes algo que no habrías aprendido si hubieras seguido el camino corto. Esta es otra razón por la que resulta útil almacenar metódicamente todo el código que escribe para poder encontrarlo y reutilizarlo fácilmente más adelante. Incluso el código que resulta ser un “callejón sin salida” puede convertirse en un recurso valioso. Matrices de datos fijos En la mayoría de los problemas de arreglo, el arreglo es un depósito de datos externos al programa, como datos ingresados por el usuario, datos en un disco local o datos de un servidor. Sin embargo, para aprovechar al máximo la herramienta de matriz, es necesario reconocer otras situaciones en las que se puede utilizar una matriz. A menudo resulta útil crear una matriz donde los valores nunca cambian después de la inicialización. Una matriz de este tipo puede permitir un bucle simple o incluso una búsqueda directa en la matriz para reemplazar un bloque completo de declaraciones de control. En el código final para el problema "Decodificar un mensaje" en la página 52, utilizamos una instrucción de cambio para traducir el número de entrada decodificado (en el rango 1­8) al carácter apropiado cuando estamos en "modo de puntuación" porque la conexión entre los El número y el carácter eran arbitrarios. Aunque esto funcionó bien, hizo que esa sección de código fuera más larga que el código equivalente para los modos mayúsculas y minúsculas, y el código no escalaría bien si aumentara la cantidad de símbolos de puntuación. Podemos usar una matriz para resolver este problema en lugar de la declaración de cambio . Primero, necesitamos asignar permanentemente los símbolos de puntuación a una matriz en el mismo orden en que aparecen en el esquema de codificación: const char puntuación[8] = {'!', '?', ',', '.', ' ', ';', '"', '\''}; Resolver problemas con matrices 67 Machine Translated by Google Observe que esta matriz se ha declarado constante porque los valores internos nunca cambiarán. Con esa declaración implementada, podemos reemplazar toda la declaración de cambio con una única declaración de asignación que haga referencia a la matriz: carácterdesalida = puntuación[número ­ 1]; Debido a que el número de entrada está en el rango de 1 a 8, pero los elementos de la matriz están numerados a partir de 0, tenemos que restar 1 del número de entrada antes de hacer referencia a la matriz; Este es el mismo ajuste que hicimos en la versión de histograma del programa "Encontrar el modo". Puedes usar la misma matriz para ir en la otra dirección. Supongamos que en lugar de decodificar el mensaje, tuviéramos que codificar un mensaje; es decir, nos dieran una serie de caracteres para convertirlos en números que pudieran decodificarse usando las reglas del problema original. Para convertir un símbolo de puntuación en su número, tenemos que ubicar el símbolo en la matriz. Se trata de una recuperación realizada mediante la técnica de búsqueda secuencial. Suponiendo que el carácter se va a convertir y almacenar en la variable char targetValue, podríamos adaptar el código de búsqueda secuencial de la siguiente manera: constante int ARRAY_SIZE = 8; int TargetPos = 0; while (puntuación[targetPos] != targetValue && targetPos < ARRAY_SIZE) Pos.objetivo++; int código de puntuación = targetPos + 1; Tenga en cuenta que así como tuvimos que restar 1 del número en el ejemplo anterior para obtener la posición correcta de la matriz, en este ejemplo tenemos que sumar 1 a la posición de la matriz para obtener nuestro código de puntuación, convirtiendo del rango de la matriz de 0 a 7 a nuestro rango de códigos de puntuación del 1 al 8. Aunque este código no es tan simple como una sola línea, es mucho más simple que una serie de declaraciones de cambio y se escala bien. Si duplicáramos la cantidad de símbolos de puntuación en nuestro esquema de codificación, se duplicaría la cantidad de elementos en la matriz, pero la longitud del código permanecería igual. Entonces, en general, las matrices constantes se pueden utilizar como tablas de búsqueda, reemplazando una serie engorrosa de declaraciones de control. Suponga que está escribiendo un programa para calcular el costo de una licencia comercial en un estado donde el costo de la licencia varía a medida que varían las cifras de ventas brutas de la empresa. Tabla 3­1: Costos de licencia comercial 68 Capítulo 3 Categoría de negocios Umbral de ventas Costo de la licencia I $0 $25 II $50.000 $200 III $150.000 $1,000 IV $500.000 $5,000 Machine Translated by Google Con este problema, podríamos usar matrices tanto para determinar la categoría comercial en función de las ventas brutas de la empresa como para asignar el costo de la licencia en función de la categoría comercial. Supongamos que una variable doble , Ventasbrutas, almacena las ventas brutas de una empresa y, según la cifra de ventas, queremos asignar los valores adecuados a categoría int y costo doble: const int NUM_CATEGORIAS = 4; const doble categoríaUmbrales[NUM_CATEGORIES] = {0,0, 50000,0, 150000,0, 500000,0}; const doble costo de licencia [NUM_CATEGORIES] = {50.0, 200.0, 1000.0, 5000.0}; categoría = 0; while (categoría < NUM_CATEGORIES && categoríaUmbrales[categoría] <= ventasbrutas) { categoría++; } costo = costo de licencia[categoría ­ 1]; Este código utiliza dos matrices de valores fijos. La primera matriz almacena el umbral de ventas brutas para cada categoría comercial . Por ejemplo, una empresa con $65 000 en ventas brutas anuales está en la categoría II porque esta cantidad excede el umbral de $50 000 de la categoría II pero es menor que el umbral de $150 000 de la categoría III. La segunda matriz almacena el costo de una licencia comercial para cada categoría en 0 . Con las matrices en su lugar, inicializamos la categoría y buscamos en la matriz categoríaUmbrales , deteniéndonos cuando el umbral excede las ventas brutas o cuando nos quedamos sin categorías . En cualquier caso, cuando finalice el ciclo, a la categoría se le asignará correctamente del 1 al 4 según las ventas brutas. El último paso es utilizar la categoría para hacer referencia al costo de la licencia de la matriz LicenseCost . Como antes, tenemos que hacer un pequeño ajuste del rango 1 a 4 de las categorías comerciales al rango 0 a 3 de nuestra matriz. Matrices no escalares Hasta ahora, sólo hemos trabajado con matrices de tipos de datos simples, como int y double. Sin embargo, a menudo los programadores deben trabajar con matrices de datos compuestos, ya sean estructuras u objetos (estructura o clase). Aunque el uso de tipos de datos compuestos necesariamente complica un poco el código, no tiene por qué complicar nuestro pensamiento sobre el procesamiento de matrices. Por lo general, el procesamiento de la matriz solo involucra un miembro de datos de la estructura o clase, y podemos ignorar las otras partes de la estructura de datos. A veces, sin embargo, el uso de tipos de datos compuestos requiere que hagamos algunos cambios en nuestro enfoque. Por ejemplo, consideremos el problema de encontrar la más alta de un conjunto de calificaciones de estudiantes. Supongamos que en lugar de una matriz de int, tenemos una matriz de estructuras de datos, cada una de las cuales representa el registro de un estudiante: estudiante de estructura { grado internacional; int ID de estudiante; nombre de cadena; }; Resolver problemas con matrices 69 Machine Translated by Google Lo bueno de trabajar con matrices es que es fácil inicializar una matriz completa con valores literales para realizar pruebas fácilmente, incluso con una matriz de estructura: constante int ARRAY_SIZE = 10; estudiante StudentArray[ARRAY_SIZE] = { {87, 10001, "Fred"}, {28, 10002, "Tomás"}, {100, 10003, "Alistair"}, {78, 10004, "Sasha"}, {84, 10005, "Erin"}, {98, 10006, "Belinda"}, {75, 10007, "Leslie"}, {70, 10008, "Dulces"}, {81, 10009, "Aretha"}, {68, 10010, "Verónica"} }; Esta declaración significa que StudentArray[0] tiene 87 para su calificación, 10001 para su ID de estudiante y "Fred" como nombre, y así sucesivamente para los otros nueve elementos de la matriz. En cuanto al resto del código, podría ser tan simple como copiar el código del principio de este capítulo y luego reemplazar cada referencia del formato intArray[subíndice] con estudianteArray[subíndice].calificación. Eso daría como resultado lo siguiente: int más alto = StudentArray[0].grado; para (int i = 1; i < ARRAY_SIZE; i++) { if (studentArray[i].calificación > más alto) más alto = StudentArray[i].calificación; } Supongamos, en cambio, que como ahora tenemos información adicional para cada estudiante, queremos encontrar el nombre del estudiante con la mejor calificación, no la calificación en sí. Esto requeriría modificaciones adicionales. Cuando nuestro ciclo termina, la única estadística que tenemos es la mejor nota, y eso no nos permite determinar directamente a qué alumno pertenece. Tendríamos que recorrer la matriz nuevamente, buscando la estructura con la calificación coincidente , lo que parece un trabajo adicional que no deberíamos tener que hacer. Para evitar este problema, debemos realizar un seguimiento adicional del nombre del estudiante que coincide con el valor actual más alto o, en lugar de realizar un seguimiento de la calificación más alta, realizar un seguimiento de la ubicación en la matriz donde se encuentra la calificación más alta, de forma muy similar a lo que hicimos con histograma anteriormente. El último enfoque es el más general porque el seguimiento de la posición de la matriz nos permite recuperar cualquiera de los datos de ese estudiante más int posiciónalta = 0; para (int i = 1; i < ARRAY_SIZE; i++) { if (matrizestudiante[i].grado > Arrayestudiante[posiciónalta].calificación) { Posiciónalta = i; } } 70 Capítulo 3 Machine Translated by Google Aquí la variable highPosition ocupa el lugar de la más alta. Debido a que no estamos rastreando directamente la calificación más cercana al promedio, cuando llega el momento de comparar la calificación más cercana con la calificación actual, usamos highPosition como referencia en StudentArray . Si la calificación en la posición actual de la matriz es mayor, la posición actual en nuestro bucle de procesamiento se asigna a highPosition . Una vez finalizado el ciclo, podemos acceder al nombre del estudiante con la calificación más cercana al promedio usando StudentArray[highPosition].name, y también podemos acceder a cualquier otro dato relacionado con ese registro de estudiante. Matrices multidimensionales Hasta ahora, sólo hemos analizado los arreglos unidimensionales porque son los más comunes. Las matrices bidimensionales son poco comunes y las matrices con tres o más dimensiones son raras. Esto se debe a que la mayoría de los datos son unidimensionales por naturaleza. Además, los datos que son inherentemente multidimensionales pueden representarse como múltiples matrices unidimensionales, por lo que utilizar una matriz multidimensional siempre es elección del programador. Considere los datos de licencias comerciales de la Tabla 3­1. Se trata claramente de datos multidimensionales. Quiero decir, mírenlo: ¡es una cuadrícula! Sin embargo, representé estos datos multidimensionales como dos matrices unidimensionales, CategoryThresholds y LicenseCost. Podría haber representado la tabla de datos como una matriz bidimensional, así: const doble licenciaData[2][númeroCategorías] = { {0,0, 50000,0, 150000,0, 500000,0}, {50,0, 200,0, 1000,0, 5000,0} }; Es difícil discernir alguna ventaja al combinar las dos matrices en una. Ninguno de nuestros códigos está simplificado porque no hay razón para procesar todos los datos de la tabla a la vez. Sin embargo, lo que está claro es que hemos reducido la legibilidad y la facilidad de uso de los datos de nuestra tabla. En la versión original, los nombres de las dos matrices separadas dejan claro qué datos se almacenan en cada una. Con la matriz combinada, los programadores tendremos que recordar que las referencias del formulario LicenseData[0][] se refieren a los umbrales de ventas brutas de las diferentes categorías comerciales, mientras que las referencias del formulario LicenseData[1][] consulte los costos de la licencia comercial. Sin embargo, a veces tiene sentido utilizar una matriz multidimensional. Supongamos que estamos procesando los datos de ventas mensuales de tres agentes de ventas y una de las tareas es encontrar las ventas mensuales más altas de cualquier agente. Tener todos los datos en una matriz de 3 x 12 significa que podemos procesar toda la matriz a la vez, usando bucles anidados: const int NUM_AGENTES = 3; constante int NUM_MESES = 12; int ventas[NUM_AGENTS][NUM_MONTHS] = { {1856, 498, 30924, 87478, 328, 2653, 387, 3754, 387587, 2873, 276, 32}, {5865, 5456, 3983, 6464, 9957, 4785, 3875, 3838, 4959, 1122, 7766, 2534}, {23, 55, 67, 99, 265, 376, 232, 223, 4546, 564, 4544, 3434} }; Resolver problemas con matrices 71 Machine Translated by Google int ventas más altas = ventas[0][0]; for ( int agente = 0; agente < NUM_AGENTES; agente++) { for ( int mes = 0; mes < NUM_MESES; mes++) { if (ventas[agente][mes] > mayoresVentas) ventasmayores = ventas[agente][mes]; } } Aunque se trata de una adaptación sencilla del código básico de “encontrar el número más grande”, existen algunas arrugas. Cuando declaramos nuestra matriz bidimensional, observe que el inicializador está organizado por agente, es decir, como 3 grupos de 12, no 12 grupos de 3 . Como verá en el siguiente problema, esta decisión puede tener consecuencias. Inicializamos ventasmásaltas en el primer elemento de la matriz, como de costumbre . Se le puede ocurrir que la primera vez que pase por los bucles anidados, ambos contadores de bucles serán 0, por lo que compararemos este valor inicial de ventas más altas consigo mismo. Esto no afecta el resultado, pero a veces los programadores novatos intentarán evitar esta pequeña ineficiencia colocando una segunda declaración if en el cuerpo del bucle interno: if (agente != 0 || mes != 0) if (ventas[agente][mes] > mayoresVentas) ventasmayores = ventas[agente][mes]; Esto, sin embargo, es considerablemente menos eficiente que la versión anterior porque estaríamos realizando 50 comparaciones adicionales evitando solo una. Observe también que he usado nombres significativos para las variables del bucle: agente para el bucle externo y mes para el bucle interno . En un bucle único que procesa una matriz unidimensional, un identificador descriptivo gana poco. Sin embargo, en un bucle doble que procesa una matriz bidimensional, los identificadores significativos me ayudan a mantener mis dimensiones y subíndices correctos porque puedo mirar hacia arriba y ver que estoy usando agente en la misma dimensión donde usé numAgents en la declaración de matriz . Incluso cuando tenemos una matriz multidimensional, a veces el mejor enfoque es tratar sólo con una dimensión a la vez. Supongamos que usando las mismas ventas matriz como el código anterior, queríamos mostrar el promedio de ventas mensual más alto del agente. Podríamos hacer esto usando un bucle doble, como lo hicimos anteriormente, pero el código sería más claro de leer y más fácil de escribir si tratáramos toda la matriz como tres matrices individuales y las procesáramos por separado. Recuerde el código que hemos estado usando repetidamente para calcular el promedio. ¿Edad de una serie de números enteros? Convirtamos eso en una función: doble matrizPromedio(int intArray[], int ARRAY_SIZE) { doble suma = 0; para (int i = 0; i < ARRAY_SIZE; i++) { suma += intArray[i]; } media doble = (suma + 0,5) / ARRAY_SIZE; promedio de retorno; } 72 Capítulo 3 Machine Translated by Google Con la función implementada, podemos modificar nuevamente el básico “buscar el número más grande” para encontrar el agente con el promedio de ventas mensual más alto: int promediomás alto = arrayPromedio(ventas[0], 12); for (int agente = 1; agente < NUM_AGENTS; agente++) { int agentePromedio = arrayPromedio(ventas[agente], 12); if (agentePromedio > mayorPromedio) promedio más alto = promedio del agente; } cout << "Promedio mensual más alto: " << promedio más alto << "\n"; La gran idea nueva aquí se muestra en las dos llamadas a arrayAverage. El primer parámetro aceptado por esta función es una matriz unidimensional de int. En la primera llamada, pasamos ventas[0] para el primer argumento llamada, pasamos ventas[agente] , y en la segunda . Entonces, en ambos casos, especificamos un subíndice para la primera dimensión de nuestras ventas de matriz bidimensional , pero no para la segunda dimensión. Debido a la relación directa entre matrices y direcciones en C++, esta referencia indica la dirección del primer elemento de la fila especificada, que luego puede ser utilizada por nuestra función como dirección base de una matriz unidimensional que consta solo de eso. fila. Si esto suena confuso, mire nuevamente la declaración de la matriz de ventas , y en particular, el inicializador. Los valores se presentan en el inicializador en el mismo orden en que se distribuirán en la memoria cuando se esté ejecutando el programa. Entonces, las ventas[0][0], que son 1856, serán las primeras, seguidas de las ventas[0][1], 498, y así sucesivamente hasta el último mes para el primer agente, las ventas[0][11], 32. Luego comenzarán los valores para el segundo agente, comenzando con ventas[1][0], 5865. Por lo tanto, aunque conceptualmente la matriz consta de 3 filas de 12 valores, se presenta en la memoria como una gran secuencia de 36 valores. Es importante tener en cuenta que esta técnica funciona debido al orden en que colocamos los datos en la matriz. Si la matriz estuviera organizada según el otro eje, es decir, por mes en lugar de por agente, no podríamos hacer lo que estamos haciendo aquí. La buena noticia es que existe una manera sencilla de asegurarse de haber configurado la matriz correctamente: simplemente verifique el inicializador. Si los datos que desea procesar individualmente no son contiguos en el inicializador de matriz, ha organizado los datos de manera incorrecta. Lo último que hay que tener en cuenta sobre este código es el uso de la variable temporal agentAverage. Debido a que potencialmente se hace referencia a las ventas mensuales promedio del agente actual dos veces, una vez en la expresión condicional de if declaración y luego nuevamente en la declaración de asignación en el cuerpo, la variable temporal elimina la posibilidad de llamar a arrayAverage dos veces para los datos del mismo agente. Esta técnica de considerar una matriz multidimensional como una matriz de matrices se deriva directamente de nuestro principio central de dividir los problemas en componentes más simples y, en general, hace que los problemas de matrices multidimensionales sean mucho más fáciles de conceptualizar. Aun así, puede que estés pensando que la técnica parece un poco complicada de emplear y, si eres como la mayoría de los nuevos programadores de C++, probablemente desconfíes un poco de las direcciones y de las direcciones detrás de escena. Resolver problemas con matrices 73 Machine Translated by Google aritmética. Creo que la mejor manera de evitar esos sentimientos es hacer que la separación entre las dimensiones sea aún más fuerte, colocando un nivel de matriz dentro de una estructura o clase. Supongamos que creamos una estructura de agente: estructura agenteEstructura { int ventas mensuales[12]; }; Después de tomarnos la molestia de crear una estructura, podríamos pensar en agregar otros datos, como un número de identificación del agente, pero esto hará el trabajo en términos de simplificar nuestros procesos de pensamiento. Con la estructura implementada, en lugar de crear una matriz bidimensional de ventas, creamos una matriz unidimensional de agentes: AgentStruct agentes[3]; Ahora, cuando hacemos nuestra llamada a la función de promedio de matrices, no estamos empleando un truco específico de C++; solo estamos pasando una matriz unidimensional. Por ejemplo: int promediomás alto = promedioarray(agentes[1].ventasmensuales, 12); Decidir cuándo utilizar matrices Una matriz es solo una herramienta. Como ocurre con cualquier herramienta, una parte importante de aprender a utilizar una matriz es saber cuándo utilizarla y cuándo no. Los problemas de muestra discutidos hasta ahora asumieron el uso de matrices en sus descripciones. Sin embargo, en la mayoría de las situaciones, no tendremos este detalle detallado y, en cambio, debemos tomar nuestra propia determinación sobre el uso de la matriz. Las situaciones más comunes en las que debemos tomar esta decisión son aquellas en las que se nos dan datos agregados pero no se nos dice cómo deben almacenarse internamente. Por ejemplo, en el problema donde encontramos el modo, supongamos que la línea que comienza escribe . ., había leído Escribir código que procesa código que procesa una matriz de datos de encuesta. recopilación de un archivo . . Ahora la elección de usar una matriz o no sería datos de encuestas. . nuestro. ¿Cómo tomaríamos esta decisión? Recuerde que no podemos cambiar el tamaño de una matriz una vez creada. Si nos quedáramos sin espacio, nuestro programa fracasaría. Entonces, la primera consideración es si sabremos, en el lugar de nuestro programa donde necesitamos una estructura de datos agregados, cuántos valores almacenaremos o al menos una estimación confiable del tamaño máximo. Esto no significa que tengamos que saber el tamaño de la matriz cuando escribimos el programa. C++, así como la mayoría de los otros lenguajes, nos permite crear una matriz cuyo tamaño se dimensiona en tiempo de ejecución. Supongamos que el problema del modo se modificó de modo que no supiéramos de antemano cuántas respuestas a la encuesta tendríamos, pero ese número llegó al programa como entrada del usuario. Luego podríamos declarar dinámicamente una matriz para almacenar los datos de la encuesta. 74 Capítulo 3 Machine Translated by Google intARRAY_SIZE; cout << "Número de respuestas a la encuesta: "; cin >> ARRAY_SIZE; int *surveyData = nuevo int[ARRAY_SIZE]; para(int i = 0; i < ARRAY_SIZE; i++) { " << i + 1 << ": "; cout << "Respuesta de la encuesta cin >> datosencuesta[i]; } Declaramos la matriz usando notación de puntero, inicializándola mediante una invocación del nuevo operador . Debido a la fluidez entre los tipos de puntero y matriz en C+ +, se puede acceder a los elementos usando la notación de matriz , aunque SurveyData se declare como un puntero. Tenga en cuenta que debido a que esta matriz se asigna dinámicamente, al final del programa, cuando ya no necesitamos la matriz, debemos asegurarnos de desasignarla: eliminar[] datos de encuesta; El operador eliminar[] , en lugar del operador de eliminación habitual , se utiliza para matrices. Aunque no hará ninguna diferencia con una matriz de números enteros, si crea una matriz de objetos, el operador eliminar[] garantiza que los objetos individuales de la matriz se eliminen antes de que se elimine la matriz misma. Por lo tanto, debería adoptar el hábito de utilizar siempre eliminar [] con matrices asignadas dinámicamente. Tener la responsabilidad de limpiar la memoria dinámica es la pesadilla del programador de C++, pero si programa en el lenguaje, es algo que simplemente debe hacer. Los programadores principiantes a menudo eluden esta responsabilidad porque sus programas son tan pequeños y se ejecutan durante períodos de tiempo tan cortos que nunca ven los efectos dañinos de las pérdidas de memoria (memoria que ya no es utilizada por el programa pero que nunca se desasigna y, por lo tanto, no está disponible para el programa). resto del sistema). No desarrolles este mal hábito. Tenga en cuenta que podemos usar la matriz dinámica solo porque el usuario nos dice de antemano el número de respuestas de la encuesta. Considere otra variante donde el usuario comienza ingresando respuestas de la encuesta sin decirnos el número de respuestas, indicando que no hay más respuestas ingresando un –1 (un método de ingreso de datos conocido como centinela ). ¿Podemos seguir usando una matriz para resolver este problema? Esta es un área gris. Aún podríamos usar una matriz si tuviéramos un número máximo garantizado de respuestas. En tal caso, podríamos declarar una matriz de ese tamaño y asumir que estamos a salvo. Sin embargo, es posible que todavía tengamos preocupaciones a largo plazo. ¿Qué pasa si el tamaño del grupo de encuestas aumenta en el futuro? ¿Qué pasa si queremos utilizar el mismo programa con un encuestador diferente? En términos más generales, ¿por qué crear un programa con una limitación conocida si podemos evitarla? Mejor entonces utilizar una recopilación de datos sin un tamaño fijo. Como se analizó anteriormente, la clase vectorial de la biblioteca de plantillas estándar de C++ actúa como una matriz pero crece según sea necesario. Una vez declarado e inicializado, el vector se puede procesar exactamente de la misma manera que una matriz. Podemos asignar un valor a un elemento vectorial o recuperar un valor usando la notación de matriz estándar. si el vector Resolver problemas con matrices 75 Machine Translated by Google ha llenado su tamaño inicial y necesitamos agregar otro elemento, podemos hacerlo usando el método push_back . Resolver el problema modificado con un vector se ve así: vector<int> datos de encuesta; encuestaData.reserve(30); int encuestaRespuesta; cout << "Ingrese la siguiente respuesta a la encuesta o ­1 para finalizar: "; cin >> encuestaRespuesta; while (respuestaencuesta! = ­1) { surveyData.push_back(respuestaencuesta); cout << "Ingrese la siguiente respuesta a la encuesta o ­1 para finalizar: "; cin >> encuestaRespuesta; } int vectorSize = encuestaData.size(); constanteint MAX_RESPONSE = 10; int histograma[MAX_RESPONSE]; para (int i = 0; i < MAX_RESPONSE; i++) { histograma[i] = 0; } for (int i = 0; i < tamañovector; i++) { histograma[datosencuesta[i] ­ 1]++; } int más frecuente = 0; para (int i = 1; i < MAX_RESPONSE; i++) { if (histograma[i] > histograma[másfrecuente]) másfrecuente = i; } más frecuente++; En este código, primero declaramos el vector Muchas respuestas y luego reservamos espacio para 30 sur­ . El segundo paso no es estrictamente necesario, pero reservar una pequeña cantidad de espacio que exceda el número probable de elementos evita que el vector cambie de tamaño con frecuencia a medida que le agregamos valores. Leemos el primer grado antes del ciclo de entrada de datos , una técnica que utilizamos por primera vez en el capítulo anterior y que nos permite verificar cada valor ingresado antes de procesarlo. En este caso, queremos evitar agregar el valor centinela, –1, a nuestro vector. Los resultados de la encuesta se agregan al vector usando el método push_back . Una vez completado el ciclo de entrada de datos, recuperamos el tamaño del vector utilizando el método de tamaño . También podríamos haber contado el número de elementos nosotros mismos en el bucle de entrada de datos, pero como el vector ya sigue su tamaño, esto evita esfuerzos duplicados. El resto del código es igual que la versión anterior con el array y el número fijo de respuestas, excepto que hemos cambiado los nombres de las variables. Sin embargo, toda esta discusión sobre los vectores pasa por alto un punto importante. Si leemos los datos directamente del usuario, en lugar de que nos digan que estamos comenzando con una matriz u otra recopilación de datos, es posible que no necesitemos una matriz para los datos de la encuesta, solo una para el histograma. En cambio, podemos procesar los valores de la encuesta a medida que los leemos. Necesitamos una estructura de datos sólo cuando necesitamos 76 Capítulo 3 Machine Translated by Google leer todos los valores antes de procesarlos o necesita procesar los valores más de una vez. En este caso, tampoco necesitamos hacer: constanteint MAX_RESPONSE = 10; int histograma[MAX_RESPONSE]; para (int i = 0; i < MAX_RESPONSE; i++) { histograma[i] = 0; } int encuestaRespuesta; cout << "Ingrese la siguiente respuesta a la encuesta o ­1 para finalizar: "; cin >> encuestaRespuesta; while (respuestaencuesta! = ­1) { histograma[respuestaencuesta ­ 1]++; cout << "Ingrese la siguiente respuesta a la encuesta o ­1 para finalizar: "; cin >> encuestaRespuesta; } int más frecuente = 0; para (int i = 1; i < MAX_RESPONSE; i++) { if (histograma[i] > histograma[másfrecuente]) másfrecuente = i; } más frecuente++; Aunque este código fue fácil de escribir, dadas las versiones anteriores como guía, habría sido aún más fácil simplemente leer los datos del usuario en una matriz y usar el ciclo de procesamiento anterior palabra por palabra. El beneficio de este enfoque de proceso sobre la marcha es la eficiencia. Evitamos almacenar innecesariamente cada una de las respuestas de la encuesta, cuando necesitamos almacenar solo una respuesta a la vez. Nuestra solución basada en vectores era ineficiente en el espacio: ocupaba más espacio del necesario sin proporcionar el beneficio correspondiente. Además, leer todas las calificaciones en el vector requería un bucle propio, separado de los bucles, para procesar todas las respuestas de la encuesta y encontrar el valor más alto en el histograma. Eso significa que la versión vectorial hace más trabajo que la versión anterior. Por lo tanto, la versión vectorial también es ineficiente en el tiempo: hace más trabajo del necesario sin proporcionar el beneficio correspondiente. En algunos casos, diferentes soluciones ofrecen compensaciones y los programadores deben decidir entre eficiencia de espacio y eficiencia de tiempo. En este caso, sin embargo, el uso del vector hace que el programa sea ineficaz en todos sus aspectos. En este libro no dedicaremos mucho tiempo a rastrear cada ineficiencia. En ocasiones, los programadores deben participar en el ajuste del rendimiento, que es el análisis sistemático y la mejora de la eficiencia de un programa en el tiempo y el espacio. La puesta a punto del rendimiento de un programa es muy parecida a la puesta a punto del rendimiento de un coche de carreras: un trabajo exigente, donde pequeños ajustes pueden tener grandes efectos y se requiere un conocimiento experto de cómo funcionan los mecanismos "debajo del capó". Sin embargo, incluso si no tenemos el tiempo, el deseo o el conocimiento para ajustar completamente el desempeño de un programa, debemos evitar decisiones que conduzcan a graves ineficiencias. Usar un vector o una matriz innecesariamente no es como un motor con una mezcla de combustible y aire demasiado pobre; es como conducir un autobús a la playa de vacaciones cuando podrías haber cabido todo lo que llevabas en un Honda Civic. Resolver problemas con matrices 77 Machine Translated by Google Si estamos seguros de que necesitamos procesar los datos varias veces y conocemos bien el tamaño máximo del conjunto de datos, el último criterio para decidir si usar una matriz es el acceso aleatorio. Más adelante, analizaremos estructuras de datos alternativas, como listas, que, al igual que los vectores, pueden crecer según sea necesario pero, a diferencia de los vectores y las matrices, solo se puede acceder a los elementos de forma secuencial. Es decir, si queremos acceder al décimo elemento de una lista, tenemos que recorrer los primeros 9 elementos para llegar a él. Por el contrario, el acceso aleatorio significa que podemos acceder a cualquier elemento de una matriz o vector en cualquier momento. Entonces, la última regla es que debemos usar una matriz cuando necesitemos acceso aleatorio. Si sólo necesitamos acceso secuencial, podríamos considerar una estructura diferente. Quizás observe que muchos de los programas de este capítulo fallan en este último criterio; accedemos a los datos de forma secuencial, no aleatoria, y aun así estamos usando una matriz. Esto lleva a la gran excepción de sentido común a todas estas reglas. Si una matriz es pequeña, entonces ninguna de las objeciones anteriores tiene mucho peso. Lo que constituye "pequeño" puede variar según la plataforma o aplicación. El punto es que, si su programa necesita una colección de tan solo 1 o hasta 10 elementos, cada uno de los cuales requiere 10 bytes, debe considerar si el posible desperdicio de 90 bytes que podría resultar de la asignación de una matriz del máximo Vale la pena buscar el tamaño requerido para encontrar una mejor solución. Utilice las matrices con prudencia, pero no permita que lo perfecto sea enemigo de lo bueno. Ejercicios Como siempre, te recomiendo que pruebes tantos ejercicios como puedas soportar. 3­1. ¿Estás decepcionado de que no hicimos más con la clasificación? Estoy aqui para ayudar. Para asegurarse de que se siente cómodo con qsort, escriba código que use la función para ordenar una matriz de nuestra estructura de estudiantes. Primero ordene por grado y luego inténtelo nuevamente usando la identificación del estudiante. 3­2. Vuelva a escribir el código que encuentra al agente con el mejor promedio de ventas mensual para que encuentre al agente con la mediana de ventas más alta. Como se dijo anteriormente, la mediana de un conjunto de valores es “la que está en el medio”, de modo que la mitad de los otros valores son más altos y la mitad de los otros valores son más bajos. Si hay un número par de valores, la mediana es el promedio simple de los dos valores del medio. Por ejemplo, en el conjunto 10, 6, 2, 14, 7, 9, los valores del medio son 7 y 9. El promedio de 7 y 9 es 8, por lo que 8 es la mediana. 3­3. Escriba una función bool a la que se le pase una matriz y el número de elementos de esa matriz y determine si los datos de la matriz están ordenados. ¡Esto debería requerir solo una pasada! 3­4. Aquí hay una variación de la matriz de valores constantes . Escriba un programa para crear un problema de cifrado de sustitución. En un problema de cifrado por sustitución, todos los mensajes están formados por letras mayúsculas y puntuación. El mensaje original se llama texto sin formato y el texto cifrado se crea sustituyendo cada letra por otra (por ejemplo, cada C podría convertirse en una X). Para este problema, codifique una matriz constante de 26 elementos char para el cifrado y haga que su programa lea un mensaje de texto plano y genere el texto cifrado equivalente. 78 Capítulo 3 Machine Translated by Google 3­5. Haga que el programa anterior convierta el texto cifrado nuevamente a texto sin formato para verificar la codificación y decodificación. 3­6. Para hacer que el problema del texto cifrado sea aún más desafiante, haga que su programa gram genera aleatoriamente la matriz de cifrado en lugar de una matriz constante codificada . Efectivamente, esto significa colocar un carácter aleatorio en cada elemento de la matriz, pero recuerda que no puedes sustituir una letra por sí misma. Entonces, el primer elemento no puede ser A y no puedes usar la misma letra para dos sustituciones. es decir, si el primer elemento es S, ningún otro elemento puede ser S. 3­7. Escriba un programa al que se le dé una matriz de números enteros y determine la moda, que es el número que aparece con más frecuencia en la matriz. 3­8. Escriba un programa que procese una serie de objetos de estudiantes y determine los cuartiles de calificaciones, es decir, la calificación que uno necesitaría para obtener una puntuación igual o mejor que el 25 % de los estudiantes, el 50 % de los estudiantes y el 75 % de los estudiantes. . 3­9. Considere esta modificación de la matriz de ventas : debido a que los vendedores van y vienen a lo largo del año, ahora marcamos los meses antes de la contratación de un agente de ventas, o después del último mes de un agente de ventas, con un –1. Vuelva a escribir su código de promedio de ventas más alto o mediana de ventas más alta para compensar. Resolver problemas con matrices 79 Machine Translated by Google Machine Translated by Google RESOLVIENDO PROBLEMAS t l S YYPAG oh C A DY Y CON PUNTEROS Y MEMORIA DINÁMICA En este capítulo, aprenderemos a resolver problemas utilizando punteros y memoria dinámica, lo que nos permitirá escribir programas flexibles que puedan acomodar tamaños de datos desconocidos. hasta que se ejecute el programa. Punteros y memoria dinámica. La asignación es una programación “dura”. Cuando puedes escribir programas que toman bloques de memoria sobre la marcha, vincularlos a estructuras útiles y limpiar todo al final para que no queden residuos, no eres simplemente alguien que puede codificar un poco: eres un programador. Como los punteros son complicados y muchos lenguajes populares, como Java, parecen prescindir del uso de punteros, algunos programadores novatos se convencerán de que pueden saltarse este tema por completo. Esto es un error. Los punteros y el acceso indirecto a la memoria siempre se utilizarán en la programación avanzada, aunque puedan estar ocultos por los mecanismos de un lenguaje de alto nivel. Por lo tanto, para pensar verdaderamente como un programador, debe ser capaz de pensar a través de punteros y problemas basados en punteros. Machine Translated by Google Sin embargo, antes de comenzar a resolver problemas de punteros, examinaremos cuidadosamente todos los aspectos de cómo funcionan los punteros, tanto en la superficie como detrás de escena. Este estudio proporciona dos beneficios. En primer lugar, este conocimiento nos permitirá hacer el uso más eficaz de los punteros. En segundo lugar, al disipar los misterios de los indicadores, podemos emplearlos con confianza. Revisión de los fundamentos del puntero Al igual que con los temas tratados en capítulos anteriores, debería haber tenido cierta exposición al uso básico del puntero, pero para asegurarse de que estamos en la misma página, aquí hay una revisión rápida. Los punteros en C++ se indican con un asterisco (*). Dependiendo del contexto, el asterisco indica que se está declarando un puntero o que nos referimos a la memoria apuntada, no al puntero en sí. Para declarar un puntero, colocamos el asterisco entre el nombre del tipo y el identificador: En t * intPuntero; Esto declara la variable intPointer como un puntero a un int. Tenga en cuenta que el asterisco se vincula con el identificador, no con el tipo. A continuación, la variable1 es un puntero a un int, pero la variable2 es solo un int: int*variable1, variable2; Un signo comercial delante de una variable actúa como la dirección del operador. Entonces podríamos asignar la dirección de la variable2 a la variable1 con: variable1 = &variable2; También podemos asignar el valor de una variable puntero a otra directamente: intPuntero = variable1; Quizás lo más importante es que podemos asignar memoria durante el tiempo de ejecución que Sólo se puede acceder a través de un puntero. Esto se logra con el nuevo operador: doble * doblePuntero = nuevo doble; Acceder a la memoria en el otro extremo del puntero se conoce como desreferenciación y se logra con un asterisco a la izquierda de un identificador de puntero. Nuevamente, esta es la misma ubicación que usaríamos para una declaración de puntero. El contexto hace que el significado sea diferente. He aquí un ejemplo: *puntero doble = 35,4; doble localDoble = *doblePuntero; 82 Capítulo 4 Machine Translated by Google Asignamos un valor al doble asignado por el código anterior antes copiando el valor de esta ubicación de memoria a la variable localDouble . Para desasignar la memoria asignada con nueva, una vez que ya no la necesitemos, usamos la palabra clave eliminar: eliminar doble puntero; La mecánica de este proceso se describe en detalle en “La memoria importa” en la página 85. Beneficios de los punteros Los punteros nos brindan capacidades que no están disponibles con la asignación de memoria estática y también brindan nuevas oportunidades para el uso eficiente de la memoria. Los tres beneficios principales del uso de punteros son: Estructuras de datos del tamaño de tiempo de ejecución Estructuras de datos redimensionables Compartir memoria Echemos un vistazo a cada uno de estos con un poco más de detalle. Estructuras de datos del tamaño de tiempo de ejecución Al usar punteros, podemos crear una matriz con un tamaño determinado en tiempo de ejecución, en lugar de tener que elegir el tamaño antes de crear nuestra aplicación. Esto nos ahorra tener que elegir entre quedarnos sin espacio en la matriz o hacer que la matriz sea tan grande como sea posible, desperdiciando así gran parte del espacio de la matriz en el caso promedio. Vimos por primera vez el tamaño de los datos en tiempo de ejecución en “Decidir cuándo usar matrices” en la página 74. Usaremos este concepto más adelante en este capítulo, en “Cadenas de longitud variable” en la página 91. Estructuras de datos redimensionables También podemos crear estructuras de datos basadas en punteros que crecen o se reducen durante el tiempo de ejecución según sea necesario. La estructura de datos de tamaño variable más básica es la lista vinculada, que quizás ya haya visto. Aunque solo se puede acceder a los datos de la estructura en orden secuencial, la lista vinculada siempre tiene tantos lugares para los datos como los datos mismos, sin desperdiciar espacio. Otras estructuras de datos basadas en punteros más elaboradas, como verá más adelante, tienen ordenamientos y “formas” que pueden reflejar la relación de los datos subyacentes mejor que una matriz. Debido a esto, aunque una matriz ofrece un acceso aleatorio completo que ninguna estructura basada en punteros ofrece, la operación de recuperación (donde encontramos el elemento en la estructura que mejor cumple con un determinado criterio) puede ser mucho más rápida con una estructura basada en punteros. . Usaremos este beneficio más adelante en este capítulo para crear una estructura de datos para los registros de los estudiantes que crezca según sea necesario. Resolver problemas con punteros y memoria dinámica 83 Machine Translated by Google Compartir memoria Los punteros pueden mejorar la eficiencia del programa al permitir que se compartan bloques de memoria. Por ejemplo, cuando llamamos a una función, podemos pasar un puntero a un bloque de memoria en lugar de pasar una copia del bloque usando parámetros de referencia. Probablemente los hayas visto antes; son parámetros en los que aparece un símbolo (&) entre el tipo y el nombre en la lista de parámetros formal: void refParamFunction (int x = 10; } & x) { número entero = 5; refParamFunction( número); cout << número << "\n"; NOTA Los espacios que se muestran antes y después del símbolo comercial no son necesarios; solo Incluirlos aquí por razones estéticas. En el código de otros desarrolladores, es posible que vea int& x, int &x o incluso int&x. En este código, el parámetro formal x no es una copia del argumento número ; más bien, es una referencia a la memoria donde se almacena el número . Por lo tanto, cuando se cambia x , el espacio de memoria para el número cambia y la salida al final del fragmento de código es 10 . Los parámetros de referencia se pueden utilizar como mecanismo para enviar valores fuera de una función, como se muestra en este ejemplo. En términos más generales, los parámetros de referencia permiten que la función llamada y la función que llama compartan la misma memoria, lo que reduce la sobrecarga. Si una variable que se pasa como parámetro ocupa un kilobyte de memoria, pasar la variable como referencia significa copiar un puntero de 32 o 64 bits en lugar del kilobyte. Podemos indicar que estamos usando un parámetro de referencia para el rendimiento, no su salida, usando la palabra clave const : int otra función (const int & x); Al anteponer la palabra const en la declaración del parámetro de referencia x, otra función recibirá una referencia al argumento pasado en la llamada pero no podrá modificar el valor de ese argumento, como cualquier otro parámetro constante . En general, podemos usar punteros de esta manera para permitir que diferentes partes de un programa, o diferentes estructuras de datos dentro del programa, tengan acceso a los mismos datos sin la sobrecarga de copiar. Cuándo utilizar punteros Como comentamos con las matrices, los punteros tienen posibles inconvenientes y deben usarse solo cuando sea apropiado. ¿Cómo sabemos cuándo es apropiado el uso del puntero? Habiendo enumerado los beneficios de los punteros, podemos decir que los punteros deben usarse solo cuando requerimos uno o más de sus beneficios. Si su programa necesita una estructura para contener un conjunto de datos, pero no puede estimar con precisión 84 Capítulo 4 Machine Translated by Google cuántos datos hay antes del tiempo de ejecución; si necesita una estructura que pueda crecer y encogerse durante la ejecución; o si tiene objetos grandes u otros bloques de datos que se pasan por su programa, los punteros pueden ser el camino a seguir. Sin embargo, en ausencia de cualquiera de estas situaciones, debe tener cuidado con los punteros y la asignación de memoria dinámica. Dada la notoria reputación de los punteros como una de las funciones más difíciles de C++, tures, se podría pensar que ningún programador intentaría utilizar un puntero cuando no sea necesario. Sin embargo, muchas veces me ha sorprendido descubrir lo contrario. A veces los programadores simplemente se engañan a sí mismos pensando que se necesita un puntero. Supongamos que está realizando una llamada a una función escrita por otra persona, desde una biblioteca o interfaz de programación de aplicaciones, tal vez, con el siguiente prototipo: cálculo nulo (entrada int, salida int*); Podríamos imaginar que esta función está escrita en C, no en C++, y es por eso que usa un puntero en lugar de una referencia (&) para crear un parámetro "saliente". Al llamar a esta función, un programador podría hacer descuidadamente algo como esto: int número1 = 10; int* número2 = nuevo int; calcular(núm1, núm2); Este código es ineficiente en el espacio porque crea un puntero donde no se necesita ninguno. En lugar del espacio para dos números enteros, utiliza el espacio para dos números enteros y un puntero. El código también es ineficiente en el tiempo porque la asignación de memoria innecesaria lleva tiempo (como se explica en la siguiente sección). Por último, el programador ahora debe recordar eliminar la memoria asignada. Todo esto podría haberse evitado usando el otro aspecto del operador & , que le permite obtener la dirección de una variable asignada estáticamente, como esta: int número1 = 10; int número2; calcular(núm1, &núm2); Estrictamente hablando, todavía usamos un puntero en la segunda versión, pero lo usamos implícitamente, sin una variable de puntero ni una asignación de memoria dinámica. La memoria importa Para comprender cómo la asignación de memoria dinámica nos permite dimensionar el tiempo de ejecución y compartir la memoria, debemos comprender un poco cómo funciona la asignación de memoria en general. Esta es una de las áreas en las que creo que beneficia a los nuevos programadores aprender C++. Todos los programadores deben eventualmente comprender cómo funcionan los sistemas de memoria en una computadora moderna, y C++ te obliga a enfrentar este problema de frente. Otros idiomas ocultan suficientes detalles sucios de la memoria. Resolver problemas con punteros y memoria dinámica 85 Machine Translated by Google sistemas que los nuevos programadores se convencen a sí mismos de que estos detalles no son motivo de preocupación, lo que simplemente no es el caso. Más bien, los detalles no importan mientras todo funcione. Sin embargo, tan pronto como surge un problema, el desconocimiento de los modelos de memoria subyacentes crea un obstáculo insuperable entre el programador y la solución. La pila y el montón C++ asigna memoria en dos lugares: la pila y el montón. Como lo implican los nombres, la pila está organizada y ordenada, y el montón está desunido y desordenado. La pila de nombres es especialmente descriptiva porque le ayuda a visualizar la naturaleza contigua de la asignación de memoria. Piense en una pila de cajas, como en la Figura 4­1 (a). Cuando tengas una caja para almacenar, la colocas en la parte superior de la pila. Para eliminar una caja en particular de la pila, primero debes eliminar todas las cajas que están encima de ella. En términos prácticos de programación, esto significa que una vez que haya asignado un bloque de memoria (una caja) en la pila, no hay forma de cambiar su tamaño porque en cualquier momento puede tener otros bloques de memoria inmediatamente después (otras cajas encima). de ello). En C++, puedes crear explícitamente tu propia pila para usarla en un determinado algoritmo, pero independientemente, hay una pila que su programa siempre usará, conocida como pila de tiempo de ejecución del programa. Cada vez que se llama a una función (y esto incluye la función principal ), se asigna un bloque de memoria en la parte superior de la pila de tiempo de ejecución. Este bloque de memoria se llama registro de activación. Una discusión completa de su contenido está más allá del alcance de este texto, pero para su comprensión como solucionador de problemas, el contenido principal del registro de activación es el espacio de almacenamiento de variables. La memoria para todas las variables locales, incluidos los parámetros de la función, se asigna dentro del registro de activación. Echemos un vistazo a un ejemplo: int funciónB(int valorentrada) { retornar valor de entrada ­ 10; } int funciónA(int núm) { int variable local = funciónB(núm * 10); devolver variable local; } int principal() { entero x = 12; int y = funciónA(x); devolver 0; } En este código, la función principal llama a la funciónA, que a su vez llama a la funciónB. La Figura 4­1 (b) muestra una versión simplificada de cómo se organizaría la pila de tiempo de ejecución en el punto justo antes de ejecutar la declaración de retorno de la función B . Los registros de activación para las tres funciones se organizarían en una pila de memoria contigua, con la función principal en la parte inferior de la pila. (Solo para hacer las cosas aún más confusas, es posible que la pila comience en el punto más alto posible de la memoria y se construya hacia abajo para reducir la memoria). 86 Capítulo 4 Machine Translated by Google direcciones en lugar de subir a direcciones de memoria más altas. Sin embargo, no se hace ningún daño al ignorar la posibilidad.) Lógicamente, el registro de activación de la función principal está en la parte inferior de la pila, con el registro de activación de la funciónA encima y el registro de activación de la funciónB encima de la funciónA. Ninguno de los dos registros de activación inferiores se puede eliminar antes de que se elimine el registro de activación de la función B. más alto valor de entrada función B en uno variable local función X y principal más bajo (a) (b) Figura 4­1: Una pila de cajas y una pila de llamadas a funciones Mientras que una pila está muy organizada, un montón, por el contrario, tiene poca organización. Supongamos que estás guardando cosas en cajas nuevamente, pero estas cajas son frágiles y no puedes apilarlas una encima de otra. Tienes un espacio grande, inicialmente vacío, para guardar las cajas, y puedes colocarlas en cualquier lugar del suelo que quieras. Sin embargo, las cajas son pesadas, por lo que una vez que coloques una, será mejor dejarla donde está hasta que estés listo para sacarla de la habitación. Este sistema tiene ventajas y desventajas en comparación con el stack. Por un lado, este sistema de almacenamiento es flexible y permite acceder al contenido de cualquier caja en cualquier momento. Por otro lado, la habitación rápidamente se convertirá en un desastre. Si las cajas son todas de diferentes tamaños, será especialmente difícil aprovechar todo el espacio disponible en el suelo. Terminarás con muchos espacios entre las cajas que son demasiado pequeños para llenarlos con otra caja. Debido a que las cajas no se pueden mover fácilmente, quitar varias cajas solo crea varios espacios difíciles de llenar en lugar de proporcionar el almacenamiento abierto de nuestro piso vacío original. En términos prácticos de programación, nuestro montón es como el suelo de esa habitación. Un bloque de memoria es una serie contigua de direcciones; por lo tanto, durante la vida útil de un programa con muchas asignaciones y desasignaciones de memoria, terminaremos con muchos espacios entre los bloques de memoria asignados restantes. Este problema se conoce como fragmentación de la memoria. Resolver problemas con punteros y memoria dinámica 87 Machine Translated by Google Cada programa tiene su propio montón, desde el cual se asigna memoria dinámicamente. En C++, esto generalmente significa una invocación de la nueva palabra clave, pero también verá llamadas a las funciones antiguas de C para la asignación de memoria, como malloc. Cada llamada a nuevo (o malloc) reserva un trozo de memoria en el montón y devuelve un puntero al trozo, mientras que cada llamada a eliminar (o liberar si la memoria se asignó con malloc) devuelve el trozo al grupo de memoria de montón disponible. Debido a la fragmentación, no toda la memoria del grupo es igualmente útil. Si nuestro programa comienza asignando las variables A, B y C en la memoria del montón, podríamos esperar que esos bloques sean contiguos. Si desasignamos B, el vacío que deja solo puede llenarse con otra solicitud que sea del tamaño de B o menor, hasta que A o C también sean desasignados. La figura 4­2 aclara la situación. En el inciso (a), vemos el piso de nuestra habitación lleno de cajas. En algún momento la sala probablemente estuvo bien organizada, pero con el tiempo, la disposición se volvió desordenada. Ahora hay una pequeña caja (b) que no cabe en ningún espacio abierto del piso, aunque el área total del piso no utilizada excede en gran medida el espacio que ocupa la caja. En la parte (c), representamos un pequeño montón. Los cuadrados de líneas discontinuas son los fragmentos de memoria más pequeños (indivisibles), que pueden ser un solo byte, una palabra de memoria o algo más grande, según el administrador del montón. Las áreas sombreadas representan asignaciones de memoria contigua; Para mayor claridad, una asignación tiene algunas de sus partes numeradas. Al igual que con el piso fragmentado, el montón fragmentado tiene separados los fragmentos de memoria no asignados, lo que reduce su usabilidad. Hay un total de 85 fragmentos de memoria no utilizados, pero el rango contiguo más grande de memoria no utilizada, como lo indica la flecha, tiene sólo 17 fragmentos de longitud. En otras palabras, si cada fragmento fuera un byte, este montón no podría satisfacer ninguna solicitud de una invocación de new de más de 17 bytes, aunque el montón tenga 85 bytes libres. 16 20 15 1 (a) (b) (C) Figura 4­2: Un piso fragmentado, una caja que no se puede colocar y una memoria fragmentada Tamaño de la memoria El primer problema práctico con la memoria es limitar su uso a lo necesario. Los sistemas informáticos modernos tienen tanta memoria que es fácil pensar en ella como un recurso infinito, pero en realidad cada programa tiene una cantidad limitada de memoria. Además, los programas necesitan usar la memoria de manera eficiente para evitar fallas generales del sistema. 88 Capítulo 4 Machine Translated by Google desacelerar. En un sistema operativo multitarea (lo que significa casi todos los sistemas operativos modernos), cada byte de memoria desperdiciado por un programa empuja al sistema como un todo hacia el punto en que el conjunto de programas actualmente en ejecución no tiene suficiente memoria para ejecutarse. . En ese momento, el sistema operativo cambia constantemente fragmentos de un programa por otro y, por lo tanto, avanza lentamente. Esta condición se conoce como paliza. Tenga en cuenta que, más allá del deseo de mantener la huella de memoria general del programa lo más pequeña posible, la pila y el montón tienen tamaños máximos. Para probar esto, asignemos memoria del montón un kilobyte a la vez, hasta que algo explote: const int enterosPorKilobyte = 1024 / tamañode(int); mientras (verdadero) { int *oneKilobyteArray = nuevo int[intsPerKilobyte]; } Permítanme enfatizar que este es un código horrible escrito únicamente para demostrar un punto. Si prueba este código en su sistema, primero debe guardar todo su trabajo, solo para estar seguro. Lo que debería suceder es que el programa se detenga y su sistema operativo se queje de que el código generó pero no manejó una excepción bad_alloc . Esta excepción la genera new cuando ningún bloque de memoria no asignada en el montón es lo suficientemente grande para cumplir con la solicitud. Quedarse sin memoria del montón se denomina desbordamiento del montón. En algunos sistemas, un desbordamiento del montón puede ser común, mientras que en otros sistemas, un programa provocará una paliza mucho antes de producir un bad_alloc (en mi sistema, la nueva llamada no falló hasta que asigné dos gigabytes en llamadas anteriores ). Existe una situación similar con la pila de tiempo de ejecución. Cada llamada a función asigna espacio en la pila y hay una sobrecarga fija para cada registro de activación, incluso para una función sin parámetros ni variables locales. La forma más sencilla de demostrar esto es con una función recursiva fuera de control: int recuento = 0; pila vacíaOverflow() { cuenta++; stackOverflow(); } int principal() { stackOverflow(); devolver 0; } Este código tiene una variable global , que en la mayoría de los casos tiene un mal estilo, pero aquí necesito un valor que persista en todas las llamadas recursivas. Como esta variable se declara fuera de la función, no se le asigna memoria en el registro de activación de la función, ni existen otras variables o parámetros locales. Todo lo que hace la función es incrementar el recuento y realizar una llamada recursiva . La recursividad se analiza ampliamente en el Capítulo 6, pero aquí se utiliza simplemente para hacer que la cadena de llamadas a funciones sea lo más larga posible. El registro de activación. Resolver problemas con punteros y memoria dinámica 89 Machine Translated by Google de una función permanece en la pila hasta que finaliza esa función. Entonces, cuando se realiza la primera llamada a stackOverflow desde main, se coloca un registro de activación en la pila de tiempo de ejecución que no se puede eliminar hasta que finalice la primera llamada a la función. Esto nunca sucederá porque la función realiza una segunda llamada a stackOverflow, colocando otro registro de activación en la pila, que luego realiza una tercera llamada, y así sucesivamente. Estos registros de activación se acumulan hasta que la pila se queda sin espac En mi sistema, el recuento es de alrededor de 4.900 cuando el programa fracasa. Mi entorno de desarrollo, Visual Studio, tiene por defecto una asignación de pila de 1 MB, lo que significa que cada una de estas llamadas a funciones, incluso sin variables o parámetros locales, crea un registro de activación de más de 200 bytes. Toda la vida La vida útil de una variable es el lapso de tiempo entre la asignación y la desasignación. Con una variable basada en pila, es decir, una variable local o un parámetro, la vida útil se maneja implícitamente. La variable se asigna cuando se llama a la función y se desasigna cuando finaliza la función. Con una variable basada en montón, es decir, una variable asignada dinámicamente usando new, la vida útil está en nuestras manos. Gestionar la vida útil de las variables asignadas dinámicamente es la pesadilla de todo programador de C++. El problema más obvio es la temida pérdida de memoria, una situación en la que la memoria se asigna desde el montón pero nunca se desasigna y ningún puntero hace referencia a ella. He aquí un ejemplo sencillo: int *intPtr = nuevo int; intPtr = NULO; En este código, declaramos un puntero a un número entero , inicializándolo asignando un número entero del montón. Luego, en la segunda línea, configuramos nuestro puntero entero en NULL (que es simplemente un alias para el número cero). Sin embargo, el número entero que asignamos con new todavía existe. Está sentado, solitario y desamparado, en su lugar en el montón, esperando una desasignación que nunca podrá llegar. No podemos desasignar el número entero porque para desasignar un bloque de memoria usamos eliminar seguido de un puntero al bloque y ya no tenemos un puntero al bloque. Si intentáramos seguir el código anterior con eliminar intPtr, obtendríamos un error porque intPtr es cero. A veces, en lugar de una memoria que nunca se desasigna, tenemos el problema opuesto: intentar desasignar la misma memoria dos veces, lo que produce un error de tiempo de ejecución. Esto puede parecer un problema fácil de evitar: simplemente no llame a eliminar dos veces en la misma variable. Lo que complica esta situación es que podemos tener múltiples variables apuntando a la misma memoria. Si varias variables apuntan a la misma memoria y llamamos a eliminar en cualquiera de esas variables, efectivamente hemos desasignado la memoria para todas las variables. Si no borramos explícitamente las variables a NULL, se las conocerá como referencias pendientes y llamar a eliminar en cualquiera de ellas producirá un error de tiempo de ejecución. 90 Capítulo 4 Machine Translated by Google Resolver problemas de puntero En este punto, probablemente esté listo para algunos problemas, así que veamos un par y veamos cómo podemos usar punteros y asignación de memoria dinámica para resolverlos. Primero trabajaremos con algunas matrices asignadas dinámicamente, lo que demostrará cómo realizar un seguimiento de la memoria del montón a través de todas nuestras manipulaciones. Entonces nos mojaremos los pies con una estructura verdaderamente dinámica. Cadenas de longitud variable En este primer problema, crearemos funciones para manipular cadenas. Aquí usamos el término en su sentido más general: una secuencia de caracteres, independientemente de cómo se almacenen esos caracteres. Supongamos que necesitamos admitir tres funciones en nuestro tipo de cadena. PROBLEMA: MANIPULACIÓN DE CUERDAS DE LONGITUD VARIABLE Escriba implementaciones basadas en montón para tres funciones de cadena requeridas: append Esta función toma una cadena y un carácter y agrega el carácter al final de la cadena. concatenar Esta función toma dos cadenas y agrega los caracteres de la segunda cadena a la primera. caracterAt Esta función toma una cadena y un número y devuelve el carácter en esa posición en la cadena (con el primer carácter de la cadena numerado cero). Escriba el código asumiendo que se llamará a caracterAt con frecuencia, mientras que las otras dos funciones se llamarán relativamente raramente. La eficiencia relativa de las operaciones debería reflejar la frecuencia de las llamadas. En este caso, queremos elegir una representación para nuestra cadena que permita una función carácter rápido , lo que significa que necesitamos una forma rápida de localizar un carácter en particular. Como probablemente recordará del capítulo anterior, esto es lo que mejor hace una matriz: acceso aleatorio. Entonces, resolvamos este problema usando matrices de caracteres. Las funciones de agregar y concatenar cambian el tamaño de la cadena, lo que significa que nos encontramos con todos los problemas de matrices que discutimos anteriormente. Debido a que no existe una limitación incorporada para el tamaño de la cadena en este problema, no podemos elegir un tamaño inicial grande para nuestras matrices y esperar lo mejor. En su lugar, necesitaremos cambiar el tamaño de nuestras matrices durante el tiempo de ejecución. Para comenzar, creemos un typedef para nuestro tipo de cadena. Sabemos que vamos a crear nuestras matrices dinámicamente, por lo que debemos hacer que nuestro tipo de cadena sea un puntero a char. typedef char * arrayString; Resolución de problemas con punteros y memoria dinámica 91 Machine Translated by Google Una vez establecido esto, comencemos con las funciones. Usando el principio de comenzar con lo que ya sabemos hacer, podemos escribir rápidamente el carácter At función. char caracterAt(arrayString s, int posición) { return s[posición]; } Recuerde del Capítulo 3 que si a un puntero se le asigna la dirección de una matriz, podemos acceder a los elementos de la matriz usando la notación de matriz normal . Sin embargo, tenga en cuenta que pueden suceder cosas malas si la posición no es en realidad un número de elemento válido para la matriz , y este código asigna la responsabilidad de validar el segundo parámetro a la persona que llama. Consideraremos alternativas a esta situación en los ejercicios. Por ahora, pasemos a la función de agregar . Podemos imaginar lo que hará esta función en general, pero para entender los detalles correctos, deberíamos considerar un ejemplo. Esta es una técnica que llamo resolución por caso d Comience con una entrada de muestra no trivial para la función o programa. Escriba todos los detalles de esa entrada junto con todos los detalles de la salida. Luego, cuando escriba su código, escribirá para el caso general y, al mismo tiempo, verificará cómo cada paso transforma su muestra para asegurarse de alcanzar el estado de salida deseado. Esta técnica es especialmente útil cuando se trata de punteros y memoria asignada dinámicamente, porque gran parte de lo que sucede en el programa está fuera de la vista directa. Seguir un caso en papel le obliga a realizar un seguimiento de todos los valores cambiantes en la memoria, no sólo los representados directamente por variables sino también los del montón. Supongamos que comenzamos con la prueba de cadena, es decir, tenemos una matriz de caracteres en el montón con t, e, s y t, en ese orden, y queremos agregar, usando nuestra función, un signo de exclamación. La Figura 4­3 muestra el estado de la memoria antes (a) y después (b) de esta operación. En estos diagramas, todo lo que esté a la izquierda de la línea vertical discontinua es memoria de pila (variables o parámetros locales) y todo lo que esté a la derecha es memoria de pila, asignada dinámicamente usando new. t Es t t! e s s t (a) (b) Figura 4­3: Estados propuestos “antes” (a) y “después” (b) para la función agregar Al observar esta figura, inmediatamente veo un problema potencial para nuestra función. Según nuestro enfoque de implementación para las cadenas, la función creará una nueva matriz que es un elemento más grande que la matriz original y copiará todos los caracteres de la primera matriz a la segunda. Pero, ¿cómo vamos a saber qué tan grande es la primera matriz? Del capítulo anterior, sabemos que tenemos que realizar un seguimiento del tamaño de nuestras matrices nosotros mismos. Entonces falta algo. 92 Capítulo 4 Machine Translated by Google Si hemos tenido experiencia trabajando con cadenas en la biblioteca estándar C/C++, ya sabremos el ingrediente que falta, pero si no lo sabemos, podemos razonarlo rápidamente. Recuerda que una de nuestras técnicas de resolución de problemas es buscar analogías. Quizás deberíamos pensar en otros problemas en los que se desconociera la longitud de algo. En el Capítulo 2, procesamos códigos de identificación con un número arbitrario de dígitos para el problema de “Validación de suma de comprobación de Luhn”. En ese problema, no sabíamos cuántos dígitos ingresaría el usuario. Al final, escribimos un bucle while que continuó hasta que el último carácter leído fue el final de la línea. Desafortunadamente, no hay ningún carácter de fin de línea esperándonos al final de nuestras matrices. Pero ¿qué pasa si ponemos un carácter de fin de línea en el último elemento de todos nuestros conjuntos de cadenas? Luego podríamos descubrir la longitud de nuestras matrices de la misma manera que descubrimos cuántos dígitos había en los códigos de identificación. El único inconveniente de este enfoque es que ya no podemos usar el carácter de fin de línea en nuestras cadenas, excepto como terminador de cadena. Esto no es necesariamente una gran restricción, dependiendo de cómo se usarán las cadenas, pero para una máxima flexibilidad, sería mejor elegir un valor que no pueda confundirse con ningún carácter que alguien quiera usar. Por lo tanto, usaremos un cero para terminar nuestras matrices porque un cero representa un carácter nulo en ASCII y otros sistemas de código de caracteres. Este es exactamente el método utilizado por la biblioteca estándar C/C++. Una vez aclarado ese problema, seamos más específicos sobre qué hará append con nuestros datos de muestra. Sabemos que nuestra función tendrá dos parámetros, el primero será una cadena de matriz, un puntero a una matriz de caracteres en el montón, y el segundo será el carácter que se agregará. Para mantener las cosas claras, sigamos adelante y escribamos el esquema de la función de agregar y el código para probarla. anexar vacío ( arrayString& s, char c) { } anular appendTester() { arrayString a = nuevo carácter[5]; a[0] = 't'; a[1] = 'e'; a[2] = 's'; a[3] = 't'; a[4] = 0; append(a, '!'); cout << a << "\n"; } La función appendTester asigna nuestra cadena en el montón . Tenga en cuenta que el tamaño de la matriz es cinco, lo cual es necesario para que podamos asignar las cuatro letras de la palabra prueba junto con nuestro carácter nulo final . Luego llamamos append , que en este punto es solo un shell vacío. Cuando escribí el shell, me di cuenta de que el parámetro arrayString tenía que ser una referencia (&) porque la función creará una nueva matriz en el montón. Después de todo, ese es el objetivo de usar memoria dinámica aquí: crear una nueva matriz cada vez que se cambia el tamaño de la cadena. Por lo tanto, el valor que tiene la variable a cuando se pasa para agregar no es el mismo valor que debería tener cuando finaliza la función, porque necesita apuntar a una nueva matriz. Tenga en cuenta que debido a que nuestras matrices utilizan la terminación de carácter nulo esperada por las bibliotecas estándar, podemos enviar la matriz a la que hace referencia el puntero a directamente al flujo de salida para verificar el valor . Resolver problemas con punteros y memoria dinámica 93 Machine Translated by Google La Figura 4­4 muestra nuestra nueva comprensión de lo que hará la función con nuestro caso de prueba. Los terminadores de matriz están en su lugar y se muestran como NULL para mayor claridad. En el estado posterior a (b), está claro que s apunta a una nueva asignación de memoria. La matriz anterior ahora está en un cuadro sombreado; En estos diagramas, uso cuadros sombreados para indicar la memoria que se ha desasignado. Incluir la memoria asignada en nuestros diagramas nos ayuda a recordarnos que debemos realizar la desasignación. s t Es s t NULO t Es s t NULO t t! mi s ! C (a) s ! C NULO (b) Figura 4­4: Estados de memoria actualizados y elaborados antes (a) y después (b) de la función de agregar Con todo correctamente visualizado, podemos escribir esta función: anexar vacío (arrayString& s, char c) { int longitud antigua = 0; mientras (s[longitudantigua] != 0) { antiguoLongitud++; } arrayString newS = nuevo carácter[oldLength + 2]; for (int i = 0; i < longitudantigua; i++) { nuevoS[i] = s[i]; } newS[oldLength] = c; nuevaS[antiguaLongitud + 1] = 0; eliminar[] s; s = nuevoS; } Están sucediendo muchas cosas en este código, así que veámoslo pieza por pieza. Al comienzo de la función, tenemos un bucle para localizar el carácter nulo que termina nuestro array . Cuando se complete el ciclo, oldLength será el número de caracteres legítimos en la matriz (es decir, sin incluir el carácter nulo final). Asignamos la nueva matriz del montón con un tamaño de oldLength + 2 . Este es uno de esos detalles que es difícil de mantener claro si lo estás descifrando todo mentalmente, pero que es fácil de entender si tienes un diagrama. Siguiendo el código de nuestro ejemplo en la Figura 4­5, vemos que oldLength 94 Capítulo 4 Machine Translated by Google Serían cuatro en este caso. Sabemos que oldLength sería cuatro porque la prueba tiene cuatro caracteres y que la nueva matriz en la parte (b) requiere seis caracteres porque necesitamos espacio para el carácter agregado y el terminador nulo. Con la nueva matriz asignada, copiamos todos los caracteres legítimos de la matriz anterior a la nueva , y luego asignamos el carácter agregado y el terminador de carácter nulo a sus ubicaciones apropiadas en la nueva matriz. Nuevamente, nuestro diagrama nos ayuda a mantener las cosas en orden. Para aclarar aún más las cosas, la Figura 4­5 muestra cómo se calculó el valor de oldLength y qué posición indicaría ese valor en la nueva matriz. Con ese recordatorio visual, es fácil corregir los subíndices en esas dos tareas. declaraciones. antiguoLongitud = 4 s C t Es s t NULO t Es s t NULO t t! mi s ! (a) s C ! NULO 012 3 4 5 (b) antiguoLongitud Figura 4­5: Muestra la relación de una variable local, parámetros y memoria asignada antes y después de la función de agregar Las últimas tres líneas de la función de agregar tratan sobre el cuadro sombreado en la parte (b) de la figura. Para evitar una pérdida de memoria, tenemos que desasignar la matriz en el montón al que nuestro parámetro s originalmente apuntaba a nuestra función con s apuntando a la nueva matriz más larga . Finalmente, dejamos . Desafortunadamente, una de las razones por las que las pérdidas de memoria son tan comunes en la programación en C++ es que hasta que la cantidad total de pérdidas de memoria sea grande, el programa y el sistema en general no mostrarán efectos nocivos. Por lo tanto, las fugas pueden pasar totalmente desapercibidas para los programadores durante las pruebas. Por lo tanto, como programadores debemos ser diligentes y considerar siempre la vida útil de nuestras asignaciones de memoria dinámica. Cada vez que utilice la palabra clave nuevo, piense dónde y cuándo eliminar correspond ocurrira. Observe cómo todo en esta función se deriva directamente de nuestros diagramas. La programación complicada se vuelve mucho menos complicada con buenos diagramas, y desearía que más programadores nuevos se tomaran el tiempo para dibujar antes de codificar. Esto se remonta a nuestro principio más fundamental de resolución de problemas: tener siempre un plan. Un diagrama bien dibujado para un ejemplo de problema es como tener un Resolución de problemas con punteros y memoria dinámica 95 Machine Translated by Google ruta trazada hacia su destino antes de comenzar un largo viaje de vacaciones. Es un poco de esfuerzo extra al principio para evitar potencialmente mucho más esfuerzo y frustración al final. CREAR DIAGRAMAS Todo lo que necesitas para dibujar un diagrama es lápiz y papel. Sin embargo, si tienes tiempo, te recomendaría utilizar un programa de dibujo. Existen herramientas de dibujo con plantillas específicas para problemas de programación, pero cualquier programa de dibujo general basado en vectores le ayudará a empezar (el término vector aquí significa que el programa funciona con líneas y curvas y no es un programa de caja de pintura como Photoshop). Hice las ilustraciones originales de este libro usando un programa llamado Inkscape, que está disponible gratuitamente. Crear los diagramas en su computadora le permite mantenerlos organizados en el mismo lugar donde almacena el código que ilustran los diagramas. También es probable que los diagramas sean más claros y, por lo tanto, más fáciles de entender si regresa a ellos después de una ausencia. Finalmente, es fácil copiar y modificar un diagrama creado por computadora, como lo hice cuando creé la Figura 4­5 a partir de la Figura 4­4, y si desea hacer algunas anotaciones temporales rápidas, siempre puede imprimir una copia para garabatear. en. Volviendo a nuestra función de agregar , el código parece sólido, pero recuerde que basamos este código en un caso de muestra particular. Por lo tanto, no debemos ser arrogantes y asumir que el código funcionará en todos los casos válidos. En particular, debemos comprobar si hay casos especiales. En programación, un caso especial es una situación en la que datos válidos harán que el flujo normal de código produzca resultados erróneos. Tenga en cuenta que este problema es distinto del de los datos incorrectos, como los datos fuera de rango. En el código de este libro, hemos asumido buenos datos de entrada para programas y funciones individuales. Por ejemplo, si el programa espera una serie de números enteros separados por comas, asumimos que eso es lo que obtiene el programa, no caracteres extraños, no números, etc. Esta suposición es necesaria para mantener una longitud de código razonable y evitar repetir el mismo código de verificación de datos una y otra vez. En el mundo real, sin embargo, debemos tomar precauciones razonables contra malas entradas. Esto se conoce como robustez. Un programa robusto funciona bien incluso con malas entradas. Por ejemplo, un programa de este tipo podría mostrar un mensaje de error al usuario en lugar de fallar. Comprobación de casos especiales Miremos addend nuevamente, comprobando casos especiales; en otras palabras, asegurándonos de que no tengamos situaciones extrañas entre los posibles buenos valores de entrada. Los culpables más comunes de casos especiales se encuentran en los extremos, como la entrada más pequeña o más grande posible. Con append, no hay un tamaño máximo para nuestra matriz de cadenas, pero sí un tamaño mínimo. Si la cadena no tiene caracteres legítimos, en realidad correspondería a una matriz de un carácter (siendo ese carácter el carácter de terminación nula). Como antes, hagamos un diagrama para mantener las cosas claras. Supongamos que agregamos el signo de exclamación a una cadena nula, como se muestra en la Figura 4­6. 96 Capítulo 4 Machine Translated by Google longitud antigua = 0 NULO s C ! (a) NULO s C ! ! NULO 01 (b) antiguoLongitud Figura 4­6: Prueba del caso más pequeño para la función de agregar Cuando miramos el diagrama, este no parece ser un caso especial, pero deberíamos ejecutar el caso a través de nuestra función para comprobarlo. Agreguemos lo siguiente a nuestro código appendTester : arrayString b = nuevo carácter[1]; b[0] = 0; añadir(b, '!'); cout << b << "\n"; Eso también funciona. Ahora que estamos razonablemente seguros de que la función de agregar es correcta, ¿nos gusta? El código parecía sencillo y no siento ningún “malo olor”, pero parece un poco largo para una operación simple. Mientras pienso en la función de concatenar , se me ocurre que, al igual que anexar, la función de concatenar necesitará determinar la longitud de una matriz de cadenas, o tal vez las longitudes de dos matrices de cadenas. Debido a que ambas operaciones necesitarán un bucle que encuentre el carácter nulo que termina la cadena, podríamos poner ese código en su propia función, que luego se llama desde agregar y concatenar según sea necesario. Sigamos adelante y hagamos eso y modifiquemos el anexo en consecuencia. longitud int (cadena de matriz s) { int recuento = 0; mientras (s[cuenta] != 0) { contar++; } recuento de devoluciones; } anexar vacío (arrayString& s, char c) { int oldLength = longitud(es); arrayString newS = nuevo carácter[oldLength + 2]; for (int i = 0; i < longitudantigua; i++) { nuevoS[i] = s[i]; } Resolución de problemas con punteros y memoria dinámica 97 Machine Translated by Google nuevoS[antiguoLongitud] = c; nuevoS[antiguoLongitud + 1] = 0; eliminar[] s; s = nuevoS; } El código en la función de longitud es esencialmente el mismo código que anteriormente comenzó la función de agregar . En la función de agregar , reemplazamos ese código con una llamada a la longitud . La función de longitud es lo que se conoce como función auxiliar, una función que encapsula una operación común a varias otras funciones. Además de reducir la longitud de nuestro código, la eliminación del código redundante significa que nuestro código es más confiable y más fácil de modificar. También nos ayuda a resolver problemas porque las funciones auxiliares dividen nuestro código en fragmentos más pequeños, lo que nos facilita reconocer oportunidades para la reutilización del código. Copiar cadenas asignadas dinámicamente Ahora es el momento de abordar esa función de concatenación . Tomaremos el mismo enfoque que hicimos con append. Primero, escribiremos una versión de shell vacía de la función para tener los parámetros y sus tipos directamente en nuestra cabeza. Luego, haremos un diagrama de un caso de prueba y, finalmente, escribiremos código que coincida con nuestro diagrama. Aquí está el shell de la función, junto con el código de prueba adicional: concatenar vacío ( arrayString& s1, arrayString s2) { } vacío concatenateTester() { arrayString a = nuevo carácter[5]; a[0] = 't'; a[1] = 'e'; a[2] = 's'; a[3] = 't'; a[4] = 0; arrayString b = nuevo carácter[4]; b[0] = 'b'; b[1] = 'e'; b[2] = 'd'; b[3] = 0; concatenar(a, b); } Recuerde que la descripción de esta función dice que los caracteres de la segunda cadena (el segundo parámetro) se agregan al final de la primera cadena. Por lo tanto, el primer parámetro a concatenar será un parámetro de referencia , por el mismo motivo que el primer parámetro de append. Sin embargo, la función no debe cambiar el segundo parámetro , por lo que será un parámetro de valor. Ahora para nuestro caso de muestra: estamos concatenando las cadenas test y bed. El diagrama de antes y después se muestra en la Figura 4­7. Los detalles del diagrama deberían resultarle familiares gracias a la función de agregar . Aquí, para concatenar, comenzamos con dos matrices asignadas dinámicamente en el montón, a las que apuntan nuestros dos parámetros, s1 y s2. Cuando la función esté completa, s1 apuntará a una nueva matriz en el montón que tiene nueve caracteres de largo. La matriz a la que s1 apuntaba anteriormente ha sido desasignada; s2 y su matriz no cambian. Si bien puede parecer inútil incluir s2 y el conjunto de camas en nuestro diagrama, cuando intentamos evitar errores de codificación, realizar un seguimiento de lo que no cambia es tan importante como realizar un seguimiento de lo que sí cambia. También numeré los elementos de las matrices antiguas y nuevas, ya que eso fue útil con el anexo función. Todo está en su lugar ahora, así que escribamos esta función. 98 Capítulo 4 Machine Translated by Google s1 s2 t Es s t b Es d NULO t Es s t NULO t Es s t b NULO 0123 (a) s1 s2 Es d NULO 0123456 7 b Es d NULO (b) Figura 4­7: Muestra los estados “antes” (a) y “después” (b) del método de concatenación concatenar vacío (arrayString& s1, arrayString s2) { int s1_OldLength = longitud(s1); int s2_Longitud = longitud(s2); int s1_NewLength = s1_OldLength + s2_Length; arrayString newS = nuevo carácter[s1_NewLength + 1]; for(int i = 0; i < s1_OldLength; i++) { nuevoS[i] = s1[i]; } para(int i = 0; i < s2_Length; i++) { nuevoS[ s1_OldLength + i] = s2[i]; } newS[s1_NewLength] = 0; eliminar[] s1; s1 = nuevoS; } Primero, determinamos las longitudes de ambas cadenas que estamos concatenando , y luego sumamos esos valores para obtener la longitud que tendrá la cadena concatenada cuando hayamos terminado. Recuerde que todas estas longitudes son para la cantidad de caracteres legítimos, sin incluir el terminador nulo. Por lo tanto, cuando creamos la matriz en el montón para almacenar la nueva cadena , asignamos una longitud más que la longitud combinada para tener un espacio para el terminador. Luego copiamos los caracteres de las dos cadenas originales a la nueva cadena . El primer ciclo es sencillo, pero observe el cálculo del subíndice en el segundo ciclo . Estamos copiando desde el principio del s2 a la mitad de newS; Este es otro ejemplo más de traducción de un rango de valores a otro rango de valores, lo que hemos estado haciendo en Al observar los números de elementos en mi diagrama, puedo ver qué variables necesito juntar para calcular el subíndice de destino correcto. El resto de la función coloca el terminador nulo al final de la nueva cadena . Al igual que con append, desasignamos la memoria del montón original señalada por nuestro primer parámetro y redireccionamos el primer parámetro a la cadena recién asignada . Resolución de problemas con punteros y memoria dinámica 99 Machine Translated by Google Este código parece funcionar, pero como antes, queremos asegurarnos de no haber creado inadvertidamente una función que tenga éxito en nuestro caso de prueba, pero no en todos los casos. Los casos de problemas más probables serían cuando uno o ambos parámetros son cadenas de longitud cero (solo el terminador nulo). Deberíamos comprobar estos casos explícitamente antes de continuar. Tenga en cuenta que cuando verifica la corrección del código que utiliza punteros, debe tener cuidado de observar los punteros en sí y no solo los valores en el montón al que hacen referencia. Aquí hay un caso de prueba: arrayString a = nuevo carácter[5]; a[0] = 't'; a[1] = 'e'; a[2] = 's'; a[3] = 't'; a[4] = 0; arrayString c = nuevo carácter[1]; c[0] = 0; concatenar(c, a); cout << a << "\n" << c << "\n"; cout << (void *) a << "\n" << (void *) c << "\n"; Quería estar seguro de que la llamada para concatenar dé como resultado que a y c apunten a la prueba de cadena, es decir, que apunten a matrices con valores idénticos. Sin embargo, es igualmente importante que apunten a cadenas diferentes , como se muestra en la Figura 4­8 (a). Compruebo esto en la segunda declaración de salida cambiando los tipos de variables a void *, lo que obliga al flujo de salida a mostrar el valor bruto de los punteros . Si los propios punteros tuvieran el mismo valor, entonces diríamos que se han entrecruzado, como se muestra en la Figura 4­8 (b). Cuando los punteros, sin saberlo, se entrecruzan, ocurren problemas sutiles porque cambiar el contenido de una variable en el montón cambia misteriosamente otra variable; en realidad la misma variable, pero en un programa grande, eso puede ser difícil de ver. Además, recuerde que si dos punteros tienen enlaces cruzados, cuando uno de ellos se desasigna mediante eliminación, el puntero restante se convierte en una referencia colgante. Por lo tanto, debemos ser diligentes cuando revisamos nuestro código y comprobar siempre posibles enlaces cruzados. a t Es s t NULO C t Es s t NULO t Es s t NULO (a) a C (b) Figura 4­8: la concatenación debería dar como resultado dos cadenas distintas (a), no dos punteros entrecruzados (b). Con las tres funciones implementadas:characterAt, append y concatenar: hemos completado el problema. 100 Capítulo 4 Machine Translated by Google Listas enlazadas Ahora vamos a intentar algo más complicado. Las manipulaciones del puntero serán más complicadas, pero mantendremos todo en orden ahora que sabemos cómo generar los diagramas. PROBLEMA: SEGUIMIENTO DE UN DESCONOCIDO CANTIDAD DE EXPEDIENTES ESTUDIANTILES En este problema, escribirá funciones para almacenar y manipular una colección de registros de estudiantes. Un registro de estudiante contiene un número de estudiante y una calificación, ambos números enteros. Se deben implementar las siguientes funciones: addRecord Esta función toma un puntero a una colección de registros de estudiantes (un número de estudiante y una calificación) y agrega un nuevo registro con estos datos a la colección. AverageRecord Esta función toma un puntero a una colección de registros de estudiantes y devuelve el promedio simple de las calificaciones de los estudiantes en la colección como un doble. La colección puede ser de cualquier tamaño. Se espera que la operación addRecord sea Se llama con frecuencia, por lo que debe implementarse de manera eficiente. Varios enfoques cumplirían con las especificaciones, pero elegiremos un método que nos ayude a practicar nuestras técnicas de resolución de problemas basadas en punteros: listas enlazadas. Es posible que ya haya visto una lista vinculada antes, pero si no, sepa que la introducción de listas vinculadas representa una especie de cambio radical con respecto a lo que hemos discutido hasta ahora en este texto. Un buen solucionador de problemas podría haber desarrollado cualquiera de las soluciones anteriores si hubiera tenido suficiente tiempo y una reflexión cuidadosa. Sin embargo, a la mayoría de los programadores no se les ocurriría el concepto de lista enlazada sin ayuda. Sin embargo, una vez que lo veas y domines los conceptos básicos, te vendrán a la mente otras estructuras vinculadas y entonces estarás listo y funcionando. Una lista enlazada es verdaderamente una estructura dinámica. Nuestras matrices de cadenas se almacenaban en una memoria asignada dinámicamente, pero una vez creadas, eran estructuras estáticas que nunca se hacían más grandes ni más pequeñas, simplemente eran reemplazadas. Una lista enlazada, por el contrario, crece pieza a pieza con el tiempo como una cadena. Construyendo una lista de nodos Construyamos una lista enlazada de muestra de registros de estudiantes. Para crear una lista vinculada, necesita una estructura que contenga un puntero a la misma estructura, además de cualquier dato que desee almacenar en la colección representada por la lista vinculada. Para nuestro problema, la estructura contendrá un número de estudiante y una calificación. estructura listaNodo { int númeroestudiante; grado internacional; listaNodo * próximo; }; typedef listNode * StudentCollection; El nombre de nuestra estructura es listNode . Una estructura utilizada para crear una lista vinculada siempre se denomina nodo. Presumiblemente el nombre es una analogía con el Resolución de problemas con punteros y memoria dinámica 101 Machine Translated by Google Término botánico, que significa un punto en un tallo desde el cual crece una nueva rama. El nodo contiene el número de estudiante y la calificación que conforman la "carga útil" real del nodo. El nodo también contiene un puntero al mismo tipo de estructura que estamos definiendo . La primera vez que la mayoría de los programadores ven esto, les parece confuso y tal vez incluso una imposibilidad sintáctica: ¿Cómo podemos definir una estructura en términos de sí misma? Pero esto es legal y el significado quedará claro en breve. Tenga en cuenta que el puntero de autorreferencia en un nodo normalmente recibe un nombre como next, nextPtr o similar. Por último, este código declara una definición de tipo para un puntero a nuestro tipo de nodo . Esto ayudará a la legibilidad de nuestras funciones. Ahora construyamos nuestra lista enlazada de muestra usando estos tipos: colección de estudiantes sc; listaNodo * nodo1 = nuevo listaNodo; nodo1­>numeroestudiante = 1001; nodo1­>grado = 78; listNode * node2 = nuevo listNode; nodo2­>numestudiante = 1012; nodo2­>grado = 93; listNode * node3 = nuevo listNode; nodo3­>numestudiante = 1076; nodo3­>grado = 85; sc = nodo1; nodo1­>siguiente = nodo2; nodo2­>siguiente = nodo3; nodo3­>siguiente = NULL; nodo1 = nodo2 = nodo3 = NULL; Comenzamos declarando una colección de estudiantes, sc nombre de nuestra lista vinculada. Luego declaramos nodo1 , que eventualmente se convertirá en el , un puntero a un nodo de lista. Nuevamente, StudentCollection es sinónimo de nodo *, pero para facilitar la lectura, estoy usando el tipo StudentCollection solo para variables que se referirán a toda la estructura de la lista. Después de declarar el nodo1 y apuntarlo a un nodo de lista recién asignado en el montón campos en ese nodo , asignamos valores a StudentNum y Grade. . En este punto, el siguiente campo no está asignado. Este no es un libro sobre sintaxis, pero si no ha visto la notación ­> antes, se usa para indicar el campo de una estructura (o clase) apuntada . Entonces node1­>studentNum significa “el campo StudentNum en la estructura apuntada por node1” y es equivalente a (*node1).studentNum. Luego repetimos el mismo proceso para el nodo2 y el nodo3. Después de asignar los valores de campo al último nodo, el estado de la memoria se muestra en la Figura 4­9. En estos diagramas, usaremos la notación de caja dividida que usamos anteriormente para las matrices para mostrar la estructura del nodo. Carolina del Sur número de estudiante ? calificación 1001 78 ? 1012 93 ? 1076 85 ? próximo nodo1 nodo2 nodo3 estructura listNode Figura 4­9: A mitad de la construcción de una lista enlazada de muestra 102 Capítulo 4 Machine Translated by Google Ahora que tenemos todos nuestros nodos, podemos unirlos para formar una lista vinculada. Eso es lo que hace el resto del listado de códigos anterior. Primero, apuntamos nuestra variable StudentCollection al primer nodo del primer nodo al segundo nodo al tercer nodo , luego apuntamos el siguiente campo , y luego apuntamos el siguiente campo del segundo nodo . En el siguiente paso, asignamos NULL (nuevamente, esto es solo un sinónimo de cero) al siguiente campo del tercer nodo . Hacemos esto por la misma razón que pusimos un carácter nulo al final de nuestras matrices en el problema anterior: para terminar la estructura. Así como necesitábamos un carácter especial para mostrarnos el final de la matriz, necesitamos un cero en el siguiente campo del último nodo de nuestra lista vinculada para que sepamos que es el último nodo. Finalmente, para limpiar las cosas y evitar posibles problemas de entrecruzamiento, asignamos NULL a cada uno de los punteros de nodo individuales . El estado resultante de la memoria se muestra en la Figura 4­10. 1001 78 Carolina del Sur nodo1 NULO nodo2 NULO 1012 93 nodo3 NULO 1076 85 NULO Figura 4­10: Lista enlazada de muestra completa Con esta imagen frente a nosotros, queda claro por qué la estructura se llama vinculada. lista: cada nodo de la lista está vinculado al siguiente. A menudo verás listas enlazadas dibujado linealmente, pero en realidad prefiero el aspecto de memoria dispersa de este diagrama porque enfatiza que estos nodos no tienen ninguna relación entre sí además de los enlaces; cada uno de ellos podría estar en cualquier lugar dentro del montón. Asegúrese de rastrear el código hasta que esté seguro de que está de acuerdo con el diagrama. Observe que, en el estado final, solo queda un puntero basado en pila. en uso, nuestra variable sc de StudentCollection , que apunta al primer nodo. Un puntero externo a la lista (es decir, no al siguiente campo de un nodo en la lista) que apunta al primer nodo en una lista vinculada se conoce como puntero principal. A nivel simbólico, esta variable representa la lista en su conjunto, pero por supuesto hace referencia directa sólo al primer nodo. Para llegar al segundo nodo, tenemos que pasar por el primero, y para llegar al tercer nodo, tenemos que pasar por los dos primeros, y así sucesivamente. Esto significa que las listas enlazadas ofrecen sólo acceso secuencial, a diferencia del acceso aleatorio que proporcionan las matrices. El acceso secuencial es la debilidad de las estructuras de listas enlazadas. La fortaleza de las estructuras de listas enlazadas, como se mencionó anteriormente, es nuestra capacidad de aumentar o reducir el tamaño de la estructura agregando o eliminando nodos, sin tener que crear una estructura completamente nueva y copiar los datos, como lo hacemos. He terminado con las matrices. Resolver problemas con punteros y memoria dinámica 103 Machine Translated by Google Agregar nodos a una lista Ahora implementemos la función addRecord . Esta función creará un nuevo nodo y lo conectará a una lista vinculada existente. Usaremos las mismas técnicas que usamos en el problema anterior. Primero: un shell de función y una llamada de muestra. Para realizar pruebas, agregaremos código al listado anterior, de modo que sc ya exista como puntero principal a la lista de tres nodos. void addRecord(colecciónestudiante& sc, int stuNum, int gr) { } addRecord(sc, 1274, 91); Nuevamente, la llamada vendría al final del listado anterior. Con el shell de función describiendo los parámetros, podemos diagramar el estado "antes" de esta llamada, como se muestra en la Figura 4­11. 1001 78 Carolina del Sur estuNum 1274 1012 93 91 gramo 1076 85 NULO Figura 4­11: El estado "antes" de la función addRecord Sin embargo, en cuanto al estado “después”, tenemos una opción. Podemos adivinar que vamos a crear un nuevo nodo en el montón y copiar los valores de los parámetros stuNum y gr en los campos StudentNum y Grade del nuevo nodo. La pregunta es dónde va a ir, lógicamente, este nodo en nuestra lista enlazada. La elección más obvia estaría al final; hay un valor NULL en el siguiente campo que simplemente solicita que se le apunte a un nuevo nodo. Eso correspondería a la Figura 4­12. 1001 78 Carolina del Sur estuNum 1274 gramo 91 1274 91 NULO 1012 93 1076 85 Figura 4­12: Estado "después" propuesto para la función addRecord Pero si podemos suponer que el orden de los registros no importa (que no necesitamos mantener los registros en el mismo orden en que se agregaron a la colección), entonces esta es la elección incorrecta. Para ver por qué, consideremos una colección, no de tres expedientes de estudiantes, sino de 3.000. Para llegar al último registro de nuestra lista vinculada y modificar su siguiente campo sería necesario viajar a través de los 3.000 nodos. Esto es inaceptablemente ineficiente porque podemos incluir el nuevo nodo en la lista sin pasar por ninguno de los nodos existentes. 104 Capítulo 4 Machine Translated by Google La Figura 4­13 muestra cómo. Una vez creado el nuevo nodo, se vincula a la lista al principio, no al final. En el estado "después", nuestro puntero principal sc apunta al nuevo nodo, mientras que el siguiente campo del nuevo nodo apunta al que anteriormente era el primer nodo de la lista, el que tiene el número de estudiante 1001. Tenga en cuenta que si bien asignamos un valor al siguiente campo del nuevo nodo, el único puntero existente que cambia es sc, y ninguno de los valores de los nodos existentes se modifica ni siquiera se inspecciona. Trabajando desde nuestro diagrama, aquí está el código: void addRecord(colecciónestudiante& sc, int stuNum, int gr) { listNode * newNode = nuevo listNode; nuevoNodo­>NumEstudiante = NumEstudiante; nuevoNodo­>grado = gr; nuevoNodo­>siguiente = sc; sc = nuevoNodo; } 1274 91 1001 78 Carolina del Sur estuNum gramo 1274 1012 93 91 1076 85 NULO Figura 4­13: Estado "después" aceptable para la función addRecord . La flecha discontinua indica el valor anterior del puntero almacenado en sc. Nuevamente, permítame enfatizar que traducir un diagrama y ese código es mucho más fácil que tratar de mantener las cosas claras en su cabeza. El código proviene directamente de la ilustración. Creamos un nuevo nodo a partir de los parámetros y asignamos el número de estudiante y la calificación . Luego vinculamos el nuevo nodo a la lista, primero apuntando el siguiente campo del nuevo nodo al primer nodo anterior (asignándole el valor de sc) luego apuntando sc al nuevo nodo y . Tenga en cuenta que los dos últimos pasos deben realizarse en ese orden; Necesitamos usar el valor original de sc antes de cambiarlo. También tenga en cuenta que debido a que cambiamos sc, debe ser un parámetro de referencia. Como siempre, cuando creamos código a partir de un caso de muestra, tenemos que comprobar posibles casos especiales. Aquí, eso significa verificar que la función funcione con una lista vacía. Con nuestras matrices de cadenas, una cadena vacía seguía siendo un puntero válido porque todavía teníamos una matriz a la que señalar, una matriz con solo el carácter de terminación nula. Aquí, sin embargo, el número de nodos es el mismo que el número de registros, y una lista vacía sería un puntero de cabecera NULL . ¿Nuestro código seguirá vigente si intentamos insertar nuestros datos de muestra cuando el puntero principal entrante es NULL? La Figura 4­14 muestra el estado "antes" y el estado "después" deseado. Resolver problemas con punteros y memoria dinámica 105 Machine Translated by Google Recorriendo este ejemplo a través de nuestro código, vemos que maneja bien este caso. El nuevo nodo se crea igual que antes. Debido a que sc es NULL en el estado "antes", cuando NULO Carolina del Sur este estuNum valor se copia en el siguiente campo de nuestro 1274 91 gramo nuevo nodo, eso es exactamente lo que queremos (a) y nuestra lista de un nodo termina correctamente. Tenga en cuenta que si hubiéramos continuado con la otra idea de implementación (agregar el nuevo nodo al final de la lista vinculada en lugar de al principio), una lista inicialmente vacía 1274 91 NULO Carolina del Sur estuNum sería un caso especial porque entonces sería el gramo único caso en el que sc es modificado. 1274 91 (b) Figura 4­14: Los estados “antes” y “después” para el caso addRecord más pequeño Recorrido de lista Ahora es el momento de descubrir la función AverageRecord . Como antes, comenzaremos con un caparazón y un diagrama. Aquí está el shell de funciones y la llamada de muestra. Supongamos que la llamada de muestra ocurre después de la creación de nuestra lista de muestra original, como se muestra en la Figura 4­10. doble registro promedio (colección de estudiantes sc) { } int promedio = registropromedio(sc); Como puedes ver, elegí calcular el promedio como un int, como hicimos con las matrices en el capítulo anterior. Sin embargo, dependiendo del problema, podría ser mejor calcularlo como un valor de coma flotante. Ahora necesitamos un diagrama, pero prácticamente ya tenemos un estado "antes" con la Figura 4­9. No necesitamos un diagrama para el estado "después" porque esta función no cambiará nuestra estructura dinámica, solo informará sobre ello. Sólo necesitamos saber el resultado esperado, que en este caso es aproximadamente 85,3333. Entonces, ¿cómo calculamos realmente el promedio? A partir de nuestra experiencia en el cálculo del promedio de todos los valores de una matriz, conocemos el concepto general. Necesitamos sumar todos los valores de la colección y luego dividir esa suma por la cantidad de valores. Con nuestro código de promedio de matriz, inspeccionamos cada valor usando un bucle for de 0 a uno menor que el tamaño de la matriz, usando el contador de bucle como subíndice de la matriz. No podemos usar un bucle for aquí porque no sabemos de antemano cuántos números hay en la lista vinculada; Tenemos que continuar hasta alcanzar el valor NULL en el siguiente campo de un nodo que indica la terminación de la lista. Esto sugiere un bucle while , algo parecido a lo que usamos anteriormente en este capítulo para procesar nuestros arreglos de longitud desconocida. corriendo a través de un 106 Capítulo 4 Machine Translated by Google Una lista vinculada como esta, desde el principio hasta el final, se conoce como recorrido de lista. Esta es una de las operaciones básicas en una lista vinculada. Pongamos en práctica la idea transversal para resolver este problema: doble registro promedio (colección de estudiantes sc) { int recuento = 0; doble suma = 0; listNode * loopPtr = sc; mientras (loopPtr! = NULL) { suma += loopPtr­>grado; cuenta++; loopPtr = loopPtr­>siguiente; } doble promedio = suma / recuento; promedio de retorno; } Comenzamos declarando una variable count para almacenar el número de nodos que encontramos en la lista ; este también será el número de valores en la colección, que usaremos para calcular el promedio. A continuación declaramos una suma variable. para almacenar el total acumulado de valores de calificación en la lista listNode * llamado loopPtr, que usaremos para recorrer la lista . Luego declaramos un . Este es el equivalente de nuestra variable de bucle entero en un bucle for de procesamiento de matrices; realiza un seguimiento de dónde estamos en la lista vinculada, no con el número de posición sino almacenando un puntero al nodo que estamos procesando actualmente. En este punto comienza el recorrido propiamente dicho. El bucle transversal continúa hasta que nuestro puntero de seguimiento del bucle alcanza nuestro NULL terminal. Dentro del bucle, agregamos el valor del campo de calificación en el nodo al que se hace referencia actualmente para sumar . Incrementamos el recuento del nodo actual a nuestro puntero de seguimiento de bucle y luego asignamos el siguiente campo . Esto tiene el efecto de adelantar nuestro recorrido un nodo. Esta es la parte complicada del código, así que asegurémonos de tenerlo claro. En la Figura 4­15, muestro cómo la variable del nodo cambia con el tiempo. Las letras (a) a (d) marcan diferentes puntos durante la ejecución del código en nuestros datos de muestra, mostrando diferentes puntos durante la vida útil de loopPtr y las ubicaciones desde las cuales se obtuvo el valor de loopPtr . El punto (a) es justo cuando comienza el ciclo; loopPtr acaba de inicializarse con el valor de sc. Por lo tanto, loopPtr apunta al primer nodo de la lista, tal como lo hace sc . Entonces, durante la primera iteración del ciclo, el valor de calificación del primer nodo de 78 se suma a la suma. El siguiente valor del primer nodo se copia en loopPtr de modo que ahora loopPtr apunte al segundo nodo de la lista; este es el punto (b). Durante la segunda iteración, sumamos 93 a la suma y copiamos el siguiente campo del segundo nodo a loopPtr; Este es el punto (c). Finalmente, durante la tercera y última iteración del bucle, sumamos 85 a la suma y asignamos el NULL del siguiente campo en el tercer nodo a loopPtr; este es el punto (d). Cuando volvemos a llegar a la parte superior del bucle while , el bucle termina porque loopPtr es NULL. Debido a que incrementamos el conteo cada vez que iteramos, el conteo es tres. Resolver problemas con punteros y memoria dinámica 107 Machine Translated by Google (a) Carolina del Sur 1001 78 (b) (a) buclePtr 1012 93 (C) (b) buclePtr 1076 85 (C) buclePtr (d) NULO (d) buclePtr Figura 4­15: Cómo cambia la variable local loopPtr durante las iteraciones del bucle en la función AverageRecord Una vez que el ciclo está completo, simplemente dividimos la suma por el recuento y devolvemos el resultado . El código funciona en nuestro caso de muestra, pero como siempre, debemos verificar posibles casos especiales. Nuevamente, con las listas, el caso especial más obvio es una lista vacía. ¿Qué sucede con nuestro código si sc es NULL cuando comienza la función? ¿Adivina qué? El código explota. (Tuve que hacer que uno de estos casos especiales saliera mal; de lo contrario, no me tomarías en serio). No hay nada malo con el bucle para el procesamiento de la lista enlazada en sí. Si sc es NULL, entonces loopPtr se inicializa en NULL, el ciclo termina tan pronto como comienza y la suma se deja en cero, lo que parece bastante razonable. El problema es que cuando realizamos la división para calcular el promedio , el recuento también es cero, lo que significa que estamos dividiendo por cero y esto provocará un bloqueo del programa o un resultado basura. Para manejar este caso especial, podríamos comparar el conteo con cero al final de la función, pero ¿por qué no manejar la situación desde el principio y verificar sc? Agreguemos lo siguiente como nueva primera línea en nuestra función AverageRecord : si (sc == NULL) devuelve 0; Como muestra este ejemplo, manejar casos especiales suele ser bastante sencillo. Nosotros Sólo tenemos que asegurarnos de que nos tomemos el tiempo para identificarlos. Conclusión y próximos pasos Este capítulo apenas ha arañado la superficie de la resolución de problemas utilizando punteros y memoria dinámica. Verá punteros y asignaciones de montón a lo largo del resto de este texto. Por ejemplo, las técnicas de programación orientada a objetos, que analizaremos en el Capítulo 5, son especialmente útiles cuando se trata de punteros. Nos permiten encapsular punteros de tal manera que no tengamos que preocuparnos por pérdidas de memoria, punteros colgantes o cualquiera de los otros errores comunes de los punteros. 108 Capítulo 4 Machine Translated by Google Aunque hay mucho más que aprender sobre la resolución de problemas en esta área, podrá desarrollar sus habilidades con estructuras basadas en punteros de complejidad creciente si sigue las ideas básicas de este capítulo: Primero, aplique las reglas generales del problema. resolviendo. Luego, aplique reglas específicas para los punteros y use un diagrama o herramienta similar para visualizar cada solución antes de comenzar a codificar. Ejercicios No bromeo acerca de hacer los ejercicios. No estás simplemente leyendo los capítulos y siguiendo adelante, ¿verdad? 4­1. Diseñe el suyo propio: tome un problema que ya sepa resolver usando una matriz pero que esté limitado por el tamaño de la matriz. Vuelva a escribir el código para eliminar esa limitación utilizando una matriz asignada dinámicamente. 4­2. Para nuestras cadenas asignadas dinámicamente, cree una subcadena de función que tome tres parámetros: una cadena de matriz, un entero de posición inicial y una longitud entera de caracteres. La función devuelve un puntero a una nueva matriz de cadenas asignada dinámicamente. Esta matriz de cadenas contiene los caracteres de la cadena original, comenzando en la posición especificada para la longitud especificada. La cadena original no se ve afectada por la operación. Entonces, si la cadena original era abcdefg, la posición era 3 y la longitud era 4, entonces la nueva cadena contendría cdef. 4­3. Para nuestras cadenas asignadas dinámicamente, cree una función reemplazarCadena que tome tres parámetros, cada uno de tipo cadenamatriz: origen, destino y reemplazarTexto. La función reemplaza cada aparición de destino en el origen con reemplazarTexto. Por ejemplo, si la fuente apunta a una matriz que contiene abcdabee, el destino apunta a ab y replaceText apunta a xyz, cuando finalice la función, la fuente debe apuntar a una matriz que contenga xyzcdxyzee. 4­4. Cambie la implementación de nuestras cadenas de modo que la ubicación [0] en la matriz almacene el tamaño de la matriz (y por lo tanto la ubicación [1] almacene el primer carácter real de la cadena), en lugar de usar un terminador de carácter nulo. Implemente cada una de las tres funciones, agregar, concatenar y caractertAt, aprovechando la información de tamaño almacenada siempre que sea posible. Debido a que ya no usaremos la convención de terminación nula esperada por el flujo de salida estándar, necesitará escribir su propia función de salida que recorra su parámetro de cadena y muestre los caracteres. 4­5. Escriba una función removeRecord que tome un puntero a una colección de estudiantes y un número de estudiante y que elimine el registro con ese número de estudiante de la colección. 4­6. Creemos una implementación para cadenas que utilice una lista vinculada de caracteres en lugar de matrices asignadas dinámicamente. Entonces tendremos una lista vinculada donde la carga de datos es un solo carácter; esto permitirá que las cadenas crezcan sin tener que volver a crear la cadena completa. Comenzaremos implementando append y caracterAt funciones. Resolver problemas con punteros y memoria dinámica 109 Machine Translated by Google 4­7. Siguiendo con el ejercicio anterior, implemente la función concatenar . Tenga en cuenta que si hacemos una llamada concatenate(s1, s2), donde ambos parámetros son punteros a los primeros nodos de sus respectivas listas enlazadas, la función debería crear una copia de cada uno de los nodos en s2 y agregarlos al final de s1 . . Es decir, la función no debería simplemente apuntar el siguiente campo del último nodo de la lista de s1 al primer nodo de la lista de s2. 4­8. Agregue una función a la implementación de cadenas de lista vinculada llamada removeChars para eliminar una sección de caracteres de una cadena según la posición y la longitud. Por ejemplo, removeChars(s1, 5, 3) eliminaría los tres caracteres comenzando por el quinto carácter de la cadena. Asegúrese de que los nodos eliminados se desasignen correctamente. 4­9. Imagine una lista vinculada donde, en lugar de que el nodo almacene un carácter, el nodo almacena un dígito: un int en el rango de 0 a 9. Podríamos representar números positivos de cualquier tamaño utilizando dicha lista enlazada; el número 149, por ejemplo, sería una lista enlazada en la que el primer nodo almacena un 1, el segundo un 4 y el tercero y último un 9. Escribe una función intToList que tome un valor entero y produzca una lista enlazada de este clasificar. Sugerencia: Puede que le resulte más fácil crear la lista vinculada al revés, por lo que si el valor fuera 149, crearía el nodo 9 primero. 4­10. Para la lista de dígitos del ejercicio anterior, escriba una función que tome dos de esas listas y produzca una nueva lista que represente su suma. 110 Capítulo 4 Machine Translated by Google t l S YYPAG oh C A DY Y RESOLVIENDO PROBLEMAS CON CLASES En este capítulo, discutiremos las clases y la programación orientada a objetos. Como antes, se supone que has visto la clase . declaración en C++ y comprender los conceptos básicos sintaxis para crear una clase, invocar los métodos de una clase, etc. Haremos una revisión rápida en la siguiente sección, pero discutiremos principalmente los aspectos de resolución de problemas de las clases. Ésta es otra situación en la que creo que C++ tiene una ventaja sobre otros lenguajes. Debido a que C++ es un lenguaje híbrido, el programador de C++ puede crear clases cuando sea apropiado, pero nunca tiene que hacerlo. Por el contrario, en un lenguaje como Java o C#, todo el código debe aparecer dentro de los límites de una declaración de clase. En manos de programadores expertos, esto no causa ningún daño indebido, pero en manos de novatos puede generar malos hábitos. Para un programador de Java o C#, todo es un objeto. Si bien todo el código escrito en estos lenguajes debe encapsularse en objetos, el resultado no siempre refleja un diseño sensato orientado a objetos. Un objeto debe ser una colección significativa y muy unida de datos y código que opere con esos datos. No debería ser una bolsa arbitraria de sobras. Machine Translated by Google Debido a que estamos programando en C++ y por lo tanto podemos elegir entre programación procedimental y orientada a objetos, hablaremos sobre el buen diseño de clases, así como también sobre cuándo se deben y no se deben usar las clases. Reconocer una situación en la que una clase sería útil es esencial para alcanzar niveles superiores de estilo de programación, pero es igualmente importante reconocer situaciones en las que una clase empeorará las cosas. Revisión de los fundamentos de la clase Como siempre, este libro supone que usted tiene contacto previo con los fundamentos y referencias de la sintaxis de C++, pero revisemos los fundamentos de la sintaxis de clases para que estemos en la misma página con la terminología. Una clase es un modelo para construir un paquete particular de código y datos; cada variable creada según el modelo de una clase se conoce como un objeto de esa clase. El código fuera de una clase que crea y usa un objeto de esa clase se conoce como cliente. de la clase. Una declaración de clase nombra la clase y enumera todos los miembros o elementos dentro de esa clase. Cada elemento es un miembro de datos (una variable declarada dentro de la clase) o un método (también conocido como función miembro), que es una función declarada dentro de la clase. Las funciones miembro pueden incluir un tipo especial llamado constructor, que tiene el mismo nombre que la clase y se invoca implícitamente cuando se declara un objeto de la clase. Además de los atributos normales de una variable o declaración de función (como el tipo y, para las funciones, la lista de parámetros), cada miembro también tiene un especificador de acceso, que indica qué funciones pueden acceder al miembro. Se puede acceder a un miembro público mediante cualquier código utilizando el objeto: código dentro de la clase, un cliente de la clase o código en una subclase, que es una clase que "hereda" todo el código y los datos de una clase existente. Solo se puede acceder a un miembro privado mediante el código dentro de la clase. Los miembros protegidos, que veremos brevemente en este capítulo, son similares a los miembros privados, excepto que los métodos en las subclases también pueden hacer referencia a ellos. Sin embargo, tanto los miembros privados como los protegidos son inaccesibles desde el código del cliente. A diferencia de atributos como el tipo de retorno, el especificador de acceso dentro de la declaración de clase se mantiene hasta que se reemplaza por un especificador diferente. Por lo tanto, cada especificador suele aparecer sólo una vez, con los miembros agrupados por acceso. Esto lleva a los programadores a referirse a "la sección pública" o "la sección privada" de una clase, como en "Deberíamos poner este método en la sección privada". Veamos un pequeño ejemplo de declaración de clase: clase muestra { público: muestra(); muestra(int num); int hace algo (doble parámetro); privado: int intDatos; } ; Esta declaración comienza nombrando la clase , por lo que luego sample se convierte en un nombre de tipo. La declaración comienza con un especificador de acceso público 112 Capítulo 5 , por lo que hasta Machine Translated by Google llegamos al especificador privado , todo lo que sigue es público. Muchos programadores incluyen primero las declaraciones públicas, esperando que la interfaz pública sea de mayor interés para otros lectores. Las declaraciones públicas aquí son dos constructores ( y ) llamados muestra y otro método, hace algo . Los constructores se invocan implícitamente cuando se declaran objetos de esta clase. objeto de muestra1; objeto de muestra2(15); Aquí, el objeto1 invocaría al primer constructor , conocido como constructor predeterminado, que no tiene parámetros, mientras que el objeto2 invocaría al segundo constructor porque especifica un valor entero único y, por lo tanto, coincide con la firma del parámetro del segundo constructor. La declaración concluye con un miembro de datos privados, intData . Recuerde que una declaración de clase termina con una llave de cierre y un punto y coma . Este punto y coma puede parecer un poco misterioso porque no concluimos funciones, bloques de instrucciones if ni ninguna otra llave de cierre con punto y coma. La presencia del punto y coma en realidad indica que las declaraciones de clase también son, opcionalmente, declaraciones de objeto; Podríamos poner identificadores entre la llave de cierre y el punto y coma y crear objetos a medida que creamos nuestras clases. Sin embargo, esto no es muy común en C++, especialmente considerando que muchos programadores colocan sus definiciones de clases en archivos separados de los programas que las usan. El misterioso punto y coma también aparece después de la llave de cierre de una estructura . Hablando de estructura, debes saber que en C++, estructura y clase denotan casi lo mismo. La única diferencia entre los dos implica los miembros (datos o métodos) declarados antes del primer especificador de acceso. En una estructura, estos miembros serían públicos, mientras que en una clase serían privados. Sin embargo, los buenos programadores utilizan las dos estructuras de diferentes maneras. Esto es análogo a cómo cualquier bucle for podría escribirse como un bucle while , pero un buen programador puede hacer que el código sea más legible utilizando bucles for en bucles de conteo más sencillos. La mayoría de los programadores reservan struct para estructuras más simples, ya sea aquellas que no tienen miembros de datos más allá de los constructores o aquellas destinadas a usarse como parámetros para métodos de una clase más grande. Objetivos del uso de la clase Para reconocer las situaciones correctas e incorrectas para el uso de clases y la forma correcta e incorrecta de construir una clase, tenemos que decidir cuáles son nuestros objetivos al usar las clases en primer lugar. Al considerar esto, debemos recordar que las clases son siempre opcionales. Es decir, las clases no nos brindan nuevas capacidades como lo hacen una matriz o una estructura basada en punteros. Si toma un programa que utiliza una matriz para ordenar 10.000 registros, no será posible escribir ese mismo programa sin la matriz. Si tiene un programa que depende de la capacidad de una lista vinculada para crecer y reducirse con el tiempo, no podrá crear los mismos efectos con la misma eficiencia sin utilizar una lista vinculada o una estructura similar basada en punteros. Sin embargo, si elimina las clases de un programa orientado a objetos y las reescribe, el programa se verá diferente, pero las capacidades y eficiencia del programa no disminuirán. En efecto, Resolver problemas con las clases 113 Machine Translated by Google Los primeros compiladores de C++ funcionaban como preprocesadores. El compilador de C++ leería el código fuente de C++ y generaría una nueva fuente sobre la marcha que fuera sintaxis legal de C. Este código fuente modificado luego se enviaría a un compilador de C. Lo que esto nos dice es que las principales adiciones que C++ hizo al lenguaje C no se referían a las capacidades funcionales del lenguaje sino a cómo lee el código fuente al programador. Por lo tanto, al elegir nuestros objetivos generales de diseño de clases, elegimos objetivos que nos ayuden, como programadores, a realizar nuestras tareas. En particular, dado que este libro trata sobre la resolución de problemas, deberíamos pensar en cómo las clases nos ayudan a resolver problemas. Encapsulación La palabra encapsulación es una forma elegante de decir que las clases reúnen múltiples datos y códigos en un solo paquete. Si alguna vez ha visto una cápsula de gelatina llena de pequeñas esferas, esa es una buena analogía: el paciente toma una cápsula y traga todas las esferas de ingredientes individuales que contiene. La encapsulación es el mecanismo que permite que muchos de los otros objetivos que enumeramos a continuación tengan éxito, pero también es un beneficio en sí mismo porque organiza nuestro código. En una larga lista de programas de código puramente procedimental (en C++, esto significaría código con funciones pero sin clases), puede ser difícil encontrar un buen orden para nuestras funciones y directivas del compilador que nos permita recordar fácilmente sus ubicaciones. En cambio, nos vemos obligados a confiar en nuestro entorno de desarrollo para encontrar nuestras funciones por nosotros. La encapsulación mantiene unidas las cosas que van juntas. Si estás trabajando en un método de clase y te das cuenta de que necesitas mirar o modificar otro código, es probable que aparezca otro código en otro método de la misma clase y, por lo tanto, esté cerca. Reutilización de código Desde el punto de vista de la resolución de problemas, la encapsulación nos permite reutilizar más fácilmente el código de problemas anteriores para resolver los problemas actuales. A menudo, aunque hayamos trabajado en un problema similar a nuestro proyecto actual, reutilizar lo que aprendimos antes todavía requiere mucho trabajo. Una clase completamente encapsulada puede funcionar como una unidad USB externa; simplemente lo conectas y funciona. Sin embargo, para que esto suceda, debemos diseñar la clase correctamente para asegurarnos de que el código y los datos estén realmente encapsulados y sean lo más independientes posible de cualquier cosa fuera de la clase. Por ejemplo, una clase que hace referencia a una variable global no se puede copiar en un nuevo proyecto sin copiar también la variable global. Más allá de reutilizar clases de un programa a otro, las clases ofrecen el potencial de una forma más inmediata de reutilización de código: la herencia. Recuerde que en el Capítulo 4 hablamos sobre el uso de funciones auxiliares para “factorizar” el código común a dos o más funciones. La herencia lleva esta idea a una escala mayor. Usando la herencia, creamos clases principales con métodos comunes a dos o más clases secundarias, "factorizando" no sólo unas pocas líneas de código sino métodos completos. La herencia es un tema amplio en sí mismo y exploraremos esta forma de reutilización de código más adelante en este capítulo. 114 Capítulo 5 Machine Translated by Google Dividiendo el problema Una técnica a la que hemos regresado una y otra vez es dividir un problema complejo en partes más pequeñas y manejables. Las clases son excelentes para dividir programas en unidades funcionales. La encapsulación no sólo mantiene los datos y el código juntos en un paquete reutilizable; también aísla esos datos y código del resto del programa, permitiéndonos trabajar en esa clase y en todo lo demás por separado. Cuantas más clases hagamos en un programa, mayor será el efecto de división del problema. Entonces, cuando sea posible, debemos dejar que la clase sea nuestro método para dividir problemas complejos. Si las clases están bien diseñadas, se impondrá la separación funcional y el problema será más fácil de resolver. Como efecto secundario, podemos encontrar que las clases que creamos para un problema son reutilizables en otros problemas, incluso si no consideramos completamente esa posibilidad cuando las creamos. Ocultación de información Algunas personas usan los términos ocultación de información y encapsulación indistintamente, pero aquí separaremos las ideas. Como se describió anteriormente en este capítulo, la encapsulación consiste en empaquetar datos y código juntos. Ocultar información significa separar la interfaz de una estructura de datos (la definición de las operaciones y sus parámetros) de la implementación de una estructura de datos o el código dentro de las funciones. Si una clase se ha escrito con información oculta como objetivo, entonces es posible cambiar la implementación de los métodos sin requerir ningún cambio en el código del cliente (el código que usa la clase). Nuevamente debemos tener claro el término interfaz; esto significa no sólo el nombre de los métodos y su lista de parámetros sino también la explicación (quizás expresada en la documentación del código) de lo que hacen los diferentes métodos. Cuando hablamos de cambiar la implementación sin cambiar la interfaz, queremos decir que cambiamos cómo funcionan los métodos de clase pero no qué hacen. Algunos autores de programación se han referido a esto como una especie de contrato implícito entre la clase y el cliente: la clase se compromete a no cambiar nunca los efectos de las operaciones existentes, y el cliente se compromete a utilizar la clase estrictamente sobre la base de su interfaz e ignorarla. cualquier detalle de implementación. Piense en tener un control remoto universal que pueda controlar cualquier televisor, ya sea un modelo de tubo antiguo o uno que use una pantalla LCD o plasma. Presiona 2, luego 5, luego Enter, y cualquiera de las pantallas mostrará el canal 25, aunque el mecanismo para que esto suceda es muy diferente dependiendo de la tecnología subyacente. No hay forma de ocultar información sin encapsular, pero como hemos definido los términos, es posible encapsular sin ocultar información. La forma más obvia en que esto puede suceder es si los miembros de datos de una clase se declaran públicos. En tal caso, la clase sigue siendo una encapsulación, en el sentido de que es un paquete de código y datos que van juntos. Sin embargo, el código del cliente ahora tiene acceso a un detalle importante de implementación de la clase: las variables y tipos que la clase usa para almacenar sus datos. Incluso si el código del cliente no modifica los datos de la clase directamente y solo los inspecciona, el código del cliente Resolver problemas con las clases 115 Machine Translated by Google entonces requiere la implementación de esa clase en particular. Cualquier cambio en la clase que cambie el nombre o tipo de cualquiera de las variables a las que accede el código del cliente también requiere cambios en el código del cliente. Su primer pensamiento podría ser que la ocultación de información está asegurada siempre que todos los datos sean privados y dediquemos suficiente tiempo a diseñar la lista de funciones miembro y sus listas de parámetros para que nunca tengan que cambiar. Si bien todo esto es necesario para ocultar información, no es suficiente porque los problemas de ocultación de información pueden ser más sutiles. Recuerde que la clase acepta no cambiar lo que hace cualquiera de los métodos, independientemente de la situación. En capítulos anteriores, tuvimos que decidir el caso más pequeño que manejará una función o qué hacer con un caso anómalo, como encontrar el promedio de una matriz cuando el parámetro que almacena el tamaño de la matriz es cero. Cambiar el resultado de un método incluso en un caso extraño representa un cambio de la interfaz y debe evitarse. Ésta es otra razón por la que considerar explícitamente casos especiales es tan importante en la programación. Muchos programas han explotado cuando se ha actualizado su tecnología subyacente o su interfaz de programación de aplicaciones (API), y alguna llamada al sistema que solía devolver de forma fiable un –1 cuando uno de los parámetros era erróneo ahora devuelve un resultado aparentemente aleatorio, pero aún así. numero negativo. Una de las mejores formas de evitar este problema es indicar los resultados de casos especiales en la documentación de la clase o método. Si su propia documentación dice que devuelve un código de error –1 cuando ocurre una situación determinada, lo pensará dos veces antes de que su método devuelva algo más. Entonces, ¿cómo afecta el ocultamiento de información a la resolución de problemas? El principio de ocultación de información le dice al programador que deje de lado los detalles de implementación de la clase cuando trabaja en el código del cliente, o más ampliamente, que se preocupe por la implementación de una clase en particular sólo cuando trabaja dentro de esa clase. Cuando pueda dejar de pensar en los detalles de la implementación, podrá eliminar los pensamientos que le distraigan y concentrarse en resolver el problema en cuestión. Sin embargo, debemos ser conscientes de las limitaciones del ocultamiento de información en lo que respecta a la resolución de problemas. A veces, los detalles de implementación sí son importantes para el cliente. En capítulos anteriores, hemos visto las fortalezas y debilidades de algunas estructuras de datos basadas en matrices y punteros. Las estructuras basadas en matrices permiten el acceso aleatorio, pero no pueden crecer ni reducirse fácilmente, mientras que las estructuras basadas en punteros solo ofrecen acceso secuencial, pero se les pueden agregar o quitar piezas sin tener que volver a crear toda la estructura. Por lo tanto, una clase construida con una estructura basada en matrices como base tendrá cualidades diferentes a una basada en una estructura basada en punteros. En informática, a menudo hablamos del concepto de tipo de datos abstracto, que es información oculta en su forma más pura: un tipo de datos definido únicamente por sus operaciones. En el Capítulo 4, analizamos el concepto de pila y describimos cómo la pila de un programa es un bloque contiguo de memoria. Pero como tipo de datos abstracto, una pila es cualquier tipo de datos en el que se pueden agregar y eliminar elementos individuales, y los elementos se eliminan en el orden opuesto al que se agregaron. Esto se conoce como orden de último en entrar, primero en salir o LIFO. Nada requiere que una pila sea un bloque de memoria contiguo, y podríamos crear una pila usando una lista vinculada. Porque un bloque de memoria contiguo y un bloque vinculado 116 Capítulo 5 Machine Translated by Google list tiene propiedades diferentes, una pila que usa una implementación u otra también tendrá propiedades diferentes, y estas pueden marcar una gran diferencia para el cliente que usa la pila. El punto de todo esto es que ocultar información será un objetivo útil para nosotros como solucionadores de problemas, en la medida en que nos permita dividir problemas y trabajar en diferentes partes de un programa por separado. Sin embargo, no podemos permitirnos ignorar por completo los detalles de la implementación. Legibilidad Una buena clase mejora la legibilidad del programa en el que aparece. Los objetos pueden corresponder a cómo vemos el mundo real y, por lo tanto, las llamadas a métodos a menudo tienen una legibilidad similar al inglés. Además, la relación entre objetos suele ser más clara que la relación entre variables simples. Mejorar la legibilidad mejora nuestra capacidad para resolver problemas, porque podemos comprender nuestro propio código más fácilmente mientras está en desarrollo y porque la reutilización mejora cuando el código antiguo es fácil de seguir. Para maximizar el beneficio de legibilidad de las clases, debemos pensar en cómo Los métodos de nuestra clase se utilizarán en la práctica. Los nombres de los métodos deben elegirse con cuidado para reflejar el significado más específico de los efectos del método. Por ejemplo, considere una clase que representa una inversión financiera que contiene un método para calcular el valor futuro. El nombre calcular no transmite tanta información como calcularFutureValue. Incluso elegir la parte correcta del discurso para el nombre puede resultar útil. El nombre ComputeFutureValue es un verbo, mientras que FutureValue es un sustantivo. Observe cómo se utilizan los nombres en los ejemplos de código que aparecen a continuación: doble FV; inversión.computeFutureValue(FV, 2050); si (inversión.futureValue(2050) > 10000) { ... Si lo piensas bien, lo primero tiene más sentido para una llamada que sería independiente, es decir, una función nula en la que el valor futuro se envía de vuelta a la persona que llama a través de un parámetro de referencia . Esto último tiene más sentido para una llamada que se usaría en una expresión, es decir, el valor futuro regresa como el valor de la función . Veremos ejemplos específicos más adelante en el capítulo, pero el principio rector para maximizar la legibilidad es pensar siempre en el código del cliente cuando se escribe cualquier parte de la interfaz de clase. expresividad Un objetivo final de una clase bien diseñada es la expresividad, o lo que en términos generales podría llamarse capacidad de escritura: la facilidad con la que se puede escribir código. Una buena clase, una vez escrita, hace que el resto del código sea más sencillo de escribir de la misma manera que una buena función hace que el código sea más sencillo de escribir. Las clases amplían efectivamente el idioma, convirtiéndose en contrapartes de alto nivel de características básicas de bajo nivel, como Resolver problemas con las clases 117 Machine Translated by Google como bucles, declaraciones if , etc. En C++, incluso la funcionalidad central como la entrada y la salida no es una parte inherente de la sintaxis del lenguaje, sino que se proporciona como un conjunto de clases que deben incluirse explícitamente en el programa que la utiliza. Con las clases programar acciones que antes requerían muchos pasos se pueden realizar en pocos pasos o en uno solo. Como solucionadores de problemas, debemos hacer de este objetivo una prioridad especial. Siempre deberíamos estar pensando: "¿Cómo va a hacer esta clase que el resto de este programa y los programas futuros que puedan usar esta clase sean más fáciles de escribir?" Construyendo una clase simple Ahora que sabemos a qué objetivos deben apuntar nuestras clases, es hora de poner la teoría en práctica y construir algunas clases. Primero, desarrollaremos nuestra clase en etapas para usarla en el siguiente problema. PROBLEMA: LISTA DE CLASE Diseñar una clase o conjunto de clases para usar en un programa que mantiene una lista de clases. Para cada estudiante, almacene el nombre del estudiante, su identificación y su calificación final en el rango de 0 a 100. El programa permitirá agregar o eliminar registros de estudiantes; mostrar el expediente de un estudiante en particular, identificado por identificación, con la calificación mostrada como un número y una letra; y mostrar el puntaje promedio de la clase. La calificación con letras apropiada para una puntuación particular se muestra en la Tabla 5­1. Tabla 5­1: Calificaciones con letras Rango de puntuación Grado de la letra 93­100 A 90–92 A­ 87–89 B+ 83–86 B 80–82 B­ 77–79 C+ 73–76 C 70–72 C­ 67–69 D+ 60–66 D 0–59 F Comenzaremos viendo un marco de clases básico que forma la base. de la mayoría de las clases. Luego veremos formas en las que se amplía el marco básico. 118 Capítulo 5 Machine Translated by Google El marco de clase básico La mejor manera de explorar el marco de clases básico es a través de una clase de muestra. Para este ejemplo, comenzaremos con la estructura de estudiantes del Capítulo 3 y la construiremos en una clase completa. Para facilitar la referencia, aquí está la estructura original: estudiante de estructura { grado internacional; int ID de estudiante; nombre de cadena; }; Incluso con una estructura simple de esta forma, al menos obtenemos encapsulación. recuerda­ Recuerde que en el Capítulo 3 construimos una matriz de datos de estudiantes con esta estructura, y sin usar la estructura, habríamos tenido que construir tres matrices paralelas, una para las calificaciones, una para los ID y otra para los nombres. ¡Qué feo! Sin embargo, lo que definitivamente no conseguimos con esta estructura es ocultar información. El marco de clases básico nos brinda información oculta al declarar todos los datos como privados y luego agregar métodos públicos para permitir que el código del cliente acceda o cambie indirectamente estos datos. registro de estudiante de clase { público: registrodeestudiante(); StudentRecord(int nuevaCalificación, int nuevaID, cadena nuevoNombre); int grado(); void setGrade(int newGrade); int ID de estudiante(); void setStudentID(int nuevoID); nombre de cadena(); void setName(cadena nuevoNombre); privado: int _grado; int _IDEstudiante; cadena _nombre; }; Como se prometió, esta declaración de clase está separada en una sección pública con funciones miembro original y una sección privada , que contiene los mismos datos que la estructura . Hay ocho funciones miembro: dos constructores y luego un par de funciones miembro para cada miembro de datos. Por ejemplo, el miembro de datos _grade tiene dos funciones miembro asociadas, calificación y setGrade . El primero de estos métodos será utilizado por el código del cliente para recuperar la calificación de un StudentRecord en particular, mientras que el segundo de estos métodos se utilizará para almacenar una nueva calificación para este StudentRecord en particular. Los métodos de recuperación y almacenamiento asociados con un miembro de datos son tan comunes que normalmente se los denomina con los términos abreviados get y set. Como puede ver, incorporé la palabra conjunto en los métodos que almacenan nuevos valores en los miembros de datos. Muchos programadores también habrían incorporado get en otros nombres, por ejemplo, getGrade en lugar de grade. Por qué Resolver problemas con las clases 119 Machine Translated by Google ¿No hice esto? Porque entonces habría estado usando un nombre de verbo para una función que se usa como sustantivo. Algunos argumentarían, sin embargo, que el término get se entiende tan universalmente y, por lo tanto, su significado es tan claro, que su uso anula la otra preocupación. En última instancia, es una cuestión de estilo personal. Aunque en este libro me he apresurado a señalar las ventajas que tiene C++ sobre otros lenguajes, debo admitir que los lenguajes más recientes, como C#, superan a C++ cuando se trata de métodos de obtención y configuración . C# tiene un mecanismo incorporado llamado propiedad que actúa como método get y set . Una vez definido, el código del cliente puede acceder a la propiedad como si fuera un miembro de datos en lugar de una llamada de función. Esta es una gran mejora en la legibilidad y expresividad. En C++, sin un mecanismo incorporado, es importante que decidamos alguna convención de nomenclatura para nuestros métodos y la usemos de manera consistente. Tenga en cuenta que mi convención de nomenclatura se extiende a los miembros de datos que, a diferencia de la estructura original, todos comienzan con guiones bajos. Esto me permite nombrar las funciones de obtención con (casi) el mismo nombre que los miembros de datos que recuperan. Esto también permite un fácil reconocimiento de las referencias de miembros de datos en el código, lo que mejora la legibilidad. Algunos programadores usan la palabra clave this para todas las referencias a miembros de datos en lugar de usar un prefijo de guión bajo. Entonces, en lugar de una declaración como: devolver _grado; ellos tendrían: devolver este grado; Si no has visto la palabra clave this antes, es una referencia al objeto en el que aparece. Entonces, si la declaración anterior apareciera en un método de clase y ese método también declarara una variable local con el nombre grado, la expresión this.grade se referiría al grado del miembro de datos, no a la variable local con el mismo nombre. Emplear la palabra clave de esta manera tiene una ventaja en un entorno de desarrollo con finalización automática de sintaxis: el programador puede simplemente escribir esto, presionar la tecla punto y seleccionar el miembro de datos de una lista, evitando tipeo adicional y posibles errores ortográficos. Sin embargo, cualquiera de las técnicas resalta las referencias de los miembros de datos, que es lo importante. Ahora que hemos visto la declaración de clase, veamos la implementación de los métodos. Comenzaremos con el primer par get/set . int registroestudiante::calificación() { return _grade; } void StudentRecord::setGrade(int nuevaCalificación) { _grade = nuevaCalificación; } Esta es la forma más básica del par get/set . El primer método, calificación, devuelve el valor actual del miembro de datos asociado, _grado parámetro newGrade a los datos. 120 Capítulo 5 . El segundo método, setGrade, asigna el valor del Machine Translated by Google miembro _grado . Sin embargo, si esto fuera todo lo que hiciéramos con nuestra clase, no habríamos logrado nada. Aunque este código proporciona información oculta porque pasa datos en ambas direcciones sin ninguna consideración o modificación, es mejor que declarar _grade público porque nos reserva el derecho de cambiar el nombre o tipo de datos del miembro. El conjunto de calificaciones El método debería al menos realizar alguna validación rudimentaria; debería evitar que se asignen valores de newGrade que no tienen sentido como calificación al miembro de datos _grade . Sin embargo, debemos tener cuidado de seguir las especificaciones del problema y no hacer suposiciones sobre los datos basadas en nuestras propias experiencias, sin considerar al usuario. Podría ser razonable limitar las calificaciones al rango de 0 a 100, pero podría no serlo, por ejemplo, si una escuela permite créditos adicionales para elevar una puntuación por encima de 100 o utiliza una calificación de ­1 como código para el retiro de una clase. En este caso, debido a que la descripción del problema nos brinda alguna orientación, podemos incorporar ese conocimiento en la validación. void StudentRecord::setGrade(int nuevaCalificación) { if ((nuevaCalificación >= 0) && (nuevaCalificación <= 100)) _calificación = nuevaCalificación; } Aquí, la validación es sólo un guardián. Sin embargo, dependiendo de la definición del problema, podría tener sentido que el método produzca un mensaje de error, escriba en un registro de errores o maneje el error de otra manera. Los otros pares get/set funcionarían exactamente de la misma manera. Sin duda, existen reglas sobre la construcción de números de identificación de estudiantes en una escuela en particular que podrían usarse para la validación. Sin embargo, con el nombre de un estudiante, lo mejor que podemos hacer es rechazar cadenas con caracteres extraños, como % o @, y hoy en día quizás ni siquiera eso sería posible. El último paso para completar nuestra clase es escribir los constructores. En el marco básico, incluimos dos constructores: un constructor predeterminado, que no tiene parámetros y establece los miembros de datos en valores predeterminados razonables, y un constructor con parámetros para cada miembro de datos. La segunda forma de constructor es importante para nuestro objetivo de expresividad , ya que nos permite crear un objeto de nuestra clase e inicializar los valores internos en un solo paso. Una vez que haya escrito el código para los otros métodos, este segundo constructor casi se escribe solo. RegistroEstudiante::RegistroEstudiante(int nuevaCalificación, int nuevoID, cadena nuevoNombre) { setGrade(newGrade); setIDEstudiante(nuevoID); setName(nuevoNombre); } Como puede ver, el constructor simplemente llama a los métodos establecidos apropiados para cada uno de los parámetros. En la mayoría de los casos, este es el enfoque correcto porque evita la duplicación de código y garantiza que el constructor aprovechará cualquier código de validación en los métodos establecidos . Resolver problemas con las clases 121 Machine Translated by Google El constructor predeterminado a veces es un poco complicado, no porque el código sea complicado sino porque no siempre hay un valor predeterminado obvio. Al elegir valores predeterminados para los miembros de datos, tenga en cuenta las situaciones en las que se utilizaría un objeto creado con el constructor predeterminado y, en particular, si existe un objeto predeterminado legítimo para esa clase. Esto le indicará si debe completar los miembros de datos con valores predeterminados útiles o con valores que indiquen que el objeto no está inicializado correctamente. Por ejemplo, considere una clase que representa una colección de valores que encapsula una lista vinculada. Hay una lista enlazada predeterminada significativa, y esa es una lista enlazada vacía, por lo que configuraríamos nuestros miembros de datos para crear una lista legítima, pero conceptualmente vacía . Pero con nuestra clase básica de muestra, no existe una definición significativa de estudiante predeterminado; No querríamos dar un número de identificación válido a un objeto StudentRecord predeterminado porque eso podría causar confusión con un StudentRecord legítimo. Por lo tanto, deberíamos elegir un valor predeterminado para el campo _studentID que obviamente sea ilegítimo, como por ejemplo –1: RegistroEstudiante::RegistroEstudiante() { setGrade(0); establecerIDEstudiante(­1); escoger un nombre(""); } Asignamos la calificación con setGrade, que valida su parámetro. Esto significa que tenemos que asignar una calificación válida, en este caso, 0. Debido a que el ID se establece en un valor no válido, el registro en su conjunto puede identificarse fácilmente como ilegítimo. Por lo tanto, la calificación válida no debería ser un problema. Si eso fuera una preocupación, podríamos asignar un valor no válido directamente al miembro de datos _grade . Esto completa el marco de clase básico. Tenemos un grupo de miembros de datos privados que hacen referencia a atributos del mismo objeto lógico, en este caso, el registro de clase de un estudiante; tenemos funciones miembro para recuperar o alterar los datos del objeto, con validación según corresponda; y tenemos un conjunto útil de constructores. Tenemos una buena base de clase. La pregunta es: ¿necesitamos hacer más? Métodos de soporte Un método de soporte es un método de una clase que no se limita a recuperar o almacenar datos. Algunos programadores pueden referirse a estos como métodos auxiliares, métodos auxiliares o cualquier otra cosa, pero como sea que se llamen, son los que llevan una clase más allá del marco de clase básico. Un conjunto bien diseñado de métodos de soporte es a menudo lo que hace que una clase sea realmente útil. Para determinar posibles métodos de soporte, considere cómo se utilizará la clase. ¿Existen actividades comunes que esperaríamos que realizara el código del cliente en los datos de nuestra clase? En este caso, se nos dice que el programa para el cual estamos diseñando inicialmente nuestra clase mostrará las calificaciones de los estudiantes no sólo como puntuaciones numéricas sino también como letras. Entonces, creemos un método de soporte que devuelva la calificación de un estudiante como una letra. Primero, agregaremos la declaración del método a la sección pública de nuestra declaración de clase. 122 Capítulo 5 Machine Translated by Google cadena letraCalificación(); Ahora necesitamos implementar este método. La función convertirá el valor numérico almacenado en _grade a la cadena apropiada según la tabla de calificaciones que se muestra en el problema. Podríamos lograr esto con una serie de si declaraciones, pero ¿existe una forma más limpia y elegante? Si simplemente pensó: "Oye, esto se parece mucho a cómo convertimos los ingresos en categorías de licencias comerciales en el Capítulo 3", felicitaciones: ha descubierto una analogía de programación adecuada. Podemos adaptar ese código, con matrices constantes paralelas para almacenar las calificaciones con letras y las puntuaciones numéricas más bajas asociadas con esas calificaciones, para convertir la puntuación numérica con un bucle. string registroestudiante::letraCalificación() { const int NUMBER_CATEGORIES = 11; cadena constante GRADE_LETTER[] = {"F", "D", "D+", "C­", "C", "C+", "B­", "B", "B+", "A­", "A"}; constante int LOWEST_GRADE_SCORE[] = {0, 60, 67, 70, 73, 77, 80, 83, 87, 90, 93}; categoría int = 0; mientras (categoría < NUMBER_CATEGORIES && LOWEST_GRADE_SCORE[categoría] <= _grado) categoría++; devolver GRADE_LETTER[categoría ­ 1]; } Este método es una adaptación directa de la función del Capítulo 3, por lo que no hay nada nuevo que explicar sobre cómo funciona el código. Sin embargo, su adaptación para un método de clase introduce algunas decisiones de diseño. Lo primero que debemos tener en cuenta es que no hemos creado un nuevo miembro de datos para almacenar la calificación con letras, sino para calcular la calificación con letras adecuada sobre la marcha para cada solicitud. El enfoque alternativo sería tener un miembro de datos _letterGrade y reescribir el método setGrade para actualizar _letterGrade junto con _grade. Entonces este método letterGrade se convertiría en un método get simple , que devolvería el valor del miembro de datos ya calculado. El problema con este enfoque es la redundancia de datos, un término que describe una situación en la que se almacenan datos que son un duplicado literal de otros datos o que pueden determinarse directamente a partir de otros datos. Este problema se ve más comúnmente con las bases de datos y los diseñadores de bases de datos siguen procesos elaborados para evitar la creación de datos redundantes en sus tablas. Sin embargo, la redundancia de datos puede ocurrir en cualquier programa, si no somos precavidos. Para ver el peligro, considere un programa de registros médicos que almacene la edad y la fecha de nacimiento de cada uno de un conjunto de pacientes. La fecha de nacimiento nos da información que la edad no. Por lo tanto, los dos elementos de datos no son iguales, pero la edad no nos dice nada que no podamos saber a partir de la fecha de nacimiento. ¿Y qué pasa si los dos valores no coinciden (lo que eventualmente sucederá, a menos que la edad se actualice automáticamente)? ¿En qué valor confiamos? Me acuerdo de la famosa (aunque posiblemente apócrifa) proclamación del califa Omar cuando ordenó quemar la Biblioteca de Alejandría. Proclamó que si los libros de la biblioteca coincidían con el Corán, eran redundantes y no necesitaban ser preservados, pero si no estaban de acuerdo con el Corán, eran perniciosos y debían ser destruidos. Los datos redundantes son problemas a la espera de ocurrir. La única justificación sería Resolver problemas con las clases 123 Machine Translated by Google sería el rendimiento, si pensáramos que las actualizaciones de _grade serían raras y las llamadas a letterGrade serían frecuentes, pero es difícil imaginar un aumento significativo en el rendimiento general del programa. Sin embargo, este método podría mejorarse. Al probar este método, noté un problema. Aunque el método produce resultados correctos para valores válidos de _grade, el método falla cuando _grade es un valor negativo. cuando el tiempo se alcanza el bucle, el valor negativo de _grade hace que la prueba del bucle falle inmediatamente; por lo tanto, la categoría permanece en cero y la declaración de devolución intenta hacer referencia a GRADE_LETTER[­1]. Podríamos evitar este problema inicializando la categoría en uno en lugar de cero, pero eso significaría que se asignaría una calificación negativa "F" cuando en realidad no se le debería asignar ninguna cadena porque, como valor de calificación no válido, no No encaja en ninguna categoría. En su lugar, podríamos validar _grade antes de convertirlo en una calificación con letras. Ya estamos validando valores de calificación en el método setGrade , por lo que en lugar de agregar un nuevo código de validación al método letterGrade , deberíamos "factorizar" cuál sería el código común en estos métodos para crear un tercer método. (Quizá se pregunte cómo, si validamos las calificaciones a medida que se asignan, podríamos tener una calificación no válida, pero recuerde que nuestro constructor predeterminado asigna – 1 para indicar que aún no se ha asignado ninguna calificación legítima). Este es otro tipo de calificación. del método de soporte, que es la clase equivalente del concepto de función auxiliar general presentado en capítulos anteriores. Implementemos este método y modifiquemos nuestros otros métodos para usarlo: bool StudentRecord:: isValidGrade( int calificación) { if ((calificación >= 0) && (calificación <= 100)) devuelve verdadero; de lo contrario devolverá falso; } void StudentRecord::setGrade(int nuevaCalificación) { si ( isValidGrade(newGrade)) _grade = nuevaCalificación; } string registroestudiante::letraCalificación() { si ( !isValidGrade(_grade)) devuelve "ERROR"; const int NUMBER_CATEGORIES = 11; cadena constante GRADE_LETTER[] = {"F", "D", "D+", "C­", "C", "C+", "B­", "B", "B+", "A­", "A"}; constante int LOWEST_GRADE_SCORE[] = {0, 60, 67, 70, 73, 77, 80, 83, 87, 90, 93}; categoría int = 0; mientras (categoría < NUMBER_CATEGORIES && LOWEST_GRADE_SCORE[categoría] <= _grado) categoría++; devolver GRADE_LETTER[categoría ­ 1]; } El nuevo método de validación de calificaciones es de tipo bool problema de sí o no, elegí el nombre isValidGrade , y como se trata de un . Esto brinda la lectura más parecida al inglés para las llamadas a este método, como las del setGrade y métodos letterGrade validar como parámetro 124 Capítulo 5 . Además, tenga en cuenta que el método toma la calificación a . Aunque letterGrade ya está validando el valor Machine Translated by Google en el miembro de datos _grade , setGrade valida el valor que podemos o no asignar al miembro de datos. Entonces isValidGrade necesita tomar la calificación como parámetro para que sea útil para los otros dos métodos. Aunque el método isValidGrade está implementado, queda una decisión al respecto: ¿Qué nivel de acceso debemos asignarle? Es decir, ¿deberíamos colocarlo en la sección pública de la clase o en la sección privada? A diferencia del get y establecer métodos del framework de clases básico, que siempre van en la sección pública, los métodos de soporte pueden ser públicos o privados dependiendo de su uso. ¿ Cuáles son los efectos de hacer público isValidGrade ? Lo más obvio es que el código del cliente puede acceder al método. Debido a que tener más métodos públicos parece hacer que una clase sea más útil, muchos programadores novatos hacen públicos todos los métodos que el cliente podría utilizar. Sin embargo, esto ignora el otro efecto de la designación de acceso público. Recuerde que la sección pública define la interfaz de nuestra clase, y deberíamos ser reacios a cambiar el método una vez que nuestra clase esté integrada en uno o más programas porque es probable que dicho cambio se produzca en cascada y requiera cambios en todo el código del cliente. Por lo tanto, colocar un método en la sección pública bloquea la interfaz del método y sus efectos. En este caso, supongamos que algún código de cliente, basado en la formulación original de isValidGrade, se basa en él como un verificador de rango de 0 a 100, pero luego, las reglas para las calificaciones aceptables se vuelven más complicadas. El código del cliente podría fallar. Para evitar eso, es posible que tengamos que crear un método de validación de segundo grado dentro de la clase y dejar el primero solo. Supongamos que esperamos que isValidGrade sea de utilidad limitada para el cliente y hemos decidido no hacerlo público. Podríamos hacer que el método sea privado, pero esa no es la única opción. Debido a que la función no hace referencia directamente a ningún miembro de datos ni a ningún otro método de la clase, podríamos declarar la función fuera de la clase por completo. Sin embargo, esto no sólo crea el mismo problema que tiene el acceso público con respecto a la modificabilidad, sino que también reduce la encapsulación porque ahora esta función, que es requerida por la clase, ya no es parte de ella. También podríamos dejar el método en la clase pero hacerlo protegido en lugar de privado. La diferencia se vería en cualquier subclase. Si isValidGrade está protegido, el método puede ser llamado por métodos en subclases; Si isValidGrade es privado, solo lo pueden utilizar otros métodos de la clase StudentRecord . Este es el mismo dilema entre lo público y lo privado a menor escala. ¿Esperamos que los métodos de las subclases obtengan mucho uso de nuestro método y esperamos que el efecto del método o su interfaz puedan cambiar en el futuro? En muchos casos, lo más seguro es hacer que todos los métodos auxiliares sean privados y hacer públicos sólo aquellos métodos de soporte que se escribieron para beneficiar al cliente. Clases con datos dinámicos Una de las mejores razones para crear una clase es encapsular estructuras de datos dinámicas. Como comentamos en el Capítulo 4, los programadores se enfrentan a una verdadera tarea de realizar un seguimiento de las asignaciones dinámicas, asignaciones de punteros y desasignaciones para evitar pérdidas de memoria, referencias colgantes y referencias de memoria ilegales. Poner todas las referencias de puntero en una clase no elimina el trabajo difícil, pero sí significa que una vez que lo hayamos hecho bien, podremos con seguridad Resolver problemas con las clases 125 Machine Translated by Google suelte ese código en otros proyectos. También significa que cualquier problema con nuestra estructura de datos dinámica está aislado del código dentro de la clase misma, lo que simplifica la depuración. Creemos una clase con datos dinámicos para ver cómo funciona. Para nuestro problema de muestra, usaremos una versión modificada del problema principal del Capítulo 4. PROBLEMA: SEGUIMIENTO DE UN DESCONOCIDO CANTIDAD DE EXPEDIENTES ESTUDIANTILES En este problema, escribirás una clase con métodos para almacenar y manipular una colección de registros de estudiantes. Un registro de estudiante contiene un número de estudiante y una calificación, ambos números enteros, y una cadena para el nombre del estudiante. Se deben implementar las siguientes funciones: addRecord Este método toma el número, el nombre y la calificación de un estudiante y agrega un nuevo registro con estos datos a la colección. recordWithNumber Esta función toma un número de estudiante y recupera el registro con ese número de estudiante de la colección. removeRecord Esta función toma un número de estudiante y elimina el registro con ese número de estudiante de la colección. La colección puede ser de cualquier tamaño. Se espera que la operación addRecord sea Se llama con frecuencia, por lo que debe implementarse de manera eficiente. Las principales diferencias entre esta descripción y la versión original son que hemos agregado una nueva operación, registrarConNúmero, y también que ninguna de las operaciones hace referencia a un parámetro de puntero. Este es el beneficio clave de utilizar una clase para encapsular una lista vinculada. El cliente puede ser consciente de que la clase implementa la recopilación de registros de los estudiantes como una lista vinculada e incluso puede estar contando con eso (recuerde nuestra discusión anterior sobre las limitaciones de ocultar información). El código del cliente, sin embargo, no tendrá interacción directa con la lista enlazada ni con ningún puntero de la clase. Debido a que este problema almacena la misma información por estudiante que el problema anterior, aquí tenemos la oportunidad de reutilizarla en clase. En nuestro tipo de nodo de lista vinculada, en lugar de campos separados para cada uno de los tres datos de los estudiantes, tendremos un objeto StudentRecord . Usar un objeto de una clase como tipo de datos en una segunda clase se conoce como composición. Ahora tenemos suficiente información para hacer una declaración de clase preliminar: clase colección de estudiantes { privado: estructura estudianteNodo { studentRecord datos del estudiante; nodoestudiante * próximo; }; 126 Capítulo 5 Machine Translated by Google público: colecciónestudiante(); void addRecord(studentRecord nuevoEstudiante); estudianteRecord recordWithNumber(int idNum); void removeRecord(int idNum); privado: typedef estudianteNodo * lista de estudiantes; studentList _listHead; }; Anteriormente dije que los programadores tienden a comenzar las clases con declaraciones públicas, pero aquí tenemos que hacer una excepción. Comenzamos con una declaración privada de la estructura del nodo, StudentNode , que usaremos para crear nuestra lista enlazada. Esta declaración debe aparecer antes de la sección pública porque varias de nuestras funciones miembro públicas hacen referencia a este tipo. A diferencia de nuestro tipo de nodo en el Capítulo 4, este nodo no tiene campos individuales para los datos de carga útil, sino que incluye un miembro del tipo de estructura StudentRecord . Las funciones de miembro público siga directamente de la descripción del problema; además, como siempre, tenemos un constructor. En la segunda sección privada, declaramos un typedef para un puntero a nuestro tipo de nodo para mayor claridad, tal como lo hicimos en el Capítulo 4. Luego declaramos nuestro puntero de encabezado de lista, hábilmente llamado _listHead . Esta clase declara dos tipos privados. Las clases pueden declarar tipos, así como funciones miembro y miembros de datos. Al igual que con otros miembros, los tipos que aparecen en la clase se pueden declarar con cualquier especificador de acceso. Sin embargo, al igual que con los miembros de datos, debe considerar que las definiciones de tipo son privadas de forma predeterminada y solo hacerlas menos restrictivas si tiene una razón clara para hacerlo. Las declaraciones de tipos suelen ser el núcleo de cómo opera una clase detrás de escena y, como tales, son fundamentales para ocultar información. Además, en la mayoría de los casos, el código del cliente no sirve para los tipos que declararás en tu clase. Se produce una excepción cuando un tipo definido en la clase se utiliza como tipo de retorno de un método público o como tipo de parámetro de un método público. En este caso, el tipo debe ser público o el código del cliente no podrá utilizar el método público. La clase StudentCollection supone que el tipo de estructura StudentRecord se declarará por separado, pero también podríamos convertirlo en parte de la clase. Si lo hiciéramos, tendríamos que declararlo en la sección pública . Ahora estamos listos para implementar nuestros métodos de clase, comenzando con el constructor. A diferencia de nuestro ejemplo anterior, aquí solo tenemos el constructor predeterminado, no un constructor que toma un parámetro para inicializar nuestro miembro de datos. El objetivo de nuestra clase es ocultar los detalles de nuestra lista enlazada, por lo que no queremos que el cliente ni siquiera piense en nuestro _listHead, y mucho menos lo manipule. Todo lo que necesitamos hacer en nuestro constructor predeterminado es establecer el puntero principal en NULL: Colección de estudiantes::Colección de estudiantes() { _listHead = NULL; } Resolver problemas con las clases 127 Machine Translated by Google Agregar un nodo Pasamos a addRecord. Debido a que nada en la descripción del problema requiere que mantengamos los registros de los estudiantes en un orden particular, podemos adaptar directamente la función addRecord del Capítulo 4 para usarla aquí. void StudentCollection::addRecord( studentRecord nuevoEstudiante) { studentNode * newNode = nuevo StudentNode; nuevoNodo­>studentData = nuevoEstudiante; newNode­>siguiente = _listHead; _listHead = nuevoNodo; } Sólo hay dos diferencias entre este código y nuestra función de modelo. Aquí, solo necesitamos un parámetro en nuestra lista de parámetros , que es el objeto StudentRecord que vamos a agregar a nuestra colección. Esto encapsula todos los datos de un estudiante, lo que reduce la cantidad de parámetros necesarios. Tampoco necesitamos pasar un puntero de encabezado de lista porque ya está almacenado en nuestra clase como _listHead y se hace referencia directamente a él cuando es necesario. Al igual que con la función addRecord del Capítulo 4, creamos un nuevo nodo , copiamos los datos del nuevo estudiante en el nuevo nodo , apuntamos el siguiente campo del nuevo nodo al primer nodo anterior de la lista y finalmente apuntamos _listHead a el nuevo nodo . Normalmente recomiendo dibujar un diagrama para todas las manipulaciones del puntero, pero como esta es la misma manipulación que ya estábamos haciendo, podemos hacer referencia a nuestro diagrama dibujado anteriormente. Ahora podemos centrar nuestra atención en la última de las tres funciones miembro, registroConNúmero. Ese nombre es un poco complicado y algunos programadores podrían haber elegido retrieveRecord o algo similar. Sin embargo, siguiendo las reglas de nomenclatura establecidas anteriormente, decidí usar un sustantivo porque este método devuelve un valor. Este método será similar a AverageRecord en que necesita recorrer la lista; la diferencia en este caso es que podemos detenernos una vez que encontremos el registro del estudiante coincidente. StudentRecord StudentCollection::recordWithNumber(int idNum) { studentNode * loopPtr = _listHead; while (loopPtr­>studentData.studentID() != idNum) { loopPtr = loopPtr­>siguiente; } bucle de retornoPtr­>studentData; } En esta función, inicializamos nuestro puntero de bucle al encabezado de la lista y recorrer la lista siempre y cuando no hayamos visto el número de ID deseado . Finalmente, al llegar al nodo deseado, devolvemos el registro coincidente completo como el valor de la función . Este código se ve bien, pero como siempre, debemos considerar posibles casos especiales. El caso que siempre consideramos cuando tratamos con listas enlazadas es un puntero de cabecera inicialmente NULL . Aquí, eso definitivamente causa un problema, ya que no estamos comprobando eso y el código explotará cuando 128 Capítulo 5 Machine Translated by Google intente eliminar la referencia a loopPtr al ingresar por primera vez al ciclo. Sin embargo, en términos más generales, debemos considerar la posibilidad de que el número de identificación proporcionado por el código del cliente en realidad no coincida con ninguno de los registros de nuestra colección. En ese caso, incluso si _listHead no es NULL, loopPtr eventualmente se convertirá en NULL cuando lleguemos al final de la lista. Entonces, el problema general es que debemos detener el ciclo si loopPtr se vuelve NULL. Eso no es difícil, pero entonces, ¿qué retornamos en esta situación? Ciertamente no podemos devolver loopPtr­>studentData porque loopPtr será NULL. En su lugar, podemos crear y devolver un StudentRecord ficticio con valores no válidos obvios en su interior. StudentRecord StudentCollection::recordWithNumber(int idNum) { StudentNode * loopPtr = _listHead; while ( loopPtr != NULL && loopPtr­>studentData.studentID() != idNum) { loopPtr = loopPtr­>siguiente; } si ( buclePtr == NULL) { studentRecord dummyRecord(­1, ­1, ""); devolver registro ficticio; } demás { retorno loopPtr­>studentData; } } En esta versión del método, si nuestro puntero de bucle es NULL cuando el bucle termina , creamos un registro ficticio con una cadena nula para un nombre y valores –1 para la calificación y el ID del estudiante y lo devolvemos. De vuelta en el bucle, estamos comprobando esa condición NULL loopPtr , lo que nuevamente puede ocurrir porque no hay una lista que recorrer o porque la hemos recorrido sin éxito. Un punto clave aquí es que la expresión condicional del bucle es una expresión compuesta con loopPtr != NULL primero. Esto es absolutamente necesario. C++ utiliza un mecanismo para evaluar expresiones booleanas compuestas conocido como evaluación de cortocircuito; En pocas palabras, no evalúa la mitad derecha de una expresión booleana compuesta cuando ya se conoce el valor general de la expresión. Debido a que && representa un booleano lógico y, si el lado izquierdo de una expresión && se evalúa como falso, la expresión general también debe ser falsa, independientemente de la evaluación del lado derecho. Para mayor eficiencia, C++ aprovecha este hecho, omitiendo la evaluación del lado derecho de una expresión && cuando el lado izquierdo es falso (para un ||, lógico o, el lado derecho no se evalúa cuando el lado izquierdo es verdadero). , por la misma razón). Por lo tanto, cuando loopPtr es NULL, la expresión loopPtr != NULL se evalúa como falsa y el lado derecho de && nunca se evalúa. Sin la evaluación de cortocircuito, se evaluaría el lado derecho y estaríamos desreferenciando un puntero NULL , colapsando el programa. La implementación evita el posible fallo de la primera versión, pero debemos ser conscientes de que deposita mucha confianza en el código del cliente. Es decir, la función que llama a este método es responsable de verificar el StudentRecord que regresa y asegurarse de que no sea el registro ficticio antes de continuar con el procesamiento. Si eres como yo, esto te incomoda un poco. Resolver problemas con las clases 129 Machine Translated by Google EXCEPCIONES Hay otra opción. C++, así como muchos otros lenguajes de programación, ofrece un mecanismo conocido como excepción, que permite que una función, ya sea un método o una función general, indique inequívocamente un estado de error a la persona que llama. Está diseñado para el tipo de situación que tenemos en este método, donde no hay una buena respuesta sobre qué devolver cuando la entrada es mala. La sintaxis de excepciones es más de lo que podemos abordar aquí y, desafortunadamente, la forma en que se implementan las excepciones en C++ no resuelve el problema de confianza explicado en el párrafo anterior. Reorganizar la lista El método removeRecord es similar a recordWithNumber en que debemos recorrer la lista para encontrar el nodo que vamos a eliminar de la lista, pero hay mucho más. Eliminar un nodo de una lista requiere cuidado para mantener vinculados los nodos restantes de la lista. La forma más sencilla de coser el agujero que habremos creado es unir el nodo que venía antes del nodo eliminado con el nodo que vino después. No necesitamos un esquema de función porque ya tenemos un prototipo de función en la declaración de clase, por lo que solo necesitamos un caso de prueba: Colección de estudiantes; StudentRecord stu3(84, 1152, "Sue"); StudentRecord stu2(75, 4875, "Ed"); StudentRecord stu1(98, 2938, "Todd"); s.addRecord(stu3); s.addRecord(stu2); s.addRecord(stu1); s.removeRecord(4875); Aquí hemos creado un objeto StudentCollection , así como tres StudentRecord. objetos, cada uno de los cuales se agrega a nuestra colección. Tenga en cuenta que podríamos reutilizar el mismo registro, cambiando los valores entre las llamadas a addRecord, pero hacerlo de esta manera simplifica nuestro código de prueba. La última línea de la prueba es la llamada a removeRecord , que en este caso eliminará el segundo registro, el del estudiante llamado "Ed". Utilizando el mismo estilo de diagramas de puntero utilizados en el Capítulo 4, la Figura 5­1 muestra el estado de la memoria antes y después. después de esta llamada. En la Figura 5­1 (a), vemos la lista vinculada que fue creada por nuestro código de prueba. Tenga en cuenta que debido a que usamos una clase, las convenciones de nuestros diagramas están un poco sesgadas. En el lado izquierdo de nuestra división de pila/montón, tenemos _listHead, que es el miembro de datos privados dentro de nuestros objetos StudentCollection , y idNum, que es el parámetro para eliminarRecord. En el lado derecho está la lista en sí, en el montón. Recuerde que addRecord coloca el nuevo registro al principio de la lista, por lo que los registros están en el orden opuesto al que se agregaron en el código de prueba. El nodo del medio, "Ed", tiene el número de identificación que coincide con el parámetro, 4875, por lo que se eliminará de la lista. La figura 5­1 (b) muestra la 130 Capítulo 5 Machine Translated by Google resultado de la convocatoria. El primer nodo de la lista, el de "Todd", apunta ahora al que era el tercer nodo de la lista, el de "Sue". El nodo "Ed" ya no está vinculado a la lista más grande y se ha eliminado. listaCabeza número de identificación "Todd" 98 2938 4875 75 4875 "Ed" 84 1152 "Demandar" NULO (a) listaCabeza número de identificación 98 2938 "Todd" 4875 75 4875 "Ed" 84 1152 "Demandar" NULO (b) Figura 5­1: Estados "antes" y "después" del caso de prueba removeRecord Ahora que sabemos qué efecto debería tener el código, podemos empezar a escribirlo. Como sabemos que necesitamos encontrar el nodo con el número de ID coincidente, podríamos comenzar con el bucle while de recordWithNumber. Cuando se complete ese ciclo, tendremos un puntero al nodo que necesitábamos eliminar. Desafortunadamente, necesitamos más que eso para completar la eliminación. Mire la Figura 5­1; Para cerrar el agujero y mantener la lista enlazada, necesitamos cambiar el siguiente campo del nodo "Todd" . Si todo lo que tenemos es un puntero al nodo "Ed" , no hay forma de hacer referencia al nodo "Todd" porque cada nodo en la lista vinculada hace referencia a su sucesor, no a su predecesor. (Debido a situaciones como esta, algunas listas enlazadas se enlazan en ambas direcciones; se conocen como listas doblemente enlazadas, pero rara vez son necesarias). Entonces, además de un puntero al nodo que se eliminará (que se llamará loopPtr si adaptar el código de la función anterior), necesitamos un puntero al nodo inmediatamente anterior: llamemos a este puntero arrastrado. La Figura 5­2 muestra este concepto aplicado a nuestro caso de muestra. Resolver problemas con las clases 131 Machine Translated by Google listaCabeza número de identificación 98 2938 "Todd" 4875 75 4875 "Ed" siguiendo buclePtr 84 1152 "Demandar" NULO Figura 5­2: Los punteros necesarios para eliminar el nodo especificado por idNum Con loopPtr haciendo referencia al nodo que estamos eliminando y siguiendo la referencia Al seleccionar el nodo anterior, podemos eliminar el nodo deseado y mantener la lista junta. void StudentCollection::removeRecord(int idNum) { StudentNode * loopPtr = _listHead; studentNode * final = NULL; while (loopPtr != NULL && loopPtr­>studentData.studentID() != idNum) { arrastre = loopPtr; loopPtr = loopPtr­>siguiente; } if (loopPtr == NULL) retorno; trailing­>siguiente = loopPtr­>siguiente; eliminar loopPtr; } La primera parte de esta función es como la de recordWithNumber, excepto que Declaramos nuestro puntero final loopPtr al puntero y, dentro del bucle, asignamos el antiguo valor de antes de avanzar loopPtr al siguiente nodo. De esta manera, el seguimiento siempre es un nodo detrás de loopPtr. Debido a nuestro trabajo con la función anterior, ya estamos en guardia contra un caso especial. Por lo tanto, cuando finaliza el ciclo, verificamos si loopPtr es NULL. Si es así, significa que nunca encontramos un nodo con el número de identificación deseado e inmediatamente devolvemos . Llamo a una declaración de devolución que aparece en medio de una función "salir de Dodge". Algunos programadores se oponen a esto porque las funciones con múltiples puntos de salida pueden ser más difíciles de leer. La alternativa en este caso, sin embargo, es otro nivel de anidamiento para las declaraciones if que siguen, y prefiero simplemente salir de Dodge. Habiendo determinado que hay un nodo que eliminar, es hora de eliminarlo. En nuestro diagrama, vemos que necesitamos configurar el siguiente campo del nodo final para que apunte al nodo actualmente apuntado por el siguiente campo del nodo loopPtr Luego podemos eliminar de forma segura el nodo al que apunta loopPtr . . Eso funciona para nuestro caso de prueba, pero como siempre, debemos verificar posibles casos especiales. Ya hemos manejado la posibilidad de que idNum no aparezca en ninguno de los registros de nuestra colección, pero ¿existe otro posible problema? Mirando nuestro caso de prueba, ¿cambiaría algo si intentáramos eliminar el primer o tercer nodo en lugar del nodo del medio? Espectáculos de prueba y control manual. 132 Capítulo 5 Machine Translated by Google no hay problemas con el tercer (último) nodo. El primer nodo, sin embargo, causa problemas porque en esta situación no hay ningún nodo anterior al que señalar . En cambio, debemos manipular el propio _listHead . La Figura 5­3 muestra la situación después de que finaliza el ciclo while . listaCabeza número de identificación siguiendo 98 2938 "Todd" 2938 NULO 75 4875 "Ed" buclePtr 84 1152 "Demandar" NULO Figura 5­3: La situación antes de eliminar el primer nodo de la lista En esta situación, necesitamos redireccionar _listHead al segundo nodo anterior de la lista, el de "Ed". Reescribamos nuestro método para manejar el caso especial. void StudentCollection::removeRecord(int idNum) { StudentNode * loopPtr = _listHead; StudentNode * final = NULL; while (loopPtr != NULL && loopPtr­>studentData.studentID() != idNum) { final = loopPtr; loopPtr = loopPtr­>siguiente; } si (loopPtr == NULL) regresa; if (final == NULL) { _listHead = _listHead­>siguiente; } demás { final­>siguiente = loopPtr­>siguiente; } eliminar loopPtr; } Como puede ver, tanto la prueba condicional especial como el código para manejar el caso son sencillos porque hemos analizado cuidadosamente la situación antes de escribir el código. Incinerador de basuras Con los tres métodos especificados por el problema implementados, podríamos pensar que nuestra clase StudentCollection está completa. Sin embargo, tal como está, tiene serios problemas. La primera es que la clase carece de destructor. Este es un método especial que se llama cuando el objeto sale del alcance (cuando se completa la función que declaró el objeto). Cuando una clase no tiene datos dinámicos, normalmente no necesita un destructor, pero si tiene el primero, definitivamente necesitará el segundo. Recuerda que tenemos que borrar todo lo que tenemos asignado. Resolver problemas con las clases 133 Machine Translated by Google con nuevo para evitar pérdidas de memoria. Si un objeto de nuestra clase StudentCollection tiene tres nodos, es necesario desasignar cada uno de esos nodos. Afortunadamente, esto no es demasiado difícil. Solo necesitamos recorrer nuestra lista enlazada, eliminando a medida que avanzamos. Sin embargo, en lugar de hacer esto directamente, escribamos un método auxiliar que elimine todos los nodos en una lista de estudiantes. En la sección privada de nuestra clase, agregamos la declaración: void eliminarLista(listadeestudiantes &listPtr); El código del método en sí sería: void StudentCollection::deleteList(studentList &listPtr) { mientras (listaPtr! = NULL) { nodoestudiante * temperatura = listaPtr; listPtr = listPtr­>siguiente; eliminar temperatura; } } El recorrido copia el puntero al nodo actual en una variable temporal nodo actual y luego elimina el nodo al que apunta la variable temporal , avanza el puntero del . Con este código implementado, podemos codificar el destructor de manera muy simple. Primero, agregamos el destructor a la sección pública de nuestra declaración de clase: ~ColecciónEstudiante(); Tenga en cuenta que, al igual que un constructor, el destructor se especifica usando el nombre de la clase y no hay ningún tipo de devolución. La tilde antes del nombre distingue el destructor de los constructores. La implementación es la siguiente: ColecciónEstudiantes::~ColecciónEstudiantes() { eliminarLista(_listHead); } El código de estos métodos es sencillo, pero es importante probar el destructor. Aunque un destructor mal escrito podría bloquear su programa, muchos problemas con los destructores no provocan bloqueos, sólo pérdidas de memoria o, peor aún, un comportamiento inexplicable del programa. Por lo tanto, es importante probar el destructor utilizando el depurador de su entorno de desarrollo para que pueda ver que el destructor realmente está llamando a eliminar en cada nodo. Copia profunda Otro problema grave persiste. En el Capítulo 4, analizamos brevemente el concepto de entrecruzamiento, donde dos variables indicadoras tenían el mismo valor. Aunque las variables en sí eran distintas, apuntaban a la misma estructura de datos; por lo tanto, modificar la estructura de una variable modificó 134 Capítulo 5 Machine Translated by Google los dos. Este problema puede ocurrir fácilmente con clases que incorporan memoria asignada dinámicamente. Para ver por qué esto puede ser un problema, considere la siguiente secuencia de código elemental de C++: entero x = 10; int y = 15; x = y; x = 5; Supongamos que le pregunto qué efecto tuvo la última afirmación sobre el valor de la variable y. Probablemente te preguntarás si me había equivocado. La última afirmación no tendría ningún efecto en y , sólo en x. Pero ahora considere esto: Colección de estudiantes s1; Colección de estudiantes s2; StudentRecord r1(85, 99837, "John"); s2.addRecord(r1); StudentRecord r2(77, 4765, "Elsie"); s2.addRecord(r2); s1 = s2; s2.removeRecord(99837); Supongamos que le pregunto qué efecto tuvo la última afirmación en s1. Desafortunadamente, tiene un efecto. Aunque s1 y s2 son dos objetos diferentes, ya no son objetos completamente separados. Por defecto, cuando un objeto se asigna a otro, como aquí asignamos s2 a s1 , C++ realiza lo que se conoce como una copia superficial. En una copia superficial, cada miembro de datos de un objeto se asigna directamente al otro. Entonces, si _listHead, nuestro único miembro de datos, fuera público, s1 = s2 sería lo mismo que s1._listHead = s2._listHead. Esto deja al miembro de datos _listHead de ambos objetos apuntando al mismo lugar en la memoria: el nodo de "Elsie", que apunta al otro nodo, el de "John". Por lo tanto, cuando se elimina el nodo de "John" , aparentemente se elimina de dos listas porque en realidad solo hay una lista. La Figura 5­4 muestra la situación al final del código. listaCabeza 77 (t1) listaCabeza 4765 "Elsie" NULO 85 99837 "Juan" NULO (t2) Figura 5­4: La copia superficial produce entrecruzamiento; Al eliminar el nodo "John" de una lista, se elimina de ambas. Resolver problemas con las clases 135 Machine Translated by Google Sin embargo, por muy peculiar que sea, en realidad podría haber sido mucho peor. ¿Qué pasaría si la última línea del código hubiera eliminado el primer registro, el "Elsie"? ¿nodo? En ese caso, el _listHead dentro de s2 se habría actualizado para que apunte a "John" y el nodo "Elsie" se habría eliminado. Sin embargo, el _listHead dentro de s1 aún apuntaría al nodo "Elsie" eliminado , una referencia peligrosa y colgante, como se muestra en la Figura 5­5. listaCabeza 77 4765 "Elsie" (t1) listaCabeza 85 99837 "Juan" NULO (t2) Figura 5­5: Eliminación de s2 que provoca una referencia colgante en s1 La solución a este problema es una copia profunda, lo que significa que no sólo copiamos el puntero a la estructura sino que hacemos copias de todo lo que hay en la estructura. En este caso, significa copiar todos los nodos de la lista para hacer una copia de lista verdadera. Como antes, comencemos creando un método auxiliar privado, en este caso, uno que copie una lista de estudiantes. La declaración en la sección privada de la clase tiene este aspecto: lista de estudiantes lista copiada (const lista de estudiantes original); Como antes, elegí un sustantivo para un método que devuelve un valor. La implementación del método es la siguiente: ColecciónEstudiantes::ListaEstudiantesColecciónEstudiantes::Listacopiada(constListaEstudiantes original) { si (original == NULL) { devolver NULO; } ListaEstudiantes nuevaLista = nuevo NodoEstudiante; newList­>studentData = original­>studentData; studentNode * oldLoopPtr = original­>siguiente; studentNode * newLoopPtr = nuevaLista; mientras (oldLoopPtr! = NULL) { newLoopPtr­>next = nuevo estudianteNodo; nuevoLoopPtr = nuevoLoopPtr­>siguiente; newLoopPtr­>studentData = oldLoopPtr­>studentData; oldLoopPtr = oldLoopPtr­>siguiente; } newLoopPtr­>siguiente = NULL; devuelve nuevaLista; } 136 Capítulo 5 Machine Translated by Google Están sucediendo muchas cosas en este método, así que vayamos paso a paso. En una nota de sintaxis, al especificar el tipo de retorno en la implementación, debemos anteponer el nombre de la clase . De lo contrario, el compilador no sabrá de qué tipo estamos hablando. (Dentro del método, eso no es necesario porque el compilador ya sabe de qué clase forma parte el método, ¡un poco confuso!) Comprobamos si la lista entrante está vacía. Si es así, salimos de Dodge . Una vez que sabemos que hay una lista para copiar, copiamos los datos del primer nodo antes del ciclo porque para ese nodo tenemos que modificar el puntero principal de nuestra nueva lista. Luego configuramos dos punteros para rastrear las dos listas. oldLoopPtr atraviesa la lista entrante ; siempre apuntará al nodo que estamos a punto de copiar. newLoopPtr atraviesa la lista nueva copiada y siempre apunta al último nodo que creamos, que es el nodo anterior a donde agregaremos el siguiente nodo. Al igual que en el método removeRecord , aquí necesitamos una especie de puntero final. Dentro del bucle , creamos un nuevo nodo, avanzamos newLoopPtr para apuntar a él, copiamos los datos del nodo antiguo al nuevo y avanzamos oldLoopPtr. Después del ciclo, terminamos la nueva lista asignando NULL al siguiente campo del último nodo y devolvemos el puntero a la nueva lista . Entonces, ¿cómo resuelve este método auxiliar el problema que vimos anteriormente? Por sí solo, no es así. Pero con este código implementado, ahora podemos sobrecargar el operador de asignación. La sobrecarga de operadores es una característica de C++ que nos permite cambiar lo que hacen los operadores integrados con ciertos tipos. En este caso, queremos sobrecargar el operador de asignación (=), de modo que en lugar de la copia superficial predeterminada, llame a nuestro método copyList para realizar una copia profunda. En la sección pública de nuestra clase, agregamos: StudentCollection& operator=( const StudentCollection & rhs); El operador que estamos sobrecargando se especifica nombrando el método usando la palabra clave operador seguido del operador que queremos sobrecargar El nombre que he elegido para el parámetro (rhs . ) es una opción común para las sobrecargas de operadores porque representa el lado derecho. Esto ayuda al programador a mantener las cosas en orden. Entonces, en la declaración de asignación que inició esta discusión, s2 = s1, el objeto s1 sería el lado derecho de la operación de asignación y s2 sería el lado izquierdo. Hacemos referencia al lado derecho a través del parámetro y hacemos referencia al lado izquierdo accediendo directamente a los miembros de la clase, como lo haríamos con cualquier otro método de la clase. Entonces nuestra tarea en este caso es crear una lista apuntada por _listHead esa es una copia de la lista señalada por _listHead de rhs. Esto tendrá el efecto en la llamada s2 = s1 de hacer de s2 una copia fiel de s1. El tipo de parámetro es siempre una referencia constante a la clase en cuestión de retorno es siempre una referencia a la clase ; el tipo . Verás por qué el parámetro es una referencia en breve. Quizás se pregunte por qué el método devuelve algo, ya que estamos manipulando el miembro de datos directamente en el método. Esto se debe a que C++ permite asignaciones encadenadas, como s3 = s2 = s1, en las que el valor de retorno de una asignación se convierte en el parámetro de la siguiente. Resolver problemas con las clases 137 Machine Translated by Google Una vez que se entiende toda la sintaxis, el código para el operador de asignación es bastante directo: Colección de estudiantes& Colección de estudiantes::operador=(const Colección de estudiantes &rhs) { si (esto!= &rhs) { deleteList(_listHead); _listHead = listacopiada(rhs._listHead); } devolver *esto; } Para evitar una pérdida de memoria, primero debemos eliminar todos los nodos de la lista del lado izquierdo . (Es para este propósito que escribimos eliminarList como método auxiliar en lugar de incluir su código directamente en el destructor). Con la lista anterior de la izquierda eliminada, copiamos la lista de la derecha usando nuestro otro método auxiliar . Sin embargo, antes de realizar cualquiera de estos pasos, comprobamos que el objeto del lado derecho es diferente del objeto del lado izquierdo (es decir, no es algo así como s1 = s1) comprobando si los punteros son diferentes . . Si los punteros son idénticos, no es necesario hacer nada, pero no es sólo una cuestión de eficiencia. Si realizamos la copia profunda en punteros idénticos, cuando eliminemos los nodos actualmente en la lista del lado izquierdo, también estaríamos eliminando los nodos en la lista del lado derecho. Finalmente, devolvemos un puntero al objeto del lado izquierdo; Esto sucede ya sea que hayamos copiado algo o no, porque aunque una declaración como s2 = s1 = s1 es una locura, aún así nos gustaría que funcionara si alguien la intenta. Mientras tengamos nuestro método auxiliar de copia de listas, también deberíamos crear un constructor de copias. Este es un constructor que toma otro objeto de la misma clase como objeto. El constructor de copia se puede invocar explícitamente siempre que necesitemos crear un duplicado de una colección de estudiantes existente, pero los constructores de copia también se invocan implícitamente cada vez que un objeto de esa clase se pasa como parámetro de valor a una función. Debido a esto, debería considerar pasar parámetros de objeto como referencias constantes en lugar de parámetros de valor, a menos que la función que recibe el objeto necesite modificar la copia. De lo contrario, su código podría estar realizando mucho trabajo innecesariamente. Consideremos, por ejemplo, una colección estudiantil de 10.000 registros. La colección podría pasarse como referencia, un único puntero. Alternativamente, podría invocar al constructor de copias para un recorrido largo y 10 000 asignaciones de memoria, y esta copia local luego invocaría al destructor al final de la función con otro recorrido largo y 10 000 desasignaciones. Esta es la razón por la que el parámetro del lado derecho de la sobrecarga del operador de asignación utiliza un parámetro de referencia constante . Para agregar el constructor de copia a nuestra clase, primero agregamos su declaración a nuestra declaración de clase en la sección pública. StudentCollection(const StudentCollection &original); Como ocurre con todos los constructores, no hay tipo de retorno y, al igual que con el operador de asignación sobrecargado, el parámetro es una referencia constante a nuestra clase. La implementación es fácil porque ya tenemos el método auxiliar. 138 Capítulo 5 Machine Translated by Google Colección de estudiantes::Colección de estudiantes(const Colección de estudiantes y original) { _listHead = listacopiada(original._listHead); } Ahora podemos hacer una declaración como esta: Colección de estudiantes s2(s1); Esta declaración tiene el efecto de declarar s2 y copiar los nodos de s1 en él. El panorama general de las clases con memoria dinámica Realmente hemos hecho mucho con esta clase desde que completamos los métodos especificados en la descripción del problema, así que tomemos un momento para revisar. Así es como se ve nuestra declaración de clase ahora. clase colección de estudiantes { privado: estructura nodoestudiante { StudentRecord StudentData; StudentNode * siguiente; }; público: colecciónestudiante(); ~ColecciónEstudiante(); StudentCollection(const StudentCollection &original); Colección de estudiantes& operador=(const Colección de estudiantes &rhs); void addRecord(studentRecord nuevoEstudiante); estudianteRecord recordWithNumber(int idNum); void removeRecord(int idNum); privado: typedef nodoestudiante * lista de estudiantes; lista de estudiantes _listHead; void eliminarLista(listadeestudiantes &listPtr); lista de estudiantes lista copiada (const lista de estudiantes original); }; La lección aquí es que se requieren nuevas piezas al crear una clase con memoria dinámica. Además de las características de nuestro marco de clases básico (datos privados, un constructor predeterminado y métodos para enviar datos dentro y fuera del objeto), tenemos que agregar métodos adicionales para manejar la asignación y limpieza de la memoria dinámica. Como mínimo, deberíamos agregar un constructor de copia y un destructor y también sobrecargar el operador de asignación si existe alguna posibilidad de que alguien lo use. La creación de estos métodos adicionales a menudo se puede facilitar creando métodos auxiliares para copiar o eliminar la estructura de datos dinámica subyacente. Esto puede parecer mucho trabajo, y puede serlo, pero es importante tener en cuenta que todo lo que agregas a la clase es algo con lo que debes ocuparte de todos modos. En otras palabras, si no tuviéramos una clase para nuestra colección de listas enlazadas Resolver problemas con las clases 139 Machine Translated by Google de los registros de los estudiantes, todavía somos responsables de eliminar los nodos de la lista cuando terminemos con ellos. Aún tendríamos que tener cuidado con los enlaces cruzados, aún tendríamos que recorrer una lista y copiar nodo por nodo si quisiéramos una copia fiel de la lista original, y así sucesivamente. Poner todo en la estructura de clases es sólo un poco más de trabajo por adelantado, y una vez que todo funciona, el código del cliente puede ignorar todos los detalles de asignación de memoria. Al final, la encapsulación y la ocultación de información hacen que sea mucho más fácil trabajar con estructuras de datos dinámicas. Errores a evitar Hemos hablado sobre cómo crear una buena clase en C++, así que terminemos la discusión hablando de un par de errores comunes que debes evitar. La clase falsa Como mencioné al principio de este capítulo, creo que C++, como lenguaje híbrido que incluye tanto el paradigma procedimental como el orientado a objetos, es un gran lenguaje para aprender programación orientada a objetos porque la creación de una clase es siempre un elección positiva por parte del programador. En un lenguaje como Java, la pregunta nunca es: "¿Debería crear una clase?". sino más bien: "¿Cómo voy a poner esto en una clase?" El requisito de poner todo en una estructura de clases da como resultado lo que yo llamo una clase falsa, una clase sin un diseño coherente que sea correcto sintácticamente pero que no tenga un significado real. La palabra clase , tal como se usa en programación, se deriva del sentido de la palabra inglesa que significa un grupo de cosas con atributos comunes, y una buena clase de C++ cumple con esta definición. Las clases falsas pueden ocurrir por varias razones. Un tipo ocurre porque el programador realmente quiere usar variables globales, no por ninguna razón defendible (tales razones son raras, aunque existen), sino por pereza, simplemente para evitar pasar parámetros de una función a otra. Si bien el programador sabe que el uso generalizado de variables globales se considera un estilo terrible, cree que ha encontrado la laguna jurídica. Todas o la mayoría de las funciones del programa se agrupan en una clase, y las variables que habrían sido globales ahora son miembros de datos de la clase. La función principal del programa simplemente crea un objeto de la clase falsa e invoca algún método "maestro" en la clase. Técnicamente, el programa no utiliza variables globales, pero la clase falsa significa que el programa tiene los mismos defectos que uno que sí lo hace. Otro tipo de clase falsa ocurre porque el programador simplemente asume que la programación orientada a objetos siempre es “mejor” y la obliga a situaciones en las que no se aplica. En estos casos, el programador suele crear una clase que encapsula una funcionalidad muy específica que sólo tiene sentido en el contexto del programa original para el que está escrita. Hay dos formas de comprobar si estás escribiendo este tipo de clase falsa. La primera es preguntar: “¿Puedo darle a la clase un nombre específico y razonablemente corto?” Si se encuentra con un nombre como PayrollReportManagerAndPrintSpooler, es posible que tenga un problema. 140 Capítulo 5 Machine Translated by Google La otra prueba pregunta: “Si tuviera que escribir otro programa con funcionalidad similar, ¿puedo imaginar cómo se podría reutilizar la clase, con sólo pequeñas modificaciones? ¿O habría que reescribirlo dramáticamente? Incluso en C++, un cierto número de clases falsas es inevitable, por ejemplo, porque tenemos que encapsular datos para usarlos en clases de colección. Sin embargo, estas clases suelen ser pequeñas y básicas. Si podemos evitar clases falsas elaboradas, nuestro código mejorará. Personas con una sola tarea Si alguna vez ha visto el programa de televisión Good Eats, sabrá que el presentador Alton Brown pasa mucho tiempo discutiendo cómo debe equipar su cocina para lograr la máxima eficiencia. A menudo critica los aparatos de cocina que llama de una sola tarea, es decir, herramientas que hacen bien una tarea pero no hacen nada más. Al escribir nuestras clases, debemos esforzarnos por hacerlas lo más generales posible, consistentes con la inclusión de todas las funciones específicas requeridas para nuestro programa. Una forma de hacerlo es con clases de plantilla. Este es un tema avanzado con una sintaxis algo arcana, pero nos permite crear clases donde uno o más de los miembros de datos tienen un tipo que se especifica cuando se crea un objeto de la clase. Las clases de plantilla nos permiten “excluir” la funcionalidad general. Por ejemplo, nuestra clase StudentCollection contiene una gran cantidad de código que es común a cualquier clase que encapsule una lista vinculada. En su lugar, podríamos crear una clase de plantilla para una lista enlazada general, de modo que el tipo de datos dentro de los nodos de la lista se especifique cuando se crea el objeto de la clase de plantilla, en lugar de estar programado como un registro de estudiante . Entonces nuestra clase StudentCollection tendría un objeto de la clase de lista vinculada de plantilla como miembro de datos, en lugar de un puntero de encabezado de lista, y ya no manipularía la lista vinculada directamente. Las clases de plantilla están más allá del alcance de este libro, pero a medida que desarrolle sus habilidades como diseñador de clases, siempre debe esforzarse por crear clases que sean multitarea. Es una gran sensación cuando descubres que un problema actual se puede resolver usando una clase que escribiste anteriormente, mucho antes de que supieras que existía el problema actual. Ejercicios Sabes lo que voy a decir, ¿no? ¡Anímate y prueba algunos! 5­1. Intentemos implementar una clase usando el marco básico. Considere una clase para almacenar los datos de un automóvil. Tendremos tres datos: el nombre del fabricante y el nombre del modelo, ambas cadenas, y el año del modelo, un número entero. Cree una clase con métodos get/set para cada miembro de datos. Asegúrese de tomar buenas decisiones con respecto a detalles como los nombres de los miembros. No es importante que sigas mi convención de nomenclatura particular. Lo importante es que piense en las decisiones que toma y sea coherente en sus decisiones. Resolver problemas con las clases 141 Machine Translated by Google 5­2. Para nuestra clase de automóvil del ejercicio anterior, agregue un método de soporte que devuelva una descripción completa del objeto automóvil como una cadena formateada, como "Chevrolet Impala 1957". Agregue un segundo método de soporte que devuelva la antigüedad del automóvil en años. 5­3. Tome las funciones de cadena de longitud variable del Capítulo 4 (añadir, concatenar y carácterAt) y úselas para crear una clase para cadenas de longitud variable, asegurándose de implementar todos los constructores necesarios, un destructor y un operador de asignación sobrecargado. 5­4. Para la clase de cadena de longitud variable del ejercicio anterior, reemplace el Método caracterAt con un operador [] sobrecargado . Por ejemplo, si miCadena es un objeto de nuestra clase, entonces myString[1] debería devolver el mismo resultado que myString.characterAt(1). 5­5. Para la clase de cadena de longitud variable de los ejercicios anteriores, agregue una eliminación método que toma una posición inicial y una cantidad de caracteres y elimina esa cantidad de caracteres del medio de la cadena. Entonces myString.remove(5,3) eliminaría tres caracteres comenzando en la quinta posición. Asegúrese de que su método se comporte cuando el valor de cualquiera de los parámetros no sea válido. 5­6. Revise su clase de cadena de longitud variable para una posible refactorización. Por ejemplo, ¿existe alguna funcionalidad común que pueda separarse en un método de soporte privado? 5­7. Tome las funciones de registro de estudiantes del Capítulo 4 (addRecord y AverageRecord) y utilícelas para crear una clase que represente una colección de registros de estudiantes, como antes, asegurándose de implementar todos los constructores necesarios, un destructor y un operador de asignación sobrecargado. 5­8. Para la clase de colección de registros de estudiantes del ejercicio anterior, agregue un método RecordsWithinRange que tome una calificación baja y una calificación alta como parámetros y devuelva una nueva colección que consta de los registros en ese rango (la colección original no se ve afectada). Por ejemplo, miCollection.RecordsWithinRange(75, 80) devolvería una colección de todos los registros con calificaciones en el rango 75–80 inclusive. 142 Capítulo 5 Machine Translated by Google t l S YYPAG oh C A DY Y RESOLVIENDO PROBLEMAS CON RECURSIÓN Este capítulo trata sobre la recursividad, que es cuando una función se llama a sí misma directa o indirectamente. La programación recursiva parece que debería ser simple. De hecho, una buena solución recursiva suele tener una apariencia simple, casi elegante. Sin embargo, muy a menudo el camino hacia esa solución es cualquier cosa. pero sencillo. Esto se debe a que la recursividad requiere que pensemos de manera diferente a como lo hacemos con otros tipos de programación. Cuando procesamos datos usando bucles, pensamos en procesarlos de manera secuencial, pero cuando procesamos datos usando recursividad, nuestro proceso normal de pensamiento secuencial no ayudará. Muchos buenos programadores novatos luchan con la recursividad porque no ven una manera de aplicar las habilidades de resolución de problemas que han aprendido a problemas recursivos. En este capítulo, analizaremos cómo atacar sistemáticamente los problemas recursivos. La respuesta es utilizar lo que llamaremos la Gran Idea Recursiva, en adelante denominada BRI. Es una idea tan sencilla que parecerá un truco, pero funciona. Machine Translated by Google Revisión de los fundamentos de la recursividad No hay mucho que saber sobre la sintaxis de la recursividad; La dificultad surge cuando intentas utilizar la recursividad para resolver problemas. La recursividad ocurre cada vez que una función se llama a sí misma, por lo que la sintaxis de la recursividad es solo la sintaxis de una llamada a una función. La forma más común es la recursividad directa, cuando se produce una llamada a una función en el cuerpo de esa misma función. Por ejemplo: int factorial(int n) { if (n == 1) devuelve 1; * factorial(n ­ 1); } de lo contrario regresar sustantivo, masculino— Esta función, que es una demostración de recursividad común pero muy ineficiente, calcula el factorial de n. Por ejemplo, si n es 5, entonces el factorial es el producto de todos los números de 5 a 1, o 120. Tenga en cuenta que en algunos casos no se produce recursividad. En esta función, si el parámetro es 1, simplemente devolvemos un valor directamente sin ninguna recursividad , lo que se conoce como caso base. De lo contrario, hacemos la llamada recursiva . La otra forma de recursividad es la recursividad indirecta, por ejemplo, si la función A llama a la función B, que luego llama a la función A. La recursividad indirecta rara vez se utiliza como técnica de resolución de problemas, por lo que no la cubriremos aquí. Recursión de cabeza y cola Antes de analizar el BRI, debemos comprender la diferencia entre recursividad principal y recursiva final. En la recursividad principal, la llamada recursiva, cuando ocurre, viene antes que otro procesamiento en la función (piense en que sucede en la parte superior o cabecera de la función). En la recursividad de cola, es lo contrario: el procesamiento ocurre antes de la llamada recursiva. Elegir entre los dos estilos recursivos puede parecer arbitrario, pero la elección puede marcar la diferencia. Para ilustrar esta diferencia, veamos dos problemas. PROBLEMA: ¿CUÁNTOS LOROS? Los pasajeros del Tropical Paradise Railway (TPR) esperan ver docenas de loros de colores desde las ventanillas del tren. Debido a esto, el ferrocarril se interesa mucho por la salud de la población local de loros y decide contar el número de loros en cada andén a lo largo de la línea principal. Cada plataforma cuenta con un empleado de TPR (ver Figura 6­1), que ciertamente es capaz de contar loros. Desafortunadamente, el trabajo se complica por el primitivo sistema telefónico. Cada plataforma puede llamar sólo a sus vecinos inmediatos. ¿Cómo obtenemos el total del loro en la terminal de la línea principal? 144 Capítulo 6 Machine Translated by Google belinda Arte debbie Cory Evan Figura 6­1: Los empleados de las cinco estaciones sólo pueden comunicarse con sus vecinos inmediatos. Supongamos que hay 7 loros de Art en la terminal principal, 5 loros de Belinda, 3 loros de Cory, 10 loros de Debbie y 2 loros de Evan en la última estación. El número total de loros es, por tanto, 27. La pregunta es, ¿cómo van a trabajar juntos los empleados para comunicar este total a Art? Cualquier solución a este problema requerirá una cadena de comunicaciones desde la terminal principal hasta el final de la línea y viceversa. Se pedirá al miembro del personal de cada plataforma que cuente los loros y luego informará sus observaciones. Aun así, existen dos enfoques distintos para esta cadena de comunicaciones, y esos enfoques corresponden a las técnicas de recursividad principal y recursiva final en programación. Enfoque 1 En este enfoque, mantenemos un total acumulado de loros a medida que avanzamos en las comunicaciones salientes. Cada empleado, al realizar la solicitud al siguiente empleado de la línea, pasa el número de loros vistos hasta el momento. Cuando lleguemos al final de la línea, Evan será el primero en descubrir el total del loro, que le pasará a Debbie, quien se lo pasará a Cory, y así sucesivamente (como se muestra en la Figura 6­2). 2 4 6 8 1 3 5 7 9 Arte belinda Cory debbie Evan 13 12 11 10 Figura 6­2: Numeración de los pasos seguidos en el Método 1 para el problema de contar loros 1. ART comienza contando los loros alrededor de su plataforma. El cuenta 7 loros. 2. ARTE a BELINDA: “Hay 7 loros aquí en la terminal principal”. 3. BELINDA cuenta 5 loros alrededor de su plataforma para un total de 12. 4. BELINDA a CORY: “Hay 12 loros alrededor de las dos primeras estaciones”. 5. CORY cuenta 3 loros. Resolver problemas con recursividad 145 Machine Translated by Google 6. CORY a DEBBIE: "Hay 15 loros alrededor de las primeras tres estaciones". 7. DEBBIE cuenta 10 loros. 8. DEBBIE a EVAN: "Hay 25 loros alrededor de las primeras cuatro estaciones". 9. EVAN cuenta 2 loros y descubre que el número total de loros es 27. 10. EVAN a DEBBIE: “El número total de loros es 27”. 11. DEBBIE a CORY: "El número total de loros es 27". 12. CORY a BELINDA: “El número total de loros es 27”. 13. BELINDA a ART: “El número total de loros es 27”. Este enfoque es análogo a la recursividad de cola. En la recursividad de cola, la llamada recursiva ocurre después del procesamiento; la llamada recursiva es el último paso de la función. En la cadena de comunicaciones anterior, observe que el “trabajo” de los empleados (el conteo y la suma de loros) ocurre antes de que le indiquen al siguiente empleado. Todo el trabajo ocurre en la cadena de comunicaciones salientes, no en la cadena entrante. Estos son los pasos que sigue cada empleado: 1. Cuente los loros visibles desde el andén de la estación. 2. Sume este conteo al total dado por la estación anterior. 3. Llame a la siguiente estación para pasar la suma acumulada de conteos de loros. 4. Espere a que la siguiente estación llame con el conteo total de loros y luego pase este total a la estación anterior. Enfoque 2 En este enfoque, sumamos los recuentos de loros desde el otro extremo. Cada empleado, al comunicarse con la siguiente estación, solicita el número total de loros desde esa estación en adelante. Luego, el empleado suma el número de loros en su propia estación y pasa este nuevo total a la línea (como se muestra en la Figura 6­3). 2 1 4 3 Arte belinda Cory debbie 13 11 9 7 12 10 8 Evan 5 6 Figura 6­3: Numeración de los pasos seguidos en el Método 2 para el problema de contar loros 1. ARTE a BELINDA: “¿Cuál es el número total de loros de tu estación? ¿Hasta el final de la línea? 2. BELINDA a CORY: “¿Cuál es el número total de loros de tu estación? ¿Hasta el final de la línea? 146 Capítulo 6 Machine Translated by Google 3. CORY a DEBBIE: “¿Cuál es el número total de loros de tu estación? ¿Hasta el final de la línea? 4. DEBBIE a EVAN: “¿Cuál es el número total de loros de tu estación? ¿Hasta el final de la línea? 5. EVAN es el final del camino. Cuenta 2 loros. 6. EVAN a DEBBIE: “El número total de loros aquí al final es 2”. 7. DEBBIE cuenta 10 loros en su estación, por lo que el total desde su estación hasta el final es 12. 8. DEBBIE a CORY: “El número total de loros desde aquí hasta el final son 12”. 9. CORY cuenta 3 loros. 10. CORY a BELINDA: “El número total de loros desde aquí hasta el final es 15.” 11. BELINDA cuenta 5 loros. 12. BELINDA al ARTE: “El total de loros de aquí hasta el final es 20.” 13. ART cuenta 7 loros en la terminal principal, haciendo un total de 27. Este enfoque es análogo a la recursión de cabezas. En la recursividad principal, la llamada recursiva ocurre antes que el otro procesamiento. Aquí primero se llama a la siguiente estación, antes de contar los loros o realizar la suma. El “trabajo” se pospone hasta que las estaciones de la línea hayan informado sus totales. Estos son los pasos que sigue cada empleado: 1. Llame a la siguiente estación. 2. Cuente los loros visibles desde el andén de la estación. 3. Sume este conteo al total dado por la siguiente estación. 4. Pasar la suma resultante a la estación anterior. Es posible que haya notado dos efectos prácticos de los diferentes enfoques. En el primer enfoque, eventualmente todos los empleados de la estación conocerán el total general de loros. En el segundo enfoque, sólo Art, en la terminal principal, aprende el total completo, pero tenga en cuenta que Art es el único empleado que necesita el total completo. El otro efecto práctico será más importante para nuestro análisis cuando pasemos la discusión al código de programación real. En el primer enfoque, cada empleado pasa el “total acumulado” a la siguiente estación al realizar la solicitud. En el segundo enfoque, el empleado simplemente realiza la solicitud de información desde la siguiente estación, sin transmitir ningún dato. Este efecto es típico del enfoque de recursividad principal. Debido a que la llamada recursiva ocurre primero, antes de cualquier otro procesamiento, no hay información nueva para proporcionar la llamada recursiva. En general, el enfoque de recursividad principal permite pasar el conjunto mínimo de datos a la llamada recursiva. Ahora veamos otro problema. Resolver problemas con recursividad 147 Machine Translated by Google PROBLEMA: ¿QUIÉN ES NUESTRO MEJOR CLIENTE? El gerente de DelegateCorp necesita determinar cuál de ocho clientes genera mayores ingresos para su empresa. Dos factores complican esta tarea que de otro modo sería sencilla. En primer lugar, determinar los ingresos totales de un cliente requiere revisar todo el expediente de ese cliente y contar las cifras de docenas de pedidos y recibos. En segundo lugar, a los empleados de DelegateCorp, como su nombre indica, les encanta delegar y cada empleado pasa el trabajo a alguien de un nivel inferior siempre que es posible. Para evitar que la situación se salga de control, el gerente impone una regla: cuando delega, debe hacer una parte del trabajo usted mismo y debe darle al empleado delegado menos trabajo del que le asignaron a usted. Las tablas 6­1 y 6­2 identifican a los empleados y clientes de DelegateCorp. Tabla 6­1: Títulos y rango de los empleados de DelegateCorp Título Rango Gerente 1 Subgerente 2 Gerente asociado 3 Subdirector 4 gerente junior 5 Interno 6 Tabla 6­2: Clientes de DelegateCorp Número de cliente Ganancia #0001 $172,000 #0002 $68,000 #0003 $193,000 #0004 $13,000 #0005 $256,000 #0006 $99,000 Siguiendo la norma de la empresa sobre la delegación del trabajo, esto es lo que sucederá con los seis expedientes de clientes. El gerente tomará un archivo y determinará cuántos ingresos ha generado ese cliente para la empresa. El gerente delegará los otros cinco expedientes en el subgerente. El subdirector procesará un expediente y pasará los otros cuatro al director asociado. Este proceso continúa hasta llegar al sexto empleado, el becario, a quien se le entrega un expediente y debe simplemente procesarlo, sin posibilidad de delegar más. La figura 6­4 describe las líneas de comunicación y la división del trabajo. Sin embargo, al igual que en el ejemplo anterior, existen dos enfoques distintos de la cadena de comunicaciones. 148 Capítulo 6 Machine Translated by Google Gerente Gerente 1 dieciséis 2 dieciséis 15 1 Subgerente Subgerente 3 14 4 15 2 Gerente asociado 13 Gerente asociado 5 12 14 6 11 3 Subdirector Subdirector 7 10 8 13 4 Gerente Junior 9 Gerente Junior 9 8 12 10 Interno 5 7 Interno 11 6 (a) (b) Figura 6­4: Numeración de los pasos en el Método 1 (a) y el Método 2 (b) para encontrar el cliente con mayores ingresos Método 1 En este enfoque, al delegar los archivos restantes, el empleado también transfiere la mayor cantidad de ingresos vista hasta el momento. Esto significa que el empleado debe contar los ingresos en un archivo y compararlos con la cantidad más alta vista anteriormente antes de delegar los archivos restantes a otro empleado. He aquí un ejemplo de cómo procedería esto en la práctica. 1. El ADMINISTRADOR cuenta los ingresos del cliente n.° 0001, que son $172 000. 2. DEL GERENTE al VICEPRESIDENTE: “Los ingresos más altos que hemos visto hasta ahora son $172 000, cliente n.° 0001. Tome estos cinco archivos y determine los ingresos más altos en general”. 3. VICE MANAGER cuenta los ingresos del cliente n.° 0002, que son $68 000. Los ingresos más altos observados hasta ahora siguen siendo de 172 000 dólares, cliente n.º 0001. Resolver problemas con recursividad 149 Machine Translated by Google 4. VICEPRESIDENTE a ASOCIADO ADMINISTRADOR: “Los ingresos más altos que hemos visto hasta ahora son $172 000, cliente n.° 0001. Tome estos cuatro archivos y determine los ingresos más altos en general”. 5. El ADMINISTRADOR ASOCIADO cuenta los ingresos del cliente n.° 0003, que son $193 000. El ingreso más alto visto hasta ahora es de $193,000, cliente #0003. 6. GERENTE ASOCIADO a GERENTE ASISTENTE: “Los ingresos más altos que hemos visto hasta ahora son $193 000, cliente n.° 0003. Tome estos tres archivos y determine los ingresos más altos en general”. 7. El SUBGERENTE cuenta los ingresos del cliente n.° 0004, que son $13 000. Los ingresos más altos observados hasta ahora siguen siendo de 193 000 dólares, cliente n.º 0003. 8. SUBGERENTE a JUNIOR MANAGER: “Los ingresos más altos que hemos visto hasta ahora son $193 000, cliente n.º 0003. Tome estos dos archivos y determine los ingresos más altos en general”. 9. JUNIOR MANAGER cuenta los ingresos del cliente #0005, que es 256.000 dólares. El ingreso más alto visto hasta ahora es de $256,000, cliente #0005. 10. GERENTE JUNIOR a PASANTE: “Los ingresos más altos que hemos visto hasta ahora son $256 000, cliente n.° 0005. Tome este archivo restante y determine los ingresos más altos en general”. 11. INTERN cuenta los ingresos del cliente n.° 0006, que son $99 000. Los ingresos más altos observados hasta ahora siguen siendo de 256 000 dólares, cliente n.º 0005. 12. PASANTE DE JUNIOR MANAGER: “El ingreso más alto de todos los clientes es de $256 000, cliente n.° 0005”. 13. GERENTE JUNIOR a GERENTE ASISTENTE: “El ingreso más alto de todos los clientes es $256 000, cliente n.° 0005”. 14. SUBGERENTE a SUBGERENTE ASOCIADO: “El ingreso más alto de todos los clientes es de $256 000, cliente n.° 0005”. 15. GERENTE ASOCIADO a VICEPRESIDENTE: “El ingreso más alto de todos los clientes es de $256 000, cliente n.° 0005”. 16. VICE DIRECTOR a DIRECTOR: “El ingreso más alto de todos los clientes es de $256 000, cliente n.° 0005”. Este enfoque, que se muestra en la Figura 6­4 (a), utiliza la técnica de recursividad de cola. Cada empleado procesa un archivo de cliente y compara los ingresos calculados para ese cliente con los ingresos más altos observados hasta el momento. Luego, el empleado pasa el resultado de esa comparación al empleado subordinado. La recursividad (la desaparición del trabajo) ocurre después del otro procesamiento. El proceso de cada empleado se ejecuta así: 1. Cuente los ingresos en un archivo de cliente. 2. Compare este total con los ingresos más altos vistos por superiores en otros archivos de clientes. 3. Pasar los archivos de clientes restantes a un empleado subordinado, junto con la mayor cantidad de ingresos vista hasta ahora. 4. Cuando el empleado subordinado devuelva los ingresos más altos de todos los archivos de clientes, páselo al superior. 150 Capítulo 6 Machine Translated by Google Enfoque 2 En este enfoque, cada empleado comienza apartando un archivo y luego pasando los demás al subordinado. En este caso, no se le pide al subordinado que determine los ingresos más altos de todos los archivos, sólo de los archivos que se le han entregado. Al igual que con el primer problema de muestra, esto simplifica las solicitudes. Utilizando los mismos datos que en el primer enfoque, la conversación sería la siguiente: 1. DEL GERENTE al VICEPRESIDENTE: “Tome estos cinco archivos de clientes y dígaselo. Yo tengo los ingresos más altos”. 2. VICEPRESIDENTE al DIRECTOR ASOCIADO: “Tome estos cuatro clientes archivos y dime los ingresos más altos”. 3. GERENTE ASOCIADO al GERENTE ADJUNTO: “Tome estos tres archivos de clientes y dime cuáles son los ingresos más altos”. 4. SUBGERENTE al JUNIOR DIRECTOR: “Tome estos dos clientes archivos y dime los ingresos más altos”. 5. GERENTE JUNIOR a PASANTE: “Tome este archivo de cliente y dígaselo. Yo tengo los ingresos más altos”. 6. INTERN cuenta los ingresos del cliente n.° 0006, que son $99 000. Este es el único archivo que el INTERN ha visto, por lo que son los ingresos más altos. 7. PASANTE a JUNIOR MANAGER: “El ingreso más alto en mis archivos es $99,000, cliente #0006”. 8. JUNIOR MANAGER cuenta los ingresos del cliente #0005, que es 256.000 dólares. El ingreso más alto que conoce este empleado es de $256 000, cliente n.º 0005. 9. JUNIOR DIRECTOR a SUBDIRECTOR: “Los mayores ingresos en mis archivos hay $256,000, cliente #0005”. 10. El SUBGERENTE cuenta los ingresos del cliente n.° 0004, que son $13 000. El ingreso más alto que conoce este empleado es de $256 000, cliente n.º 0005. 11. SUBGERENTE a SUBGERENTE ASOCIADO: “El ingreso más alto en mis archivos es $256 000, cliente n.° 0005”. 12. El ADMINISTRADOR ASOCIADO cuenta los ingresos del cliente n.° 0003, que son $193 000. El ingreso más alto que conoce este empleado es de $256 000, cliente n.º 0005. 13. GERENTE ASOCIADO a VICEPRESIDENTE: “Los ingresos más altos en mis archivos cuestan $256,000, cliente #0005”. 14. VICE MANAGER cuenta los ingresos del cliente #0002, que es $68,000. El ingreso más alto que conoce este empleado es de $256 000, cliente n.º 0005. 15. VICE MANAGER a MANAGER: “El ingreso más alto en mis archivos es $256 000, cliente #0005”. 16. El ADMINISTRADOR cuenta los ingresos del cliente n.° 0001, que son $172 000. El ingreso más alto que conoce este empleado es de $256 000, cliente n.º 0005. Resolver problemas con recursividad 151 Machine Translated by Google Este enfoque, que se muestra en la figura 6­4 (b), utiliza la técnica de recursividad de la cabeza. Cada empleado todavía tiene que contabilizar los ingresos en un archivo de cliente, pero esa acción se pospone hasta que el empleado subordinado determine los ingresos más altos entre los archivos restantes. El proceso que realiza cada empleado es el siguiente: 1. Pasar todos los archivos de clientes excepto uno a un empleado subordinado. 2. Obtener los mayores ingresos de esos archivos del empleado subordinado. 3. Cuente los ingresos en el archivo de un cliente. 4. Pasar el mayor de esos dos ingresos al superior. Como en el problema de “contar loros”, la técnica de recursividad principal permite que cada empleado pase la cantidad mínima de información al subordinado. La gran idea recursiva Llegamos ahora a la Gran Idea Recursiva. De hecho, si ha leído los pasos de los problemas de muestra, ya habrá visto el BRI en acción. ¿Cómo es eso? Ambos problemas de muestra siguen la forma de una solución recursiva. Cada persona en la cadena de comunicaciones realiza los mismos pasos en un subconjunto cada vez más pequeño de los datos originales. Es importante señalar, sin embargo, que los problemas no implican recurrencia alguna. En el primer problema, cada empleado ferroviario hace una solicitud al siguiente estación en la línea, y al cumplir con esa solicitud, el siguiente empleado sigue los mismos pasos que el empleado anterior. Pero nada en el texto de la solicitud exige que un empleado siga esos pasos en particular. Cuando Art llamó a Belinda usando el Método 2, por ejemplo, le pidió que contara el número total de loros desde su estación hasta el final de la línea. No dictó un método para descubrir este total. Si lo hubiera pensado, se habría dado cuenta de que Belinda tendría que seguir los mismos pasos que él mismo estaba siguiendo, pero no tiene por qué considerar esto. Para completar su tarea, todo lo que Art necesitaba era que Belinda proporcionara la respuesta correcta a la pregunta que hizo. De manera similar, en el segundo problema, cada empleado de la cadena gerencial entrega tanto trabajo como sea posible a un subordinado. El subgerente, por ejemplo, puede conocer bien al gerente subalterno y esperar que éste le entregue todos los archivos menos uno al pasante. Sin embargo, el subdirector no tiene por qué preocuparse si el subdirector procesa todos los archivos restantes o pasa algunos de ellos a un subordinado. Al subdirector sólo le importa que el subdirector dé la respuesta correcta. Debido a que el subgerente no va a repetir el trabajo del subgerente, el subgerente simplemente asume que el resultado arrojado por el subgerente es correcto y utiliza esos datos para resolver la tarea general que el subgerente recibió del subgerente. 152 Capítulo 6 Machine Translated by Google En ambos problemas, cuando los empleados hacen peticiones a otros empleados, les preocupa qué pero no cómo. Se entrega una pregunta; se recibe una respuesta. Ésta, entonces, es la gran idea recursiva: si sigues ciertas convenciones en tu codificación, puedes fingir que no se está produciendo ninguna recursividad. Incluso puedes usar un truco económico (que se muestra a continuación) para pasar de una implementación iterativa a una implementación recursiva, sin considerar explícitamente cómo la recursión realmente resuelve el problema. Con el tiempo, desarrollará una comprensión intuitiva de cómo funcionan las soluciones recursivas, pero antes de que se desarrolle esa intuición, podrá diseñar implementaciones recursivas y tener confianza en su código. Pongamos el concepto en práctica mediante un ejemplo de código. PROBLEMA: CÁLCULO DE LA SUMA DE UNA ARRAY DE ENTEROS Escriba una función recursiva a la que se le proporcione una matriz de números enteros y el tamaño de la matriz como parámetros. La función devuelve la suma de los números enteros de la matriz. Es posible que su primer pensamiento haya sido que este problema sería trivial de resolver de forma iterativa. De hecho, comencemos con una solución iterativa a este problema: int iterativeArraySum(int enteros[], int tamaño) { suma int = 0; para (int i = 0; i < tamaño; i++) { suma += enteros[i]; } suma de devolución; } Viste un código muy similar a este en el Capítulo 3, por lo que la función debería ser fácil de entender. El siguiente paso es escribir código que esté a medio camino entre la solución iterativa y la solución recursiva final deseada. Mantendremos la función iterativa y agregaremos una segunda función a la que nos referiremos como despachador. El despachador entregará la mayor parte del trabajo a la función iterativa previamente escrita y utilizará esta información para resolver el problema general. Para escribir un despachador, debemos seguir dos reglas: 1. El despachador debe manejar completamente el caso más trivial, sin llamar a la función iterativa. 2. El despachador, al llamar a la función iterativa, debe pasar un valor más pequeño versión del problema. Al aplicar la primera regla a este problema, debemos decidir cuál es el caso más trivial. Si el tamaño es 0, entonces a la función se le ha pasado conceptualmente una matriz “nula”, con una suma de 0. También se podría argumentar que el caso más trivial debería ser cuando el tamaño es 1. En ese caso, solo habría un número en la matriz lógica, y podríamos devolver ese número como Resolver problemas con recursividad 153 Machine Translated by Google suma. Cualquiera de estas interpretaciones funcionará, pero hacer la primera elección permite que la función maneje un caso especial. Tenga en cuenta que la función iterativa original no fallará cuando el tamaño sea cero, por lo que sería preferible mantener esa flexibilidad. Para aplicar la segunda regla a este problema, debemos encontrar una manera de pasar una versión más pequeña del problema desde el despachador a la función iterativa. No existe una manera fácil de pasar una matriz más pequeña, pero podemos pasar fácilmente un valor de tamaño más pequeño. Si al despachador se le da el valor de 10 para el tamaño, se le pide a la función que calcule la suma de 10 valores en la matriz. Si el despachador pasa 9 como valor de tamaño a la función iterativa, está solicitando la suma de los primeros 9 valores de la matriz. Luego, el despachador puede sumar el valor del valor restante en la matriz (el décimo) para calcular la suma de los 10 valores. Tenga en cuenta que reducir el tamaño en 1 al llamar a la función iterativa maximiza el trabajo de la función iterativa y, por lo tanto, minimiza el trabajo del despachador. Este es siempre el enfoque deseado: al igual que los gerentes de DelegateCorp, la función de despachador evita tanto trabajo como sea posible. Juntando estas ideas, aquí hay una función de despachador para este problema: int arraySumDelegate(int enteros[], int tamaño) { if (tamaño == 0) devuelve 0; int últimoNúmero = enteros[tamaño ­ 1]; int allButLastSum = iterativeArraySum(enteros, tamaño ­ 1); return lastNumber + allButLastSum; } La primera declaración aplica la primera regla de los despachadores: busca un caso trivial y lo maneja por completo, en este caso, devolviendo 0 . De lo contrario, el control pasa al código restante, que aplica la segunda regla. El último número de la matriz se almacena en una variable local llamada últimoNúmero , y luego la suma de todos los demás valores de la matriz se calcula mediante una llamada a la función iterativa . Este resultado se almacena en otra variable local, allButLastSum, y finalmente la función devuelve la suma de las dos variables locales . Si hemos creado correctamente una función de despachador, ya hemos creado efectivamente una solución recursiva. Esta es la Gran Idea Recursiva en acción. Para convertir esta solución iterativa en una solución recursiva solo se requiere un paso más y simple: hacer que la función delegada se llame a sí misma donde antes llamaba a la función iterativa. Luego podemos eliminar la función iterativa por completo. int arraySumRecursive(int enteros[], int tamaño) { si (tamaño == 0) devuelve 0; int últimoNúmero = enteros[tamaño ­ 1]; int allButLastSum = arraySumRecursive(enteros, tamaño ­ 1); devolver últimoNúmero + todoPeroÚltimaSuma; } 154 Capítulo 6 Machine Translated by Google Sólo se han realizado dos cambios al código anterior. El nombre de la función se ha cambiado para describir mejor su nueva forma a la función iterativa , y la función ahora se llama a sí misma donde antes llamaba . La lógica de las dos funciones, arraySumDelegate y arraySumRecursive, es idéntica. Cada función busca un caso trivial en el que la suma ya sea conocida; en este caso, una matriz de tamaño 0 que tiene una suma de 0. De lo contrario, cada función calcula la suma de los valores en la matriz realizando una llamada a función. para calcular la suma de todos los valores, guarde el último. Finalmente, cada función suma ese último valor a la suma devuelta para obtener un total general. La única diferencia es que la primera versión de la función llama a otra función, mientras que la versión recursiva se llama a sí misma. El BRI nos dice que si seguimos las reglas descritas anteriormente para escribir al despachador, podemos ignorar esa distinción. No es necesario seguir literalmente todos los pasos que se muestran arriba para seguir el BRI. En particular, normalmente no implementaría una solución iterativa al problema antes de implementar una solución recursiva. Escribir una función iterativa como trampolín es un trabajo extra que eventualmente se desperdiciará. Además, la recursividad se aplica mejor a situaciones en las que una solución iterativa es difícil, como se explica más adelante. Sin embargo, puede seguir el esquema de la BRI sin tener que escribir la solución iterativa. La clave es pensar en una llamada recursiva como una llamada a otra función, sin tener en cuenta los aspectos internos de esa función. De esta manera, eliminas las complejidades de la lógica recursiva de la solución recursiva. Errores comunes Como se muestra arriba, con el enfoque correcto, las soluciones recursivas a menudo pueden ser muy fáciles de escribir. Pero puede ser igual de fácil encontrar una implementación recursiva incorrecta o una solución recursiva que "funcione" pero que sea desgarbada. La mayoría de los problemas con las implementaciones recursivas surgen de dos errores básicos: pensar demasiado en el problema o comenzar la implementación sin un plan claro. Pensar demasiado en los problemas recursivos es común para los nuevos programadores porque la experiencia limitada y la falta de confianza con la recursividad los llevan a pensar que el problema es más difícil de lo que realmente es. El código producido por pensar demasiado puede reconocerse por su apariencia demasiado cuidadosa. Por ejemplo, una función recursiva puede tener varios casos especiales en los que solo necesita uno. Comenzar la implementación demasiado pronto puede llevar a un código “Rube Goldberg” demasiado complicado, donde interacciones imprevistas conducen a correcciones que se incorporan al código original. Veamos algunos errores específicos y cómo evitarlos. Demasiados parámetros Como se describió anteriormente, la técnica de recursividad principal puede reducir los datos pasados a la llamada recursiva, mientras que la técnica de recursividad final puede resultar en pasar datos adicionales a las llamadas recursivas. Los programadores a menudo se quedan atrapados en el modo recursivo de cola porque piensan demasiado y comienzan la implementación demasiado pronto Resolver problemas con recursividad 155 Machine Translated by Google Considere nuestro problema de calcular recursivamente la suma de un conjunto de números enteros. Al escribir una solución iterativa a este problema, el programador sabe que se necesitará una variable de "total acumulado" (en la solución iterativa proporcionada, llamé a esta suma) y la matriz se sumará comenzando desde el primer elemento. Considerando la solución recursiva, el programador naturalmente imagina una implementación que refleje más directamente la solución iterativa, con una variable de total acumulado y la primera llamada recursiva manejando el primer elemento de la matriz. Sin embargo, este enfoque requiere que la función recursiva pase el total acumulado y la ubicación donde debe comenzar a procesarse la siguiente llamada recursiva. Una solución de este tipo se vería así: int arraySumRecursiveExtraParams(int enteros[], int tamaño, int suma, int índiceactual) { if (currentIndex == tamaño) devuelve la suma; suma += enteros[currentIndex]; devolver arraySumRecursiveExtraParameters (enteros, tamaño, suma, índice actual + 1); } Este código es tan corto como la otra versión recursiva pero considerablemente más complejo semánticamente debido a los parámetros adicionales, suma y índice actual . Desde el punto de vista del código del cliente, los parámetros adicionales no tienen sentido y siempre tendrán que ser ceros en la llamada, como se muestra en este ejemplo: int a[10] = {20, 3, 5, 22, 7, 9, 14, 17, 4, 9}; int total = arraySumRecursiveExtraParameters(a, 10, 0, 0); Este problema se puede evitar con el uso de una función contenedora, como se describe en la siguiente sección, pero como no podemos eliminar esos parámetros por completo, esa no es la mejor solución. La función iterativa para este problema y la función recursiva original responden a la pregunta: ¿cuál es la suma de esta matriz con tantos elementos? En contraste, a esta segunda función recursiva se le pregunta: ¿cuál es la suma de esta matriz si tiene tantos elementos, estamos comenzando con este elemento en particular y esta es la suma de todos los elementos anteriores? El problema de “demasiados parámetros” se evita eligiendo su función. parámetros de ción antes de pensar en la recursividad. En otras palabras, oblíguese a utilizar la misma lista de parámetros que usaría si la solución fuera iterativa. Si utiliza el proceso BRI completo y escribe primero la función iterativa, evitará este problema automáticamente. Sin embargo, si omite el uso formal de todo el proceso, aún puede usar la idea conceptualmente si escribe la lista de parámetros según lo que esperaría de una función iterativa. Variables globales Evitar demasiados parámetros a veces lleva a los programadores a cometer un error diferente: usar variables globales para pasar datos de una llamada recursiva a otra. El uso de variables globales es generalmente una mala práctica de programación, aunque a veces está permitido por motivos de rendimiento. Global 156 Capítulo 6 Machine Translated by Google Las variables siempre deben evitarse en funciones recursivas cuando sea posible. Veamos un problema específico para ver cómo los programadores se convencen a sí mismos de cometer este error. Supongamos que nos pidieran que escribiéramos una función recursiva que contara el número de ceros que aparecen en una matriz de números enteros. Este es un problema simple de resolver usando iteración: int zeroCountIterative(int números[], int tamaño) { suma int = 0; int recuento = 0; para (int i = 0; i < tamaño; i++) { if (números[i] == 0) cuenta ++; } recuento de devoluciones; } La lógica de este código es sencilla. Simplemente recorremos la matriz desde la primera ubicación hasta la última, contando los ceros a medida que avanzamos y usando una variable local, contamos , como rastreador. Sin embargo, si tenemos una función como esta en mente cuando escribimos nuestra función recursiva, podemos asumir que también necesitamos una variable de seguimiento en esa versión. No podemos simplemente declarar el recuento como una variable local en la versión recursiva porque entonces sería una nueva variable en cada llamada recursiva. Entonces podríamos sentirnos tentados a declararla como una variable global: recuento int; int zeroCountRecursive(int números[], int tamaño) { if (tamaño == 0) recuento de retorno; if (números[tamaño ­ 1] == 0) contar++; zeroCountRecursive(números, tamaño ­ 1); } Este código funciona, pero la variable global es completamente innecesaria y causa todos los problemas que suelen causar las variables globales, como una mala legibilidad y un mantenimiento del código más difícil. Algunos programadores podrían intentar mitigar el problema haciendo que la variable sea local, pero estática: int zeroCountStatic(int números[], int tamaño) { recuento int estático = 0; if (tamaño == 0) recuento de retorno; if (números[tamaño ­ 1] == 0) contar++; zeroCountStatic(números, tamaño ­ 1); } En C++, una variable local declarada como estática conserva su valor de una llamada de función a la siguiente; por lo tanto, el recuento de la variable estática local actuaría igual que la variable global en la versión anterior. ¿Entonces, cuál es el problema? La inicialización de la variable a cero ocurre sólo la primera vez que se llama a la función. Esto es necesario para que la declaración estática sea de alguna utilidad, pero significa que la función devolverá una respuesta correcta solo la primera vez que se llama. Si esta función fuera llamada dos veces, primero con una matriz que tenía tres Resolver problemas con recursividad 157 Machine Translated by Google ceros, luego con una matriz que tuviera cinco ceros; la función devolvería una respuesta de ocho para la segunda matriz porque el conteo comenzaría donde lo dejó. La solución para evitar la variable global en este caso es utilizar el BRI. Podemos suponer que una llamada recursiva con un valor de tamaño menor devolverá el resultado correcto y calculará el valor correcto para la matriz general a partir de ahí. Esto conducirá a una solución recursiva principal: int zeroCountRecursive(int números[], int tamaño) { si (tamaño == 0) devuelve 0; int count = zeroCountRecursive(números, tamaño ­ 1); if (números[tamaño ­ 1] == 0) contar++; recuento de devoluciones; } En esta función, todavía tenemos una variable local, contar , pero aquí no se intenta mantener su valor de una llamada a la siguiente. En cambio, almacena el valor de retorno de nuestra llamada recursiva; opcionalmente incrementamos la variable antes de devolverlo . Aplicar recursividad a estructuras de datos dinámicas La recursividad se aplica a menudo a estructuras dinámicas como listas vinculadas, árboles y gráficos. Cuanto más complicada sea la estructura, más se beneficiará la codificación de una solución recursiva. Procesar estructuras complicadas es a menudo muy parecido a encontrar el camino a través de un laberinto, y la recursividad nos permite retroceder a pasos anteriores de nuestro procesamiento. Recursividad y listas enlazadas Sin embargo, comencemos con la estructura dinámica más básica: una lista enlazada. Para las discusiones en esta sección, supongamos que tenemos la estructura de nodos más simple para nuestra lista vinculada, solo un int para datos. Aquí están nuestras declaraciones de tipo: estructura listaNnodo { datos enteros; listaNodo * próximo; }; typedef listNode * listPtr; La aplicación del BRI a una lista enlazada individualmente sigue el mismo esquema general independientemente de la tarea específica. La recursividad requiere que dividamos el problema para poder pasar una versión reducida del problema original a la llamada recursiva. Sólo hay una forma práctica de dividir una lista enlazada individualmente: el primer nodo de la lista y el resto de la lista. En la Figura 6­5, vemos una lista de muestra dividida en partes desiguales: el primer nodo y todos los demás nodos. Conceptualmente, podemos ver el "resto de" la lista original como su propia lista, comenzando con el segundo nodo de la lista original. Es esta vista la que permite que la recursividad funcione sin problemas. 158 Capítulo 6 Machine Translated by Google listaCabeza 7 12 14 NULO 9 Primer nodo Resto de la lista Figura 6­5: Una lista dividida en un primer nodo y "el resto de la lista" Nuevamente, sin embargo, no estamos obligados a imaginar todos los pasos de la recursividad para que funcione. escribe una función recursiva para conceptualizar como el primer nodo, con el que tenemos que tratar, y el resto de la lista, ? Primer nodo Resto de la lista Desde el punto de vista de alguien que procesar una lista enlazada, se puede 7 listaCabeza Figura 6­6: La lista como programador que usa recursividad debería representarla: un primer nodo y el resto de la lista como una forma nebulosa que se pasará a la llamada recursiva. que no lo hacemos y, por lo tanto, no lo hacemos. preocupado por. Esta actitud se muestra en la Figura 6­6. Una vez fijada la división del trabajo, podemos decir que el procesamiento recursivo de listas enlazadas individualmente se realizará de acuerdo con el siguiente plan general. Dada una lista enlazada L y una pregunta Q: 1. Si L es mínimo, asignamos directamente un valor predeterminado. De lo contrario . . . 2. Utilice una llamada recursiva para producir una respuesta a Q para el “resto de” la lista L (la lista que comienza con el segundo nodo de L). 3. Inspeccione el valor en el primer nodo de L. 4. Utilice los resultados de los dos pasos anteriores para responder Q para el total de L. Como puede ver, esta es solo una aplicación sencilla de la BRI dadas las restricciones prácticas para dividir una lista vinculada. Ahora apliquemos este modelo a un problema específico. PROBLEMA: CONTAR NEGATIVO NÚMEROS EN UNA LISTA ENLAZADA ÚNICAMENTE Escriba una función recursiva a la que se le proporcione una lista enlazada individualmente donde el tipo de datos sea un número entero. La función devuelve el recuento de números negativos en la lista. La pregunta Q que queremos responder es ¿cuántos números negativos hay? ¿en la lista? Por tanto, nuestro plan se puede enunciar como: 1. Si la lista no tiene nodos, el recuento es 0 de forma predeterminada. De lo contrario . . . 2. Utilice una llamada recursiva para contar cuántos números negativos hay en el "resto". de” la lista. 3. Vea si el valor en el primer nodo de la lista es negativo. Resolver problemas con recursividad 159 Machine Translated by Google 4. Utilice los resultados de los dos pasos anteriores para determinar cuántos números negativos hay en la lista completa. Aquí hay una implementación de función que se deriva directamente de este plan: int countNegative(listPtr encabezado) { si (head == NULL) devuelve 0; int listCount = countNegative(head­>siguiente); if (cabeza­>datos < 0) listCount++; devolver listaContar; } Observe cómo este código sigue los mismos principios que los ejemplos anteriores. Contará los números negativos "hacia atrás", desde el final de la lista hacia el frente. También tenga en cuenta que el código emplea la técnica de recursividad principal; Procesamos el “resto de” la lista antes de procesar el primer nodo. Como antes, esto nos permite evitar pasar datos adicionales en la llamada recursiva o usar variables globales. Observe también cómo la regla 1 de la lista enlazada, "si la lista L es mínima", se interpreta en la implementación específica de este problema como "si la lista no tiene nodos". Esto se debe a que tiene sentido decir que una lista sin nodos tiene cero valores negativos. Sin embargo, en algunos casos no hay una respuesta significativa para nuestra pregunta Q para una lista sin nodos, y el caso mínimo es una lista con un nodo. Supongamos que nuestra pregunta fuera: ¿cuál es el número más grande en esta lista? Esa pregunta no se puede responder para una lista sin valores. Si no entiendes por qué, imagina que eres un maestro de escuela primaria y que en tu clase sólo hay niñas. Si el director de tu escuela te preguntara cuántos niños de tu salón de clases eran miembros del coro de niños, podrías simplemente responder cero porque no tienes niños. Si el director te pidiera que nombraras al niño más alto de tu clase, no podrías dar una respuesta significativa a esa pregunta; tendrías que tener al menos un niño para tener un niño más alto. De la misma manera, si la pregunta sobre un conjunto de datos requiere al menos un valor para ser respondida de manera significativa, el conjunto de datos mínimo es un elemento. Sin embargo, es posible que aún quieras devolver algo para el caso de “tamaño cero”, aunque sólo sea por flexibilidad en el uso de la función y para protegerte contra un bloqueo. Recursión y árboles binarios Todos los ejemplos que hemos explorado hasta ahora no realizan más de una llamada recursiva. Sin embargo, las estructuras más complicadas pueden requerir múltiples llamadas recursivas. Para tener una idea de cómo funciona, consideremos la estructura conocida como árbol binario, en la que cada nodo contiene enlaces "izquierdos" y "derechos" a otros nodos. Estos son los tipos que usaremos: estructura nodoárbol { datos enteros; treeNode * izquierda; treeNode * derecha; }; typedef treeNode * treePtr; 160 Capítulo 6 Machine Translated by Google Debido a que cada nodo del árbol apunta a otros dos nodos, las funciones recursivas de procesamiento de árboles requieren dos llamadas recursivas. Conceptualizamos las listas enlazadas con dos partes: un primer nodo y el resto de la lista. Para aplicar la recursividad, conceptualizaremos los árboles con tres partes: el nodo en la parte superior, conocido como nodo raíz; todos los nodos alcanzados desde el enlace izquierdo de la raíz, conocido como subárbol izquierdo; y todos los nodos llegan desde el enlace derecho de la raíz, conocido como subárbol derecho. Esta conceptualización se muestra en la Figura 6­7. Al igual que con la lista enlazada y como desarrolladores de una solución recursiva, simplemente nos centramos en la existencia de los subárboles izquierdo y derecho, sin considerar su contenido. Esto se muestra en la Figura 6­8. datos izquierdo derecho nodo raíz 17 estructura treeNode 4 NULO 3 NULO NULO 144 dieciséis NULO NULO subárbol izquierdo 99 NULO NULO 217 NULO subárbol derecho Figura 6­7: Un árbol binario dividido en un nodo raíz y un subárbol izquierdo y derecho Como siempre, al resolver recursivamente nodo raíz problemas que involucran árboles binarios, queremos 17 emplear el BRI. Haremos llamadas a funciones recursivas y asumiremos que arrojan resultados correctos sin preocuparnos de cómo el proceso recursivo resuelve el problema general. Al igual que con las listas enlazadas, trabajaremos con las divisiones naturales de un árbol binario. Esto produce el siguiente plan general. Para responder una pregunta Q para el árbol T: 1. Si el árbol T tiene un tamaño mínimo, asigne ? subárbol izquierdo ? subárbol derecho Figura 6­8: Un programador que usa recursividad debería imaginar un árbol binario: un nodo raíz con subárboles izquierdo y derecho de estructura desconocida y no considerada .. directamente un valor predeterminado. De lo contrario . 2. Realice una llamada recursiva para responder Q para el subárbol izquierdo de T. 3. Realice una llamada recursiva para responder Q para el subárbol derecho de T. 4. Inspeccione el valor en el nodo raíz de T. 5. Utilice los resultados de los tres pasos anteriores para responder Q para todo T. Ahora apliquemos el plan general a un problema específico. Resolver problemas con recursividad 161 Machine Translated by Google PROBLEMA: ENCUENTRA EL VALOR MÁS GRANDE EN UN ÁRBOL BINARIO Escriba una función que, cuando se le proporcione un árbol binario donde cada nodo contiene un número entero, devuelva el número entero más grande del árbol. Aplicar el plan general a este problema específico da como resultado los siguientes pasos: 1. Si la raíz del árbol no tiene hijos, devuelve el valor en la raíz. De lo contrario . . . 2. Realice una llamada recursiva para encontrar el valor más grande en el subárbol izquierdo. 3. Realice una llamada recursiva para encontrar el valor más grande en el subárbol derecho. 4. Inspeccione el valor en el nodo raíz. 5. Devuelva el mayor de los valores de los tres pasos anteriores. Con esos pasos en mente, podemos escribir directamente el código de la solución: int maxValue(árbolPtr raíz) { if (raíz == NULL) devuelve 0; if (raíz­>derecha == NULL && raíz­>izquierda == NULL) return raíz­>datos; int leftMax = maxValue(raíz­>izquierda); int rightMax = maxValue(raíz­>derecha); int maxNum = raíz­>datos; if (leftMax > maxNum) maxNum = leftMax; if (rightMax > maxNum) maxNum = rightMax; devolver númmáx; } Observe cómo el árbol mínimo para este problema es un solo nodo vacío está cubierto por seguridad (aunque el caso del árbol ). Esto se debe a que la pregunta que formulamos sólo puede responderse de manera significativa con al menos un valor de datos. Considere el problema práctico si intentáramos hacer del árbol vacío el caso base. ¿Qué valor podríamos devolver? Si devolvemos cero, implícitamente requerimos algunos valores positivos en el árbol; Si todos los valores del árbol son negativos, se devolverá cero por error como el valor más grande del árbol. Podríamos resolver este problema devolviendo el número entero más bajo (más negativo) posible, pero entonces tendríamos que tener cuidado al adaptar el código a otros tipos numéricos. Al hacer de un solo nodo el caso base, evitamos esta decisión por completo. El resto del código es sencillo. Usamos recursividad para encontrar los valores máximos en los subárboles izquierdo y derecho . Luego encontramos el mayor de los tres valores (valor en la raíz, mayor en el subárbol izquierdo, mayor en el subárbol derecho) usando una variante del algoritmo "Rey de la colina" que hemos estado usando a lo largo de este libro 162 Capítulo 6 . Machine Translated by Google Funciones de contenedor En los ejemplos anteriores de este capítulo, hemos analizado sólo la función recursiva en sí. En algunos casos, sin embargo, la función recursiva necesita ser "configurada" por una segunda función. Más comúnmente, esto ocurre cuando escribimos funciones recursivas dentro de estructuras de clases. Esto puede provocar una discrepancia entre los parámetros necesarios para la función recursiva y los parámetros necesarios para un método público de la clase. Debido a que las clases normalmente obligan a ocultar información, es posible que el código del cliente de la clase no tenga acceso a los datos o tipos que requiere la función recursiva. Este problema y su solución se muestran en el siguiente ejemplo. PROBLEMA: ENCUENTRA EL NÚMERO DE HOJAS DE UN ÁRBOL BINARIO Para una clase que implementa un árbol binario, agregue un método de acceso público que devuelva el número de hojas (nodos sin hijos) en el árbol. El recuento de hojas debe realizarse mediante recursividad. Esbocemos un esquema de cómo podría verse esta clase antes de intentar implementar una solución a este problema. Para simplificar, incluiremos solo las partes relevantes de la clase, ignorando los constructores, el destructor e incluso los métodos que nos permitirían construir el árbol para centrarnos en nuestro método recursivo. clase árbol binario { público: int contarHojas(); privado: estructura binarioTreeNode { datos enteros; binarioTreeNode * izquierda; binarioTreeNode * derecha; }; typedef treeNode * treePtr; árbolPtr _root; }; Tenga en cuenta que nuestra función de recuento de hojas no toma parámetros . Desde el punto de vista de la interfaz, esto es exactamente correcto. Considere una llamada de ejemplo para un objeto binarioTree previamente construido : int numLeaves = bt.countLeaves(); Después de todo, si le preguntamos al árbol cuántas hojas tiene, ¿qué información podríamos proporcionarle al objeto que no conozca ya sobre sí mismo? Por muy correcto que sea esto para la interfaz, todo es incorrecto para la implementación recursiva. Si no hay ningún parámetro, ¿qué cambia de un recursivo? Resolver problemas con recursividad 163 Machine Translated by Google llamar a la siguiente? Nada puede cambiar en ese caso, excepto a través de variables globales, que, como se dijo anteriormente, deben evitarse. Si nada cambia, no hay forma de que la recursión progrese o finalice. La forma de solucionar este problema es escribir primero la función recursiva, conceptualizándola como una función fuera de una clase. En otras palabras, escribiremos esta función para contar las hojas en un árbol binario con el mismo estilo que escribimos la función para encontrar el valor más grande en un árbol binario. El único parámetro que debemos pasar es un puntero a nuestra estructura de nodos. Esto nos brinda otra oportunidad de emplear la BRI. ¿Cuál es la pregunta Q en este caso? ¿Cuántas hojas tiene el árbol? La aplicación del plan general para el procesamiento recursivo de árboles binarios a este problema específico da como resultado lo siguiente: 1. Si la raíz del árbol no tiene hijos, entonces el árbol tiene un nodo en total. Ese nodo es una hoja por definición, así que devuelve 1. En caso contrario. . . 2. Realice una llamada recursiva para contar las hojas en el subárbol izquierdo. 3. Realice una llamada recursiva para contar las hojas en el subárbol derecho. 4. En este caso, no es necesario inspeccionar el nodo raíz porque si llegamos a En este paso, no hay forma de que la raíz sea una hoja. Entonces . . . 5. Devuelve la suma de los pasos 2 y 3. Traducir este plan a código da como resultado esto: estructura binarioTreeNode { datos enteros; treeNode * izquierda; treeNode * derecha; }; typedef binarioTreeNode * treePtr; int countLeaves(treePtr rootPtr) { si (rootPtr == NULL) devuelve 0; si (rootPtr­>right == NULL && rootPtr­>left == NULL) devuelve 1; int leftCount = countLeaves(rootPtr­>left); int rightCount = countLeaves(rootPtr­>right); devolver Cuentaizquierda + Cuentaderecha; } Como puede ver, el código es una traducción directa del plan. La pregunta es, ¿cómo pasamos de esta función independiente a algo que podamos usar en la clase? Aquí es donde el programador desprevenido podría fácilmente meterse en problemas, pensando que necesitamos usar una variable global o hacer público el puntero raíz. Pero no necesitamos hacer eso; Podemos mantener todo dentro de la clase. El truco consiste en utilizar una función contenedora. Primero, colocamos la función independiente, con el parámetro treePtr , en la sección privada de nuestra clase. Luego, escribimos una función pública, la función contenedora, que "envolverá" la función privada. 164 Capítulo 6 Machine Translated by Google Debido a que la función pública tiene acceso a la raíz del miembro de datos privados, puede pasar esto a la función recursiva y luego devolver los resultados al cliente de esta manera: clase árbol binario { público: int publicCountLeaves(); privado: estructura binarioTreeNode { datos enteros; binarioTreeNode * izquierda; binarioTreeNode * derecha; }; typedef binarioTreeNode * treePtr; árbolPtr _root; int privateCountLeaves(árbolPtr raízPtr); }; int árbol binario::cuentaprivadaHojas(árbolPtr raízPtr) { si (rootPtr == NULL) devuelve 0; si (rootPtr­>right == NULL && rootPtr­>left == NULL) devuelve 1; int leftCount = privateCountLeaves(rootPtr­>izquierda); int rightCount = privateCountLeaves(rootPtr­>right); devolver Cuentaizquierda + Cuentaderecha; } int árbol binario::publicCountLeaves() { return privateCountLeaves(_root); } Aunque C++ permitiría que ambas funciones tuvieran el mismo nombre, para mayor claridad he usado nombres diferentes para distinguir entre las funciones públicas y privadas de “contar hojas”. El código en privateCountLeaves es exactamente el mismo que nuestra función independiente anterior countLeaves. La función contenedora publicCountLeaves es simple. Llama a privateCountLeaves, pasa la raíz del miembro de datos privados y devuelve el resultado . En esencia, "ceba la bomba" del proceso recursivo. Las funciones contenedoras son muy útiles al escribir funciones recursivas dentro de clases, pero se pueden usar siempre que exista una discrepancia entre la lista de parámetros requerida por una función y la lista de parámetros deseada de una persona que llama. Cuándo elegir la recursividad Los nuevos programadores a menudo se preguntan por qué alguien tiene que lidiar con la recursividad. Es posible que ya hayan aprendido que cualquier programa se puede construir utilizando estructuras de control básicas, como la selección (declaraciones if ) y la iteración (por ejemplo). y bucles while ). Si la recursividad es más difícil de emplear que las estructuras de control básicas y es innecesaria, tal vez debería ignorarse. Hay varias refutaciones a esto. En primer lugar, la programación recursiva ayuda a los programadores a pensar de forma recursiva, y el pensamiento recursivo se emplea en todo el mundo de la informática en áreas como el diseño de compiladores. Segundo, Resolver problemas con recursividad 165 Machine Translated by Google algunos lenguajes simplemente requieren recursividad porque carecen de algunas estructuras de control básicas. Las versiones puras del lenguaje Lisp, por ejemplo, requieren recursividad en casi todas las funciones no triviales. Sin embargo, la pregunta sigue siendo: si un programador ha estudiado la recursividad lo suficiente como para “entenderlo” y está utilizando un lenguaje con todas las funciones como C++, Java o Python, ¿debería emplearse alguna vez la recursividad? ¿Tiene la recursividad un uso práctico en estos lenguajes o es sólo un ejercicio mental? Argumentos en contra de la recursividad Para explorar esta pregunta, enumeremos las características negativas de la recursividad. Complejidad conceptual Para la mayoría de los problemas, es más difícil para el programador promedio resolver un problema usando la recursividad. Incluso una vez que comprenda la gran idea recursiva, en la mayoría de los casos seguirá siendo más fácil escribir código utilizando bucles. Actuación Las llamadas a funciones generan una sobrecarga significativa. La recursividad implica muchas llamadas a funciones y, por lo tanto, puede ser lenta. Requisitos de espacio La recursividad no emplea simplemente muchas llamadas a funciones; también los anida. Es decir, puede terminar con una larga cadena de llamadas a funciones esperando a que se completen otras llamadas. Cada llamada a función que ha comenzado pero que aún no ha finalizado ocupa espacio adicional en la pila del sistema. A primera vista, esta lista de características constituye una fuerte acusación contra la recursividad por considerarla difícil, lenta y derrochadora de espacio. Sin embargo, estos argumentos no son válidos universalmente. Entonces, la regla más básica para decidir entre recursividad e iteración es elegir la recursividad cuando estos argumentos no se aplican. Considere nuestra función que cuenta el número de hojas en un árbol binario. ¿Cómo resolverías este problema sin recursividad? Es posible, pero necesitaría un mecanismo explícito para mantener el "sendero de ruta de navegación" de los nodos para los cuales los hijos de la izquierda ya han sido visitados pero no los hijos de la derecha. Estos nodos tendrían que ser revisados en algún momento para que pudiéramos viajar por el lado derecho. Podrías almacenar estos nodos en una estructura dinámica, como una pila. A modo de comparación, aquí hay una implementación de la función que utiliza la clase de pila de la biblioteca de plantillas estándar de C++: int árbol binario::stackBasedCountLeaves() { si (_root == NULL) devuelve 0; int número de hojas = 0; stack< binaryTreeNode *> nodos; nodos.push(_root); mientras ( !nodos.empty()) { treePtr currentNode = nodes.top(); nodos.pop(); if (currentNode­>left == NULL && currentNode­>right == NULL) leafCount++; 166 Capítulo 6 Machine Translated by Google demás { if (currentNode­>right != NULL) nodes.push(currentNode­>right); if (currentNode­>left != NULL) nodes.push(currentNode­>left); } } devolver recuento de hojas; } Este código sigue el mismo patrón que el original, pero si nunca antes ha usado la clase de pila, es necesario hacer algunos comentarios. La clase pila funciona como la pila del sistema que analizamos en el Capítulo 3; puedes agregar y eliminar elementos solo en la parte superior. Tenga en cuenta que podríamos realizar nuestra operación de recuento de hojas utilizando cualquier estructura de datos que no tenga un tamaño fijo. Podríamos haber usado un vector, por ejemplo, pero el uso de la pila refleja más directamente el código original. Cuando declaramos la pila , especificamos el tipo de elementos que almacenaremos allí. En este caso, almacenaríamos punteros a nuestra estructura binariaTreeNode métodos de clase de pila en este código. El método push . Usamos cuatro coloca un elemento (un puntero de nodo, en este caso) en la parte superior de la pila. El método vacío nos dice si quedan elementos en la pila. El método top parte superior de la pila, y el método pop nos da una copia del elemento en la elimina el elemento superior de la pila. El código resuelve el problema colocando un puntero al primer nodo de la pila y luego eliminando repetidamente un puntero a un nodo de la pila, verificando si es una hoja, incrementando nuestro contador si lo es y colocando punteros a los nodos secundarios. , si existen, en la pila. Entonces, la pila realiza un seguimiento de los nodos que hemos descubierto, pero que aún tenemos que procesar, de la misma manera que la cadena de llamadas recursivas en la versión recursiva realiza un seguimiento de los nodos que debemos volver a visitar. Al comparar esta versión iterativa con la versión recursiva, vemos que ninguna de las objeciones estándar a la recursividad se aplica con mucho vigor en este caso. Primero, este código es más largo y complicado que la versión recursiva, por lo que no hay ningún argumento en contra de la versión recursiva sobre la base de la complejidad conceptual. En segundo lugar, observe cuántas llamadas a funciones realiza stackBasedCountLeaves : para cada visita a un nodo interior (es decir, no una hoja), esta función realiza cuatro llamadas a funciones: una para vaciar y otra para tapar, y dos para empujar. La versión recursiva realiza sólo dos llamadas recursivas para cada nodo interior. (Tenga en cuenta que es posible evitar las llamadas de función al objeto de pila incorporando la lógica de la pila dentro de la función. Sin embargo, esto aumentaría aún más la complejidad de la función). En tercer lugar, si bien esta versión iterativa no No utiliza espacio adicional en la pila del sistema, sino que hace uso explícito de una pila privada. Para ser justos, esto es menos espacio que la sobrecarga de la pila del sistema de las llamadas recursivas, pero sigue siendo un gasto de memoria del sistema en proporción a la profundidad máxima del árbol binario que estamos atravesando. Debido a que en este caso las objeciones contra la recursividad se mitigan o minimizan, la recursividad es una buena opción para el problema. En términos más generales, si un problema es fácil de resolver de forma iterativa, entonces la iteración debería ser su primera opción. La recursividad debe usarse cuando la iteración sea complicada. A menudo, esto implica la necesidad del mecanismo de “ruta de ruta de navegación” que se muestra aquí. Resolver problemas con recursividad 167 Machine Translated by Google Los recorridos de estructuras ramificadas, como árboles y gráficos, son inherentemente recursivos. El procesamiento de estructuras lineales, como matrices y listas enlazadas, normalmente no requiere recursividad, pero existen excepciones. Nunca te equivocarás al intentar por primera vez un problema mediante la iteración y viendo hasta dónde llegas. Como último conjunto de ejemplos, considere los siguientes problemas de listas enlazadas. PROBLEMA: MOSTRAR UNA LISTA VINCULADA EN ORDEN Escriba una función a la que se le pase el puntero principal de una lista enlazada individualmente donde el tipo de datos de cada nodo sea un número entero y que muestre esos números enteros, uno por línea, en el orden en que aparecen en la lista. PROBLEMA: MUESTRA UNA LISTA VINCULADA EN ORDEN INVERSO Escriba una función a la que se le pase el puntero principal de una lista enlazada individualmente donde el tipo de datos de cada nodo sea un número entero y que muestre esos números enteros, uno por línea, en el orden inverso en que aparecen en la lista. Debido a que estos problemas son imágenes especulares entre sí, es natural suponer que sus implementaciones también serían imágenes especulares. De hecho, ese es el caso de las implementaciones recursivas. Usando listNode y listPtr tipo dado anteriormente, aquí hay funciones recursivas para resolver ambos problemas: void displayListForwardsRecursion (listPtr cabeza) { si (cabeza! = NULL) { cout << cabeza­>datos << "\n"; displayListForwardsRecursion(cabeza­>siguiente); } } void displayListBackwardsRecursion (listPtr cabeza) { si (cabeza! = NULL) { displayListBackwardsRecursion(cabeza­>siguiente); cout << cabeza­>datos << "\n"; } } Como puede ver, el código en estas funciones es idéntico excepto por el orden de las dos declaraciones dentro de la declaración if . Eso hace toda la diferencia. En el primer caso, mostramos el valor en el primer nodo antes de realizar la llamada recursiva para mostrar el resto de la lista . En el segundo caso, hacemos la llamada para mostrar el resto de la lista valor en el primer nodo 168 Capítulo 6 antes de mostrar el . Esto da como resultado una visualización general hacia atrás. Machine Translated by Google Dado que ambas funciones son igualmente concisas, se podría suponer que la recursividad se utiliza correctamente para resolver ambos problemas, pero ese no es el caso. Para ver eso, veamos implementaciones iterativas de ambas funciones. void displayListForwardsIterative(listPtr encabezado) { for (listPtr actual = encabezado; actual != NULL; actual = actual­>siguiente) cout << actual­>datos << "\n"; } void displayListBackwardsIterative(listPtr encabezado) { apilar nodos<listPtr>; for (listPtr actual = encabezado; actual! = NULL; actual = actual­>siguiente) nodos.push(actual); mientras (!nodos.empty()) { nodePtr actual = nodos.top(); nodos.pop(); cout << datos actuales­><< "\n"; } } La función para mostrar la lista en orden no es más que un bucle transversal directo , como los que vimos en el Capítulo 4. Sin embargo, la función para mostrar la lista en orden inverso es más complicada. Adolece del mismo requisito de “sendero de ruta de navegación” que nuestros problemas de árbol binario. Mostrar los nodos en una lista vinculada en orden inverso requiere regresar a los nodos anteriores por definición. En una lista enlazada individualmente, no hay manera de hacerlo usando la lista misma, por lo que se requiere una segunda estructura. En este caso, necesitamos otra pila. Después de declarar la pila , empujamos todos los nodos de nuestra lista enlazada a la pila usando un bucle for . Debido a que se trata de una pila, donde cada elemento se agrega encima de los elementos anteriores, el primer elemento de la lista vinculada estará en la parte inferior de la pila y el último elemento de la lista vinculada estará en la parte superior. Entramos en un bucle while que continúa hasta que la pila está vacía , tomando repetidamente un puntero al nodo superior de la pila , eliminando ese puntero de nodo de la pila y luego mostrando los datos en el nodo al que se hace referencia . Debido a que los datos en la parte superior son los últimos datos en la lista vinculada, esto tiene el efecto de mostrar los datos en la lista vinculada en ord Al igual que con la función de árbol binario iterativo mostrada anteriormente, sería posible escribir esta función sin usar una pila (construyendo una segunda lista dentro de la función que sea inversa a la original). Sin embargo, no hay manera de hacer que la segunda función sea tan simple como la primera o de evitar atravesar efectivamente dos estructuras en lugar de una. Al comparar las implementaciones recursiva e iterativa, es fácil ver que la función iterativa “hacia adelante” es tan simple que no existe ninguna ventaja práctica en emplear la recursividad, y existen varias desventajas prácticas. Por el contrario, la función recursiva "hacia atrás" es más simple que la versión iterativa y se debe esperar que funcione aproximadamente tan bien como la versión iterativa. Por lo tanto, la función “hacia atrás” es un uso razonable de la recursividad, mientras que la función “hacia adelante”, aunque es un buen ejercicio de programación recursiva, no es un buen uso práctico de la recursividad. Resolver problemas con recursividad 169 Machine Translated by Google Ejercicios Como siempre, ¡es imperativo probar las ideas presentadas en el capítulo! 6­1. Escribe una función para calcular la suma solo de los números positivos en una matriz de números enteros. Primero, resuelva el problema mediante iteración. Luego, utilizando la técnica que se muestra en este capítulo, convierta su función iterativa en una función recursiva. 6­2. Considere una matriz que representa una cadena binaria, donde el valor de datos de cada elemento es 0 o 1. Escriba una función bool para determinar si la cadena binaria tiene paridad impar (un número impar de 1 bits). Sugerencia: recuerde que la función recursiva devolverá verdadero (impar) o falso (par), no el recuento de 1 bit. Resuelva el problema primero usando la iteración y luego la recursividad. 6­3. Escriba una función a la que se le pase una matriz de números enteros y un número "objetivo" y eso devuelve el número de apariciones del objetivo en la matriz. Resuelva el problema primero usando la iteración y luego la recursividad. 6­4. Diseñe el suyo propio: encuentre un problema que procese una matriz unidimensional que ya haya resuelto o que sea trivial para usted en su nivel de habilidad actual, y resuelva el problema (o resuélvalo nuevamente) usando recursividad. 6­5. Resuelva el ejercicio 6­1 nuevamente, usando una lista enlazada en lugar de una matriz. 6­6. Resuelva el ejercicio 6­2 nuevamente, usando una lista enlazada en lugar de una matriz. 6­7. Resuelva el ejercicio 6­3 nuevamente, usando una lista enlazada en lugar de una matriz. 6­8. Diseñe el suyo propio: intente descubrir un problema de procesamiento de listas enlazadas que sea Es difícil de resolver mediante iteración, pero se puede resolver directamente mediante recursividad. 6­9. Algunas palabras en programación tienen más de un significado común. En En el capítulo 4, aprendimos sobre el montón, del cual obtenemos memoria asignada con nueva. El término montón también describe un árbol binario en el que cada valor de nodo es mayor que cualquiera del subárbol izquierdo o derecho. Escriba una función recursiva para determinar si un árbol binario es un montón. 6­10. Un árbol de búsqueda binario es un árbol binario en el que el valor de cada nodo es mayor que cualquier valor en el subárbol izquierdo de ese nodo pero menor que cualquier valor en el subárbol derecho del nodo. Escriba una función recursiva para determinar si un árbol binario es un árbol de búsqueda binario. 6­11. Escriba una función recursiva a la que se le pase el puntero raíz de un árbol de búsqueda binario y un nuevo valor para insertar y que cree un nuevo nodo con el nuevo valor, colocándolo en la ubicación correcta para mantener la estructura del árbol de búsqueda binario. Sugerencia: considere convertir el parámetro del puntero raíz en un parámetro de referencia. 6­12. Diseñe el suyo propio: considere preguntas estadísticas básicas que pueda formular sobre un conjunto de valores numéricos, como promedio, mediana, moda, etc. Intente escribir funciones recursivas para calcular esas estadísticas para un árbol binario de números enteros. Algunos son más fáciles de escribir que otros. ¿Por qué? 170 Capítulo 6 Machine Translated by Google RESOLVIENDO PROBLEMAS CON REUTILIZACIÓN DE CÓDIGO Y t l S YYPAG oh C Este capítulo es muy diferente a los anteriores. En capítulos anteriores, enfaticé la importancia de encontrar su propia solución a los problemas. Después de todo, de eso se trata el libro: de escribir soluciones originales a problemas de programación. Incluso en capítulos anteriores, sin em A DY Hablé de cómo siempre aprendes de lo que has escrito antes y es por eso que debes conservar todo el código que escribes para referencia futura. En este capítulo, iremos un paso más allá y discutiremos cómo usar código e ideas de otros programadores para resolver nuestros problemas. Si recuerda cómo empezó este libro, este tema puede parecer una inclusión extraña. Al principio hablé del error que era intentar resolver problemas complejos modificando el código de otra persona. Esto no sólo tiene pocas posibilidades de éxito, sino que incluso cuando tiene éxito, no le proporciona ninguna experiencia de aprendizaje. Y si esto es todo lo que hace, en realidad nunca se convertirá en programador y su utilidad en el desarrollo de software será limitada. Dicho esto, una vez que cualquier problema de programación alcanza un tamaño respetable, no es razonable Machine Translated by Google Espere que un programador desarrolle una solución completamente desde cero. Ése es un uso ineficiente del tiempo del programador y depende demasiado de que el programador sea un experto en todas las cosas. Además, es más probable que el programa tenga errores o sea difícil de mantener. Buena reutilización y mala reutilización Por lo tanto, debemos distinguir entre una buena reutilización, que nos permite escribir mejores programas y escribirlos más rápidamente, y una mala reutilización, que puede permitirnos pasar por un programador por un tiempo pero que finalmente conduce a un desarrollo deficiente, tanto del código como del programador. . La tabla 7­1 resume las diferencias. La columna de la izquierda muestra las propiedades de una buena reutilización y la columna de la derecha muestra las propiedades de una mala reutilización. Al considerar si intentar o no reutilizar el código, pregúntese si es más probable que produzca las propiedades en la columna de la izquierda o en la columna de la derecha. Tabla 7­1: Reutilización de códigos buenos y malos Buena reutilización Mala reutilización Siguiendo un modelo Copiar el trabajo de otra persona Magnifica y amplía tus capacidades Falsifica tus capacidades Te ayuda a aprender Te ayuda a evitar el aprendizaje. Ahorra tiempo a corto y largo plazo Puede ahorrar tiempo a corto plazo pero puede alargar el tiempo a largo plazo Resultados en un programa de trabajo. Puede resultar en un programa que de todos modos no funciona Es importante tener en cuenta que la diferencia entre una buena y una mala reutilización no reside en qué código reutiliza o cómo lo reutiliza, sino en su relación con el código y los conceptos que está tomando prestado. Una vez, mientras escribía un trabajo final en una clase de literatura, descubrí que algo que había aprendido en un curso anterior era relevante para el tema de mi trabajo, así que lo incluí. Cuando le envié un borrador de mi artículo a la profesora, ella me dijo que necesitaba una cita para esa información. Frustrado, le pregunté a mi profesor en qué momento podía simplemente exponer mis conocimientos en un artículo sin proporcionar una referencia. Su respuesta fue que podía dejar de hacer referencia a otros por lo que tenía en la cabeza cuando me volviera tan experto que otros hicieran referencia a mí. En términos de programación, una buena reutilización ocurre cuando usted mismo escribe el código. basado en la lectura de la descripción de alguien de un concepto general o cuando hace uso de código que podría haber escrito usted mismo. A lo largo de este capítulo, hablaremos sobre cómo puede apropiarse de los conceptos de codificación para estar seguro de que su reutilización lo ayudará a convertirse en un mejor programador, no en uno más vago. Permítanme llamar también la atención sobre la última fila del Cuadro 7­1. Los intentos de mala reutilización a menudo fracasan por completo. Esto no es sorprendente, porque se trata de un programador que utiliza un código que en realidad no comprende. En algunas situaciones, el código prestado funcionará inicialmente, pero cuando el programador 172 Capítulo 7 Machine Translated by Google Cuando se intenta modificar o ampliar el código base prestado, la falta de comprensión profunda elimina la posibilidad de un enfoque organizado. Luego, el programador recurre a la agitación y al ensayo y error, violando así la primera y más importante de nuestras reglas generales de resolución de problemas: tener siempre un plan. Revisión de los fundamentos de los componentes Ahora que sabemos el tipo de reutilización que buscamos, categoricemos las diferentes formas en que se puede reutilizar el código. En este libro, usaré el término componente para referirme a cualquier cosa creada por un programador que pueda ser reutilizada por otro para ayudar a resolver un problema de programación. Los componentes pueden existir en cualquier parte del continuo, desde lo abstracto a lo concreto, desde una idea hasta un código completamente implementado. Si pensamos que resolver un problema de programación es algo análogo a abordar un proyecto de personal de mantenimiento, las técnicas que hemos aprendido para resolver problemas son como herramientas y los componentes son como piezas especiales. Cada uno de los siguientes componentes es una forma diferente de reutilizar el trabajo anterior de los programadores. Bloque de código Un bloque de código es simplemente eso: un bloque de código que ha sido copiado de una lista de programas a otra. De manera más coloquial, lo llamaríamos un trabajo de copiar y pegar. Esta es la forma más baja de uso de componentes y, a menudo, es una mala reutilización, con todos los problemas que ello implica. Por supuesto, si el código que está copiando es suyo, no hay ningún daño real, excepto que podría considerar empaquetar el código existente como una biblioteca de clases u otra estructura para permitir su reutilización de una manera más limpia y más fácil de mantener. Algoritmos Un algoritmo es una receta de programación; es un método particular para lograr una meta y se expresa en lenguaje sencillo o gráficamente como en un diagrama de flujo. Por ejemplo, en el Capítulo 3, analizamos la operación de clasificación para matrices y las diferentes formas en que se podría lograr esta clasificación. Un método para ordenar una matriz es el algoritmo de ordenación por inserción, y mostré una implementación de muestra del algoritmo. Es importante tener en cuenta que el código proporcionado fue una implementación de la ordenación por inserción, pero la ordenación por inserción es el algoritmo en sí (esa forma de ordenar una matriz) y no el código en particular. La ordenación por inserción funciona tomando repetidamente el siguiente valor no ordenado en la matriz y desplazando los valores ordenados “hacia arriba” una posición hasta que hayamos hecho un agujero en la posición correcta para el valor que estamos insertando actualmente. Cualquier código que utilice este método para ordenar una matriz es un ordenamiento por inserción. Los algoritmos son una forma de reutilización de alto nivel y generalmente conducen a buenas propiedades de reutilización. Los algoritmos son esencialmente sólo ideas y usted, el programador, debe implementar las ideas, recurriendo a sus habilidades de programación y su profundo conocimiento del algoritmo en sí. Los algoritmos que comúnmente Resolver problemas con la reutilización de código 173 Machine Translated by Google su uso está bien estudiado y tiene un rendimiento predecible en diversas situaciones. Con un algoritmo como modelo, puede tener confianza en la exactitud de su código y en su rendimiento. Sin embargo, existen algunas desventajas potenciales al basar el código en un algoritmo. Cuando utilizas un algoritmo, estás comenzando en el nivel conceptual. Por lo tanto, le queda un largo camino por recorrer hasta llegar al código terminado para esa sección del programa. El algoritmo ciertamente ahorra tiempo, porque el aspecto de resolución de problemas es esencialmente completo, pero dependiendo del algoritmo y su aplicación particular en su programación, la implementación del algoritmo puede no ser trivial. Patrones En programación, un patrón (o patrón de diseño) es una plantilla para una técnica de programación particular. El concepto está relacionado con un algoritmo pero es distinguible. Los algoritmos son como recetas para resolver problemas particulares, mientras que los patrones son técnicas generales utilizadas en situaciones de programación particulares. Los problemas que resuelven los patrones suelen estar dentro de la estructura del propio código. Por ejemplo, en el Capítulo 6 analizamos el problema que presenta una función recursiva en una clase de lista enlazada: la función recursiva necesitaba el puntero "encabezado" al primer nodo de la lista como parámetro, pero esos datos debían permanecer privados. La solución fue crear un contenedor, una función que adaptaría una lista de parámetros a otra. La técnica del envoltorio es un patrón de diseño. Podemos usar este patrón para resolver el problema de una función recursiva en una clase, pero también se puede usar de otras maneras. Por ejemplo, supongamos que tenemos una clase LinkedList que permite insertar o eliminar elementos en cualquier punto de la lista, pero lo que necesitábamos es una clase de pila, es decir, una lista que permite la inserción y eliminación solo en un extremo. Podríamos crear una nueva pila de clases que tuviera métodos públicos para las operaciones típicas de la pila, como push y pop. Estos métodos simplemente llamarían a funciones miembro en la lista vinculada objeto que era un miembro de datos privados de nuestra clase de pila . De esta manera, reutilizaríamos la funcionalidad de una clase de lista vinculada y al mismo tiempo proporcionaríamos la interfaz de una clase de pila. Al igual que los algoritmos, los patrones son una forma de uso de componentes de alto nivel, y aprender patrones es una excelente manera de desarrollar su caja de herramientas de programación. Sin embargo, los patrones comparten algunos de los problemas potenciales de los algoritmos. Saber que existe un patrón no es lo mismo que saber cómo implementar un patrón en el lenguaje particular que ha elegido para una solución de programación, y los patrones suelen ser difíciles de implementar correctamente o con el máximo rendimiento. Por ejemplo, existe un patrón conocido como singleton, que es una clase que permite crear solo un objeto de la clase. Crear una clase singleton es sencillo, pero crear una clase singleton que no crea el único objeto de instancia permitido hasta que realmente se necesita puede ser sorprendentemente difícil, y la mejor técnica puede variar de un idioma a otro. 174 Capítulo 7 Machine Translated by Google Tipos de datos abstractos Un tipo de datos abstracto, como analizamos en el Capítulo 5, es un tipo definido por sus operaciones, no por cómo se implementan esas operaciones. El tipo pila, que hemos utilizado varias veces en este libro, es un buen ejemplo. Los tipos de datos abstractos son como patrones en el sentido de que definen los efectos de las operaciones, pero no definen específicamente cómo se implementan esas operaciones. Sin embargo, al igual que ocurre con los algoritmos, existen técnicas de implementación bien conocidas para estas operaciones. Por ejemplo, una pila se puede implementar utilizando cualquier cantidad de estructuras de datos subyacentes, como una lista vinculada o una matriz. Sin embargo, una vez que tomamos la decisión de utilizar una estructura de datos particular, a veces las decisiones de implementación ya están tomadas. Supongamos que implementamos una pila usando una lista vinculada y no podemos ajustar una lista vinculada existente, pero debemos escribir nuestro propio código de lista. Debido a que la pila es una estructura de último en entrar, primero en salir, solo tiene sentido para nosotros insertar y eliminar elementos en un extremo de la lista vinculada. Además, sólo tiene sentido insertar y eliminar al principio de la lista. En teoría, podría insertar y eliminar al final, pero esto daría como resultado un recorrido ineficiente de toda la lista para cada inserción o eliminación. Para evitar esos recorridos se necesitaría una lista doblemente enlazada con un puntero separado al último nodo de la lista. Insertar y eliminar al principio de la lista permite la implementación más simple y eficiente, por lo que las implementaciones de pilas de listas vinculadas se implementan casi todas de la misma manera. Por lo tanto, aunque el tipo de datos abstracto en abstracto significa que el tipo es conceptual y sin detalles de implementación, en la práctica, cuando elige implementar un tipo de datos abstracto en su código, no estará descubriendo la implementación desde cero. Más bien, tendrá implementaciones existentes del tipo como guías. Bibliotecas En programación, una biblioteca es una colección de fragmentos de código relacionados. Una biblioteca normalmente incluye el código en forma compilada, junto con las declaraciones del código fuente necesarias. Las bibliotecas pueden incluir funciones independientes, clases, declaraciones de tipos o cualquier otra cosa que pueda aparecer en el código. En C++, los ejemplos más obvios son las bibliotecas estándar. La función strcmp que usamos en capítulos anteriores proviene de la antigua biblioteca C cstring, las clases contenedoras como vector provienen de la biblioteca de plantillas estándar de C++, e incluso el NULL que hemos usado en todo nuestro código basado en punteros no es parte de la Lenguaje C++ en sí, pero definido en un archivo de encabezado de biblioteca, stdlib.h. Debido a que las bibliotecas contienen tantas funciones básicas, su uso es inevitable en la programación moderna. Generalmente, el uso de la biblioteca es una buena reutilización de código. El código se incluye en una biblioteca porque proporciona una funcionalidad que comúnmente se necesita en una variedad de programas; el código de la biblioteca ayuda a los programadores a evitar "reinventar la rueda". Sin embargo, como programadores en desarrollo, cuando utilizamos código de biblioteca, debemos esforzarnos por aprender de la experiencia y no simplemente tomar un atajo. Veremos un ejemplo de esto más adelante en el capítulo. Resolver problemas con la reutilización de código 175 Machine Translated by Google Tenga en cuenta que, si bien muchas bibliotecas son de uso general, otras están diseñadas como interfaces de programación de aplicaciones (API) que brindan al programador de lenguajes de alto nivel una vista simplificada o más coherente de una plataforma subyacente. Por ejemplo, el lenguaje Java incluye una API llamada JDBC, que proporciona clases que permiten a los programas interactuar con bases de datos relacionales de forma estándar. Otro ejemplo es DirectX, que proporciona a los programadores de juegos de Microsoft Windows una amplia funcionalidad con sonido y gráficos. En ambos casos, la biblioteca proporciona una conexión entre el programa de alto nivel y el hardware y software de nivel básico: el motor de base de datos en el caso de JDBC y el hardware de gráficos y sonido en el caso de DirectX. Además, en ambos casos, la reutilización del código no sólo es buena: es, a todos los efectos prácticos, necesaria. Un programador de bases de datos en Java o un programador de gráficos que escriba código C++ para Windows hará uso de una API; si no estas API, entonces algo más, pero el programador no va a crear una nueva conexión a la plataforma. desde cero. Conocimiento de los componentes de construcción Los componentes son tan útiles que los programadores los utilizan siempre que sea posible. Sin embargo, para utilizar un componente para ayudar a resolver un problema, un programador debe conocer su existencia. Dependiendo de cuán finamente los defina, los componentes disponibles pueden ser cientos o incluso miles, y un programador principiante estará expuesto a solo unos pocos de ellos. Por lo tanto, un buen programador siempre debe agregar conocimientos sobre componentes a su conjunto de herramientas. Esta recopilación de conocimientos se produce de dos maneras diferentes: un programador puede asignar tiempo explícitamente para aprender nuevos componentes como una tarea general, o el programador puede buscar un componente para resolver un problema específico. Llamaremos al primer enfoque aprendizaje exploratorio y al segundo aprendizaje según sea necesario. Para desarrollarse como programador, deberá emplear ambos enfoques. Una vez que haya dominado la sintaxis del lenguaje de programación elegido, descubrir nuevos componentes es una de las principales formas de mejorar como programador. Aprendizaje exploratorio Comencemos con un ejemplo de aprendizaje exploratorio. Supongamos que quisiéramos aprender más sobre patrones de diseño. Afortunadamente, existe un acuerdo general sobre qué patrones de diseño son los más útiles o los más utilizados, por lo que podríamos comenzar con cualquier cantidad de recursos sobre este tema y estar bastante seguros de que no nos estamos perdiendo nada importante. Nos beneficiaríamos simplemente encontrando una lista de patrones de diseño y estudiándola, pero obtendríamos más información si implementáramos algunos de los patrones. Un patrón que encontraremos en una lista típica se llama estrategia o política. Este es el idea de permitir que un algoritmo, o parte de un algoritmo, sea elegido en tiempo de ejecución. En su forma más pura, la forma de estrategia, este patrón permite cambiar cómo opera una función o método pero no altera el resultado. Por ejemplo, un método de una clase que ordena sus datos, o implica ordenar datos, podría permitir que 176 Capítulo 7 Machine Translated by Google metodología de clasificación (clasificación rápida o clasificación por inserción, por ejemplo) que se elija. El resultado es el mismo en cualquier caso (datos ordenados), pero permitir que el cliente elija la metodología de clasificación podría ofrecer beneficios de rendimiento. Por ejemplo, el cliente podría evitar el uso de clasificación rápida para datos con una alta tasa de duplicados. En la forma de póliza, la elección del cliente afecta el resultado. Por ejemplo, supongamos que una clase representa una mano de naipes. La política de clasificación podría determinar si los ases se consideran altos (por encima de un rey) o bajos (menos de un 2 Poner el aprendizaje en práctica Al leer ese párrafo, ahora sabe cuál es el patrón de estrategia/política, pero no lo ha hecho suyo. Es la diferencia entre buscar herramientas en la ferretería y comprar una y usarla. Así que saquemos este patrón de diseño del estante y pongámoslo en práctica. La forma más rápida de probar una nueva técnica es incorporarla al código que ya ha escrito. Creemos un problema que pueda resolverse usando este patrón y que se base en el código que ya hemos escrito. PROBLEMA: EL PRIMER ALUMNO En una escuela en particular, cada clase tiene un "primer estudiante" designado que es responsable de mantener el orden en el aula si el maestro tiene que salir del salón. Originalmente, este título se otorgaba al estudiante con la calificación más alta, pero ahora algunos maestros piensan que el primer estudiante debería ser el estudiante con mayor antigüedad, lo que significa el número de identificación de estudiante más bajo, ya que se asignan secuencialmente. Otra facción de profesores piensa que la tradición del primer estudiante es tonta y pretende protestar simplemente eligiendo al estudiante cuyo nombre aparece primero en la lista alfabética de la clase. Nuestra tarea es modificar la clase de colección de estudiantes, agregando un método para recuperar al primer estudiante de la colección, al mismo tiempo que adaptamos los criterios de selección de los distintos grupos de maestros. Como puede ver, este problema empleará la forma política del patrón. Queremos que nuestro método que devuelve al primer estudiante devuelva un estudiante diferente según un criterio elegido. Para que esto suceda en C++, usaremos punteros de función. Hemos visto brevemente este concepto en acción en el Capítulo 3 con la función qsort , que toma un puntero a una función que compara dos elementos de la matriz que se va a ordenar. Haremos algo similar aquí; Tendremos un conjunto de funciones de comparación que toma dos de nuestros objetos StudentRecord y determina si el primer estudiante es "mejor" que el segundo observando las calificaciones, los números de identificación o los nombres de los estudiantes. Para comenzar, necesitamos definir un tipo para nuestras funciones de comparación: typedef bool (* firstStudentPolicy)(studentRecord r1, StudentRecord r2); Esta declaración crea un tipo llamado firstStudentPolicy como puntero a un función que devuelve un bool y toma dos parámetros de tipo StudentRecord. Los paréntesis alrededor de * firstStudentPolicy son necesarios para evitar Resolver problemas con la reutilización de código 177 Machine Translated by Google declaración sea interpretada como una función que devuelve un puntero a un bool. Con esta declaración implementada, podemos crear nuestras tres funciones de política: bool calificaciónsuperior(registroestudiante r1, registroestudiante r2) { return r1.grado() > r2.grado(); } bool lowerNumeroEstudiante(RegistroEstudiante r1, RegistroEstudiante r2) { return r1.studentID() < r2.studentID(); } bool nombreComesFirst(studentRecord r1, StudentRecord r2) { return strcmp(r1.name().c_str() , r2.name().c_str() ) < 0; } Las dos primeras funciones son muy simples: mayorCalificación devuelve verdadero cuando el primer registro tiene la calificación más alta, y menorNúmero de Estudiante devuelve verdadero cuando el primer registro tiene el número de estudiante menor. La tercera función, nameComesFirst, es esencialmente la misma, pero requiere la función de biblioteca strcmp , que espera dos cadenas “estilo C”, es decir, matrices de caracteres terminadas en nulo en lugar de objetos de cadena . Entonces tenemos que invocar el método c_str() en las cadenas de nombres en ambos registros de estudiantes. La función strcmp devuelve un número negativo cuando la primera cadena va antes de la segunda alfabéticamente, por lo que verificamos el valor de retorno para ver si es menor que cero . Ahora estamos listos para modificar la propia clase StudentCollection : clase StudentCollection { privado: estructura StudentNode { StudentRecord StudentData; StudentNode * siguiente; }; público: colección de estudiantes(); ~ColecciónEstudiante(); Colección de estudiantes(const Colección de estudiantes &copiar); Colección de estudiantes& operador=(const Colección de estudiantes &rhs); void addRecord(studentRecord nuevoEstudiante); estudianteRecord recordWithNumber(int IDnum); void removeRecord(int IDnum); void setFirstStudentPolicy(primeraPolicíaEstudiante f); studentRecord primerEstudiante(); privado: firstStudentPolicy _currentPolicy; typedef nodoestudiante * lista de estudiantes; lista de estudiantes _listHead; void eliminarLista(listadeestudiantes &listPtr); lista de estudiantes lista copiada (copia constante de lista de estudiantes); }; 178 Capítulo 7 Machine Translated by Google Esta es la declaración de clase que vimos en el Capítulo 5 con tres nuevos miembros: un miembro de datos privados, _currentPolicy de política; un método setFirstStudentPolicy , para almacenar un puntero a una de nuestras funciones para cambiar esta política; y el método firstStudent en sí , que devolverá el primer estudiante de acuerdo con la política actual. El código para setFirstStudentPolicy es simple: void StudentCollection::setFirstStudentPolicy(primeraPolicíaEstudiante f) { _PolíticaActual = f; } También necesitamos modificar el constructor predeterminado para inicializar la política actual: Colección de estudiantes::Colección de estudiantes() { _listHead = NULL; _CurrentPolicy = NULL; } Ahora estamos listos para escribir primeroEstudiante: StudentRecord StudentCollection::primerEstudiante() { if (_listHead == NULL || _currentPolicy == NULL) { StudentRecord dummyRecord(­1, ­1, ""); devolver registro ficticio; } StudentNode * loopPtr = _listHead; studentRecord primero = loopPtr­>studentData; buclePtr = buclePtr­>siguiente; mientras (buclePtr! = NULL) { if ( _currentPolicy(loopPtr­>studentData, primero)) { primero = loopPtr­>studentData; } loopPtr = loopPtr­>siguiente; } regresar primero; } El método comienza comprobando casos especiales. Si no hay una lista para revisar o no existe una política vigente , devolvemos un registro ficticio. De lo contrario, recorremos la lista para encontrar al estudiante que mejor cumpla con la política actual, utilizando las técnicas de búsqueda básicas que hemos estado utilizando a lo largo de este libro. Asignamos el registro al principio de la lista al primer , comenzamos nuestra variable de bucle en el segundo registro de la lista y comenzamos el recorrido. Dentro del bucle transversal, una llamada a la función de política actual nos dice si el estudiante que estamos observando actualmente es "mejor" que el mejor estudiante que hemos encontrado hasta ahora, según el criterio actual. Cuando finaliza el ciclo, devolvemos el “primer estudiante” . Resolver problemas con la reutilización de código 179 Machine Translated by Google Análisis de la primera solución estudiantil. Habiendo resuelto un problema utilizando el patrón estrategia/política, es mucho más probable que reconozcamos situaciones en las que se puede emplear la técnica que si hubiéramos leído sobre la técnica una vez y nunca la hubiéramos usado. También podemos analizar nuestro problema de muestra para empezar a formarnos nuestra propia opinión sobre el valor de la técnica, cuándo se puede emplear correctamente y cuándo podría ser un error, o al menos generar más problemas de los que vale la pena. Una idea que se le puede haber ocurrido acerca de este patrón en particular es que debilita la encapsulación y el ocultamiento de información. Por ejemplo, si el código del cliente proporciona las funciones de política, requiere acceso a tipos que normalmente permanecerían internos a la clase, en este caso, el tipo StudentRecord . (Consideraremos una solución a este problema en los ejercicios). Esto significa que el código del cliente podría fallar si alguna vez modificamos ese tipo, y debemos sopesar esta preocupación con los beneficios del patrón antes de aplicarlo en otros proyectos. En capítulos anteriores, analizamos cómo saber cuándo usar una técnica (o cuándo no usarla) es tan importante como saber cómo usarla. Al examinar su propio código, obtendrá información sobre esta cuestión crítica. Para practicar más, puede revisar su biblioteca de proyectos completados en busca de código que pueda refactorizarse utilizando esta técnica. Recuerde que gran parte de la programación del “mundo real” implica complementar o modificar una base de código existente, por lo que esta es una práctica excelente para dichas modificaciones, además de desarrollar su habilidad con el componente en particular. Además, uno de los beneficios de una buena reutilización de código es que aprendemos de él, y esta práctica maximiza el aprendizaje. Aprendizaje según sea necesario La sección anterior describió lo que podríamos llamar “aprender a través de la deambulación”. Si bien estos viajes son valiosos para los programadores, hay otros momentos en los que debemos avanzar hacia un objetivo particular. Si está trabajando en un problema particular, especialmente si está trabajando contra algún tipo de fecha límite, y sospecha que un componente podría ser de gran ayuda para usted, no querrá vagar al azar por el mundo de programación y espero que encuentres lo que necesitas. En lugar de ello, desea encontrar el componente o componentes que se apliquen directamente a su situación lo más rápido posible. Sin embargo, eso suena muy complicado: ¿cómo encuentras lo que necesitas cuando no sabes exactamente lo que estás buscando? Considere el siguiente problema de muestra: PROBLEMA: RECORRIDO EFICIENTE Un proyecto de programación utilizará su clase StudentCollection . El código del cliente necesita la capacidad de atravesar a todos los estudiantes de la colección. Obviamente, para mantener la información oculta, no se puede dar acceso directo al código del cliente a la lista, pero es un requisito que los recorridos sean eficientes. 180 Capítulo 7 Machine Translated by Google Debido a que la palabra clave en esta descripción es eficiente, seamos precisos acerca de lo que eso significa en este caso. Supongamos que un objeto particular de nuestra clase StudentCollection tiene 100 estudiantes. Si tuviéramos acceso directo a la lista vinculada, podríamos escribir un bucle para recorrer la lista que se repetiría 100 veces. Eso es lo más eficiente que puede ser cualquier recorrido de lista. Cualquier solución que requiera que realicemos un bucle más de 100 veces para determinar el resultado sería ineficiente. Sin el requisito de eficiencia, podríamos intentar resolver el problema agregando un método recordAt simple a nuestra clase que devolvería el registro del estudiante en una posición particular de la colección, numerando el primer registro como 1: StudentRecord StudentCollection::recordAt(int posición) { StudentNode * loopPtr = _listHead; int yo = 1; while (loopPtr! = NULL && i < posición) { yo ++; loopPtr = loopPtr­>siguiente; } si (buclePtr == NULL) { studentRecord dummyRecord(­1, ­1, ""); devolver registro ficticio; } demás { bucle de retornoPtr­>studentData; } } En este método, usamos un bucle para recorrer la lista hasta llegar a la posición deseada o llegar al final de la lista. Al final del ciclo, si se ha llegado al final de la lista, creamos y devolvemos un registro ficticio , o devolvemos el registro en la posición especificada . El problema es que estamos realizando un recorrido simplemente para encontrar un registro de estudiante. Esto no es necesariamente un recorrido completo, porque nos detendremos cuando alcancemos la posición deseada, pero es un recorrido de todos modos. Supongamos que el código del cliente intenta promediar las calificaciones de los estudiantes: int calificaciónTotal = 0; for (int recNum = 1; recNum <= numRecords; recNum++) { studentRecord temp = sc.recordAt(recNum); calificaciónTotal += temp.calificación(); } doble promedio = (doble) calificaciónTotal / numRecords; Para este segmento de código, supongamos que sc es una colección de estudiantes previamente declarada y poblada y recNum es un int que almacena el número de registros. Supongamos que recNum es 100. Si echa un vistazo a este código, podría parecer que calcular el promedio requiere solo 100 viajes a través del bucle, pero dado que cada llamada a recordAt es en sí misma un recorrido parcial de la lista, este código implica 100 recorridos. Resolver problemas con la reutilización de código 181 Machine Translated by Google cada uno de los cuales implicará realizar bucles unas 50 veces en el caso promedio. Entonces, en lugar de 100 pasos, lo que sería eficiente, esto podría requerir alrededor de 5000 pasos, lo cual es muy ineficiente. Cuándo buscar un componente Hemos llegado ahora al verdadero problema. Proporcionar acceso de cliente a los miembros de la colección para realizar recorridos es fácil; proporcionar dicho acceso de manera eficiente no lo es. Por supuesto, podríamos intentar resolver este problema utilizando sólo nuestra propia capacidad de resolución de problemas, pero alcanzaríamos la solución mucho más rápido si pudiéramos utilizar un componente. El primer paso para encontrar un componente previamente desconocido que pueda ayudar a nuestra solución es asumir que dicho componente realmente existe. Dicho de otra manera, no encontrará un componente a menos que comience a buscarlo. Por lo tanto, para maximizar el beneficio de los componentes, es necesario estar atento a situaciones en las que puedan ayudar. Cuando se encuentre atascado en algún aspecto del problema, intente lo siguiente: 1. Replantee el problema de manera genérica. 2. Pregúntese: ¿Es probable que este sea un problema común? El primer paso es importante porque si planteamos nuestro problema como “Permitir que el código del cliente calcule eficientemente la calificación promedio de un estudiante en una lista vinculada de registros encapsulados en una clase”, parece que es específico de nuestra situación. Sin embargo, si planteamos el problema como "Permitir que el código del cliente atraviese eficientemente una lista vinculada sin proporcionar acceso directo a los punteros de la lista", entonces comenzamos a comprender que este podría ser un problema común. Seguramente, podríamos preguntarnos, tan a menudo como los programas almacenan listas enlazadas y otras estructuras a las que se accede secuencialmente dentro de las clases, ¿otros programadores deben haber descubierto formas de permitir el acceso eficiente a cada elemento de la estructura? Encontrar un componente Ahora que hemos acordado buscar, es hora de encontrar nuestro componente. Para aclarar las cosas, reformulemos el problema de programación original como un problema de investigación: "Encuentre un componente que podamos usar para modificar nuestra clase StudentCollection para permitir que el código del cliente atraviese eficientemente la lista interna". ¿ Cómo solucionamos este problema? Podríamos comenzar analizando cualquiera de nuestros tipos de componentes: patrones, algoritmos, tipos de datos abstractos o bibliotecas. Supongamos que empezamos mirando las bibliotecas estándar de C++. No necesariamente estaríamos buscando una clase para "conectarnos" a nuestra solución, sino que podríamos extraer una clase de biblioteca que fuera similar a nuestra colección de estudiantes . clase para ideas. Esto emplea la estrategia de analogía que utilizamos para resolver problemas de programación. Si encontramos una clase que tiene un problema análogo, podemos tomar prestada su solución análoga. Nuestra exposición previa a la biblioteca C++ nos ha puesto en contacto con sus clases contenedoras, como vector, y debemos buscar la clase contenedora que más se parezca a nuestra clase de colección de estudiantes. Si vamos a una referencia de C++ favorita, ya sea un libro o un sitio en la Web, y revisamos las clases contenedoras de C++, vemos que hay un "contenedor de secuencias" llamado lista . 182 Capítulo 7 Machine Translated by Google eso encaja a la perfección. ¿La clase de lista permite un recorrido eficiente por el código del cliente? Lo hace, utilizando un objeto conocido como iterador. Vemos que la clase de lista proporciona métodos de inicio y fin que producen iteradores, que son objetos que pueden hacer referencia a un elemento particular en la lista y ser incrementados para hacer que el iterador haga referencia al siguiente objeto en la lista. Si integerList es una lista<int>, llena de números enteros, e iter es una lista<int>::iterator, entonces podríamos mostrar todos los números enteros en la lista con lo siguiente: iter = intList.begin(); mientras (iter != intList.end()) { cout << *iter << "\n"; iter++; } Mediante el uso del iterador, la clase de lista ha resuelto el problema de proporcionar un mecanismo al cliente para recorrer la lista de manera eficiente. En este punto, podríamos pensar en colocar la clase de lista en nuestra colección de estudiantes. clase, reemplazando nuestra lista vinculada hecha en casa. Entonces podríamos crear un comienzo y un final. métodos para nuestra clase que incluirían los mismos métodos del objeto de lista incrustado y el problema se resolvería. Esto, sin embargo, lleva directamente a la cuestión de la reutilización buena o mala. Una vez que comprendamos completamente el concepto de iterador y podamos reproducirlo por nuestra cuenta en nuestro propio código, conectar una clase existente de la Biblioteca de plantillas estándar a nuestro código será una buena opción, quizás la mejor opción. Si no podemos hacer eso, usar la clase lista se convierte en un atajo que no nos ayuda a crecer como programadores. A veces, por supuesto, debemos aprovechar componentes que no podríamos reproducir, pero si caemos en el hábito de depender de otros programadores para resolver nuestros problemas, corremos el riesgo de no convertirnos nunca en solucionadores de problemas. Así que implementemos el iterador nosotros mismos. Pero antes de hacer eso, veamos brevemente otras formas en que podríamos haber llegado al mismo lugar. Comenzamos la búsqueda en las bibliotecas de plantillas estándar, pero podríamos haber comenzado en otro lugar. Por ejemplo, podríamos haber buscado en una lista de patrones de diseño comunes. Bajo el título de “patrones de comportamiento”, encontraríamos el patrón iterador , en el que al cliente se le permite acceso secuencial a una colección de elementos sin exponer la estructura subyacente de la colección. Esto es exactamente lo que necesitamos, pero sólo podríamos haberlo encontrado buscando en una lista de patrones o recordándolo de investigaciones previas de patrones. Podríamos haber comenzado nuestra búsqueda con tipos de datos abstractos porque las listas en general, y las listas enlazadas en particular, son tipos de datos abstractos comunes. Sin embargo, muchas discusiones e implementaciones del tipo de datos abstractos de lista no consideran que el recorrido de la lista de clientes sea una operación básica, por lo que el concepto de iterador nunca surge. Finalmente, si comenzamos nuestra búsqueda en el área de algoritmos, es poco probable que encontremos algo útil. Los algoritmos tienden a describir código complicado y el código para crear un iterador es bastante simple, como veremos pronto. En este caso, entonces, la biblioteca de clases era la ruta más rápida hacia nuestro destino, seguida de los patrones. Sin embargo, como regla general, debe considerar todos los tipos de componentes cuando busque un componente útil. Resolver problemas con la reutilización de código 183 Machine Translated by Google Aplicar el componente Ahora sabemos que vamos a crear un iterador para nuestra clase StudentCollection , pero lo único que nos ha mostrado la lista de clases de biblioteca estándar es cómo funcionan externamente los métodos de iterador. Si nos quedamos atascados en la implementación, podríamos considerar revisar la lista de código fuente y sus clases antecesoras, pero dada la dificultad de leer grandes extensiones de código desconocido, esa es una medida de último recurso. En cambio, pensemos en cómo superar esto. Usando el ejemplo de código anterior como guía, podemos decir que un iterador está definido por cuatro operaciones centrales: 1. Un método en la clase de colección que proporciona un iterador que hace referencia al primer elemento de la colección. En la clase de lista , esto fue el comienzo. 2. Un mecanismo para probar si el iterador ha avanzado más allá del último elemento de la colección. En el ejemplo anterior, este era un método llamado fin en la clase de lista que producía un objeto iterador especial para realizar la prueba. 3. Un método en la clase de iterador que mueve el iterador para que haga referencia al siguiente elemento de la colección. En el ejemplo anterior, este era el operador ++ sobrecargado . 4. Un método en la clase iterador que devuelve el objeto al que se hace referencia actualmente. elemento de la colección. En el ejemplo anterior, este era el operador * (prefijo) sobrecargado. En términos de escribir el código, nada aquí parece difícil. Es solo una pregunta­ ción de poner todo en el lugar correcto. Entonces empecemos. Según las descripciones anteriores, nuestro iterador, al que llamaremos scIterator, necesita almacenar una referencia a un elemento en StudentCollection y debe poder avanzar al siguiente elemento. Por lo tanto, nuestro iterador debería almacenar un puntero a un nodo de estudiante. Eso le permitirá devolver el StudentRecord contenido en él, así como avanzar al siguiente StudentNode. Por lo tanto, la sección privada de la clase iteradora tendrá este miembro de datos: colecciónestudiante::nodoestudiante * actual; De inmediato, tenemos un problema. El tipo StudentNode se declara dentro una sección privada de StudentCollection y, por lo tanto, la línea anterior no funcionará. Nuestro primer pensamiento es que quizás StudentNode no debería haberse declarado de forma privada, pero esa no es la respuesta correcta. El tipo de nodo es inherentemente privado porque no queremos que el código de cliente aleatorio dependa de una implementación particular del tipo de nodo, creando así código que podría romperse si modificamos nuestra clase. Sin embargo, debemos permitir el acceso de scIterator a nuestro tipo privado. Lo hacemos con una declaración de amigo . En la sección pública de StudentCollection, agregamos: scIterador de clase amiga; 184 Capítulo 7 Machine Translated by Google Ahora scIterator puede acceder a las declaraciones privadas dentro de StudentCollection, incluida la declaración de StudentNode. También podemos declarar algunos constructores: scIterador::scIterador() { actual = NULO; } scIterator::scIterator(colecciónestudiante::nodoestudiante * inicial) { actual = inicial; } Saltemos a la colección de estudiantes por un segundo y escribamos nuestro inicio. método: un método que devuelve un iterador que hace referencia al primer elemento de nuestra colección. Siguiendo el esquema de nombres que he usado en este libro, este método debe tener un sustantivo como nombre, como firstItemIterator: scIterator StudentCollection::firstItemIterator() { devolver scIterator(_listHead); } Como puede ver, todo lo que tenemos que hacer aquí es introducir el puntero principal de la lista vinculada en un objeto scIterator y devolverlo. Si eres como yo, ver los punteros volando por aquí puede ponerte un poco nervioso, pero ten en cuenta que scIterator simplemente mantendrá una referencia a un elemento en la lista StudentCollection . No asignará memoria propia y, por lo tanto, no necesitamos preocuparnos por la copia profunda ni por los operadores de asignación sobrecargados. Volvamos a scIterator y escribamos nuestros otros métodos. Necesitamos un método para avanzar el iterador al siguiente elemento, así como un método para determinar si hemos pasado el final de la colección. Deberíamos pensar en ambas cosas al mismo tiempo. Al hacer avanzar el iterador, necesitamos saber qué valor debe tener el iterador cuando pasa más allá del último nodo de la lista. Si no hacemos nada especial, el iterador naturalmente obtendrá el valor NULL, por lo que sería el valor más fácil de usar. Tenga en cuenta que hemos inicializado nuestro iterador en NULL en el constructor predeterminado, por lo que cuando usamos NULL para indicar pasado el final perdemos cualquier distinción entre estos dos estados, pero para este problema actual eso no es un problema. El código de los métodos es: void scIterator::avanzado() { if (actual! = NULL) actual = actual­>siguiente; } bool scIterator::pastadoEnd() { devolver actual == NULL; } Recuerde que solo estamos usando el concepto de iterador para resolver el problema original. No intentamos duplicar la especificación exacta de un C++. Resolver problemas con la reutilización de código 185 Machine Translated by Google Iterador de biblioteca de plantillas estándar, por lo que no tenemos que usar la misma interfaz. En este caso, en lugar de sobrecargar el operador ++ , tengo un método llamado avance , que verifica que el puntero actual no sea NULL antes de avanzar al siguiente nodo . De manera similar, me parece engorroso tener que crear un iterador de "final" especial para compararlo, así que solo tengo un bool método llamado pastEnd que determina si nos hemos quedado sin nodos. Por último, necesitamos una forma de obtener el objeto StudentRecord al que se hace referencia actualmente : registro de estudiante scIterator::estudiante() { if (actual == NULL) { StudentRecord dummyRecord(­1, ­1, ""); devolver registro ficticio; } más { devolver actual­>studentData; } } Como hemos hecho anteriormente, por seguridad, si nuestro puntero es NULL, creamos y devolver un registro ficticio actualmente . De lo contrario, devolvemos el registro al que se hace referencia . Esto completa la implementación del concepto de iterador con nuestra clase StudentCollection . Para mayor claridad, aquí está la declaración completa de la clase scIterator : clase scIterador { público: scIterador(); scIterator(colecciónestudiante::nodoestudiante * inicial); avance nulo(); bool pasadoEnd(); estudianteRegistro de estudiante(); privado: colecciónestudiante::nodoestudiante * actual; }; Con el código en su lugar, podemos probarlo con un recorrido de muestra. Implementemos ese cálculo de calificación promedio para comparar: iterador de ruta; int calificaciónTotal = 0; int númRegistros = 0; iter = sc.firstItemIterator(); mientras (!iter.pastEnd()) { númRegistros++; gradoTotal += iter.estudiante().grado(); iter.advance(); } doble promedio = (doble) calificaciónTotal / numRecords; Este listado utiliza todos nuestros métodos relacionados con iteradores, por lo que es una buena prueba de nuestro código. Llamamos a firstItemIterator para inicializar nuestro scIterator . objeto 186 Capítulo 7 . Llamamos pastEnd como nuestra prueba de terminación de bucle . llamamos al estudiante Machine Translated by Google método del objeto iterador para obtener el StudentRecord actual para que podamos extraer la calificación avanzado . Finalmente, para mover el iterador al siguiente registro, llamamos al método . Cuando este código funciona, podemos estar razonablemente seguros de que hemos implementado los diversos métodos correctamente y, más que eso, de que tenemos una comprensión firme del concepto de iterador. Análisis de una solución transversal eficiente Como antes, el hecho de que el código funcione no significa que se haya acabado la posibilidad de aprender de este evento. Deberíamos considerar cuidadosamente lo que hemos hecho, sus efectos positivos y negativos, y contemplar ampliaciones de la idea básica que acabamos de implementar. En este caso, podemos decir que el concepto de iterador definitivamente resuelve el problema original del recorrido ineficiente del cliente en nuestra colección y, una vez implementado, el uso del iterador es elegante y altamente legible. En el lado negativo, no se puede negar que el enfoque ineficiente basado en el método recordAt fue mucho más fácil de escribir. Para decidir si la implementación de un iterador es valiosa o no para una situación particular, debemos preguntarnos con qué frecuencia se producirían los recorridos, cuántos elementos normalmente habría en nuestra lista, etc. Si los recorridos son poco frecuentes y la lista es pequeña, la ineficiencia probablemente no sea importante, pero si esperamos que la lista crezca o no podemos garantizar que no lo hará, es posible que se requiera el iterador. Por supuesto, si hubiéramos decidido utilizar un objeto de lista de la biblioteca de plantillas estándar, ya no nos preocuparíamos por la dificultad de implementar el iterador porque no lo estaríamos implementando nosotros mismos. La próxima vez que surja una situación como esta, podemos hacer uso de la clase lista sin sentir que nos estamos defraudando o preparándonos para dificultades posteriores, porque hemos investigado tanto las listas como los iteradores hasta el punto en que entendemos lo que debe ser. sucediendo detrás de escena, incluso si nunca revisamos el código fuente real. Yendo más allá, podemos pensar en aplicaciones más amplias de los iteradores y sus posibles limitaciones. Supongamos, por ejemplo, que necesitáramos un iterador que pudiera pasar de manera eficiente no solo al siguiente elemento de nuestra colección de estudiantes sino también al elemento anterior. Ahora que sabemos cómo funciona el iterador, podemos ver que realmente no hay forma de hacer esto con nuestra colección de estudiantes actual. implementación. Si el iterador mantiene un enlace a un nodo particular en la lista, avanzar al siguiente nodo requiere simplemente seguir el enlace en el nodo. Sin embargo, volver al nodo anterior requiere recorrer la lista nuevamente hasta ese punto. En su lugar, necesitaríamos una lista doblemente enlazada, donde los nodos tengan punteros en ambas direcciones, tanto al siguiente nodo como al anterior. Podemos generalizar este pensamiento y comenzar a considerar diferentes estructuras de datos y qué tipos de recorridos o acceso a datos se pueden ofrecer de manera eficiente a los clientes. Por ejemplo, en el capítulo anterior sobre recursividad, encontramos brevemente la estructura de árbol binario. ¿Existe alguna forma de permitir que el cliente recorra eficientemente esta estructura en su forma estándar? Si no, ¿cómo tendríamos que modificarlo para permitir reversiones eficientes? ¿Cuál es incluso el orden correcto para recorrer los nodos de un árbol binario? Pensar en preguntas como estas nos ayuda a convertirnos en mejores programadores. No sólo aprenderemos nuevas habilidades, sino que también aprenderemos más sobre las fortalezas y Resolver problemas con la reutilización de código 187 Machine Translated by Google debilidades de diferentes componentes. Conocer los pros y los contras de un componente nos permitirá utilizarlo sabiamente. No considerar las limitaciones de un enfoque particular puede llevarnos a callejones sin salida, y cuanto más sepamos sobre los componentes que utilizamos, es menos probable que esto nos suceda a nosotros. Elegir un tipo de componente Como hemos visto en estos ejemplos, el mismo problema se puede resolver utilizando diferentes tipos de componentes. Un patrón puede expresar la idea de una solución, un algoritmo puede esbozar una implementación de esa idea u otra idea que resolverá el mismo problema, un tipo de datos abstracto puede encapsular el concepto y una clase en una biblioteca puede contener una solución completa. Implementación probada del tipo de datos abstractos. Si cada uno de estos es una expresión del mismo concepto que necesitamos para resolver nuestro problema, ¿cómo sabemos qué tipo de componente sacar de nuestra caja de herramientas? Una consideración principal es cuánto trabajo puede ser necesario para integrar el componente en nuestra solución. Vincular una biblioteca de clases a nuestro código suele ser una forma rápida de resolver un problema, mientras que implementar un algoritmo a partir de una descripción de pseudocódigo puede llevar mucho tiempo. Otra consideración importante es cuánta flexibilidad ofrece el componente propuesto. A menudo, un componente viene en una forma bonita y preempaquetada, pero cuando se integra en el proyecto, el programador descubre que, si bien el componente hace la mayor parte de lo que necesita, no lo hace todo. Quizás el valor de retorno de un método esté en el formato incorrecto y requiera procesamiento adicional, por ejemplo. Si el componente se utiliza de todos modos, es posible que se descubran más problemas en el futuro antes de que el componente finalmente se descarte por completo y se desarrolle un nuevo código para esa parte del problema desde cero. Si el programador hubiera elegido un componente en un nivel conceptual superior, como un patrón, la implementación del código resultante se ajustaría perfectamente al problema porque fue creado específicamente para ese problema. La figura 7­1 resume la interacción de estos dos factores. Generalmente, el código de una biblioteca viene listo para usar, pero no se puede modificar directamente. Sólo se puede modificar indirectamente mediante el uso de plantillas de C++ o si el código en cuestión implementa algo parecido al patrón de estrategia que vimos anteriormente en este capítulo. En el otro extremo de la escala, un patrón puede presentarse como nada más que una idea (“una clase que sólo puede tener una instancia”), ofreciendo máxima flexibilidad de implementación pero requiriendo mucho trabajo por parte del programador. Por supuesto, esto es sólo una pauta general y los casos individuales serán diferentes. Quizás la clase que estamos usando de la biblioteca esté en un nivel tan bajo en nuestro programa que la flexibilidad no se verá afectada. Por ejemplo, podríamos envolver una clase de colección de nuestro propio diseño alrededor de una clase de contenedor básica como lista, que tiene capacidades lo suficientemente amplias como para que incluso si tenemos que expandir la funcionalidad de nuestra clase de contenedor, podemos esperar que la clase de lista la maneje. . Antes de usar un patrón, quizás ya hayamos implementado un patrón particular antes, por lo que no estamos tanto creando código nuevo sino adaptando código escrito previamente. 188 Capítulo 7 Machine Translated by Google Alto Patrón Algoritmo Flexibilidad Datos abstractos Tipo Biblioteca Bajo Alto Requiere trabajo Figura 7­1: Flexibilidad versus trabajo requerido para los tipos de componentes Cuanta más experiencia tenga en el uso de componentes, más confianza tendrá puedes ser que estás empezando en el lugar correcto. Hasta que desarrolle esa experiencia, puede utilizar el equilibrio entre flexibilidad y trabajo requerido como guía aproximada. Para cada situación específica, hágase preguntas como las siguientes: ¿ Puedo usar el componente tal como está o requiere código adicional para atornillarlo? ¿En mi proyecto? ¿ Estoy seguro de que comprendo el alcance total del problema o la parte relacionada con este componente y que no cambiará en el futuro? ¿ Aumentaré mis conocimientos de programación eligiendo este componente? Sus respuestas a estas preguntas le ayudarán a estimar cuánto trabajo le costará participará y cuánto beneficio recibirá de cada enfoque posible. Elección de componentes en acción Ahora que entendemos la idea general, veamos un ejemplo rápido para demostrar los detalles. PROBLEMA: CLASIFICAR ALGUNOS, DEJAR OTROS SOLOS Un proyecto requiere que ordenes una serie de objetos StudentRecord por grado, pero hay un problema. Otra parte del programa utiliza un valor de calificación especial de –1 para indicar un estudiante cuyo registro no se puede mover. Entonces, si bien todos los demás registros deben moverse, aquellos con calificaciones de ­1 deben dejarse exactamente donde están, lo que da como resultado una matriz ordenada excepto las calificaciones de ­1 intercaladas. Resolver problemas con la reutilización de código 189 Machine Translated by Google Este es un problema complicado y hay muchas maneras en que podríamos intentar resolverlo. Para simplificar las cosas, reduzcamos nuestras opciones a dos: o elegimos un algoritmo (es decir, una rutina de clasificación como la clasificación por inserción) y lo modificamos para ignorar los objetos StudentRecord con calificaciones de ­1, o encontramos una manera de Utilice la rutina de la biblioteca qsort para resolver este problema. Ambas opciones son posibles. Debido a que nos sentimos cómodos con el código de ordenación por inserción, no debería ser demasiado difícil incluir algunas declaraciones if para verificar y omitir explícitamente registros con calificaciones de ­1. Hacer que qsort haga el trabajo por nosotros requerirá una pequeña solución. Podríamos copiar los registros de los estudiantes con las calificaciones reales en una matriz separada, ordenarlos usando qsort y luego copiarlos nuevamente, asegurándonos de no copiar ninguno de los registros de calificaciones ­1. Sigamos con ambas opciones para ver cómo la elección del tipo de componente afecta el código resultante. Comenzaremos con el componente del algoritmo y escribiremos nuestro propio tipo de inserción modificado para resolver el problema. Como es habitual, abordaremos este problema por etapas. Primero, reduzcamos el problema eliminando todo el problema de la calificación ­1 y simplemente ordenando una serie de objetos StudentRecord sin ninguna regla especial. Si sra es una matriz que contiene objetos de tamaño de matriz de tipo StudentRecord, el código resultante se ve así: int inicio = 0; int end = tamaño de matriz ­ 1; for (int i = inicio + 1; i <= fin; i++) { for (int j = i; j > inicio && sra[j­1].grade() > sra[j].grade(); j­­) { temperatura de registro de estudiante = sra[j­1]; sra[j­1] = sra[j]; sra[j] = temperatura; } } Este código es muy similar al ordenamiento por inserción para números enteros. Las únicas diferencias son que la comparación requiere llamadas al método de calificación temporal usado para el espacio de intercambio ha cambiado de tipo , y nuestro objeto . Este código funciona bien, pero hay una advertencia para probar este y otros bloques de código que siguen en esta sección: nuestra clase StudentRecord valida los datos y, como se escribió anteriormente, no aceptará una calificación de –1, así que asegúrese de realizar lo necesario. cambios. Ahora estamos listos para completar esta versión de la solución. Necesitamos el tipo de inserción para ignorar registros con calificaciones de ­1. Esto no es tan simple como parece. En el algoritmo básico de ordenación por inserción, siempre intercambiamos ubicaciones adyacentes en la matriz, j y j ­ 1 en el código anterior. Sin embargo, si dejamos registros con calificaciones de –1, las ubicaciones de los siguientes registros que se intercambiarán podrían estar a una distancia arbitraria. La Figura 7­2 ilustra este problema con un ejemplo. Si esto muestra la matriz en su configuración original, entonces las flechas indican las ubicaciones de los primeros registros que se intercambiarán y no son adyacentes. Además, eventualmente el último registro (de Arte) tendrá que cambiarse de la ubicación [5] a la [3]. y luego de [3] a [0], por lo que todos los intercambios necesarios para ordenar esta matriz (por mucho que la estemos ordenando) involucran registros no adyacentes. 190 Capítulo 7 Machine Translated by Google [0] [1] [2] [3] [4] [5] Él mismo jane John Arte 87 −1 −1 84 −1 72 11523 83764 65342 11523 11764 77663 gladys Tomás Figura 7­2: Distancia arbitraria entre registros que se intercambiarán en el orden de inserción modificado Al considerar cómo resolver este problema, busqué una analogía y encontré una en el procesamiento de listas enlazadas. En muchos algoritmos de listas enlazadas, tenemos que mantener un puntero no sólo al nodo actual en nuestro recorrido de lista sino también al nodo anterior. Entonces, al final de los cuerpos del bucle, a menudo asignamos el puntero actual al puntero anterior antes de avanzar el puntero actual. Algo similar tiene que suceder aquí. Necesitamos realizar un seguimiento del último registro "real" del estudiante a medida que avanzamos linealmente a través de la matriz para encontrar el siguiente registro "real". Poner esta idea en práctica da como resultado el siguiente cód for (int i = inicio + 1; i <= fin; i++) { if (sra[i].grado() != ­1) { int intercambio de derechos = i; for (int leftswap = i ­ 1; leftswap >= start && (sra[leftswap].grade() > sra[rightswap].grade() == ­1); leftswap ­­) || sra[leftswap].grade() { if(sra[intercambio izquierdo].grado() != ­1) { StudentRecord temp = sra[leftswap]; sra[intercambio izquierdo] = sra[intercambio derecho]; sra[intercambio de derechos] = temporal; intercambio de derechas = intercambio de izquierdas; } } } } En el algoritmo básico de ordenación por inserción, insertamos repetidamente elementos sin clasificar en un área ordenada en constante crecimiento dentro de la matriz. El bucle exterior selecciona el siguiente elemento sin clasificar que se colocará en orden. En esta versión del código, comenzamos verificando que la calificación en la ubicación i no sea –1 dentro del cuerpo del bucle exterior. Si es así, simplemente pasaremos al siguiente registro y dejaremos este registro en su lugar. Una vez que hayamos establecido que el registro del estudiante en la ubicación i se puede mover, inicializamos el intercambio de derechos en esta ubicación . Luego comenzamos el bucle interior. En el algoritmo básico de ordenación por inserción, cada iteración del bucle interno intercambia un elemento con su vecino. Sin embargo, en nuestra versión, debido a que dejamos registros con calificaciones ­1 en su lugar, realizamos un intercambio solo cuando la ubicación j no contiene una calificación de –1 . Luego intercambiamos entre ubicaciones leftswap Resolver problemas con la reutilización de código 191 Machine Translated by Google y rightwap y asigna leftswap a rightwap , configurando el siguiente intercambio en el bucle interno si lo hay. Finalmente, tenemos que modificar nuestra condición de bucle interno. Normalmente, el bucle interno en una ordenación por inserción se detiene cuando llegamos al extremo frontal de la matriz o cuando encontramos un valor menor que el valor que estamos insertando. Aquí, tenemos que hacer una condición compuesta usando lógica o de modo que el bucle continúe más allá de –1 grados (porque –1 será menor que cualquier grado legítimo, deteniendo así el bucle prematuramente). Este código resuelve nuestro problema, pero es posible que esté emitiendo algunos "malos olores". El código estándar de ordenación por inserción es fácil de leer, especialmente si comprende la esencia de lo que está haciendo, pero esta versión modificada es difícil de ver y probablemente necesite algunas líneas de comentarios si queremos poder entenderlo más adelante. Quizás sea necesaria una refactorización, pero probemos el otro enfoque para resolver este problema y veamos cómo se interpreta. Lo primero que necesitaremos es una función de comparación para usar con qsort. En este caso, compararemos dos objetos StudentRecord y nuestra función restará una calificación de la otra: int compareStudentRecord(const void * voidA, const void * voidB) { RegistroEstudiante * registroA = (RegistroEstudiante *) voidA; RegistroEstudiante * registroB = (RegistroEstudiante *) voidB; return registroA­>calificación() ­ registroB­>calificación(); } Ahora estamos listos para ordenar los registros. Haremos esto en tres fases. Primero, copiaremos todos los registros que no tengan una calificación de ­1 a una matriz secundaria, sin dejar espacios. Luego, llamaremos a qsort para ordenar la matriz secundaria. Finalmente, copiaremos los registros de la matriz secundaria nuevamente a la matriz original, omitiendo los registros con calificaciones ­1. El código resultante se ve así: StudentRecord sortArray[arraySize]; int ordenarArrayCount = 0; para (int i = 0; i < tamañomatriz; i++) { if (sra[i].grado() != ­1) { sortArray[sortArrayCount] = sra[i]; ordenarArrayCount++; } } qsort(sortArray, sortArrayCount, sizeof(studentRecord), compareStudentRecord); ordenarArrayCount = 0; for (int i = 0; i < tamañomatriz; i++) { if (sra[i].grado() != ­1) { sra[i] = ordenarArray[sortArrayCount]; ordenarArrayCount++; } } Aunque este código tiene aproximadamente la misma longitud que la otra solución, es más sencillo y fácil de leer. Comenzamos declarando nuestra matriz secundaria, sortArray , del mismo tamaño que la matriz original. La variable sortArrayCount se inicializa a cero ; en el primer bucle, usaremos esto para rastrear 192 Capítulo 7 Machine Translated by Google cuántos registros hemos copiado en la matriz secundaria. Dentro de ese bucle, cada vez que encontramos un registro sin una calificación –1 , lo asignamos al siguiente espacio disponible en sortArray e incrementamos sortArrayCount. Cuando termina el ciclo, ordenamos la matriz secundaria . La variable sortArrayCount se restablece a 0 ; Lo usaremos en el segundo ciclo para rastrear cuántos registros hemos copiado de la matriz secundaria a la matriz original. Tenga en cuenta que el segundo bucle atraviesa la matriz original , buscando espacios que deban llenarse . Si abordamos esto de otra manera, intentando recorrer la matriz secundaria y empujando los registros a la matriz original, necesitaríamos un bucle doble, con el bucle interno buscando la siguiente ranura de grado real en la matriz original. . Este es otro ejemplo de cómo el problema puede hacerse fácil o difícil según nuestra conceptualización del mismo. Comparando los resultados Ambas soluciones funcionan y son enfoques razonables. Para la mayoría de los programadores, la primera solución, en la que modificamos el orden de inserción para dejar algunos registros en su lugar mientras los ordenábamos, es más difícil de escribir y de leer. Sin embargo, la segunda solución parece introducir cierta ineficiencia porque requiere copiar los datos a la matriz secundaria y viceversa. Aquí es donde resulta útil un poco de conocimiento sobre el análisis de algoritmos. Supongamos que estamos clasificando 10.000 registros; si clasificáramos muchos menos, realmente no nos importaría la eficiencia. No podemos saber con certeza qué algoritmo subyace a la llamada qsort , pero el peor de los casos para una clasificación de propósito general requeriría 100 millones de intercambios de registros, y el mejor de los casos sería alrededor de 130.000. Independientemente de dónde acabemos en el rango, copiar 10.000 registros de un lado a otro no supondrá una pérdida importante de rendimiento en comparación c Además, debemos considerar que cualquier algoritmo utilizado por qsort puede ser más eficiente que nuestra simple ordenación por inserción, eliminando cualquier beneficio que podamos haber obtenido al evitar copiar los datos hacia y desde la matriz secundaria. Entonces, en este escenario, el segundo enfoque, usar qsort, parece ser el ganador. Es más sencillo de implementar, más sencillo de leer y, por lo tanto, más fácil de mantener, y podemos esperar que su rendimiento sea tan bueno o posiblemente mejor que la primera solución. Lo mejor que podemos decir sobre el primer enfoque es que podemos haber aprendido habilidades que podemos aplicar a otros problemas, mientras que el segundo enfoque, en virtud de su simplicidad, no ofrece tales conocimientos. Como regla general, cuando se encuentra en la etapa de programación en la que intenta maximizar su aprendizaje, debe favorecer los componentes de nivel superior, como algoritmos y patrones. Cuando esté en la etapa de intentar maximizar su eficiencia como programador (o tenga una fecha límite estricta), debe favorecer los componentes de nivel inferior y elegir código prediseñado cuando sea posible. Por supuesto, si el tiempo lo permite, probar varios enfoques diferentes, como hemos hecho aquí, proporciona lo mejor de todos los mundos. Ejercicios Pruebe tantos componentes como pueda. Una vez que aprendas cómo aprender nuevos componentes, tus habilidades como programador comenzarán a crecer rápidamente. Resolver problemas con la reutilización de código 193 Machine Translated by Google 7­1. Una queja ofrecida contra el patrón de política/estrategia es que requiere exponer algunos aspectos internos de la clase, como los tipos. Modifique el programa "primer estudiante" de este capítulo para que todas las funciones de política se almacenen dentro de la clase y se elijan pasando un valor de código (de un nuevo tipo enumerado, por ejemplo), en lugar de pasar la función de política en sí. . 7­2. Vuelva a escribir nuestras funciones StudentCollection del Capítulo 4 (addRecord y AverageRecord) para que, en lugar de implementar directamente una lista vinculada, utilice una clase de la biblioteca C++. 7­3. Considere una colección de objetos StudentRecord . Queremos poder encontrar rápidamente un registro particular según el número de estudiante. Almacene los registros de los estudiantes en una matriz, ordene la matriz por número de estudiante e investigue e implemente el algoritmo de búsqueda por interpolación . 7­4. Para el problema del 7­3, implemente una solución implementando un tipo de datos abstracto que permita almacenar una cantidad arbitraria de elementos y recuperar registros individuales en función de un valor clave. Un término genérico para una estructura que puede almacenar y recuperar elementos de manera eficiente en función de un valor clave es una tabla de símbolos, y las implementaciones comunes de la idea de la tabla de símbolos son tablas hash. y árboles de búsqueda binarios. 7­5. Para el problema del 7­3, implemente una solución utilizando una clase de la biblioteca C++. 7­6. Suponga que está trabajando en un proyecto en el que un estudiante en particularRegistro Es posible que sea necesario aumentarlo con uno de los siguientes datos: título del trabajo final, año de inscripción o un bool que indique si el estudiante está auditando la clase. No desea incluir todos estos campos de datos en la clase base StudentRecord , sabiendo que en la mayoría de los casos no se utilizarán. Su primera idea es crear tres subclases, cada una con uno de los campos de datos, con nombres como StudentRecordTitle, StudentRecordYear y StudentRecordAudit. Luego se le informa que algunos registros de estudiantes contendrán dos de estos campos de datos adicionales o quizás los tres. Crear subclases para cada variación posible no es práctico. Encuentre un patrón de diseño que resuelva este enigma e implemente una solución. 7­7. Desarrolle una solución al problema descrito en 7­6 que no utilice el patrón que descubrió sino que resuelva el problema utilizando clases de la biblioteca C++. En lugar de centrarse en los tres campos de datos particulares descritos en la pregunta anterior, intente crear una solución general: una versión de la clase StudentRecord que permita agregar campos de datos adicionales arbitrarios a objetos particulares. Entonces, por ejemplo, si sr1 es un registro de estudiante, es posible que desee que el código del cliente realice la llamada sr1.addExtraField("Título", "Problemas de ramificación incondicional") y luego sr1.retrieveField("Título") devolvería “ Problemas de ramificación incondicional ". 7­8. Diseña el tuyo propio: toma un problema que ya hayas resuelto y resuélvelo nuevamente usando un componente diferente. Recuerde analizar los resultados en comparación con su solución original. 194 Capítulo 7 Machine Translated by Google t l S YYPAG oh C A DY Y PENSAR COMO UN PROGRAMADOR Es hora de que reunamos todo lo que hemos experimentado en los capítulos anteriores para completar el viaje desde la novata codificador a programador de resolución de problemas. En capítulos anteriores, hemos resuelto problemas en una variedad de áreas. Yo creo estas áreas son las más beneficiosas para que el programador en desarrollo las domine, pero, por supuesto, siempre hay más cosas que aprender y muchos problemas requerirán habilidades que no se tratan en este libro. Así que en este capítulo cerraremos el círculo de los conceptos generales de resolución de problemas, tomando el conocimiento que hemos adquirido en nuestro viaje para desarrollar un plan maestro para atacar cualquier problema de programación. Aunque podríamos llamar a esto un plan general, en cierto modo es en realidad un plan muy específico: será su plan y el de nadie más. También veremos las muchas formas en que puede ampliar sus conocimientos y habilidades como programador. Machine Translated by Google Creando tu propio plan maestro En el primer capítulo aprendimos que la primera regla para la resolución de problemas era que siempre se debe tener un plan. Una formulación más precisa sería decir que siempre debes seguir tu plan. Debes construir un plan maestro que maximice tus fortalezas y minimice tus debilidades y luego aplicar este plan maestro a cada problema que debas resolver. Durante muchos años de enseñanza, he visto estudiantes de todas las habilidades diferentes. Con esto no me refiero simplemente a que algunos programadores tengan más habilidades que otros, aunque, por supuesto, esto es cierto. Incluso entre programadores con el mismo nivel de habilidad, existe una gran diversidad. He perdido la cuenta de la frecuencia con la que me ha sorprendido un estudiante con dificultades que domina rápidamente una habilidad particular o un estudiante talentoso que muestra una debilidad en un área nueva. Así como no hay dos huellas dactilares iguales, no hay dos cerebros iguales y las lecciones que son fáciles para una persona son difíciles para otra. Suponga que es un entrenador de fútbol y planifica su ofensiva para el próximo partido. Debido a una lesión, no estás seguro de cuál de los dos mariscales de campo podrá ser titular. Ambos mariscales de campo son profesionales altamente capaces, pero como cualquier individuo en cualquier esfuerzo, tienen sus fortalezas y debilidades. El plan de juego que crea la mejor oportunidad de victoria para un mariscal de campo puede ser terrible para el otro. Al crear su plan maestro, usted es el entrenador y su conjunto de habilidades es su mariscal de campo. Para maximizar sus posibilidades de éxito, necesita un plan que reconozca tanto sus fortalezas como sus debilidades. Aprovechando tus fortalezas y debilidades Entonces, el paso clave para elaborar su propio plan maestro es identificar sus fortalezas y debilidades. Esto no es difícil, pero requiere esfuerzo y un buen grado de honesta autoevaluación. Para poder beneficiarte de tus errores no sólo debes corregirlos en los programas en los que aparecen, sino que también debes anotarlos, al menos mentalmente, o mejor aún, en un documento. De esta manera, puedes identificar patrones de comportamiento que de otro modo habrías pasado por alto. Voy a describir las debilidades en dos categorías diferentes: codificación y diseño. Las debilidades de la codificación son áreas en las que tiendes a repetir errores cuando estás escribiendo el código. Por ejemplo, muchos programadores escriben con frecuencia bucles que se repiten una vez de más o de menos. Esto se conoce como error de poste de cerca, debido a un viejo acertijo sobre cuántos postes se necesitan para construir una cerca de 50 pies con rieles de 10 pies de largo entre los postes. La respuesta inmediata de la mayoría de las personas es cinco, pero si lo piensas detenidamente, la respuesta es seis, como se muestra en la Figura 8­1. La mayoría de las debilidades de la codificación son situaciones en las que el programador crea errores semánticos al codificar demasiado rápido o sin suficiente preparación. Las debilidades de diseño, por el contrario, son problemas que comúnmente surgen en la etapa de resolución de problemas o de diseño. Por ejemplo, es posible que descubra que tiene problemas para comenzar o para integrar subprogramas escritos previamente en una solución completa. 196 Capítulo 8 Machine Translated by Google 10 pies 50 pies Figura 8­1: El rompecabezas del poste de la cerca Aunque existe cierta superposición entre estas dos categorías, los dos tipos de debilidades tienden a crear diferentes tipos de problemas y deben defenderse de diferentes maneras. Planificación contra las debilidades de la codificación Quizás la actividad más frustrante en programación es pasar horas rastreando un error semántico que resulta sencillo de corregir una vez identificado. Como nadie es perfecto, no hay forma de eliminar por completo estas situaciones, pero un buen programador hará todo lo posible para evitar cometer los mismos errores una y otra vez. Conocí a un programador que se había cansado de cometer el que quizás sea el error semántico más común en la programación en C++: la sustitución del operador de asignación (=) por el operador de igualdad (==). Debido a que las expresiones condicionales en C++ son enteras, no estrictamente booleanas, una declaración como la siguiente es sintácticamente legal: si (número = 1) bandera = verdadero; En este caso, el valor entero 1 se asigna al número y luego el valor 1 se utiliza como resultado de la declaración condicional, que C++ evalúa como verdadera. Lo que el programador pretendía hacer, por supuesto, era: if (número == 1) bandera = verdadero; Frustrado por cometer este tipo de error una y otra vez, el programador aprendió por sí mismo a escribir siempre pruebas de igualdad al revés, con el literal numérico en el lado izquierdo, como por ejemplo: if (1 == número) bandera = verdadero; Al hacer esto, si el programador comete un error y sustituye el operador de igualdad, la expresión 1 = número ya no sería una sintaxis legal de C++ y produciría un error de sintaxis que se detectaría en el momento de la compilación. El error original es de sintaxis legal, por lo que es solo un error semántico, que se detectaría en el momento de la compilación o no se detectaría en absoluto. Como yo mismo había cometido este error muchas veces (y me había vuelto loco tratando de localizar el error), empleé este método, colocando el literal numérico en el lado izquierdo del operador de igualdad. Al hacerlo, descubrí algo curioso. Porque esto Pensando como un programador 197 Machine Translated by Google iba en contra de mi estilo habitual, poner el literal a la izquierda me obligó a hacer una pausa momentánea al escribir declaraciones condicionales. Pensaba: "Necesito acordarme de poner el literal a la izquierda para poder controlarme si uso el operador de asignación". Como era de esperar, al tener ese pensamiento en mi cabeza, en realidad nunca usé el operador de asignación, pero siempre usé correctamente el operador de igualdad. Ahora, ya no pongo el literal en el lado izquierdo del operador de igualdad, pero todavía hago una pausa y dejo que esos pensamientos pasen por mi cabeza, lo que me impide usar el operador equivocado. La lección aquí es que ser consciente de las debilidades a nivel de codificación suele ser todo lo que se necesita para evitarlas. Ésa es la buena noticia. La mala noticia es que, en primer lugar, todavía tienes que esforzarte para ser consciente de tus debilidades en la codificación. La técnica clave es preguntarse por qué cometió un error en particular, en lugar de limitarse a corregir el error y seguir adelante. Esto le permitirá identificar el principio general que no siguió. Por ejemplo, suponga que ha escrito la siguiente función para calcular el promedio de los números positivos en una matriz de números enteros: doble promedioPositivo(int matriz[ARRAYSIZE]) { entero total = 0; int recuentopositivo = 0; para (int i = 0; i < ARRAYSIZE; i++) { si (matriz[i] > 0) { total += matriz[i]; positivoCount++; } } retorno total / (doble) recuento positivo; } A primera vista, esta función parece estar bien, pero tras una inspección más cercana, tiene un problema. Si no hay números positivos en la matriz, entonces el valor de positivCount será cero cuando finalice el ciclo, y esto dará como resultado una división por cero al final de la función . Debido a que se trata de una división de punto flotante, es posible que el programa no se bloquee sino que produzca un comportamiento extraño, dependiendo de cómo se utilice el valor de esta función en el programa general. Si estaba intentando ejecutar rápidamente su código y descubrió este problema, puede agregar algo de código para manejar el caso en el que positivCount es cero y continuar. Pero si quieres crecer como programador, debes preguntarte qué error cometiste. El problema específico, por supuesto, es que no tomaste en cuenta la posibilidad de dividir por cero. Sin embargo, si el análisis es tan profundo, no le ayudará mucho en el futuro. Claro, es posible que te encuentres con otra situación en la que un divisor podría resultar ser cero, pero esa no es una situación muy común. Más bien, deberíamos preguntarnos qué principio general se ha violado. La respuesta: que siempre debemos buscar casos especiales que puedan hacer estallar nuestro código. Al considerar este principio general, será más probable que veamos patrones en nuestros errores y, por lo tanto, será más probable que los detectemos en el futuro. Preguntarnos: "¿Hay alguna posibilidad de dividir por cero aquí?" No es tan útil como preguntarnos: "¿Cuáles son los casos especiales de estos datos?" Al preguntarle al 198 Capítulo 8 Machine Translated by Google En una pregunta más amplia, se nos recordará que debemos verificar no solo la división por cero sino también los conjuntos de datos vacíos, los datos fuera del rango esperado, etc. Planificación contra las debilidades del diseño Las debilidades del diseño requieren un enfoque diferente para evitarlas. Sin embargo, el primer paso es el mismo: identificar las debilidades. Mucha gente tiene problemas con este paso porque no les gusta ser tan críticos consigo mismos. Estamos condicionados a ocultar nuestros errores personales. Es como cuando un entrevistador de trabajo te pregunta cuál es tu mayor debilidad y se espera que respondas con tonterías acerca de que te preocupas demasiado por la calidad de tu trabajo en lugar de mencionar una debilidad real . Pero así como Superman tiene su Krypto­nite, incluso los mejores programadores tienen debilidades reales. Aquí hay una lista de muestra (y ciertamente no exhaustiva) de los puntos débiles del programador. nesses. Comprueba si te reconoces en alguna de estas descripciones. Diseños complicados El programador con esta debilidad crea programas que tienen demasiadas partes o demasiados pasos. Si bien los programas funcionan, no inspiran confianza (como la ropa gastada que parece desmoronarse al primer tirón de un hilo) y son claramente ineficientes. No puedo empezar Este programador tiene un alto grado de inercia. Ya sea por falta de confianza en la resolución de problemas o por simple procrastinación, este programador tarda demasiado en realizar algún progreso inicial en un problema. No se puede probar A este programador no le gusta probar formalmente el código. A menudo, el código funcionará para casos generales, pero no para casos especiales. En otras situaciones, el código funcionará bien pero no se “ampliará” para conjuntos de problemas más grandes que el programador no haya probado. Demasiado seguro La confianza es algo grandioso (este libro pretende aumentar la confianza de sus lectores), pero a veces demasiada confianza puede ser tanto un problema como muy poca. El exceso de confianza se manifiesta de varias maneras. El programador demasiado confiado podría intentar una solución más complicada de lo necesario o dejar muy poco tiempo para terminar un proyecto, lo que daría como resultado un programa apresurado y plagado de errores. zona débil Esta categoría es un poco generalizada. Algunos programadores trabajan con bastante fluidez hasta que alcanzan ciertos conceptos. Considere los temas discutidos en capítulos anteriores de este libro. La mayoría de los programadores, incluso después de completar los ejercicios, tendrán más confianza en algunas de las áreas que hemos cubierto que en otras. Por ejemplo, tal vez el programador se pierda con los programas de puntero, o la recursividad le da vuelta la cabeza al programador. Quizás el programador tenga problemas para diseñar clases elaboradas. No es que el programador no pueda salir del paso y resolver el problema, pero es un trabajo duro, como conducir sobre barro. Pensando como un programador 199 Machine Translated by Google Hay diferentes maneras de afrontar tus debilidades a gran escala, pero una vez que las reconoces, es fácil planificar en torno a ellas. Si usted es el tipo de programador que a menudo se salta las pruebas, por ejemplo, haga de las pruebas una parte explícita de su plan para escribir cada módulo y no pase al siguiente módulo hasta que marque esa casilla. O considere un paradigma de diseño llamado desarrollo basado en pruebas, en el que primero se escribe el código de prueba y luego se escribe el código para completar esas pruebas. Si tiene problemas para comenzar, utilice los principios de dividir o reducir problemas y comience a escribir código tan pronto como pueda, entendiendo que es posible que tenga que reescribir ese código más adelante. Si sus diseños suelen ser demasiado complicados, agregue un paso de refactorización explícito a su plan maestro. El punto es que, no importa qué debilidades tengas como programador, si las reconoces, puedes planificar en torno a ellas. Entonces sus debilidades ya no serán debilidades, sino simplemente obstáculos en el camino que deberá sortear en el camino hacia la finalización exitosa del proyecto. Planificación de sus fortalezas La planificación de sus debilidades se trata en gran medida de evitar errores. Sin embargo, una buena planificación no se trata sólo de evitar errores. Se trata de trabajar para lograr el mejor resultado posible dadas sus capacidades actuales y cualquier restricción bajo la cual pueda estar operando. Esto significa que también debes incorporar tus fortalezas en tu plan maestro. Podrías pensar que esta sección no es para ti, o al menos no todavía. Después de todo, si estás leyendo este libro, todavía te estás convirtiendo en programador. Quizás se pregunte si tiene alguna fortaleza en esta etapa de su desarrollo. Estoy aquí para decirte que sí, incluso si aún no los has reconocido. Aquí hay una lista de fortalezas comunes de los programadores, de ninguna manera exhaustiva, con descripciones de cada una y sugerencias para ayudarlo a reconocer si el término se aplica a usted: Ojo al detalle Este tipo de programador puede anticipar casos especiales, ver posibles problemas de rendimiento antes de que surjan y nunca dejar que el panorama general opaque los detalles importantes que deben manejarse para que el programa sea una solución completa y correcta. Los programadores con esta fortaleza tienden a probar sus planes en papel antes de codificar, codifican lentamente y realizan pruebas con frecuencia. Aprendiz rapido Quien aprende rápido adquiere nuevas habilidades rápidamente, ya sea aprendiendo una nueva técnica en un lenguaje ya conocido o trabajando con un nuevo marco de aplicación. Este tipo de programador disfruta del desafío de aprender cosas nuevas y puede elegir proyectos según esta preferencia. codificador rápido El codificador rápido no necesita pasar mucho tiempo con un libro de referencia para desarrollar una función. Una vez que llega el momento de comenzar a escribir, el código fluye de las puntas de los dedos del codificador rápido sin mucho esfuerzo y con pocos errores sintácticos. 200 Capítulo 8 Machine Translated by Google Nunca se rinde Para algunos programadores, un error molesto es una afrenta personal que no se puede ignorar. Es como si el programa hubiera abofeteado al programador en la boca con un guante de cuero y le tocara al programador responder. Este tipo de programador siempre parece mantenerse sensato, decidido pero nunca muy frustrado y confiado en que, con suficiente esfuerzo, la victoria está asegurada. Súper solucionador de problemas Es de suponer que usted no era un gran solucionador de problemas cuando compró este libro, pero ahora que ha recibido alguna orientación, tal vez todo esté empezando a resultarle más fácil. El programador con este rasgo comienza a imaginar posibles soluciones a un problema incluso mientras lo lee. manitas Para este tipo de programador, un programa en funcionamiento es como una maravillosa caja de juguetes. El manitas nunca ha perdido la emoción de hacer que la computadora cumpla sus órdenes y le encanta seguir encontrando algo más que pueda hacer la computadora. Tal vez los retoques signifiquen agregar más y más funcionalidad a un programa de trabajo, un síntoma conocido como caracteristicismo progresivo. Quizás el programa pueda refactorizarse para mejorar el rendimiento. Quizás el programa pueda hacerse más bonito para el programador o el usuario. Pocos programadores exhibirán más de un par de estas fortalezas; de hecho, algunas de ellas tienden a anularse entre sí. Pero cada programador tiene puntos fuertes. Si no te reconoces en ninguno de estos, simplemente significa que aún tienes que aprender lo suficiente sobre ti mismo o que tu fortaleza es algo que no encaja en una de mis categorías. Una vez que haya identificado sus puntos fuertes, deberá tenerlos en cuenta en su plan Maestro. Supongamos que eres un codificador rápido. Obviamente, esto ayudará a que cualquier proyecto llegue a la meta, pero ¿cómo se puede aprovechar esta fortaleza de manera sistemática? En ingeniería de software formal, existe un enfoque llamado creación rápida de prototipos, en el que un programa se escribe inicialmente sin una planificación extensa y luego se mejora mediante iteraciones sucesivas hasta que los resultados cumplan con los requisitos del problema. Si es un codificador rápido, puede intentar adoptar este método, codificar tan pronto como tenga una idea básica y dejar que su prototipo aproximado guíe el diseño y desarrollo del código final del programa. Si aprende rápido, tal vez debería comenzar cada proyecto buscando nuevos recursos o técnicas para resolver el problema actual. Si no aprendes rápido, pero eres el tipo de programador que no se frustra fácilmente, tal vez deberías comenzar el proyecto con las áreas que crees que serán más difíciles y que te darás más tiempo para abordar. a ellos. Entonces, cualesquiera que sean sus fortalezas, asegúrese de aprovecharlas en su programación. Diseña tu plan maestro para que dediques el mayor tiempo posible a hacer lo que mejor sabes hacer. De esta manera no sólo obtendrás los mejores resultados, sino que también te divertirás al máximo. Pensando como un programador 201 Machine Translated by Google Elaborar el plan maestro Veamos cómo construir un plan maestro de muestra. Los ingredientes incluyen todas las técnicas de resolución de problemas que hemos desarrollado, además de nuestro análisis de nuestras fortalezas y debilidades. Para este ejemplo, usaré mis propias fortalezas y debilidades. En términos de técnicas de resolución de problemas, utilizo todas las técnicas que comparto en este libro, pero me gusta especialmente la técnica de “reducir el problema” porque usar esa técnica me permite sentir que siempre estoy tomando decisiones. Cretar progreso hacia mi meta. Si actualmente no puedo encontrar una manera de escribir código que cumpla con la especificación completa, simplemente descarto parte de la especificación hasta que gane impulso. Mi mayor debilidad en la codificación es el entusiasmo excesivo. Me encanta programar porque me encanta ver las computadoras siguiendo mis instrucciones. A veces esto me lleva a pensar: "Vamos a darle una oportunidad a esto y ver qué pasa", cuando todavía debería estar analizando la exactitud de lo que acabo de escribir. El peligro aquí no es que el programa falle, sino que parezca tener éxito pero no cubra todos los casos especiales, o tenga éxito pero no sea la mejor solución posible que pueda escribir. Me encantan los diseños de programas elegantes que son fáciles de ampliar y reutilizar. A menudo, cuando codifico proyectos más grandes, dedico mucho tiempo a desarrollar diseños alternativos. En general, esta es una buena característica, pero a veces esto hace que dedique demasiado tiempo a la fase de diseño, sin dejar suficiente tiempo para implementar el diseño seleccionado. Además, esto a veces puede dar como resultado una solución demasiado diseñada. Es decir, a veces la solución es más elegante, ampliable y robusta de lo que realmente necesita. Debido a que cada proyecto está limitado en tiempo y dinero, la mejor solución debe equilibrar el deseo de una alta calidad del software con la necesidad de conservar recursos. Creo que mi mejor punto fuerte en la programación es que capto bien nuevos conceptos y me encanta aprender. Si bien a algunos programadores les gusta usar las mismas habilidades una y otra vez, a mí me encantan los proyectos en los que puedo aprender algo nuevo y ese desafío siempre me entusiasma. Con todo eso en mente, aquí está mi plan maestro para un nuevo proyecto. Para combatir mi principal debilidad de diseño, limitaré estrictamente el tiempo que dedico a la fase de diseño o, alternativamente, limitaré la cantidad de diseños distintos que consideraré antes de continuar. Esto puede parecer una idea peligrosa para algunos lectores. ¿No deberíamos dedicar todo el tiempo posible a la fase de diseño antes de pasar a la codificación? ¿No fracasan la mayoría de los proyectos porque no se dedicó suficiente tiempo al principio, lo que lleva a una cascada de compromisos en el final? Estas inquietudes son válidas, pero recuerde que no estoy creando una guía general para el desarrollo de software. Estoy creando mi propio plan maestro personal para abordar los problemas de programación. Mi debilidad es el diseño excesivo, no el insuficiente, por lo que una regla que limite el tiempo de diseño tiene sentido para mí. Para otro programador, una regla así podría ser desastrosa, y algunos programadores pueden necesitar una regla que los obligue a dedicar más tiempo al diseño. 202 Capítulo 8 Machine Translated by Google Después de completar mi análisis inicial, consideraré si el proyecto presenta oportunidades para aprender nuevas técnicas, bibliotecas, etc. Si es así, escribiré un pequeño programa de prueba para probar estas nuevas habilidades antes de intentar incorporarlas a mi solución en desarrollo. Para combatir el entusiasmo excesivo, podría incorporar un paso de revisión de código en miniatura cuando termine de codificar cada módulo. Sin embargo, eso requerirá un ejercicio de fuerza de voluntad de mi parte; cuando complete cada módulo, querré seguir adelante y probarlo. Simplemente esperar poder convencerme de no hacerlo cada vez es como dejar una bolsa abierta de papas fritas al lado de un hombre hambriento y sorprenderme cuando la bolsa se vacía. Es mejor subvertir las debilidades con un plan que no requiera que el programador luche contra sus instintos. Entonces, ¿qué pasa si creo dos versiones del proyecto: una versión crujiente, todo vale y una versión pulida para la entrega? Si me permito jugar con la primera versión a voluntad pero me impido incorporar código en la versión pulida hasta que haya sido examinada por completo, es mucho más probable que supere mi debilidad. Abordar cualquier problema Una vez que tenemos un plan maestro, estamos listos para cualquier cosa. En última instancia, de eso se trata este libro: comenzar con un problema, cualquier problema, y encontrar el camino hacia la solución. En todos los capítulos anteriores, las descripciones de los problemas nos llevaron en una dirección inicial particular, pero en el mundo real, la mayoría de los problemas no requieren el uso de una matriz o recursividad o de encapsular alguna parte de la funcionalidad del programa en una clase. . En cambio, el programador toma esas decisiones como parte del proceso de resolución de problemas. Al principio, podría parecer que un menor número de requisitos facilitaría los problemas. Después de todo, un requisito de diseño es una restricción, ¿y las restricciones no dificultan los problemas? Si bien esto es cierto, también es cierto que todos los problemas tienen limitaciones. lo que pasa es que en algunos casos se explican más explícitamente que en otros. Por ejemplo, no saber si un problema particular requiere una estructura asignada dinámicamente no significa que la decisión no tenga efecto. Las limitaciones generales del problema (ya sea de rendimiento, modificabilidad, velocidad de desarrollo u otra cosa) pueden ser más difíciles, o tal vez imposibles, de cumplir si tomamos decisiones de diseño equivocadas. Imagina que un grupo de amigos te pide que selecciones una película para que todos la vean. Si una amiga definitivamente quiere una comedia, a otra no le gustan las películas antiguas y otra enumera cinco películas que acaba de ver y no quiere volver a ver, estas limitaciones dificultarán la selección. Sin embargo, si nadie tiene sugerencias más allá de "simplemente elegir algo bueno", su trabajo es aún más difícil y es muy probable que elija algo que al menos a un miembro del grupo no le guste en absoluto. Por lo tanto, los problemas más grandes, ampliamente definidos y débilmente restringidos son los más difíciles de todos. Sin embargo, son susceptibles a las mismas técnicas de resolución de problemas que hemos utilizado a lo largo de este libro; simplemente toman más tiempo para resolver. Con tu conocimiento de estas técnicas y tu plan maestro en mano podrás resolver cualquier problema. Pensando como un programador 203 Machine Translated by Google Para demostrar de qué estoy hablando, te guiaré a través de los primeros pasos de un programa que juega al ahorcado, el clásico juego infantil, pero con un giro. Antes de pasar a la descripción del problema, repasemos las reglas básicas del juego. El primer jugador selecciona una palabra y le dice al segundo jugador cuántas letras tiene la palabra. Luego, el segundo jugador adivina una letra. Si la letra está en la palabra, el primer jugador muestra dónde aparece la letra en la palabra; si la carta aparece más de una vez, se indican todas las apariciones. Si la letra no está en la palabra, el primer jugador agrega una pieza a un dibujo de figura de palo de un hombre ahorcado. Si el segundo jugador adivina todas las letras de la palabra, el segundo jugador gana, pero si el primer jugador completa el dibujo, gana el primer jugador. Existen diferentes reglas sobre cuántas piezas componen el dibujo del ahorcado, por lo que, en términos más generales, podemos decir que los jugadores acuerdan de antemano cuántas "fallas" harán que el primer jugador gane el juego. Ahora que hemos cubierto las reglas básicas, veamos el problema específico, incluido el giro desafiante. PROBLEMA: ENGAÑAR AL AHORCADO Escriba un programa que sea el Jugador 1 en una versión de texto del ahorcado (es decir, en realidad no es necesario dibujar un ahorcado; simplemente lleve la cuenta del número de conjeturas incorrectas). El jugador 2 establecerá la dificultad del juego especificando la longitud de la palabra a adivinar, así como el número de aciertos incorrectos que harán perder el juego. El problema es que el programa hará trampa. En lugar de elegir una palabra al comienzo del juego, el programa puede evitar elegir una palabra, siempre que cuando el jugador 2 pierda, el programa pueda mostrar una palabra que coincida con toda la información dada al jugador 2. Las letras adivinadas correctamente deben aparecen en sus posiciones correctas y ninguna de las letras adivinadas incorrectamente puede aparecer en la palabra. Cuando termine el juego, el Jugador 1 (el programa) le dirá al Jugador 2 la palabra que fue elegida. Por lo tanto, el jugador 2 nunca podrá probar que el juego es trampa; es sólo que la probabilidad de que gane el jugador 2 es pequeña. Este no es un problema del tamaño de un monstruo según los estándares del mundo real, pero es lo suficientemente grande como para demostrar los problemas que enfrentamos cuando lidiamos con un problema de programación que especifica resultados pero no una metodología. Según la descripción del problema, puede iniciar su entorno de desarrollo y comenzar a escribir código en uno de docenas de lugares diferentes. Eso, por supuesto, sería un error porque siempre queremos programar con un plan, por lo que necesito aplicar mi plan maestro a esta situación específica. La primera parte de mi plan maestro es limitar la cantidad de tiempo que dedico a la fase de diseño. Para que esto sea una realidad, necesito pensar detenidamente en el diseño antes de trabajar en el código de producción. Sin embargo, creo que en este caso será necesario algo de experimentación para encontrar una solución al problema. Mi plan maestro también me permite crear dos proyectos, 204 Capítulo 8 Machine Translated by Google un prototipo preliminar y listo y una solución final pulida. Así que me permitiré comenzar a codificar el prototipo en cualquier momento, antes de cualquier trabajo de diseño real, pero no permitiré ninguna codificación en la solución final hasta que crea que mi diseño está configurado. Eso no garantiza que estaré completamente satisfecho con el diseño del segundo proyecto, pero ofrece la mejor oportunidad para que así sea. Ahora es el momento de empezar a analizar este problema. En capítulos anteriores, a veces enumeramos todas las subtareas necesarias para completar un problema, por lo que me gustaría hacer un inventario de las subtareas. En este punto, sin embargo, esto sería difícil porque no sé qué hará realmente el programa para lograr hacer trampa. Necesito investigar más esta área. Encontrar una manera de hacer trampa Hacer trampa en el ahorcado es lo suficientemente específico como para que no espero encontrar ayuda en las fuentes normales de componentes; no existe ningún patrón NefariousStrategy . En este punto, tengo una vaga idea de cómo se podría lograr el engaño. Estoy pensando en elegir una palabra inicial del rompecabezas y conservarla siempre que el jugador 2 elija letras que en realidad no están en esa palabra. Sin embargo, una vez que el jugador 2 encuentre una letra que realmente esté en la palabra, cambiaré a otra palabra si es posible encontrar una que no tenga ninguna de las letras seleccionadas hasta el momento. En otras palabras, le negaré una partida al jugador 2 el mayor tiempo posible. Esa es la idea, pero necesito más que una idea: necesito algo que pueda implementar. Para consolidar mis ideas, voy a trabajar con un ejemplo en papel, asumiendo el papel del Jugador 1, trabajando a partir de una lista de palabras. Para simplificar las cosas, voy a suponer que el jugador 2 ha solicitado una palabra de tres letras y que la lista completa de palabras de tres letras que conozco se muestra en la primera columna de la Tabla 8­1. Asumiré que mi primera opción "palabra de rompecabezas" es la primera palabra de la lista, murciélago. Si el jugador 2 adivina cualquier letra además de b, a o t, diré "no" y estaremos un paso más cerca de completar la horca. Si el jugador 2 adivina una letra de la palabra, elegiré otra palabra, una que no contenga esa letra. Sin embargo, al mirar mi lista, no estoy tan seguro de que esta estrategia sea la mejor. En algunas situaciones, probablemente tenga sentido. Supongamos que el jugador 2 adivina b. Ninguna otra palabra de la lista contiene b, por lo que puedo cambiar la palabra del rompecabezas a cualquiera de ellas. Esto también significa que he minimizado el daño; He eliminado sólo una palabra posible de mi lista. Pero ¿qué pasa si el jugador 2 adivina a? Si simplemente digo "no", elimino todas las palabras que contienen una a, lo que me deja solo las tres palabras de la segunda columna de la tabla 8­1 para elegir. Si, en cambio, decidiera admitir la presencia de la letra a en la palabra del rompecabezas, me quedarían cinco palabras entre las que podría elegir, como se muestra en la tercera columna. Sin embargo, tenga en cuenta que esta selección ampliada existe sólo porque las cinco palabras tienen la a en la misma posición. Una vez que declaro que una suposición es correcta, tengo que mostrar exactamente dónde aparece la letra en la palabra. Me sentiré mucho mejor acerca de mis posibilidades para el resto del juego si me quedan más opciones de palabras para reaccionar ante futuras conjeturas. Pensando como un programador 205 Machine Translated by Google Tabla 8­1: Lista de palabras de muestra Todas las palabras Palabras sin palabras con un uno punto uno auto fosa auto punto arriba comer comer sierra fosa grifo sierra grifo arriba Además, incluso si logré evitar revelar letras al principio del juego, debo esperar que el jugador 2 eventualmente adivine correctamente. El jugador 2 podría empezar con todas las vocales, por ejemplo. Por lo tanto, en algún momento tendré que decidir qué hacer cuando se revele una carta y, según mi experimento con la lista de muestra, parece que tendré que encontrar la ubicación (o ubicaciones) donde la carta aparece con más frecuencia. A partir de esta observación, me di cuenta de que había estado pensando en hacer trampa de manera incorrecta. En realidad, nunca debería elegir una palabra de rompecabezas, ni siquiera temporalmente, sino simplemente realizar un seguimiento de todas las palabras posibles que podría elegir si fuera necesario. Con esta idea en mente, ahora puedo definir el engaño de una manera diferente: tantas palabras como sea posible en la lista de palabras candidatas para el rompecabezas. Por cada suposición que haga el jugador 2, el programa tiene que tomar una decisión. ¿Afirmamos que la suposición fue un error o una coincidencia? Si fuera coincidencia, ¿en qué posiciones aparece la letra adivinada? Haré que mi programa mantenga una lista cada vez menor de palabras candidatas para rompecabezas y, después de cada suposición, tomará la decisión que dejará la mayor cantidad de palabras en esa lista. Operaciones necesarias para hacer trampa en el ahorcado Ahora entiendo el problema lo suficientemente bien como para crear mi lista de subtareas. En un problema de este tamaño, existe una buena probabilidad de que una lista hecha en esta etapa temprana omita algunas operaciones. Esto está bien, porque mi plan maestro prevé que no crearé un diseño perfecto la primera vez. Almacenar y mantener una lista de palabras. Este programa debe tener una lista de palabras en inglés válidas. Por tanto, el programa tendrá que leer una lista de palabras de un archivo y almacenarlas internamente en algún formato. Esta lista se reducirá o extraerá durante el juego a medida que el programa haga trampa. 206 Capítulo 8 Machine Translated by Google Crea una sublista de palabras de una longitud determinada. Dada mi intención de mantener una lista de palabras candidatas para rompecabezas, tengo que comenzar el juego con una lista de palabras de la longitud especificada por el Jugador 2. Seguimiento de las letras elegidas. El programa deberá recordar qué letras se han adivinado, cuántas de ellas fueron incorrectas y, en el caso de las que se consideraron correctas, dónde aparecen en la palabra del rompecabezas. Contar palabras en las que no aparece una letra. Para facilitar las trampas, necesitaré saber cuántas palabras de la lista no contienen la letra adivinada más recientemente. Recuerde que el programa decidirá si la letra adivinada más recientemente aparece en la palabra del rompecabezas con el objetivo de dejar el número máximo de palabras en la lista de palabras candidatas. Determine la mayor cantidad de palabras según la letra y la posición. Esta parece la operación más complicada. Supongamos que el jugador 2 acaba de adivinar la letra d y el juego actual tiene una longitud de tres palabras de rompecabezas. Quizás la lista actual de palabras candidatas en su conjunto contenga 10 palabras que incluyan d, pero eso no es lo importante porque el programa tendrá que indicar dónde aparece la letra en la palabra del rompecabezas. Llamemos patrón a la posición de las letras en una palabra. Entonces d?? es un patrón de tres letras que especifica que la primera letra es una d y las otras dos letras no son una d. Considere la Tabla 8­2. Supongamos que la lista de la primera columna contiene todas las palabras de tres letras que contienen d conocidas por el programa. Las otras columnas desglosan esta lista por patrón. El patrón que aparece con más frecuencia es ?? d, con 17 palabras. Este número, 17, se compararía con el número de palabras en la lista de candidatos que no contienen una d para determinar si la suposición es coincidente o fallida. Crea una sublista de palabras que coincidan con un patrón. Cuando el programa declara que una suposición del Jugador 2 coincide, creará una nueva lista de palabras candidatas con sólo aquellas palabras que coincidan con el patrón de letras elegido. En el ejemplo anterior, si declaramos d una coincidencia, la tercera columna de la Tabla 8­2 se convertiría en la nueva lista de palabras candidatas. Sigue jugando hasta que termine el juego. Una vez realizadas todas las demás operaciones, necesito escribir el código que une todo y jugar el juego. El programa debe solicitar repetidamente una suposición al jugador 2 (el usuario), determinar si la lista de palabras candidatas sería más larga al rechazar o aceptar esa suposición, reducir la lista de palabras en consecuencia y luego mostrar la palabra del rompecabezas resultante, con las letras adivinadas correctamente. revelado, junto con una revisión de todas las letras adivinadas previamente. Este proceso continuaría hasta que el juego terminara, habiendo sido ganado por un jugador u otro, cuyas condiciones también necesito determinar. Pensando como un programador 207 Machine Translated by Google Tabla 8­2: Palabras de tres letras Todas las palabras ?dd ??d ¿¿d?? d?d agregar agregar ayuda día hizo ayuda extraño y el y malo gama malo cama perro cama licitación seco licitación fin pendiente día alimentado hizo tenía el escondido gama niño perro condujo seco enojado debido contra fin viejo alimentado rojo tenía deshacerse escondido triste niño condujo enojado contra extraño viejo rojo deshacerse triste Diseño inicial Aunque pueda parecer que la lista anterior de operaciones requeridas simplemente enumera hechos en bruto, se están tomando decisiones de diseño. Considere la operación "Crear una sublista de palabras que coincidan con un patrón". Esa operación aparecerá en mi solución, o al menos en esta versión inicial, pero estrictamente hablando, no es una operación requerida en absoluto. Tampoco lo es "Crear una sublista de palabras de una longitud determinada". En lugar de mantener una lista de palabras candidatas para acertijos que se hace cada vez más pequeña, podría mantener la lista maestra original de palabras durante todo el juego. Sin embargo, esto complicaría la mayoría de las demás operaciones. La operación para “Contar palabras en las que no aparece una letra” no podría simplemente recorrer la lista de palabras candidatas y contar todas las palabras sin la letra especificada. Debido a que buscaría en la lista maestra, también tendría que verificar la longitu 208 Capítulo 8 Machine Translated by Google coincide con las letras reveladas hasta ahora en la palabra del rompecabezas. Creo que el camino que he elegido es más fácil en general, pero debo ser consciente de que incluso estas primeras decisiones están afectando el diseño final. Sin embargo, más allá del desglose inicial del problema en subtareas, tengo otras decisiones que tomar. Cómo almacenar las listas de palabras La estructura de datos clave del programa será la lista de palabras, que el programa irá reduciendo a lo largo del juego. Al elegir una estructura, hago las siguientes observaciones. Primero, no creo que necesite acceso aleatorio a las palabras de la lista, sino que siempre procesaré la lista como un todo, de adelante hacia atrás. En segundo lugar, no sé el tamaño de la lista inicial que necesito. En tercer lugar, reduciré la lista con frecuencia. Cuarto y último, los métodos de la clase de cadena estándar probablemente serán útiles en este programa. Al reunir todas estas observaciones, decido que mi elección inicial para esta estructura será la clase de lista de plantilla estándar , con un tipo de elemento de cadena. Cómo rastrear letras adivinadas Las letras elegidas son conceptualmente un conjunto, es decir, una letra ha sido elegida o no, y ninguna letra puede elegirse más de una vez. Por lo tanto, en realidad se trata de si una letra particular del alfabeto es miembro del conjunto "elegido". Por lo tanto, voy a representar las letras elegidas como una matriz de bool de tamaño 26. Si la matriz se llama letras adivinadas, entonces letras adivinadas [0] es verdadera si se ha adivinado a durante el juego hasta el momento y falsa en caso contrario; letras adivinadas[1] es para b, y así sucesivamente. Usaré las técnicas de conversión de rangos que hemos estado empleando a lo largo de este libro para convertir entre una letra minúscula del alfabeto y su posición correspondiente en la matriz. Si letra es un carácter que representa una letra minúscula, entonces letras adivinadas[letra ­ 'a'] es la ubicación correspondiente. Cómo almacenar patrones Una de las operaciones que codificaré, “Crear una sublista de palabras que coincidan con un patrón”, utilizará el patrón de las posiciones de una letra en una palabra. Este patrón se producirá mediante otra operación, "Determinar la mayor cantidad de palabras según la letra y la posición". Entonces, ¿qué formato usaré para esos datos? El patrón es una serie de números que representan las posiciones en las que aparece una letra en particular. Hay muchas maneras de almacenar estos números, pero voy a mantener las cosas simples y usaré otra lista, esta con un tipo de elemento int. ¿Estoy escribiendo una clase? Debido a que estoy codificando este programa en C++, puedo usar programación orientada a objetos o no, a mi discreción. Lo primero que pensé es que muchas de las operaciones en mi lista podrían fusionarse naturalmente en una clase, llamada wordList . tal vez, con métodos para eliminar palabras según criterios específicos (es decir, longitud y patrón). Sin embargo, como estoy tratando de evitar tomar decisiones de diseño ahora que tendré que revocar más adelante, haré que mi primer programa, preliminar y listo, sea completamente procedimental. Una vez que he trabajado Pensando como un programador 209 Machine Translated by Google Después de analizar todos los aspectos complicados del programa y el código realmente escrito para todas las operaciones de mi lista, estaré en una excelente posición para determinar la aplicabilidad de la programación orientada a objetos para la versión final. Codificación inicial Ahora comienza la diversión. Enciendo mi entorno de desarrollo y me pongo a trabajar. Este programa utilizará varias clases de la biblioteca estándar, así que para mayor claridad, permítanme configurarlas todas primero: #incluir <iostream> usando std::cin; usando std::cout; usando std::ios; #incluir <fstream> usando std::ifstream; #incluir <cadena> usando std::string; #incluir <lista> usando std::lista; usando std::iterador; #incluir <cstring> Ahora estoy listo para comenzar a codificar las operaciones de mi lista. Hasta cierto punto, podría codificar las operaciones en cualquier orden, pero comenzaré con una función para leer un archivo de texto sin formato de palabras en la estructura lista<cadena> elegida . En este punto, me doy cuenta de que necesito encontrar un archivo maestro de palabras existente; no quiero escribirlo yo mismo. Afortunadamente, buscar en Google la lista de palabras revela una serie de sitios que tienen listas de palabras en inglés en formato de texto sin formato, una palabra por línea del archivo. Ya estoy familiarizado con la lectura de archivos de texto en C++, pero si no lo estuviera, escribiría un pequeño programa de prueba solo para jugar con esa habilidad primero y luego integraría esa habilidad en el programa tramposo del ahorcado, una práctica que analizo más adelante. en este capítulo. Con el archivo en mano, puedo escribir la función: lista<cadena> readWordFile(char * nombre de archivo) { lista<cadena> lista de palabras; ifstream wordFile(nombre de archivo, ios::in); if (archivopalabra == NULL) { cout << "Error al abrir el archivo. \n"; devolver lista de palabras; } char palabraactual[30]; mientras (archivo de palabras >> palabra actual) { if (strchr(palabraactual, '\'') == 0) { temperatura de cadena (palabra actual); lista de palabras.push_back(temp); } } devolver lista de palabras; } 210 Capítulo 8 Machine Translated by Google Esta función es sencilla, por lo que sólo haré unos breves comentarios. Si Como nunca has visto uno antes, un objeto ifstream es un flujo de entrada que funciona igual que cin, excepto que lee desde un archivo en lugar de una entrada estándar. Si el constructor no puede abrir el archivo (generalmente esto significa que no se encontró el archivo), el objeto será NULL, algo que reviso explícitamente . Si el archivo existe, se procesa en un bucle que lee cada línea del archivo en una matriz de caracteres, convierte la matriz en un objeto de cadena y lo agrega a una lista. El archivo de palabras en inglés que terminé usando incluía palabras con apóstrofes, que no son legales para nuestro juego, por lo que las excluyo explícitamente . A continuación, escribo una función para mostrar todas las palabras en mi lista <cadena>. Esto no está en mi lista requerida de operaciones, y no lo usaría en el juego (después de todo, eso solo ayudaría al Jugador 2, a quien estoy tratando de engañar), pero es una buena manera de probar si mi La función readWordFile funciona correctamente: void displayList( lista constante<cadena> & lista de palabras) { list<cadena>::const_iterator iter; iter = lista de palabras.begin(); while (iter != ListaPalabras.end()) { cout << iter­>c_str() << "\n"; iter++; } } Este es esencialmente el mismo código de recorrido de lista introducido en el capítulo anterior. Tenga en cuenta que he declarado el parámetro como referencia constante . Debido a que la lista puede ser bastante grande al principio, tener un parámetro de referencia reduce la sobrecarga de la llamada a la función, mientras que un parámetro de valor tendría que copiar la lista completa. Declarar ese parámetro de referencia como constante indica que la función no cambiará la lista, lo que ayuda a la legibilidad del código. Una lista constante requiere un iterador constante . La secuencia cout no puede generar un objeto de cadena, por lo que este método produce el carácter equivalente terminado en nulo. matriz usando c_str() . Utilizo esta misma estructura básica para escribir una función que cuente las palabras en la lista que no contienen una letra especificada: int countWordsWithoutLetter(lista constante<cadena> & lista de palabras, letra char) { lista<cadena>::const_iterator iter; recuento int = 0; iter = lista de palabras.begin(); while (iter != ListaPalabras.end()) { if (iter­>buscar(letra) == cadena::npos) { contar++; } iter++; } recuento de devoluciones; } Pensando como un programador 211 Machine Translated by Google Como puede ver, este es el mismo bucle transversal básico. En el interior, llamo al hallazgo método de la clase de cadena , que devuelve la posición de su parámetro char en el objeto de cadena , devolviendo el valor especial npos cuando no se encuentra el carácter. Utilizo esta misma estructura básica para escribir la función que elimina todos los palabras de mi lista de palabras que no coinciden con la longitud especificada: void removeWordsOfWrongLength(lista<cadena> & lista de palabras, int longitud aceptable) { lista<cadena>::iterador iter; iter = lista de palabras.begin(); while (iter != ListaPalabras.end()) { if (iter­>longitud() != Longitud aceptable) { iter = lista de palabras.erase(iter); } demás { iter++; } } } Esta función es un buen ejemplo de cómo cada programa que escribe es una oportunidad para profundizar su comprensión de cómo funcionan los programas. Esta función fue sencilla de escribir para mí porque entendí lo que estaba sucediendo "debajo del capó" de programas anteriores que había escrito. Esta función emplea el código transversal básico de las funciones anteriores, pero el código se vuelve interesante dentro del bucle. El método erase() elimina un elemento, especificado por un iterador, de un objeto de lista . Pero por nuestra experiencia en la implementación del patrón de iterador para una lista enlazada en el Capítulo 7, sé que es casi seguro que el iterador es un puntero. Por nuestra experiencia con los punteros en el Capítulo 4, sé que un puntero es inútil y, a menudo, peligroso, cuando es una referencia colgante a algo que ha sido eliminado. Por lo tanto, sé que necesito asignar un valor válido a iter después de esta operación. Afortunadamente, los diseñadores de erase() han anticipado este problema y hacen que el método devuelva un nuevo iterador que apunta al elemento inmediatamente posterior al que acabamos de borrar, por lo que puedo asignar ese valor nuevamente al iter en cuenta que avanzo explícitamente el iter . También tenga solo cuando no he eliminado la cadena actual de la lista, porque la asignación del valor de retorno de erase() efectivamente hace avanzar el iterador y no quiero omitir ningún elemento. Ahora viene la parte difícil: encontrar el patrón más común de una letra específica en la lista de palabras restantes. Esta es otra oportunidad para utilizar la técnica de dividir el problema. Sé que una de las subtareas de esta operación es determinar si una palabra concreta coincide con un patrón concreto. Recuerde que un patrón es una lista<int>, donde cada int representa una posición donde aparece la letra en la palabra, y que para que una palabra coincida con un patrón, la letra no solo debe aparecer en las posiciones especificadas en la palabra. , pero la letra no debe aparecer en ningún otro lugar de la palabra. Con ese pensamiento en mente, voy a probar una cadena para detectar una coincidencia atravesándola; para cada posición en la cadena, si aparece la letra especificada, me aseguraré 212 Capítulo 8 Machine Translated by Google esa posición está en el patrón, y si aparece alguna otra letra, me aseguraré de que esa posición no esté en el patrón. Para simplificar aún más las cosas, primero escribiré una función separada para verificar si un número de posición en particular aparece en un patrón: bool númeroInPattern(lista constante<int> & patrón, número int) { lista<int>::const_iterator iter; iter = patrón.begin(); mientras (iter! = patrón.end()) { si (*iter == número) { devolver verdadero; } iter++; } falso retorno; } Este código es bastante sencillo de escribir basándose en las funciones anteriores. Simplemente recorro la lista en busca del número. O lo encuentro y vuelvo verdadero o llego al final de la lista y devuelvo falso. Ahora puedo implementar la prueba general de coincidencia de patrones: bool coincide con Patrón (palabra de cadena, letra de carácter, patrón de lista <int>) { para (int i = 0; i < palabra.longitud(); i++) { si (palabra[i] == letra) { if (!numberInPattern(patrón, i)) { falso retorno; } } demás { if (númeroEnPatrón(patrón, i)) { falso retorno; } } } devolver verdadero; } Como puede ver, esta función sigue el plan descrito anteriormente. Para cada carácter de la cadena, si coincide con una letra, el código verifica que la posición actual esté en el patrón. Si el carácter no coincide con la letra, el código verifica que la posición no esté en el patrón. Si una sola posición no coincide con el patrón, la palabra se rechaza; de lo contrario, se llega al final de la palabra y se acepta la palabra. En este punto, se me ocurre que encontrar el patrón más frecuente Será más fácil si cada palabra de la lista contiene la letra especificada. Entonces escribo una función rápida para recortar las palabras sin la letra: void eliminarPalabrasSinLetra(lista<cadena> & lista de palabras, carácter letra requerida) { lista<cadena>::const_iterator iter; iter = lista de palabras.begin(); Pensando como un programador 213 Machine Translated by Google while (iter != ListaPalabras.end()) { if (iter­>find(letrarequerida) == cadena::npos) { iter = lista de palabras.erase(iter); } demás { iter++; } } } Este código es solo una combinación de las ideas utilizadas en las funciones anteriores. Ahora que lo pienso, voy a necesitar también la función opuesta, una que corte todas las palabras que tengan la letra especificada. Usaré esto para reducir la lista de palabras candidatas cuando el programa falle la última suposición: void removeWordsWithLetter(lista<cadena> & lista de palabras, char letra prohibida) { lista<cadena>::const_iterator iter; iter = lista de palabras.begin(); while (iter != ListaPalabras.end()) { if (iter­>find(letraprohibida) != cadena::npos) { iter = lista de palabras.erase(iter); } demás { iter++; } } } Ahora estoy listo para encontrar el patrón más frecuente en la lista de palabras para la letra dada. Consideré varios enfoques y elegí el que pensé que podría implementar más fácilmente. Primero, usaré una llamada a la función anterior para eliminar todas las palabras sin la letra especificada. Luego, tomaré la primera palabra de la lista, determinaré su patrón y contaré cuántas otras palabras de la lista tienen el mismo patrón. Todas estas palabras se borrarán de la lista a medida que las cuento. Luego, el proceso se repetirá nuevamente con la palabra que ahora esté al principio de la lista y así sucesivamente hasta que la lista esté vacía. El resultado se ve así: void mostFreqPatternByLetter( list<cadena> lista de palabras, letra char, lista<int> & maxPattern, int & maxPatternCount) { eliminarPalabrasSinLetra(lista de palabras, letra); lista<cadena>::iterador iter; maxPatternCount = 0; mientras (listapalabras.tamaño() > 0) { iter = lista de palabras.begin(); lista<int> patrón actual; for (int i = 0; i < iter­>longitud(); i++) { si ((*iter)[i] == letra) { patrón actual.push_back(i); } } int currentPatternCount = 1; iter = lista de palabras.erase(iter); 214 Capítulo 8 Machine Translated by Google mientras (iter != ListaPalabras.end()) { if (matchesPattern(*iter, letra, currentPattern)) { currentPatternCount++; iter = lista de palabras.erase(iter); } demás { iter++; } } if (CuentaPatrónActual > CuentaPatrónmax) { maxPatternCount = currentPatternCount; patrónmax = patrónactual; } patrón actual.clear(); } } La lista llega como un parámetro de valor porque esta función va para reducir la lista a nada durante el procesamiento, y no quiero afectar el parámetro pasado por el código de llamada. Tenga en cuenta que maxPattern y maxPatternCount son parámetros salientes únicamente; estos se utilizarán para enviar el patrón que ocurre con mayor frecuencia y su número de apariciones al código de llamada. Elimino todas las palabras sin la letra bucle principal de la función, que continúa mientras la lista no esté vacía . Luego entro al . El código dentro del bucle tiene tres secciones principales. Primero, un bucle for construye el patrón para la primera palabra de la lista coinciden con ese patrón . Luego, un bucle while cuenta cuántas palabras de la lista . Finalmente, vemos si este recuento es mayor que el recuento más alto visto hasta ahora, empleando la estrategia del “Rey de la colina” vista por primera vez en el Capítulo 3 . La última función de utilidad que debería necesitar mostrará todas las letras adivinadas. hasta ahora. Recuerde que los estoy almacenando como una matriz de 26 valores booleanos : visualización vacía de letras adivinadas (letras bool [26]) { cout << "Letras adivinadas: "; para (int i = 0; i < 26; i++) { if (letras[i]) cout << (char)('a' + i) << " "; } cout << "\n"; } Tenga en cuenta que estoy sumando el valor base de un rango, en este caso, el carácter a, a un valor de otro rango , una técnica que empleamos por primera vez en el Capítulo 2. Ahora tengo todas las subtareas clave completadas y estoy listo para intentar resolver todo el problema, pero tengo muchas funciones aquí que no se han probado por completo y me gustaría probarlas lo antes posible. Entonces, en lugar de abordar el resto del problema en un solo paso, voy a reducirlo. Haré esto convirtiendo algunas de las variables, como el tamaño de la palabra del rompecabezas, en constantes. Como voy a desechar esta versión, me siento cómodo poniendo toda la lógica del juego en la función principal . Sin embargo, debido a que el resultado es largo, presentaré el código en etapas. Pensando como un programador 215 Machine Translated by Google int principal () { lista<cadena> lista de palabras = readWordFile("lista de palabras.txt"); const int longitud de palabra = 8; const int maxMisses = 9; int fallas = 0; int descubiertaLetterCount = 0; removeWordsOfWrongLength(listadepalabras, longitud de palabra); char revelóPalabra[palabraLongitud + 1] = "********"; bool letras adivinadas[26]; for (int i = 0; i < 26; i++) letras adivinadas[i] = false; char siguienteLetra; " cout << "Palabra hasta ahora: << Palabra revelada << "\n"; Esta primera sección de código configura las constantes y variables que necesitaremos para jugar. La mayor parte de este código se explica por sí mismo. La lista de palabras se crea a partir de un archivo el valor constante 8 y luego se reduce a la longitud de palabra especificada, en este caso, . La variable misses almacena el número de conjeturas erróneas del jugador 2, mientras que discoverLetterCount rastrea el número de posiciones reveladas en la palabra (por lo que si d aparece dos veces, adivinar d aumenta este valor en dos). La variable PalabraRevelada almacena la palabra del rompecabezas tal como la conoce actualmente el Jugador 2, con asteriscos para las letras que aún no se han adivinado El conjunto de letras adivinadas de bool . rastrea las letras específicas adivinadas hasta el momento; un bucle establece todos los valores en falso. Finalmente, nextLetter almacena la suposición actual del Jugador 2. Envío la Palabra revelada inicial y luego estoy listo para el bucle principal del juego. while (descubiertaLetterCount < longitud de palabra && misses < maxMisses) { cout << "Letra para adivinar: "; comer >> siguienteLetra; Letras adivinadas[letrasiguiente ­ 'a'] = verdadero; int faltaContar = contarPalabrasSinLetra(listadepalabras, siguienteLetra); lista<int> siguientePatrón; int siguientePatrónCount; mostFreqPatternByLetter(wordList, nextLetter, nextPattern, nextPatternCount); si (cuentafaltante> siguientePatrónCount) { removeWordsWithLetter(wordList, nextLetter); falla ++; } demás { lista<int>::iterador iter = nextPattern.begin(); while (iter! = siguientePatrón.end()) { descubiertoLetterCount++; palabra revelada [*iter] = letra siguiente; iter++; } Lista de palabras = reducirPorPatrón(Lista de palabras, siguienteLetra, siguientePatrón); } " cout << "Palabra hasta ahora: << Palabra revelada << "\n"; mostrar letras adivinadas (letras adivinadas); } 216 Capítulo 8 Machine Translated by Google Hay dos condiciones que pueden terminar el juego. O el jugador 2 descubre todos los caracteres de la palabra, de modo que el número de letras descubiertas alcanza la longitud de la palabra, o las malas conjeturas del jugador 2 completan el ahorcado, en cuyo caso los fallos equivaldrán al máximo de fallos. Entonces el ciclo continúa mientras no se haya producido ninguna condición . Dentro del ciclo, después de que el usuario lee la siguiente suposición, se actualiza la posición correspondiente en letras adivinadas . Entonces comienza el engaño. El programa determina cuántos candidatos quedarían en la lista de palabras si la suposición se declarara fallida usando countWordsWithoutLetter , y determina el máximo que podría quedar si la suposición se declarara acertada usando mostFreqPatternByLetter . Si el primero es mayor, las palabras con la letra adivinada se eliminan y los errores se incrementan . Si este último es más grande, tomaremos el patrón proporcionado por mostFreqPatternByLetter y actualizaremos la palabra revelada, al mismo tiempo que eliminaremos todas las palabras de la lista que no coincidan con el patrón si (falla == maxMisses) { cout << "Lo siento. Perdiste. La palabra en la que estaba pensando era '"; cout << (wordList.cbegin())­>c_str() << "'.\n"; } demás { '" << Palabra revelada << "'.\n"; cout << "Buen trabajo. Tú ganas. Se corrió la voz } devolver 0; } El resto del código es lo que yo llamo un bucle postmortem, donde la acción posterior al bucle está determinada por la condición que "mató" el bucle. En este caso, nuestro programa logró hacer trampa para lograr la victoria o el Jugador 2, contra todo pronóstico, obligó al programa a revelar la palabra completa. Tenga en cuenta que cuando el programa gana, al menos una palabra debe permanecer en la lista, por lo que solo muestro la primera palabra y afirmo que fue en la que estuve pensando todo el tiempo. Un programa más astuto podría seleccionar aleatoriamente una de las palabras restantes para reducir la posibilidad de que el oponente detecte la trampa. Análisis de resultados iniciales Reuní todo este código, lo probé y funciona, pero claramente hay muchas mejoras por hacer. Más allá de cualquier consideración de diseño, al programa le faltan muchas funciones. No permite al usuario especificar el tamaño de la palabra del rompecabezas o el número de acertijos incorrectos permitidos. No comprueba si la letra adivinada ya se ha adivinado antes. De hecho, ni siquiera verifica que el carácter de entrada sea una letra minúscula. Le faltan muchos detalles agradables en la interfaz, como decirle al usuario cuántos fallos más hay disponibles. Creo que también sería bueno si el programa pudiera ofrecer jugar nuevamente, en lugar de obligar al usuario a volver a ejecutar el programa. En cuanto al diseño, cuando empiece a pensar en la versión final del programa, consideraré seriamente un diseño orientado a objetos. Una clase de lista de palabras ahora parece una elección natural. La función principal me parece demasiado grande. Me gusta un diseño modular y fácil de mantener, y eso debería dar como resultado una función principal que sea breve y simplemente dirija el tráfico entre los subprogramas que hacen el trabajo real. Entonces mi función principal debe dividirse en Pensando como un programador 217 . Machine Translated by Google varias funciones. Es posible que sea necesario repensar algunas de mis elecciones de diseño iniciales. Por ejemplo, en retrospectiva, almacenar patrones como list<int> parece engorroso. ¿Quizás podría probar una matriz de bool, de manera análoga a las letras adivinadas? O quizás debería buscar otra estructura completamente diferente. Ahora también es el momento de dar un paso atrás para ver si hay oportunidades de aprender nuevas técnicas para resolver este problema. Me pregunto si existen estructuras de datos especializadas que aún no he considerado y que podrían resultar útiles. Incluso si termino apegándome a mis elecciones originales, podría aprender mucho de la investigación. Aunque todas estas decisiones aún son inminentes, siento que estoy bien encaminado. camino con este proyecto. Tener un programa de trabajo que cumpla con los requisitos esenciales del problema es un excelente lugar para estar. Puedo experimentar fácilmente con las diferentes ideas de diseño en esta versión preliminar, con la confianza que surge al saber que ya tengo una solución y que solo estoy buscando una solución mejor. CREAR UN PUNTO DE RESTAURACIÓN El sistema operativo Microsoft Windows crea lo que llama un punto de restauración antes de instalar o modificar componentes del sistema. El punto de restauración contiene copias de seguridad de archivos clave, como el registro. Si una instalación o actualización genera un problema grave, se puede "revertir" o deshacer copiando los archivos desde el punto de restauración. Recomiendo encarecidamente adoptar el mismo enfoque con su propio código fuente. Cuando tenga un programa en funcionamiento que espera modificar más adelante, haga una copia de todo el proyecto y modifique sólo la copia. Es rápido de hacer y puede ahorrarle un tiempo considerable más adelante si sus modificaciones salen mal. Los programadores pueden caer fácilmente en la trampa de pensar: “Lo logré una vez; por lo tanto, puedo hacerlo de nuevo”. Esto suele ser cierto, pero hay una gran diferencia entre saber que puedes hacer algo nuevamente y poder recuperar el código fuente antiguo para referencia instantánea. También puede utilizar un software de control de versiones, que automatiza la copia y el almacenamiento de archivos de proyecto. El software de control de versiones realiza más que la función de "punto de restauración"; también puede permitir que varios programadores trabajen de forma independiente en los mismos archivos, por ejemplo. Si bien estas herramientas están más allá del alcance de este libro, son algo que debes investigar a medida que te desarrollas como programador. El arte de resolver problemas ¿Reconoció todas las técnicas de resolución de problemas que empleé en mi solución hasta ahora? Tenía un plan para resolver el problema. Como siempre, esta es la más crucial de todas las técnicas de resolución de problemas. Decidí comenzar con lo que sabía para la primera versión de mi solución, empleando un par de estructuras de datos con las que estaba muy familiarizado, matrices y la clase lista . Reduje la funcionalidad para que fuera más fácil escribir mi versión preliminar y para permitirme probar mi código antes de lo que podría hacerlo de otra manera. Dividí el problema en operaciones e hice de cada operación una función diferente, lo que me permitió trabajar en partes del programa por separado. Cuando no estaba seguro de cómo hacer trampa, experimenté, lo que me permitió reformular "hacer trampa" como "maximizar el tamaño del candidato". 218 Capítulo 8 Machine Translated by Google lista de palabras”, que era un concepto concreto para codificar. En los detalles de la codificación de las operaciones, empleé técnicas análogas a las utilizadas a lo largo de este libro. También evité con éxito frustrarme, aunque supongo que tendrás que confiar en mi palabra. Antes de continuar, permítanme dejar claro que he demostrado los pasos que tomó llegar a esta etapa en el proceso de solución de este problema. Estos no son necesariamente los mismos pasos que seguiría para resolver este problema. El código que se muestra arriba no es la mejor solución al problema y no es necesariamente mejor de lo que se le ocurriría. Lo que espero que demuestre es que cualquier problema, sin importar el tamaño, se puede resolver utilizando variaciones de las mismas técnicas básicas utilizadas a lo largo de este libro. Si estuvieras abordando un problema dos veces más grande que éste, o 10 veces más grande, podría poner a prueba tu paciencia, pero podrías resolverlo. Aprender nuevas habilidades de programación Hay un tema más para discutir. Al dominar las técnicas de resolución de problemas de este libro, estará dando un paso clave en el camino de la vida como programador. Sin embargo, como ocurre con la mayoría de las profesiones, este es un camino sin destino, ya que siempre debes esforzarte por mejorar como programador. Como ocurre con todo lo demás en programación, debes tener un plan sobre cómo aprenderás nuevas habilidades y técnicas, en lugar de simplemente confiar en que aprenderás cosas nuevas aquí y allá a lo largo del camino. En esta sección, analizaremos algunas de las áreas en las que es posible que desee adquirir nuevas habilidades y algunos enfoques sistemáticos para cada una. El hilo conductor de todas las áreas es que debes poner en práctica lo que quieres aprender. Es por eso que cada capítulo de este libro termina con ejercicios: y has estado trabajando en esos ejercicios, ¿verdad? Leer sobre nuevas ideas en programación es un primer paso vital para aprenderlas, pero es sólo el primer paso. Para llegar al punto en el que pueda emplear con confianza una nueva técnica en la solución de un problema del mundo real, primero debe probar la técnica en un problema sintético más pequeño. Recuerde que una de nuestras técnicas básicas de resolución de problemas es descomponer problemas complejos, ya sea dividiendo el problema o reduciéndolo temporalmente para que cada estado con el que estemos tratando tenga solo un elemento no trivial. No querrás intentar resolver un problema no trivial al mismo tiempo que aprendes la habilidad que será fundamental para tu solución porque entonces tu atención se dividirá entre dos problemas difíciles. Nuevos idiomas Creo que C++ es un gran lenguaje de programación para código de producción y expliqué en el primer capítulo por qué creo que también es un gran lenguaje para aprender. Dicho esto, ningún lenguaje de programación es superior en todas las situaciones; Por lo tanto, los buenos programadores deben aprender varios. Pensando como un programador 219 Machine Translated by Google Tómese el tiempo para aprender Siempre que sea posible, debes darte tiempo para estudiar un nuevo lenguaje antes de intentar escribir código de producción con uno. Si intenta resolver un problema no trivial en un idioma que nunca antes ha usado, rápidamente irá en contra de una importante regla de resolución de problemas: evitar la frustración. Fíjese la tarea de aprender un idioma y complétela antes de asignarse cualquier programa "real" en ese idioma. Por supuesto, en el mundo real, a veces no tenemos el control total. de cuando nos asignan proyectos. En cualquier momento, alguien podría solicitar que escribiéramos un programa en un idioma en particular, y esa solicitud podría ir acompañada de una fecha límite que nos impediría estudiar tranquilamente el idioma antes de abordar el problema real. La mejor defensa contra esta situación es comenzar a estudiar otros lenguajes de programación antes de que sea absolutamente necesario conocerlos. Investiga los idiomas que te interesen o que se utilicen en áreas en las que esperas programar durante tu carrera. Ésta es otra situación en la que una actividad que parece un mal uso del tiempo en el corto plazo pagará grandes dividendos en el largo plazo. Incluso si resulta que no necesitas el idioma que has estudiado en un futuro cercano, estudiar otro idioma puede mejorar tus habilidades con los otros idiomas que ya conoces porque te obliga a pensar de maneras nuevas y diferentes, rompiendo sacarte de viejos hábitos y brindarte nuevas perspectivas sobre tus habilidades y técnicas. Piense en ello como el equivalente en programación del entrenamiento cruzado. Comience con lo que sabe Cuando empiezas a aprender un nuevo lenguaje de programación, por definición no sabes nada al respecto. Sin embargo, si no es tu primer lenguaje de programación, sabes mucho sobre programación. Entonces, un buen primer paso para aprender un nuevo idioma es comprender cómo el código que ya sabes escribir en otro idioma se puede escribir en el nuevo idioma. Como se dijo antes, usted quiere aprender esto haciendo, no solo leyendo. Tome programas que haya escrito en otros idiomas y reescríbalos en el nuevo idioma. Investigue sistemáticamente elementos individuales del lenguaje, como declaraciones de control, clases, otras estructuras de datos, etc. El objetivo es transferir la mayor cantidad posible de conocimientos previos al nuevo idioma. Investiga qué es diferente El siguiente paso es estudiar qué tiene de diferente el nuevo idioma. Si bien dos lenguajes de programación de alto nivel pueden tener grandes similitudes, algo debe ser diferente con el nuevo idioma, o no habría razón para elegir este idioma sobre cualquier otro. Nuevamente, aprenda haciendo. Simplemente leer, por ejemplo, que la declaración de selección múltiple de un lenguaje permite rangos (en lugar de los valores individuales de una declaración de cambio de C++ ) no es tan útil para su desarrollo como escribir código que emplee de manera significativa esta capacidad. 220 Capítulo 8 Machine Translated by Google Este paso es obviamente importante para lenguajes que son notablemente diferentes, pero es igualmente importante para lenguajes que tienen un ancestro común, como C++, C# y Java, que son todos descendientes de C orientados a objetos. Las similitudes de sintaxis pueden engañarlo. Creer que sabes más sobre el nuevo idioma de lo que realmente sabes. Considere el siguiente código: lista de enterosLista de números de clase; lista de números.addInteger(15); Si estas líneas se le presentaran como código C++, comprendería que la primera línea construye un objeto, numberList, de una clase, integerListClass, y la segunda línea invoca un método addInteger en ese objeto. Si esa clase realmente existe y tiene un método con ese nombre que toma un parámetro int , este código tiene mucho sentido. Ahora supongamos que le dije que este código se había escrito en Java, no en C++. Sintácticamente, no hay nada ilegal en estas dos líneas. Sin embargo, en Java, una mera declaración de variable de un objeto de clase en realidad no construye el objeto porque las variables del objeto son en realidad referencias. es decir, se comportan de forma análoga a los punteros. Para realizar los pasos equivalentes en Java, el código correcto sería: integerListClass numberList = nueva integerListClass; lista de números.addInteger(15); Probablemente se dé cuenta rápidamente de esta diferencia particular entre Java y C++, pero muchas otras diferencias podrían ser bastante sutiles. Si no se toma el tiempo para descubrirlos, pueden dificultar mucho la depuración en el nuevo idioma. A medida que escanea su código, su intérprete interno de lenguaje de programación le proporcionará información incorrecta sobre lo que está leyendo. Estudiar código bien escrito A lo largo de este libro he señalado que no deberías intentar aprender a programar tomando el código de otra persona y modificándolo. Hay ocasiones, sin embargo, en las que el estudio del código de otra persona es vital. Si bien puedes desarrollar tus habilidades en un nuevo idioma escribiendo una serie de programas originales, para alcanzar un nivel de dominio, querrás buscar código escrito por un programador experto en ese idioma. No estás buscando "cubrir" este código; No vas a tomar prestado este código para resolver un problema específico. En lugar de eso, estás mirando el código existente para descubrir las "mejores prácticas" en ese lenguaje. Mire el código de un programador experto y pregúntese no sólo qué está haciendo el programador sino también por qué lo está haciendo. Si el código va acompañado de las explicaciones del programador, mucho mejor. Diferenciar entre opciones de estilo y beneficios para el rendimiento. Al completar este paso, evitará un error común. Con demasiada frecuencia, los programadores aprenderán lo suficiente en un nuevo lenguaje para sobrevivir, Pensando como un programador 221 Machine Translated by Google y el resultado es un código débil que no utiliza todas las características del lenguaje. Si es un programador de C++ y necesita escribir código en Java, por ejemplo, no querrá conformarse con escribir código en pidgin C++; en su lugar, desea aprender a escribir código Java real como lo haría un programador de Java. Como con todo lo demás, pon en práctica lo que aprendes. Tome el código original y modifíquelo para hacer algo nuevo. Guarde el código fuera de la vista e intente reproducirlo. El objetivo es sentirse lo suficientemente cómodo con el código como para poder responder preguntas al respecto de otro programador. Es importante enfatizar que este paso viene después de los demás. Antes Llegamos a la etapa de estudiar el código de otra persona en un nuevo idioma, ya hemos aprendido la sintaxis y la gramática del nuevo idioma y hemos aplicado las habilidades de resolución de problemas que aprendimos en otro idioma al nuevo idioma. Si intentamos acortar el proceso comenzando el estudio del nuevo lenguaje con el estudio de muestras de programas largos y la modificación de esas muestras, existe un riesgo real de que eso sea todo lo que podamos hacer. Nuevas habilidades para un idioma que ya conoces El hecho de que llegues al punto en el que puedas decir que “conoces” un idioma no significa que sepas todo sobre ese idioma. Incluso una vez que haya dominado la sintaxis del idioma, siempre habrá nuevas formas de combinar las características existentes del idioma para resolver problemas. La mayoría de estas nuevas formas se incluirán en uno de los títulos de “componentes” del capítulo anterior, en el que analizamos cómo desarrollar el conocimiento de los componentes. El factor importante es el esfuerzo. Una vez que seas bueno resolviendo problemas de ciertas maneras, es fácil confiar en lo que ya sabes y dejar de crecer como programador. En ese punto, eres como un lanzador de béisbol que lanza una mala bola rápida pero no sabe lanzar nada más. Algunos lanzadores han tenido carreras profesionales exitosas con un solo lanzamiento, pero el lanzador que quiere pasar de relevista a abridor necesita más. Para ser el mejor programador posible, necesitas buscar nuevos conocimientos. y nuevas técnicas y ponerlas en práctica. Busca desafíos y supéralos. Investigue el trabajo de programadores expertos en los idiomas elegidos. Recuerda que la necesidad es la madre de la invención. Busque problemas que no puedan resolverse satisfactoriamente con sus habilidades actuales. A veces puedes modificar problemas que ya has resuelto para ofrecer nuevos desafíos. Por ejemplo, es posible que haya escrito un programa que funcione bien cuando el conjunto de datos es pequeño, pero ¿qué sucede cuando permite que los datos crezcan hasta proporciones gigantescas? ¿O qué sucede si ha escrito un programa que almacena sus datos en el disco duro local, pero desea que los datos se almacenen de forma remota? ¿Qué sucede si necesita múltiples ejecuciones del programa que puedan acceder y actualizar los datos remotos simultáneamente? Al comenzar con un programa funcional y agregar nuevas funciones, puede concentrarse solo en los aspectos nuevos de l 222 Capítulo 8 Machine Translated by Google Nuevas bibliotecas Los lenguajes de programación modernos son inseparables de sus bibliotecas principales. Cuando aprenda C++, inevitablemente aprenderá algo sobre las bibliotecas de plantillas estándar, por ejemplo, y cuando estudie Java, aprenderá sobre las clases estándar de Java. Sin embargo, más allá de las bibliotecas incluidas con el idioma, necesitarás estudiar bibliotecas de terceros. A veces se trata de marcos de aplicaciones generales, como el marco .NET de Microsoft, que se puede utilizar con varios lenguajes de alto nivel diferentes. En otros casos, la biblioteca es específica de un área particular, como OpenGL para gráficos, o es parte de un paquete de software propietario de terceros. Al igual que con el aprendizaje de un nuevo idioma, no debe intentar aprender una nueva biblioteca durante un proyecto importante que requiera esa biblioteca. En su lugar, aprenda los componentes principales de la biblioteca por separado en un proyecto de prueba de importancia cero antes de emplearlos en un proyecto real. Asígnate una progresión de problemas cada vez más difíciles de resolver. Recuerde que el objetivo no es necesariamente completar ninguno de esos problemas, sólo aprender del proceso, por lo que no necesita pulir las soluciones o incluso completarlas una vez que haya empleado con éxito esa parte de la biblioteca en su programa. Estos programas pueden servir luego como referencia para trabajos posteriores. Cuando te quedas atascado porque no puedes recordar cómo, digamos, superponer una pantalla 2D sobre una escena 3D en OpenGL, no hay nada mejor que poder abrir un programa antiguo que fue creado solo para demostrar esa misma tecnología. ­nique y está escrito en tu propio estilo porque fue escrito por ti. Además, al igual que con el aprendizaje de un nuevo idioma, una vez que se sienta cómodo con los conceptos básicos de una biblioteca, debe revisar el código escrito por expertos en el uso de esa biblioteca. La mayoría de las bibliotecas grandes tienen idiosincrasias y advertencias que no están expuestas en la documentación oficial y que, aparte de una larga experiencia, solo pueden descubrirse a través de otros programadores. En verdad, para avanzar mucho con algunas bibliotecas se requiere el uso inicial de un marco proporcionado por otro programador. Lo importante es no confiar en el código de otros más de lo necesario y llegar rápidamente a la etapa en la que recrea el código que se le mostró originalmente. Es posible que se sorprenda de lo mucho que aprende del proceso de recrear el código existente de otra persona. Es posible que vea una llamada a una función de biblioteca en el código original y comprenda que los argumentos pasados en esta llamada producen un resultado determinado. Sin embargo, cuando dejas de lado ese código e intentas reproducir ese efecto por tu cuenta, te verás obligado a investigar la documentación de la función, todos los valores particulares que los argumentos podrían tomar y por qué tienen que ser lo que deben obtener. el efecto deseado. Tomar una clase Como educador desde hace mucho tiempo, siento que tengo que concluir esta sección hablando de clases, no en el sentido de programación orientada a objetos, sino en el sentido de un curso en una escuela. Cualquiera que sea el área de programación que desee aprender, encontrará a alguien que se ofrecerá a enseñarle, ya sea en un aula tradicional. Pensando como un programador 223 Machine Translated by Google o en algún entorno en línea. Sin embargo, una clase es un catalizador del aprendizaje, no el aprendizaje en sí, especialmente en un área como la programación. No importa cuán conocedor o entusiasta sea un instructor de programación, cuando realmente aprenda nuevas habilidades de programación, sucederá mientras está sentado frente a su computadora, no como si estuviera sentado en una sala de conferencias. Como reitero a lo largo de este libro, debes poner en práctica las ideas de programación y debes hacerlas tuyas para aprenderlas verdaderamente. Esto no quiere decir que las clases no tengan valor, porque a menudo tienen un valor tremendo. Algunos conceptos de programación son inherentemente difíciles o confusos, y si tiene acceso a un instructor con talento para explicar conceptos difíciles, eso puede ahorrarle mucho tiempo y frustración. Además, las clases proporcionan una evaluación de tu aprendizaje. Si vuelve a tener suerte con su instructor, podrá aprender mucho de la evaluación de su código, lo que agilizará el proceso de aprendizaje. Finalmente, completar con éxito una clase proporciona a los empleadores actuales o futuros alguna evidencia de que usted comprende las materias impartidas (si no tiene suerte y tiene un instructor deficiente, al menos puede consolarse con eso). Solo recuerda que tu educación en programación es tu responsabilidad, incluso cuando tomas una clase. Un curso proporcionará un marco para adquirir una calificación y un crédito al final del trimestre, pero ese marco no te limita en tu aprendizaje. Piense en su tiempo en la clase como una gran oportunidad para aprender tanto como sea posible sobre el tema, más allá de los objetivos enumerados en el programa del curso. Conclusión Recuerdo con cariño mi primera experiencia en programación. Escribí una breve simulación basada en texto de una máquina de pinball, y no, eso tampoco tiene ningún sentido para mí, pero debió tenerlo en ese momento. Entonces no tenía computadora. ¿Quién lo hizo en 1976? Pero en la oficina de mi padre había una terminal de teletipo, esencialmente una enorme impresora matricial con un teclado de clic­clac, que se comunicaba con la computadora central de la universidad local a través de un módem acústico. (Levantaste el teléfono para marcar a mano y cuando escuchaste un chirrido electrónico, dejaste caer el auricular en un soporte especial conectado al terminal). Por muy primitiva e inútil que fuera mi simulación de pinball, en el momento en que el programa funcionó y la computadora actuó según mis instrucciones, quedé enganchado. La sensación que tuve ese día (que una computadora era como una pila infinita de Legos, juegos de construcción y troncos Lincoln, todos para que pudiera construir cualquier cosa que pudiera imaginar) es lo que impulsa mi amor por la programación. Cuando mi entorno de desarrollo anuncia una compilación limpia y mis dedos alcanzan la tecla que comenzará la ejecución de mi programa, siempre estoy emocionado, anticipando el éxito o el fracaso, y ansioso por ver los resultados de mis esfuerzos, ya sea que esté escribiendo un proyecto de prueba simple o dando los toques finales a una solución grande, o si estoy creando hermosos gráficos o simplemente construyendo la interfaz de una aplicación de base de datos. Espero que tengas sentimientos similares cuando programes. Incluso si todavía tienes dificultades con algunas de las áreas cubiertas en este libro, espero que ahora entiendas que mientras la programación te entusiasme tanto, siempre querrás 224 Capítulo 8 Machine Translated by Google Para seguir adelante, no hay problema que no puedas resolver. Todo lo que se requiere es la voluntad de esforzarse y emprender el proceso de la manera correcta. El tiempo se encarga del resto. ¿Ya estás pensando como un programador? Si ha resuelto los ejercicios al final de estos capítulos, entonces debería pensar como un programador y tener confianza en su capacidad para resolver problemas. Si no has resuelto muchos de los ejercicios, entonces tengo una sugerencia para ti y apuesto a que puedes adivinar cuál es: Resuelve más ejercicios. Si se ha saltado algunos de los capítulos anteriores, no comience con los ejercicios de este capítulo; vuelva al punto donde lo dejó y avance desde allí. Si no quieres hacer más ejercicios porque no te gusta la programación, entonces no puedo ayudarte. Una vez que esté pensando como programador, siéntase orgulloso de sus habilidades. Si alguien lo llama codificador en lugar de programador, diga que a un pájaro bien entrenado se le podría enseñar a picotear código: no solo escribe código, sino que lo usa para resolver problemas. Cuando esté sentado frente a una mesa de entrevistas con un futuro empleador o cliente, sabrá que cualquier cosa que requiera el trabajo, puede resolverlo. Ejercicios Tenías que saber que habría una última serie de ejercicios. Estos son, por supuesto, más difíciles y más abiertos que cualquiera de los capítulos anteriores. 8­1. Escribe una implementación completa para el problema del ahorcado tramposo que sea mejor que la mía. 8­2. Expande tu programa del ahorcado para que el usuario pueda elegir ser el Jugador 1. El usuario aún selecciona el número de letras de la palabra y el número de conjeturas fallidas, pero el programa adivina. 8­3. Vuelva a escribir su programa del ahorcado en otro idioma, uno del que actualmente sepa poco o nada. 8­4. Haz que tu juego del ahorcado sea gráfico, mostrando la horca y el ahorcado mientras se construye. Estás intentando pensar como un programador, no como un artista, así que no te preocupes por la calidad del arte. Sin embargo, debes crear un programa gráfico real. No dibujes al verdugo usando texto ASCII; es demasiado fácil. Es posible que desees investigar bibliotecas de gráficos 2D para C++ o elegir una plataforma diferente que esté más orientada gráficamente para empezar, como Flash. Tener un ahorcado gráfico puede requerir limitar el número de conjeturas erróneas, pero puede haber una manera de ofrecer al menos una variedad de opciones para este número. 8­5. Diseña tu propio ejercicio: emplea las habilidades que aprendiste en el ahorcado problema para resolver algo completamente diferente que implique manipular una lista de palabras, como otro juego que use palabras, como Scrabble, un corrector ortográfico o cualquier otra cosa que se te ocurra. 8­6. Diseña tu propio ejercicio: Busca un problema de programación en C++ de tal tamaño o dificultad que estés seguro de que alguna vez hubieras considerado imposible resolver con tus habilidades, y resuélvelo. Pensando como un programador 225 Machine Translated by Google 8­7. Diseña tu propio ejercicio: Encuentra una biblioteca o API que te interese pero que todavía tienes que usarlo en un programa. Luego investigue esa biblioteca o API y úsela en un programa útil. Si está interesado en la programación general, considere la biblioteca Microsoft .NET o una biblioteca de bases de datos de código abierto. Si te gustan los gráficos de bajo nivel, considera OpenGL o DirectX. Si quieres intentar crear juegos, considera un motor de juegos de código abierto como Ogre. Piensa en los tipos de programas que te gustaría escribir, encuentra una biblioteca que se ajuste y ponte manos a la obra. 8­8. Diseña tu propio ejercicio: escribe un programa útil para una nueva plataforma (una (eso es nuevo para usted), por ejemplo, programación web o móvil. 226 Capítulo 8 Machine Translated by Google ÍNDICE Números y símbolos Operador && (lógico y), 48 evaluación de cortocircuito de, 129, 132, 133 & operador (dirección de), 85 & símbolo (parámetro de referencia), 84– 85, 137, 211, 213 * operador (desreferencia), 59–60, 82, 128– 129, 138, 213–216 * símbolo (declaración de puntero), 59, 75, 82, 85, 99–100, 160, 177– 178, 186, 192 puntero a función, 177­178 == operador (igualdad), 197–198 = operador (asignación), 137–138, 197– 198 ­> operador (deferencia de estructura), 102, 128 % operador (módulo), 33–34, 39–40, 50–52 matrices, 56 ARRAY_SIZE constante, 58 estadísticas agregadas, 61–62 operaciones básicas, 56–62 de bool, 209, 215 media informática, 61 declaración de matriz constante , 67 copiando, 57 asignación dinámica, 93, 97, 98 elemento, 56 encontrar el valor más grande en, 58–59, 66, 70–71, 73 de datos fijos, 67–69 inicialización, 57, 70, 71 mediana, 67 moda (estadística), 62–65 multidimensional, 71–74 tratar como una matriz de matrices, 72–74 cuándo usar, 71–72 no escalar, 69–71 procesamiento recursivo de, 153­155 A tipo de datos abstractos, 116, 175, 183, 188–189 especificador de acceso, 112, 119, 125, 127 registro de activación, 86–87, 89–90 dirección del operador (&), 85 algoritmo, xv, 173–174, 176–177, 182–183, 188–193 buscando basado en criterios, 58–59 por valor específico, 58 clasificación, 59–61, 189–193 clasificación por inserción, 60–61, 190–192, 193 qsort, 59–60, 192–193 de cuerda, 123 analogía. Ver encontrar una analogía de estructura, 69–71 y (lógica booleana), 48 evaluación de cortocircuito de, 129, 132, subíndice, 56, 66 frente a vectores, 75–76 133 interfaz de programación de aplicaciones (API), 176 cuándo usar, 74–78 operador de asignación (=), 137–138, 197–198 Machine Translated by Google evitando la frustración, 21–22, 95–96, 201, 220, 224 dividiendo validación de suma de comprobación, 31–32 flujo estándar cin, 26 problemas, 41 especificador de acceso de clase, 112, 119, 125, B 127 marco básico, 119–122 excepción bad_alloc , 89 malos olores, 65, 97, 192 caso base, 144, 162 composición, 126 constructor, 112–113, 119, 121–122, 126–127 Gran idea recursiva (BRI), 143, 152–155 árbol binario vacío, prueba de, 162 hoja, 163 procesamiento recursivo, 160–165, 166– 167 nodo raíz, 161 subárbol, 161 miembro de datos, 112 declaración, 112–113 copia profunda, 134–137 destructor, 133–134 estructuras de datos dinámicas, 125–140 encapsulación, 114, 126, 180 expresividad, 117–118, 121, 128 falso, 140– 141 método amigo , 184 obtener y configurar, C 119–121 objetivos de uso, 113–118 ocultación de Declaración de matriz C++, 55 información, 115, 180 interfaz, 115 inicialización de matriz, 57 método, 112 como opción para este libro, xvii flujo nombres de estándar cin , 26 declaración de métodos, elección, 117, 119–120 sobrecarga de clase, 112–113 flujo estándar cout , 26 operador de eliminación , 83 operadores, 137 miembro privado, excepción, 130 112 miembro protegido, 112 procesamiento de miembro público, 112 copia archivos, 210–211 función libre , superficial, 135 tarea única, 88 amigo palabra clave, 141 subclase, 112 184 método get , 34 archivos método de soporte, 122 de encabezado para plantilla, 141 entrada/salida, 26 clase de lista , 182–183, validación, 121, 124 función 210–214, 216, 218 función malloc , 88 nuevo operador, 75, 82, 97, 98 contenedora, 163– 165 rompecabezas clásicos el Zorro, el Ganso y el declaración de puntero, 82 requisitos previos, xv parámetros de referencia, 84 Maíz, 3–7, 15, 17, 20 evaluación de cortocircuito, 129, rompecabezas de números móviles, 7–11, 132, 133 18 sudoku, 11–13 Bloqueo Quarrasi, 13–15, 20 Biblioteca de plantillas estándar, 175 esta bloques de códigos, palabra clave, 120 173 reutilización de códigos, 53, 172–173 palabra clave typedef , 91, 101, 127, 160, 177 códigos de caracteres, 34–35 228 ÍNDICE tipo de datos abstractos, 175 algoritmo, 173–174 aprendizaje según sea necesario, 180–188 Machine Translated by Google uso de clase, 114 bloque de código, copia profunda, 134–137 constructor predeterminado, 113, 122, 179 173 componente, 173 desreferenciación, 82 elección, 188–193 búsqueda, 182–183 patrón de diseño. Ver destructor de patrones, 133–134 biblioteca, 175–176 diagramas, puntero, 92, 94, 96, 103 recursividad directa, 144 patrón, 174 DirectX, 176 propiedades, deseado, 172 función del despachador, 153–154 guardar código para uso posterior, 44, 67, problemas de división, 17–18, 31–41, 41– 53 uso aprendizaje exploratorio, 176–180 Validación de código 218. Consulte de clases, 115 la función del comparador de deslizamiento rompecabezas pruebas, 59 de mosaicos, 8–11 división por componentes, 173 tipos, 173–176 flexibilidad de, 188–189 composición, 126 cero, 108, 198 lista doblemente enlazada, 131 registro ficticio, 129, 179, 181, 186 estructuras de datos dinámicas, 158–165 constante matrices, 67–69, 71 tipos numéricos, 58 parámetros, 59, 211 restricciones, 1–2, 6, 11–13, 19, 31, 33, 38, 40–41, 203 Y eficiencia, 181–182, 193 encapsulación, 114, 126, 180 final de línea código de caracteres para, importancia de, 26 constructor, 112–113, 119, 121–122, 126–127 constructor de copia, 138 constructor predeterminado, 113, 122, 179 conversión entre rangos 37 búsqueda en flujo de caracteres, 38 operador de igualdad (==), 197–198 excepción, 130 experimentar con programas, 20–21, 28, 30, 37 expresividad, 117–118 dígito de carácter a entero, 35, 43–48 número F a letra del alfabeto, 49 trabajo de copiar y clase falsa, 140–141 pegar, 173 constructor de aprende rápido, 200–201 copias, 138 secuencia estándar de cout , 26 codificador rápido, 200– características progresivas, cerca, 196 procesamiento de 201 enlaces cruzados, 100, 103, 134–135 archivos, 210–211 encuentra una analogía, 2, 20, 62, cruces ­entrenamiento, 201 error de poste de método 220 c_str , 211 93, 182, 191 crea tu propia analogía, 38–39 D problemas de bucle, 29–30 referencia colgante, 90, 100, 125, 212 causada por entrecruzamiento, 136 miembro de datos, 112, 119–120 Problema de bloqueo de Quarrasi, 13­15 método de búsqueda (cadena), 211– 212 flexibilidad, 93, 154, 160, 188–189 el zorro, el ganso y el maíz, 3–7, 15, 17, 20 redundancia de datos, 123–124 ÍNDICE 229 Machine Translated by Google funciones registro de activación, 86 comparador, 59 despachador, 153–154 salidas múltiples, 132 nombres, elección, 117, 119–120 j Java, xiv, 111, 176, 221 JDBC, 176 k puntero a, 177 recursivo, 152–165 Algoritmo del rey de la colina , 58, 66, 70–71, 73, 214–215 contenedor, 163–165 Kobayashi Maru, 2, 19, 26 frustración, 21. Véase también evitar la frustración l aprender nuevas habilidades, 219– 224 trabajo de clase, GRAMO 223–224 para idiomas conocidos, 222 bibliotecas, obtener método (general), 119 obtener método (iostream), 34 h 223 nuevos idiomas, 219–222 lado izquierdo, 137 ahorcado, 204–218 biblioteca, 175–176, 223 de por vida, puntero de cabeza, 103, 123, 127, 137 recursividad de cabeza, 144, 146– 147, 151– 90 listas enlazadas, 101–108, 175 agregar nodo a, 104–106, 128 edificio, 101–103 152 montón, 87–88 diagrama, 103 lista doblemente enlazada, desbordamiento, 89 131 vacío, prueba de, 108 función auxiliar, 98 histograma, 65–66 puntero principal, 103, 123, 127, 137 iterador, 182–187 I nodo, 101, 127 recursividad indirecta, 144 Terminador NULL , 103 ineficiencia en recursividad, 168–169 procesamiento recursivo, 158–160 el espacio, 77 en el tiempo, 77, 181– eliminación de nodos, 130–133 recorrido inverso, 168–169 182 ocultación de información, 115– acceso secuencial, 103 recorrido, 106–108, 129, 168–169, 179, 117 procesamiento de entrada, 31–41 iteración, 25. Consulte también clase de 181 clase iterador de bucle , 183, de lista , 182 –183, 210–214, 216, 210 método de inicio , 183 const_iterator, de búsqueda, 67 211 final método, 183 bucles, 26–41, 71, 94 bucle método de borrado , 212 postmortem, 217 218 tabla método de búsqueda , 211– 212 patrón de iterador, 183–187 avanzar al METRO plan maestro, 196–203 siguiente nodo, mediana, 67 185 beneficios, 183 inicialización, 185 métodos, 184 miembros, 112 230 ÍNDICE Machine Translated by Google registro de activación de asignación de igualdad (==), 197–198 memoria, 86 lógico y (&&), 48 evaluación de cortocircuito de, matriz, 74, 97 excepción bad_alloc , 89 en módulo (%), 33–34, 39–40, 50–52 clases, 125–140 referencia colgante, 90, 100 operador de eliminación , 83 fragmentación, 129, 132, 133 sobrecarga, 137–138 exceso de confianza, 199 desbordamiento montón, 89 pila, 89–90 87–88 función libre , 88 montón, 87– 88 desbordamiento de montón, 89 fuga (ver pérdida de memoria) vida útil, 90 función malloc , 88 nuevo operador, 75, 82, 97, 98 razones para minimizar, 88– 90 pila, 86–87, 89–90 paliza, 89 sobrecarga, 137–138 PAG parámetros funciones recursivas, uso en, 155–156 referencia, 84 patrón, 174 iterador, 183–187 fragmentación de política, 176–180 memoria, 87– 88 pérdida de memoria, singleton, 174 75, 90 evitando, 95 función contenedora, 174 conjunto mínimo de datos, 160 modo (estadística), estrategia, 176–180 ineficiencia de rendimiento en el espacio, 77, 85 62 operador de módulo (%), 33–34, ineficiencia en el tiempo, 77, 37, 39–40, 50–52 variable más restringida, 12 matriz multidimensional, 181–182,71–74 193 tratar como una matriz de matrices, 72– 74, cuándo usar, 71–72 sintonización, 77 planificación, 16–17, 33, 95–96, 173 individualidad de, 40 plan maestro, 196–203 norte nuevo operador, 75, 82, 97, árbol binario de 98 nodos, 160–161, 163 lista enlazada, 101, 127 carga útil, 102, 145 valor npos , 211–212 consejos beneficios de, 83–84 entrecruzamiento, 100 declaración, 59, 75 , 82, 85, 99–100, 160, 177–178, 186, 192 Puntero NULO , 90 desreferenciación, 59–60, 82, 128–129, 138, 213–216 oh diagramas, 92, 94, 96, 103 para funcionar, 177 OpenGL, 223 Puntero NULL , 90 operadores dirección de (&), 85 parámetros de referencia, 84 cuándo usar, 84 asignación (=), 137–138, 197– 198 desreferenciación (*), 59–60, 82, 128–129, 138, 213–216 política, 176–180 miembro público, 112 método push_back , 76 miembro privado, 112 ÍNDICE 231 Machine Translated by Google protegido, 112 requisitos previos, punto de restauración, 218 reutilización. Consulte propiedad xv (C#), 120 reutilización de código en el pseudocódigo, 63 conversión lado derecho, 137 programas robustos, definición a documentación, 64 de, 96 nodo raíz, 161 resolución de problemas, xiii–xv, 2, 203–219 miembro resolución de problemas con, 63–64 estructura de datos del tamaño de tiempo de ejecución, 83 S q variable escalar, 55 acceso qsort, 59–60, 65, 192–193 función de comparación, 59, 192 Problema de bloqueo de Quarrasi, 13­15, 20 secuencial, 103 búsqueda secuencial, 58 método de configuración , 119 copia superficial, 135 evaluación de cortocircuito, 129 tarea R acceso aleatorio, 56, 78 creación rápida de prototipos, 201 legibilidad, 117 recursividad, 143 caso base, 144 Gran única, 141 singleton, 174 rompecabezas de números deslizantes, 7–11, 18 resolución por caso de muestra, 92– 96 clasificación, 59, 176–177, 189–193 clasificación por inserción, 60–61, 190–192, 193 qsort, 59– idea recursiva, 143, 152–155 árbol binario, 160– 165 ruta de navegación, 166– 169 errores comunes, 155–158 directo, 144 estructuras de datos dinámicas, que se aplican a, 158–165 cabeza, 144, 146–147, 151–152 indirecta, 144 lista enlazada, 158–160 frente a pila, 166–169 cola, 144, 145– 146, 149–150 cuándo uso, 165–169 función contenedora, 163–165 problemas de reducción, 19–20, 41–53, 63, 190 problemas de bucle, 26–29 datos redundantes, 123–124 refactorización, 65–67, 180, 200 parámetros de referencia, 84 – 85, 137, 211, 213 const, 211, 213 estructura de datos redimensionable, 83 problemas de reformulación, 17, 33, 42, 182, 193 el zorro, el ganso y el maíz, 5–7 problemas de bucle, 31 60, 192–193 casos especiales, 96 verificación, 96– 97, 100, 124, 128, 132, 198­199 pila, 86 lista enlazada, 175 desbordamiento, 89– 90 tiempo de ejecución, 86–87 comenzando con lo que sabe, 18–19, 62, 92 problemas de bucle, 29–30 variable más restringida, 12 sudoku, 11–13 estrategia, 176– 180 clase de cadena , 119 matriz, 123 método c_str , 211 método de búsqueda , 211–212 valor npos , 211–212 cadenas, 91 implementación de matriz, 91– 100 copia, 98 Estilo C, 178 implementación de lista enlazada, 101– 107 terminador, 93 232 ÍNDICE Machine Translated by Google estructura, 69 deferencia de estructura (­>), 102, 128 subclase, 122 subíndice, 56 sudoku, 11– 13 método de soporte, 122–125 t recursividad de cola, 144, 145–146, 149–150 clase de plantilla, 141 desarrollo basado en pruebas, 200 pruebas, 124, 190, 199–200, 215 pérdidas de memoria, 95 promoción de la facilidad de, 34, 57, 66, 70, 218 almacenar programas de prueba, 44 casos de prueba, codificación, 93, 98– 100, 130–134, 186– 187 esta palabra clave, 120 paliza, 89 estado de seguimiento, 50­51 recorrido, lista vinculada, 106–108, 129, 168–169, 179, 181 palabra clave typedef , 91, 101–102, 127, 160, 177 EN suma de comprobación de validación 31–32 código (ver pruebas) datos, 61–62, 92, 96, 121, 124– 125 vectores, 55 vs. matrices, 75–76 declaración, 76 método push_back , 76 EN debilidades debilidades de codificación, 196, 197–199 debilidades de diseño, 196, 199–200 espacios en blanco, 34 función contenedora, 163–165, 174 ÍNDICE 233 Machine Translated by Google Machine Translated by Google Think Like a Programmer está ambientado en New Baskerville, TheSansMono Condensed, Futura y Dogma. Este libro fue impreso y encuadernado en Edwards Brothers Malloy en Ann Arbor, Michigan. El papel es Glatfelter Spring Forge 60# Antique, certificado por la Sustainable Forestry Initiative (SFI). El libro utiliza una encuadernación RepKover, que le permite permanecer plano cuando está abierto. Machine Translated by Google La Electronic Frontier Foundation (EFF) es la organización líder en defensa de las libertades civiles en el mundo digital. Defendemos la libertad de expresión en Internet, luchamos contra la vigilancia ilegal, promovemos los derechos de los innovadores a desarrollar nuevas tecnologías digitales y trabajamos para garantizar que los derechos y libertades que disfrutamos mejoren (en lugar de erosionarse) a medida que crece nuestro uso de la tecnología. PRIVACIDAD EFF ha demandado al gigante de las telecomunicaciones AT&T por dar a la NSA acceso ilimitado a las comunicaciones privadas de millones de sus clientes. eff.org/nsa LIBERTAD DE EXPRESIÓN El Proyecto de Derechos de los Codificadores de la EFF defiende los derechos de los programadores e investigadores de seguridad a publicar sus hallazgos sin temor a desafíos legales. eff.org/ libertad de expresión INNOVACIÓN El Proyecto Anti Patentes de la EFF desafía las patentes excesivamente amplias que amenazan la innovación tecnológica. eff.org/patent USO JUSTO La EFF está luchando contra estándares prohibitivos que le quitarían el derecho a recibir y utilizar transmisiones de televisión por aire de la forma que elija. eff.org/IP/fairuse TRANSPARENCIA EFF ha desarrollado la herramienta de prueba de red de Suiza para brindar a las personas las herramientas para probar el filtrado de tráfico encubierto. eff.org/transparencia INTERNACIONAL EFF está trabajando para garantizar que los tratados internacionales no restrinjan nuestra libertad de expresión, privacidad o derechos de los consumidores digitales. eff.org/global EFF es una organización apoyada por sus miembros. ¡Únete ahora! www.eff.org/support Machine Translated by Google Más libros sensatos de SIN PRENSA DE ALMIDÓN ¡APRENDE UN HASKELL TIERRA DE LISP JAVASCRIPT ELOCUENTE PARA UN GRAN BIEN! ¡Aprenda a programar en Lisp, un juego a la Una introducción moderna a la Una guía para principiantes de MIRAN LIPOVAČA ABRIL DE 2011, 400 págs., 44,95 dólares ISBN 978­1­59327­283­8 programación por vez! por CONRAD BARSKI, MD MARIJN HAVERBEKE OCTUBRE DE 2010, 504 págs., 49,95 dólares ISBN 978­1­59327­281­4 ENERO DE 2011, 224 págs., 29,95 dólares ISBN 978­1­59327­282­1 HACKING, 2DA EDICIÓN El Arte de la PYTHON PARA NIÑOS Una EL ARTE DE LA PROGRAMACIÓN R Un recorrido Explotación por JON ERICKSON introducción lúdica a la programación por JASON R. por el diseño de software estadístico por NORMAN FEBRERO 2008, 488 PP. Con CD, $49,95 ISBN 978­1­59327­144­2 BRIGGS NOVIEMBRE MATLOFF OCTUBRE DE 2011, 400 págs., 39,95 dólares ISBN 978­1­59327­384­2 DE 2012, 346 PP. a todo color, $29,95 ISBN 978­1­59327­407­8 TELÉFONO: 800.420.7240 O 415.863.9900 CORREO ELECTRÓNICO: VENTAS@NOSTARCH.COM WEB: WWW.NOSTARCH.COM Machine Translated by Google ACTUALIZACIONES Visite http://nostarch.com/thinklikeaprogrammer para obtener actualizaciones, erratas y otra información. Machine Translated by Google l YYPAG TU CEREBRO TU CEREBRO El verdadero desafío de la programación no es aprender un la sintaxis del lenguaje: es aprender a resolver problemas creativamente para poder construir algo grandioso. En este texto único en su tipo, el autor V. Anton Spraul analiza las formas en que los programadores resuelven problemas y te enseña lo que otros libros introductorios a menudo ignoran: cómo pensar como un programador. Cada capítulo aborda un único concepto de programación, como clases, punteros, y recursividad, y ejercicios abiertos a lo largo Retarte a aplicar tus conocimientos. También aprenderá cómo: • Dominar herramientas de programación más avanzadas como la recursividad. y memoria dinámica • Organice sus pensamientos y desarrolle estrategias para abordar tipos particulares de problemas Aunque los ejemplos del libro están escritos en C++, el Los conceptos creativos de resolución de problemas que ilustran van más allá. cualquier idioma en particular; de hecho, a menudo van más allá del ámbito de la informática. Como saben los programadores más hábiles, escribir código excelente es un arte creativo, y el primero El primer paso para crear tu obra maestra es aprender a pensar como un programador. • Dividir los problemas en componentes discretos para que sean más SOBRE EL AUTOR fáciles de resolver. • Aprovechar al máximo la reutilización de código con funciones, clases, y bibliotecas PROGRAMACIÓN/ GENERALES GUARDAR EN: S oh C t RENOVAR EL ALAMBRADO DE A DY Y Machine Translated by Google V. Anton Spraul ha enseñado programación introductoria y informática desde hace más de 15 años. Este libro es un destilación de las técnicas que ha utilizado y perfeccionado • Elija la estructura de datos perfecta para un trabajo en particular Muchas sesiones individuales con programadores con dificultades. También es autor de Computer Science Made Simple. LO MEJOR EN GE EKE NTERTA INME NT ™ www.nostarch.com “ME VIVO PLANO”. Este libro utiliza RepKover, una encuadernación duradera que no se cierra de golpe. $34.95 ($36.95 CDN)