Acabas de heredar una base de código C++ legacy, ¿y ahora qué?
(gaultier.github.io)- Si te toca hacerte cargo de una base de código C++ legacy grande y compleja, el objetivo no es tener “código limpio”, sino llevar la seguridad, la experiencia de desarrollo, la corrección y el rendimiento a un estado aceptable.
- El punto de partida es conseguir acuerdo y límites de tiempo dentro de la organización, documentar en el README las plataformas compatibles y lograr que la compilación y las pruebas locales pasen de forma estable.
- La mejora de la velocidad de compilación y pruebas debe hacerse midiendo primero mejoras de baja dificultad, como eliminar pruebas de dependencias, limpiar targets innecesarios o experimentar con linkers rápidos como
mold. - Después de reducir código no usado y código para plataformas no compatibles, hay que crear un bucle de verificación automática con
clang-tidy,cppcheck,clang-format,-fsanitize=address,undefinedy CI. - Reescribir el sistema o adoptar estándares modernos de C++ no es un objetivo sino un medio, y reescribir en un lenguaje con seguridad de memoria solo vale la pena considerarlo cuando haya razones fuertes.
Al principio, ordena personas y procesos antes que el código
- Mejorar una base de código C++ legacy no debe ser algo que empujes solo después del trabajo ni una “marcha de la muerte” de largo plazo, sino una tarea sostenible de ingeniería de software.
- Hay que explicar el problema, la solución y los límites de tiempo en palabras simples a jefes, colegas y personas no técnicas.
- Si a una persona recién llegada le tomó 3 semanas lograr una compilación local y su primera contribución, puedes proponer como meta reducirlo a unos minutos.
- Si una configuración simple de fuzzing hace crashear la app 253 veces en pocos segundos, eso sirve como base para explicar el riesgo en producción.
- Si el despliegue depende de un único servidor de compilación con FreeBSD 9 que no recibe soporte desde hace 8 años, una falla de ese servidor bloquearía los despliegues.
- Si un linter estándar de la industria detecta de inmediato comportamiento indefinido que causó un bug en producción, hay razones suficientes para ejecutarlo en cada cambio.
- Si no se puede saber el impacto de una vulnerabilidad por una biblioteca criptográfica copiada y modificada manualmente, hace falta ordenar las dependencias y tener alertas automáticas.
- También conviene dejar claros los enfoques que deben evitarse.
- Hacer una actualización completa al estándar más reciente de C++ durante 2 semanas sin tener pruebas.
- Crear cambios masivos durante meses en una rama separada esperando que algún día se integren.
- Empezar desde el inicio una reescritura completa bajo la suposición de que terminará en unas semanas.
- Empezar a “mejorar la base de código” sin saber qué se hará ni para cuándo.
Una forma segura de mejorar
- Todos los cambios deben ser pequeños e incrementales, y la app debe funcionar antes y después del cambio, con pruebas y linters pasando.
- Las correcciones urgentes de bugs deben seguir siendo posibles como antes, y no deben bloquearse por el trabajo de mejora.
- Cada cambio debe representar una mejora medible y poder explicarse o demostrarse incluso a personas no especialistas.
- Aunque todo el trabajo se detenga por prioridades o presupuesto, debe quedar una ganancia neta medible respecto del punto de partida.
Especifica las plataformas compatibles en el README
- El README debe listar los pares
<arquitectura>-<sistema-operativo>oficialmente compatibles.- Ej.:
x86_64-linux,aarch64-darwin
- Ej.:
- Esta lista es el criterio para verificar que el proyecto compila en todas las plataformas compatibles, y más adelante sirve como fundamento para eliminar código destinado a plataformas no compatibles.
- Si hace falta, también se pueden especificar versiones de arquitectura como ARMv6 o ARMv7.
- La lista de plataformas compatibles permite responder preguntas como estas:
- Si se puede depender de soporte de hardware para punto flotante, SIMD o SHA256.
- Si hace falta soporte de 32 bits.
- Si se ejecuta en plataformas big-endian.
- Si hay que considerar la posibilidad de que
chartenga 7 bits.
- Las estaciones de trabajo de desarrollo también deben estar incluidas obligatoriamente en esta lista.
Primero estabiliza la compilación y las pruebas locales
- Incluso bases de código C++ centrales de productos exitosos muchas veces no compilan de forma estable; el objetivo no es una “compilación que a veces funciona”, sino una compilación consistente en todas las plataformas compatibles.
- El mejor estado es poder compilar y ejecutar directamente en la máquina de desarrollo.
- Si el proyecto es demasiado grande y falta RAM, se puede pedir prestado un servidor grande para compilar.
- Si se necesita una API específica de una plataforma, como
io_uring, se puede implementar un shim o compilar dentro de una máquina virtual en la estación de trabajo. - Aun así, la compilación local directa sigue siendo la mejor opción.
- Si no hay pruebas, primero hay que escribir pruebas antes de cambiar código.
- El punto de partida más simple suele ser capturar entradas y salidas de programas que se ejecutan en la realidad para crear pruebas end-to-end.
- Estas pruebas no garantizan que el comportamiento existente sea correcto, pero reducen regresiones al hacer cambios.
- Si existe una suite de pruebas pero algunas fallan, conviene desactivarlas por ahora y lograr primero un estado en el que todas las pruebas pasen, aunque tarden horas.
Documenta cómo compilar y probar
- El README debe explicar cómo compilar y probar la app.
- La forma ideal es un solo comando de compilación y un solo comando de pruebas.
- Si al principio el procedimiento es complejo, puedes envolver los comandos en
build.shytest.shpara ocultar esa complejidad. - El objetivo es que incluso alguien que no sea especialista en C++ pueda ejecutar la compilación y las pruebas sin hacer preguntas.
- Es mejor documentar la estructura del proyecto o la arquitectura más adelante, después de eliminar código innecesario.
Reduce la velocidad de compilación y pruebas con mejoras de baja dificultad
- Sin reemplazar todo el sistema de build ni hacer optimizaciones heroicas, primero hay que medir mejoras de baja dificultad.
- Cosas para revisar primero:
- Verifica si se compilan y ejecutan las pruebas de las dependencias cada vez.
- Hubo un caso en que, al usar
unittest++como subproyecto de CMake, también se compilaban y ejecutaban siempre las pruebas del framework de testing.
- Hubo un caso en que, al usar
- Verifica si se compilan y ejecutan programas de ejemplo de las dependencias.
- También en
mbedtlsse podía desactivar la compilación de ejemplos mediante variables de CMake.
- También en
- Verifica si tu proyecto compila pruebas y ejemplos por defecto cuando se incluye como subproyecto de otro proyecto.
- Se recomienda desactivar por defecto variables de build como
MYPROJECT_TESTy activarlas solo durante el desarrollo directo.
- Se recomienda desactivar por defecto variables de build como
- Verifica si estás compilando toda una dependencia de terceros aunque en realidad uses solo una pequeña parte.
mbedtlsofrece muchos flags de tiempo de compilación para desactivar partes innecesarias.
- Verifica si las dependencias entre targets están mal configuradas y provocan que un cambio pequeño recompile todo.
- Muchos sistemas de build pueden imprimir el grafo de dependencias.
- Experimenta con un linker rápido como
mold. - Si es posible, compara también compiladores.
- En algunos proyectos, clang fue 2 veces más rápido que gcc; en otros, no hubo diferencia.
- Verifica si se compilan y ejecutan las pruebas de las dependencias cada vez.
- Otros puntos que también se pueden experimentar, aunque la ganancia puede ser pequeña o negativa:
- Activar/desactivar LTO/thin LTO.
- Separar información de debug.
- Comparar Make y Ninja.
- Tipo y configuración del sistema de archivos.
- Si la compilación tarda demasiado, modificar el código deja de ser realista.
Elimina código innecesario
- Hay casos donde más del 30% de la base de código era código completamente muerto, y ese tipo de código sigue aumentando el costo de compilación y refactorización.
- Formas de eliminarlo:
- Usa advertencias
-Wunused-xxxdel compilador.- Ej.:
-Wunused-function - En la mayoría de los casos basta con borrar, recompilar y probar, pero rara vez puede ser un síntoma de un bug donde se llamaba a la función equivocada, así que hay que tener cuidado con automatizarlo por completo.
- Ej.:
- Usa linters como
cppcheckpara encontrar funciones o campos de clases sin uso.- Puede haber muchos falsos positivos con herencia y funciones virtuales, pero puede encontrar elementos no usados que el compilador no detecta.
- También existe la opción de hacer que el linker ponga cada función en una sección separada e imprima la eliminación de secciones no usadas, pero puede no ser práctico por el ruido de funciones de la biblioteca estándar.
- Comparar el ensamblador generado con el código fuente no funciona tan bien con funciones virtuales.
- Usa advertencias
- Usa la lista de plataformas compatibles para eliminar código de plataformas no compatibles.
- Código antiguo de soporte para Solaris en un proyecto que en realidad solo corría en FreeBSD.
- Código que incluye su propio generador de números aleatorios aunque la plataforma real siempre tiene uno.
- Código defensivo para falta de soporte de POSIX 2001 aunque solo se ejecuta en Linux y macOS modernos.
- Código que verifica si la CPU es big-endian para hacer byte swap.
- Código agregado años atrás que nunca llegó a convertirse en una funcionalidad real.
- Un PR que borra mucho código puede mostrar al mismo tiempo reducción del tiempo de compilación y menor costo de mantenimiento.
Agrega linters, formateadores y sanitizers
- En los linters, no conviene activar demasiadas reglas; hay que incorporar primero unas pocas reglas básicas al ciclo de desarrollo.
clang-tidyycppcheckpueden ser útiles, pero también lentos y ruidosos.- No tener ningún linter no es una opción; al ejecutarlos por primera vez, pueden encontrar muchos problemas reales que no aparecen solo con advertencias del compilador.
- El formateo de código debe aplicarse todo junto en el momento adecuado.
- Hay que esperar a que no haya ramas activas para evitar conflictos de merge terribles.
- No pierdas tiempo discutiendo estilo: usa una herramienta como
clang-formatpara formatear toda la base de código sin excepciones. - Confirma también la configuración en el commit.
- Los sanitizers son necesarios para detectar bugs difíciles que pueden afectar producción.
- Se recomienda
-fsanitize=address,undefinedpor defecto. - En general no tienen falsos positivos, así que si detectan algo hay que corregirlo.
- Ejecuta también las pruebas con sanitizers activados para encontrar problemas.
- Si el presupuesto de rendimiento lo permite, también se puede considerar ejecutar en producción con algunos sanitizers activados.
- Se recomienda
- Aunque el compilador de despliegue no soporte sanitizers, en desarrollo y pruebas se puede usar un compilador como clang.
- Al activar sanitizers pueden aparecer bugs y memory leaks ocultos durante mucho tiempo, y corregirlos puede requerir mucho trabajo y refactorización.
- Si es posible, al probar conviene compilar también las dependencias de terceros con sanitizers activados para encontrar problemas dentro de dependencias.
Automatiza con CI
- CI automatiza en un entorno limpio los linters, el formateo, las pruebas y demás configuraciones hechas hasta ahora.
- En cada cambio debería poder generarse el binario de producción.
- La mayoría de los sistemas de CI soportan matrices de varias plataformas, así que se puede verificar que la lista de plataformas compatibles del README realmente compile.
- Un pipeline común puede ser tan simple como
make all test lint fmt. - Los problemas reportados por linters y sanitizers deben hacer fallar el pipeline; si no, nadie los corregirá.
Simplifica el código de forma gradual
- Una vez que exista un bucle estable de compilación, pruebas y verificación, se puede empezar a simplificar el código poco a poco.
- Si una clase compleja en realidad solo asignaba un puntero y verificaba si era null, puede ser un caso donde se pueda reemplazar básicamente por un booleano.
- En esta etapa es difícil fijar un límite de tiempo, porque cada simplificación abre oportunidades para simplificar más, así que hay que juzgar con prudencia.
- El objetivo debe ser un valor concreto como seguridad, corrección o rendimiento, y se deben evitar criterios subjetivos como “código limpio”.
- Actualizar el estándar de C++ no es un objetivo, sino un medio.
- Por ejemplo, puede ayudar a convertir código con incremento manual de iteradores en un loop
for (auto x : items). - Si lo único que necesitas es
std::clamp, quizá sea mejor escribirlo directamente.
- Por ejemplo, puede ayudar a convertir código con incremento manual de iteradores en un loop
Reescribir en un lenguaje con seguridad de memoria requiere una evaluación separada
- Reescribir partes en un lenguaje con seguridad de memoria es una opción posible, pero hay muchos puntos a tener en cuenta.
- Solo debería hacerse cuando existan razones sólidas.
En gestión de dependencias, prefiere compilar desde código fuente
- C++ no tiene una gestión de dependencias consistente, y muchos proyectos usan el gestor de paquetes del sistema.
- Problemas del enfoque basado en paquetes del sistema:
- Las instrucciones de instalación dependen del OS, la distribución y la versión de la distribución.
- Al pasar de Ubuntu 20.04 a 22.04, pueden cambiar las versiones de paquetes y aparecer una situación donde haya que actualizar 100 dependencias al mismo tiempo.
- Las dependencias de terceros que no existen como paquete igual terminan compilándose desde código fuente.
- Los paquetes no están compilados con los flags que necesitas.
- Sanitizers, LTO,
-march, información de debug, frame pointers y diferencias de ABI de C++ pueden causar problemas.
- Sanitizers, LTO,
- Es difícil ver el código fuente de la versión exacta que se usa al auditar, desarrollar o depurar.
- Es difícil parchear una dependencia y volver a compilarla.
- Es difícil usar exactamente las mismas versiones de paquetes entre macOS, Ubuntu y FreeBSD.
- Se vuelve difícil generar un BOM automatizado.
- Puede que no existan las variantes de paquete necesarias, como biblioteca estática/dinámica.
- Gestores de paquetes de C++ como Conan o vcpkg pueden ser una mejora, pero tienen restricciones.
- Las dependencias externas pueden volver el CI más complejo y lento.
- Puede que no tengan todas las versiones de paquetes.
- mbedtls en Conan tiene un ejemplo donde se salta de
2.16.12a2.23.0.
- mbedtls en Conan tiene un ejemplo donde se salta de
- Puede que no soporten el OS o la arquitectura necesarios.
- El enfoque recomendado es traer las dependencias con git submodule y compilarlas desde código fuente.
- Es simple.
- Permite aprovechar el historial y los diff de git mejor que el vendoring manual.
- Permite saber la versión de una dependencia con precisión a nivel de commit.
- Actualizar una sola dependencia se puede hacer con
git checkout. - Funciona en todas las plataformas.
- Permite elegir flags de compilación y compilador por dependencia.
- Aunque no tengan experiencia en C++, los desarrolladores conocen git.
- Funciona de forma recursiva.
- Se puede compilar cada submódulo con
add_subdirectoryde CMake ogit submodule foreach make. - Si los submódulos son difíciles, también es posible usar un enfoque como el de Neovim, donde un único script trae y compila las dependencias.
- Si el grafo de dependencias es muy grande, puede hacer falta un sistema de build híbrido local-remoto como Buck2 y reutilización de artefactos de build.
- Los gestores de paquetes de lenguajes compilados como Go y Rust usan el enfoque de compilar desde código fuente.
Puntos reforzados por sugerencias de lectores
- Hay que enfatizar más las pruebas, y las suites de pruebas de C++ deben ejecutarse bajo sanitizers para no caer en una falsa sensación de seguridad.
- vcpkg puede ser una mejor opción que git submodule si satisface los requisitos y el cross-compilation.
- Nix puede funcionar como gestor de dependencias de C++, pero su complejidad y lentitud son un problema.
- Si solo se corrige un bug al año, invertir en una gran refactorización es una cuestión de criterio, pero eliminar código muerto y usar sanitizers siguen teniendo valor incluso con cambios poco frecuentes.
- La eliminación de código debe concentrarse en casos donde el análisis estático pueda confirmar que no se llama; si hay dudas, es más seguro no eliminarlo.
- En métodos virtuales, el objetivo de llamada se decide en runtime, así que es difícil eliminarlos mediante análisis estático.
- Una conversación de 15 minutos con ventas, product managers o usuarios sobre si una función o plataforma se usa puede reducir mucho el trabajo técnico.
- El enfoque de poner código en un LLM y hacer preguntas debe ser legalmente seguro, ejecutarse localmente si es posible, y sus resultados deben tomarse con cautela.
- También existe la idea de usar herramientas de análisis de código para generar diagramas y relaciones entre clases y entender la estructura general.
- Si no hay sistema de control de código fuente, el paso 0 es poner el código en un VCS.
- También hay opiniones de que CI debería considerarse el paso 1, y aunque lo local sea más rápido, es un punto de vista válido.
- Eliminar plataformas casi no usadas puede reducir la complejidad combinatoria y permitir una gran simplificación.
- Una build completamente reproducible puede no ser realista en una base de código C++ común, pero una build confiable sí lo es.
- En un commit de formateo completo del código, se puede configurar git para que ignore ese commit en
blamey así reducir la carga al rastrear historial. - También existe la idea de usar estadísticas del historial del VCS para encontrar partes que cambian con frecuencia y archivos que suelen cambiar juntos.
- Este enfoque puede aplicarse no solo a C++, sino también a otras bases de código legacy; basta con excluir partes específicas de C++, como los sanitizers.
Working effectively with Legacy Codees un libro recomendado con consejos relacionados.- Hay que enfocarse en valor real, pero al mirar una base de código C++ grande desde la perspectiva de seguridad pueden aparecer muchas vulnerabilidades, y eso tiene valor como reducción de riesgo, no como ganancia financiera.
1 comentarios
Opiniones en Hacker News
Hay buenos consejos y también algunos bastante polémicos. Después de heredar varios proyectos enormes en C++, parece que al inicio hay algunas tareas con beneficios claramente altos.
Primero hay que crear builds reproducibles y envolver el entorno de compilación con Docker o la herramienta de empaquetado que prefieras, para que las herramientas y dependencias sean explícitas y reproducibles.
Hacer que compile sin advertencias con
-Wallexpone mal código, comportamiento indefinido y bugs; además, después permite ver advertencias de inmediato cuando uno introduce código sospechoso.Revisar al inicio errores de lectura/escritura con herramientas como
valgrindtambién es una ganancia fácil en estabilidad, y antes de entender toda la arquitectura es más seguro mantener el refactoring localizado.Son comunes los Dockerfile que apuntan a tags de imágenes cambiantes sin hashes de integridad o que llaman a
apt-get, y ambos dejan mucho margen para terminar en estados sutilmente distintos.Aunque son más difíciles de aprender, soluciones como Bazel o Nix dan una base mucho mejor.
El paso 2 es ejecutar todas las pruebas bajo sanitizers y marcar las que fallen; el paso 3 es corregir todas esas fallas de sanitizer, y el paso 4 corresponde al resto del trabajo.
Los contenedores son buenos, pero son terribles como forma de fingir que no hay problemas.
Hay que empaquetar todas las dependencias y evitar un estado en el que algo solo funcione si hay miles de millones de cosas instaladas en
/usr/liben la versión correcta.-Wallestá bien, pero me opongo firmemente a poner-Wall -Werroren builds de producción.Algunas advertencias del compilador son más bien opiniones, y si una nueva versión del compilador agrega una advertencia nueva, código que antes estaba limpio puede empezar a ser rechazado de golpe.
Si necesitas
-Werror, es mejor usarlo solo en builds de debug.Yo invertiría el orden de los puntos 2 y 3. Tener primero CI, linting, formateo automático, etc., es más importante que empezar a arrancar cosas.
Porque todavía no sabes qué deberías eliminar ni qué consecuencias habrá al hacerlo.
Los linters y las herramientas de análisis estático dan mucha información sobre qué partes del programa necesitan trabajo.
Las partes que hoy marca una herramienta de análisis estático suelen ser lugares donde más adelante podrás eliminar funciones, clases o archivos completos que reimplementan conceptos de la STL.
Puedes reemplazar una librería de iteradores propia, smart pointers hechos en casa, uso de funciones de cadenas C, etc., por algoritmos de la STL, smart pointers reales y clases de strings de C++.
Pero antes de escanear el código no se ve con claridad, y antes de empujar hacia builds o despliegues de prueba rápidos también es difícil evaluar los resultados.
Donde faltan, agrego pruebas de humo básicas.
Esto no hace que el trabajo sea más lento; al contrario, lo acelera. Con una cobertura razonable puedes moverte mucho más rápido al refactorizar, así que la recompensa por una inversión pequeña es grande.
En proyectos grandes de C++ suele haber no solo scripts de generación de código, sino también scripts que parsean el código para recopilar datos destinados a la generación de código.
El formateo automático puede romperlos, e incluso he visto proyectos malditos donde los usuarios parseaban archivos de headers públicos con scripts frágiles.
Es muy probable que termines tocando muchas partes, y cuando se acumulan muchos cambios locales puedes crear por accidente commits con preocupaciones mezcladas.
En ese momento, CI se vuelve tu salvador.
Si vas por ese camino, recomiendo al menos no borrarlo de inmediato sino comentarlo para mantener el diff simple, hasta que la rama esté lista para hacer squash y merge.
Faltan herramientas y técnicas para entender el código. Antes usaba una herramienta escrita en Tcl/Tk llamada Source Navigator, que era muy buena para indexar codebases.
Permitía ver la jerarquía de llamadas del método actual y usar eso para crear diagramas de secuencia UML.
Una herramienta parecida es Source Insight, mencionada abajo.
Y las notas son importantes. La clave es escribir como si estuvieras enseñándole a alguien.
Con los años me volví bastante bueno entendiendo código, y en una época mantenía y desarrollaba activamente, yo solo, una codebase de trading algorítmico en Java que operaba unos 200 millones de dólares diarios en 4 o 5 bolsas.
Tenía 35 MB de documentación sobre ese código y, dejando de lado el riesgo de persona clave, la responsabilidad se sentía bien.
Sinceramente, la mayoría de las codebases grandes tienen demasiado sobrediseño y duplicación.
[1] References in "Source Insight" https://d4.alternativeto.net/6S4rr6_0rutCUWnpHNhVq7HMs8GTBs6...
En la clase de arquitectura de computadoras de la universidad nos obligaron a usar esa reliquia, porque tenía un conjunto extraño de funciones que no existía en otros lados y soporte para emulador ARM.
Lo usamos para programar ensamblador ARM bare-metal.
Sentí que se me revolvía el estómago en la parte que decía que meter
std::cmakeen la biblioteca estándar sería un cambio radicalEntiendo el consejo de arrancar con motosierra todo lo que no sea absolutamente necesario para ofrecer las funcionalidades que la empresa o el proyecto open source promociona y vende, pero es extremadamente peligroso
En esencia es un problema de la cerca de Chesterton, así que no podés quitar algo si no entendés por completo por qué entró en el software y cómo se usa actualmente
En el peor de los casos, al lanzar una versión un mes después, los usuarios descubren que una funcionalidad importante se rompió de forma sutil, y terminás pasando días rastreando exactamente cómo se rompió
Agregar CI, linters, fuzzing, formateo automático, etc. es una buena idea, pero también es difícil
Si una persona usa principalmente VIM, otra emacs, otra QTCreator y otra VSCode, es muy difícil lograr que todos estén en la misma página
Si es un paso opcional que requiere instalar herramientas nuevas, en la práctica no va a ocurrir; y un linter tampoco ayuda si apenas abrís el proyecto aparecen más de 2000 advertencias
cpplint,clang-tidyni un fuzzer integrados al IDE, y son demasiado lentos para ejecutarse automáticamente con cada pulsación de teclaSolo el formateo automático a veces viene integrado
Estas cosas se pueden hacer desde la línea de comandos, independientemente del entorno de desarrollo de cada quien, así que no hay que rendirse desde el inicio solo porque se usen dos editores de texto distintos; al menos hay que intentarlo
En C++, si el equipo no va a instalar ninguna herramienta, se viene un camino duro. También vale la pena considerar containerizar las herramientas para hacerlo más fácil
Tenemos que trabajar como profesionales, y si el trabajo requiere armar el IDE y la cadena de herramientas, hay que aprenderlo y usarlo
Que algo compile y funcione en mi computadora, mi IDE y de la forma que me gusta se parece más a una artesanía que a software
Aunque instalar el hook local sea opcional, si hacés que el PR falle en CI, al final lo van a hacer
Esto es realmente básico, pero parece haber una brecha de conocimiento bastante grande sobre cómo manejar bien CI e infraestructura
Hay que permitir ejecutar localmente pruebas, lint, fuzzing, formateo, validación de formato YAML, verificación de saltos de línea EOF faltantes, etc., para ayudar a los desarrolladores a evitar fallas de CI antes de hacer push
Si el linter emite miles de advertencias al abrir el proyecto, el desarrollador que agrega el linter debería dejar las advertencias en 0 antes de fusionar ese cambio
Eso puede lograrse desactivando advertencias o archivos específicos, corrigiendo algunas, o con una combinación de ambas cosas
Creo que el primer paso es contactar al mantenedor anterior, ir a verlo, invitarle un té o una cerveza y, eventualmente, hablar del codebase
Esos magos que son los antiguos mantenedores pueden contarte muchas cosas
El resto de las propuestas, como hacerlo correr en varias plataformas o lograr que pasen las pruebas, me parecen buenas pruebas de estrés que llevan a robustez y entendimiento
Aun así, primero iría sí o sí por ese fruto al alcance de la mano: hablar con quienes pasaron antes
Si primero trabajás un poco por tu cuenta, te trabás en varios lugares y después hablás con el mantenedor anterior, será mucho más productivo
Ellos también apreciarán ese esfuerzo
Si heredaste un codebase sin pruebas, donde la compilación falla una de cada dos veces, la información de dependencias no está clara y solo compila en un único servidor con un OS muy viejo, no podés estar seguro de que el mantenedor anterior sea realmente un mago
También hay que evaluar si todos los problemas surgieron por falta de tiempo, o si era un “mago” que lo mantuvo roto por estabilidad laboral o porque no quería aprender cosas nuevas
Ese es el mejor de los casos; lo común es que a todas las preguntas responda: “cuando yo lo recibí, ya estaba así”
Durante el trabajo encontré varios bugs y problemas, así que intenté contactar a los desarrolladores que habían escrito ese software a medida para mi empleador
Resultó que lo había escrito un solo contratista, y había muerto varios años antes
En la industria de defensa esto es común. Hay muchas piezas personalizadas de un solo uso hechas para sistemas específicos y, especialmente del lado del hardware, no es raro que el ingeniero que creó el equipo se haya ido o jubilado hace mucho
Eso ayuda muchísimo
¿Un codebase C++ con pruebas? Me parece optimista
No entiendo muy bien por qué hay tanto enfoque en refactorizar o mejorar. Si se puede agregar una funcionalidad pegándola al código, entonces hay que hacerlo así sin tocar nada más.
Si el cambio es lo suficientemente grande, se puede extraer lo necesario del código legacy mediante llamadas a funciones externas, introduciendo una capa de red o separando el mismo código en una biblioteca, y hacer el resto en un entorno nuevo.
A menos que varias personas vayan a trabajar en ese código en adelante y se necesiten ciertos supuestos y estándares para que el grupo pueda colaborar fácilmente, no intentaría una gran refactorización.
El enfoque de ir pegando cosas funciona hasta cierto punto, pero llega un momento en que, al cambiar algo, de pronto se rompe algo y está todo tan desordenado que se tarda más de lo necesario en encontrar la causa.
La idea del artículo original se acerca más a evitar una gran reescritura, pero hacer orden y limpieza para mantener la cohesión y facilitar los cambios.
A veces había que agregar la misma funcionalidad en ambas versiones.
La forma de tratar un código cuyo último lanzamiento sabes que está cerca es muy distinta de la de un código que se va a mantener y ampliar durante décadas más.
En la realidad, muchas veces una nueva funcionalidad es un cambio en un comportamiento ya existente, y de pronto terminas teniendo que hacer refactorizaciones pesadas en varios lugares.
Es un hilo con muchos buenos consejos. Agregaría, sin limitarlo a C/C++, que si tienes el margen para usar un sistema de control de versiones, deberías aprovechar bien su valor.
Muchos equipos lo usan solo como una herramienta simple de colaboración, pero puede hacer más que eso.
Basta con tomar el historial y construir una base de datos sencilla. No tiene que ser una RDB; se puede empezar con archivos JSON o una hoja de cálculo.
Solo con un enfoque basado en datos se puede extraer mucha información útil casi de inmediato.
Los archivos y funciones que cambian con frecuencia probablemente sean hotspots de trabajo futuro, así que si quieres introducir pruebas unitarias o reducir conflictos de merge, conviene concentrarse ahí.
Si archivos que parecen muy alejados suelen cambiar juntos, eso puede sugerir una estructura implícita que no se ve solo mirando el código.
El modelo real de propiedad de cada módulo también se puede inferir del historial, y una propiedad poco clara puede ser una señal de que hace falta refactorizar.
En C/C++, las mejoras de tiempo de compilación también deberían enfocarse con datos en los módulos importantes. En lugar de eliminar dependencias de archivos al azar, se pueden dividir los módulos que cambian con frecuencia y, combinándolo con las dependencias de headers, puntuar el impacto real en el tiempo de build.
Si se integra el VCS con otras herramientas de desarrollo, se puede hacer aún más, y en la era de los LLMs parece posible meter el historial y los metadatos del proyecto en el modelo para preguntarle por insights interesantes.
Para hacerlo sin una ventana de contexto enorme quizá se necesite ingeniería de modelos dedicada, pero tengo la intuición de que vale la pena intentarlo.
El consejo de agregar CI, linters, fuzzing, formateo automático, etc., hay que desglosarlo más.
CI debería garantizar que el proyecto compila no solo en mi computadora sino también en otros lugares, para evitar regresiones basadas en compilación.
Las advertencias del compilador y los analizadores estáticos por lo general son más inteligentes que yo, así que si aparece una advertencia preocupante de que estoy haciendo cosas raras con punteros, es una señal fuerte para revisarlo.
Las pruebas unitarias deberían verificar que el código importante haga exactamente lo esperado hasta un nivel bajo, y como es muy probable que en realidad no sea así, hay que entender por qué.
Si arreglas algo, otra cosa puede explotar, porque el código existente podría haberse escrito asumiendo un comportamiento con bugs.
El formateo automático no es una prioridad, y creo que es mejor seguir el estilo de los mantenedores existentes.
La idea de que la última etapa de una base de código C++ heredada sea una reescritura en un lenguaje seguro en memoria probablemente tampoco encaje bien.
Es difícil conseguir recursos adicionales de trabajo para algo que no está roto, se vuelve necesario conocer no solo C++ sino también otro lenguaje, y las pruebas pueden volverse más complejas.
También es muy probable que, por restricciones de memoria o rendimiento, no sea adecuado escribirlo en varios lenguajes; además, el hecho de haber heredado una base de código legacy ya es casi una admisión de que faltaron recursos como tiempo, dinero o conocimiento para escribirla de nuevo.
“Reescribir en X” solo agrega complejidad por estar de moda.
Si ya se está reescribiendo una gran parte de la base de código en C++, es mejor seguir un subconjunto más restrictivo de C++, y creo que High Integrity C++ está bien.
Si se puede conseguir el estándar MISRA más reciente, probablemente también sea una buena opción.
Es mejor usar un lenguaje que el equipo ya conoce e imponer guías para evitar trampas conocidas, en lugar de hacer que todo el equipo vuelva a aprender las aristas peligrosas de un lenguaje nuevo.
Es extraño que el autor critique tanto la automatización de BOM, la gestión de versiones de paquetes, la procedencia de dependencias, etc., y luego proponga git submodules como algo mejor que un gestor de paquetes.
Antes de hacer ese tipo de críticas habría que probar vcpkg.
Tiene algunas aristas filosas, pero casi todo se satisface de forma intuitiva con vcpkg.
Actualizar dependencias es un poco más difícil que con git submodules, pero lo veo más como una funcionalidad que como un bug. Las dependencias se compilan en sandboxes individuales y luego se instalan en el directorio especificado.
vcpkg permite configurar un repositorio interno como registro en lugar del repositorio oficial para mantener el carácter de vendored-in, hacer chain-load del toolchain para compilar todo con un conjunto fijo de flags y permitir personalizaciones por port.
Como estas abstracciones son útiles, los gestores de paquetes son populares, y no todo el mundo tiene que manejar directamente cadenas interminables con flags de compilación, macros, advertencias, etc.
El artículo fue interesante y aprendí algunas cosas. Pero me dio curiosidad a qué lenguaje se refiere la gente cuando dice “reescribir en un lenguaje con seguridad de memoria”.
Me pregunto si hablan de reescribir partes en Go, Java o C#, o si es una indirecta tipo “reescribirlo en Rust”, dejando abierta la posibilidad de negarlo.
La conclusión depende totalmente del equipo y de las restricciones. Por ejemplo, importa si la recolección de basura es viable; si lo es, si Go es una buena opción; si la seguridad es la prioridad principal, etc.
Creo que la mayoría de los desarrolladores de C++ por lo general pueden usar Rust con facilidad y obtener un rendimiento equivalente.
Pero también hay casos en los que el proyecto nunca tuvo una buena razón para estar en C++ en primer lugar, y he visto reescrituras exitosas en Java.
Apple está reescribiendo parte de su código C++ en Swift.
Una buena regla práctica es elegir un lenguaje con el que el equipo o la empresa se sienta cómodo.
Ya llegamos al punto de sospechar que el autor es un malvado programador de Rust encubierto y cifrado. Supongo que, como Rust ni siquiera apareció, no podían quejarse directamente de eso.