6 cosas eficientes que puede hacer para refactorizar un proyecto de C++

6 cosas eficientes que puede hacer para refactorizar un proyecto de C++

Tomé mi antiguo proyecto favorito de 2006, lo experimenté, lo refactoricé y lo hice más moderno en C++. Aquí están mis lecciones y seis prácticas pasos que puedes aplicar en tus proyectos.

Empecemos

Antecedentes y proyecto de prueba

Todos los cambios que describo aquí se basan en mi experiencia con un proyecto favorito que desenterré de los estudios. Es una aplicación que visualiza algoritmos de clasificación. Lo escribí en 2005/2006 y usé C++98/03, Win32Api y OpenGL, todo creado en Visual Studio (probablemente en 2003, si mal no recuerdo :).

Aquí está la vista previa de la aplicación:

Arriba puede ver una animación genial del algoritmo de clasificación rápida. El algoritmo funciona en una matriz de valores (se pueden aleatorizar, ordenar, ordenar de forma inversa, etc.) y realiza un solo paso del algoritmo unas 30 veces por segundo. Luego, los datos de entrada se toman y dibujan como un diagrama con algo de reflexión debajo. El elemento verde es el valor al que se accede actualmente y la sección azul claro representa la parte de la matriz en la que está trabajando el algoritmo.

Si bien la aplicación se ve bien, tiene algunas ideas horribles en el código... entonces, ¿por qué no mejorarla y experimentar?

Aquí está el repositorio de Github:github/fenbf/ViAlg-Update

Comencemos con el primer paso:

1. Actualizar el compilador y establecer la conformidad estándar correcta de C++

Quedarse con GCC 3.0 no es útil cuando GCC 10 está listo :)

Trabajar en Visual Studio 2008 no es la mejor idea cuando VS 2019 está disponible y es estable :)

Si puede, y la política de su empresa lo permite, y hay recursos, entonces actualice el compilador a la última versión que pueda obtener. No solo tendrá la oportunidad de aprovechar las funciones más recientes de C++, sino que también se corregirán muchos errores del compilador. Tener actualizaciones periódicas puede hacer que sus proyectos sean más seguros y estables.

Desde mi perspectiva, también es bueno actualizar las cadenas de herramientas con frecuencia. De esa manera, es más fácil reparar el código roto y tener una transición más fluida. Si actualiza una vez cada 5... 7 años, esa tarea parece ser "enorme", y se retrasa y se retrasa.

Otro tema es que cuando tenga el compilador, recuerde configurar la versión correcta de C++.

Puede usar el último VS 2019 y aún compilar con el indicador C ++ 11 o C ++ 14 (eso podría ser beneficioso, ya que los errores del compilador se resolverán y podrá disfrutar de las últimas funcionalidades de IDE). También le resultará más fácil actualizar al estándar C++17 una vez que el proceso funcione.

Por supuesto, puede ir más allá y también actualizar u obtener las mejores herramientas que pueda obtener para C++:el IDE más reciente, sistemas de compilación, integraciones, herramientas de revisión, etc., etc., pero esa es una historia para un artículo largo e independiente. :) Mencioné algunas técnicas con herramientas en mi artículo anterior:"Use the Force, Luke"... o Modern C++ Tools, por lo que es posible que desee comprobarlo también.

2. Arreglar código con funciones de C++ obsoletas o eliminadas

Una vez que haya configurado el compilador y la versión de C++, puede corregir algunos códigos rotos o mejorar las cosas que quedaron obsoletas en C++.

Estos son algunos de los elementos que podría considerar:

  • auto_ptr obsoleto en C++11 y eliminado en C++17
  • cosas funcionales como bind1st , bind2nd , etc. - usa bind , bind_front o lambdas
  • especificación de excepción dinámica, obsoleta en C++11 y eliminada en C++17
  • el register palabra clave, eliminada en C++17
  • random_shuffle , en desuso desde C++11 y eliminado en C++17
  • trigraphs eliminados en C++17
  • y muchos más

Su compilador puede advertirle sobre esas características, e incluso puede usar algunas herramientas adicionales como clang-tidy para modernizar parte del código automáticamente. Por ejemplo, prueba modernise_auto_ptr que puede arreglar auto_ptr uso en su código. Ver más en mi blog C++17 en detalles:correcciones y obsolescencia - auto_ptr

Y también aquí están las listas de funciones eliminadas/obsoletas entre las versiones de C++:

  • P1319:Funciones obsoletas o eliminadas en C++14,
  • P0636:Funciones obsoletas o eliminadas en C++17
  • P2131:Funciones obsoletas o eliminadas en C++20

3. Comience a agregar pruebas unitarias

¡Eso es un cambio de juego!

Las pruebas unitarias no solo me permiten tener más confianza en el código, sino que también me obligan a mejorarlo.

¿Una parte útil?

Haciendo cosas para compilar sin traer todas las dependencias

Por ejemplo, tuve el DataRendered clase:

class DataRenderer {
public:
    void Reset();
    void Render(const CViArray<float>& numbers, AVSystem* avSystem);
private:
    // ..
};

El renderizador sabe cómo representar una matriz con números usando el AVSystem . El problema es que AVSystem es una clase que hace llamadas a OpenGL y no es fácil de probar. Para hacer utilizable toda la prueba, decidí extraer la interfaz del AVSystem - se llama IRenderer . De esa manera, puedo proporcionar un sistema de renderizado de prueba y puedo compilar mi conjunto de pruebas sin ninguna llamada de función de OpenGL.

La nueva declaración del DataRenderer::Render función miembro:

void Render(const CViArray<float>& numbers, IRenderer* renderer);

Y una simple prueba de unidad/componente:

TEST(Decoupling, Rendering) {
    TestLogger testLogger;
    CAlgManager mgr(testLogger);
    TestRenderer testRenderer;

    constexpr size_t NumElements = 100;

    mgr.SetNumOfElements(NumElements);
    mgr.GenerateData(DataOrder::doSpecialRandomized);
    mgr.SetAlgorithm(ID_METHOD_QUICKSORT);
    mgr.Render(&testRenderer);

    EXPECT_EQ(testRenderer.numDrawCalls, NumElements);
}

Con TestRenderer (solo tiene un contador para las llamadas de sorteo) Puedo probar si todo se está compilando y funcionando como se esperaba, sin ninguna carga por manipular o burlarse de OpenGL. Continuaremos con ese tema más adelante, vea el punto 4.

Si usa Visual Studio, puede usar varios marcos de prueba, por ejemplo, aquí hay alguna documentación:

  • Cómo usar Google Test para C++ - Visual Studio | Documentos de Microsoft
  • Cómo usar Boost.Test para C++ - Visual Studio | Documentos de Microsoft

4. Desacoplar o extraer clases

Si bien las pruebas unitarias pueden exponer algunos problemas con el acoplamiento y las interfaces, a veces los tipos simplemente se ven mal. Echa un vistazo a la siguiente clase:

template <class T>
class CViArray {
public:
    CViArray(int iSize);
    CViArray(): m_iLast(-1), m_iLast2(-1), m_iL(-1), m_iR(-1) { }
    ~CViArray();

    void Render(CAVSystem *avSystem);

    void Generate(DataOrder dOrder);
    void Resize(int iSize);
    void SetSection(int iLeft, int iRight);
    void SetAdditionalMark(int iId);
    int GetSize()

    const T& operator [] (int iId) const;
    T& operator [] (int iId);

private:
    std::vector<T> m_vArray;
    std::vector<T> m_vCurrPos;  // for animation
    int m_iLast;            // last accessed element
    int m_iLast2;           // additional accesed element
    int m_iL, m_iR;         // highlighted section - left and right

    static constexpr float s_AnimBlendFactor = 0.1f;
};

Como puedes ver ViArray intenta envolver un vector estándar y agregar algunas capacidades adicionales que se pueden usar para implementaciones de algoritmos.

Pero, ¿realmente tenemos que tener código de renderizado dentro de esta clase? Ese no es el mejor lugar.

Podemos extraer la parte de renderizado en un tipo separado (de hecho lo has visto en el tercer punto):

class DataRenderer {
public:
    void Reset();
    void Render(const CViArray<float>& numbers, AVSystem* avSystem);
private:
    // ..
};

Y ahora en lugar de llamar:

array.Render(avSystem);

Tengo que escribir:

renderer.Render(array, avSystem);

¡Mucho mejor!

Estos son algunos de los beneficios del nuevo diseño:

  • Es extensible, fácil de agregar nuevas funciones de renderizado que no estropearán la interfaz del arreglo.
  • ViArray se enfoca solo en las cosas que están relacionadas con el procesamiento de datos/elementos.
  • Puedes usar ViArray en situaciones en las que no necesita renderizar nada

También podemos ir más allá, vea el siguiente paso:

5. Extraer funciones no miembro

En el paso anterior, vio cómo extraje el método Render en una clase separada... pero todavía hay un código sospechoso allí:

template <class T>
class CViArray {
public:
    CViArray(int iSize);
    CViArray(): m_iLast(-1), m_iLast2(-1), m_iL(-1), m_iR(-1) { }
    ~CViArray();

    void Generate(DataOrder dOrder);
    
    // ...

¿Debería el Generate función estar dentro de esta clase?

Podría ser mejor si se trata de una función no miembro, similar a los algoritmos que tenemos en la Biblioteca estándar.

Saquemos el código de esa clase:

template<typename T>
void GenerateData(std::vector<T>& outVec, DataOrder dOrder) {
    switch (dOrder) {
        // implement...
    }
}

Todavía no es el mejor enfoque; Probablemente podría usar iteradores aquí para que pueda admitir varios contenedores. Pero este puede ser el próximo paso para la refactorización y por ahora es lo suficientemente bueno.

Con todo, después de algunas iteraciones de refactorización, el ViArray la clase se ve mucho mejor.

Pero no es todo, ¿qué tal mirar el estado global?

6. Reducir el estado global

Registradores... son útiles, pero ¿cómo hacer que estén disponibles para todas las unidades y objetos de compilación?

¿Qué tal si los hacemos globales?

Sí :)

Si bien esta fue mi primera solución, en 2006, en la versión más reciente de la aplicación, la refactoricé y ahora el registrador es solo un objeto definido en main() y luego pasa a los objetos que lo necesitan.

int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int) {
    CLog logger{ "log.html" };

    AppState appState{ logger };

    InitApp(logger, appState);
    
    // ...
}

Y otro tema:Ves que AppState ¿clase? Es una clase que envuelve dos "gestores" que eran globales:

Antes:

CAlgManager g_algManager;
CAVSystem g_avSystem;

Y después:

struct AppState {
    explicit AppState(const CLog& logger);

    CAlgManager m_algManager;
    CAVSystem m_avSystem;
};

AppState::AppState(const CLog& logger) :
    m_algManager { logger},
    m_avSystem { logger}
{
    // init code...
}

Y un objeto del tipo AppState se define dentro de main() .

¿Cuáles son los beneficios?

  • mejor control sobre la vida útil de los objetos
    • es importante cuando quiero registrar algo en destrucción, por lo que debo asegurarme de que los registradores se destruyan en último lugar
  • código de inicialización extraído de un gran Init() función

Todavía tengo algunos otros globales que planeo convertir, por lo que es un trabajo en progreso.

Extra:7. Mantenlo simple

¿Te gustaría ver más?
Este punto adicional sobre Mantener la refactorización simple está disponible para C++ Stories Premium/Patreon miembros Vea todos los beneficios Premium aquí.

Extra:8. Más herramientas

¿Te gustaría ver más?
Este punto adicional sobre el uso de más herramientas está disponible para C++ Stories Premium/Patreon miembros Vea todos los beneficios Premium aquí.

Resumen

En el artículo, ha visto varias técnicas que puede usar para mejorar un poco su código. Cubrimos la actualización de compiladores y cadenas de herramientas, desacoplamiento de código, uso de pruebas unitarias, manejo del estado global.

Probablemente debería mencionar otro punto:Divertirse :)

Si estás refactorizando la producción, tal vez sea bueno mantener el equilibrio, pero si tienes el placer de refactorizar tu proyecto favorito... entonces, ¿por qué no experimentar? Pruebe nuevas características, patrones. Esto puede enseñarte mucho.

De vuelta a ti

Las técnicas que presenté en el artículo no están talladas en piedra ni a prueba de balas... Me pregunto cuáles son sus técnicas con código heredado. Agregue sus comentarios debajo del artículo.