QA

Jak opracować strategię testowania oraz inne popularne problemy w C++

praca w trakcie podrozy

Jednym z problemów, który lepiej rozwiązać na wczesnym etapie projektu jest obranie właściwej strategii testowania. Na najbardziej elementarnym poziomie przyjmiemy zapewne strategię testów jednostkowych. Z założenia powinny one testować najmniejszy funkcjonalny kawałek kodu. Sytuacja ulega komplikacji, gdy mamy do czynienia z metodami zwracającymi typ void oraz nie modyfikującymi argumentów wejściowych. Jak temu zaradzić?

Piotr Janus. Senior Software Developer w Ericsson. Z wykształcenia fizyk z doświadczeniem w analizie danych z eksperymentów fizycznych. W Ericsson pracuje w projekcie zajmującym się oprogramowaniem sterującym zasilaniem i bateriami. Razem ze swoim zespołem zajmują się pisaniem nowych funkcjonalności, które pozwalają na autonomiczną pracę stacji bazowych bez potrzeby wizyty techników.

Michał Niemczyk. Senior Software Developer w Ericsson. Z wykształcenia informatyk, po ukończonych studiach na Wydziale Fizyki i Informatyki Stosowanej AGH. Pracuje w firmie Ericsson przy projektach pisanych w języku C++, głównie w obszarze zajmującym się dystrybucją zasilania do poszczególnych urządzeń stacji bazowych. Wraz z zespołem uczestniczy zarówno w planowaniu, implementacji jak i testowaniu nowych funkcjonalności.


Jakby nie było wystarczająco mało problemów, takie klasy typowo mają zależność do innych klas. Dlatego gdy zaczynamy pisać testy takich funkcjonalności to przestają być one jednostkowe. Testują one wiele tysięcy linii kodu przechodząc przez rozmaite wyrażenia if-else. Zanim przejdziemy do propozycji rozwiązania tego problemu przyjrzyjmy się przykładowi:

class XMLEditor
{
  public:
  XMLEditor()
  : m_xmlChecker()
  {
  }
  //pozostałe metody

  private:
  const XMLChecker m_xmlChecker;

};

Jest to przykład klasy mogącej posłużyć do edycji plików XML. Klasa została wyposażona w obiekt wykonujący walidację danych, czy są one zapisane w odpowiednim formacie. Obiekt ten jest polem prywatnym klasy XMLEditor i jest to konstrukcja w pełni poprawna. Wadą danej implementacji jest brak możliwości napisania poprawnych testów jednostkowych. Napisane testy z pewnością będą testowały także klasę XMLChecker oraz prawdopodobnie będą testowały tylko logikę klasy XMLChecker. Jednym z rozwiązań tego problemu jest propozycja wykorzystania paradygmatu odwróconego sterowania (ang. Inversion of control). W tym podejściu klasa XMLEditor wykorzystuje framework XMLCheckerIf, który następnie deleguje zadanie do wyspecjalizowanego kodu.

Zobaczmy, jak taki kod może być napisany wedle tego paradygmatu:

class XMLCheckerIf
{
  public:
  virtual ~XMLChecker()=0;
  virtual bool validate(const std::string&)=0; 
};

class XMLChecker : public XMLCheckerIf
{
  //implementacja metod czysto wirtualnych
};

class XMLEditor
{
  public:
  XMLEditor(const XMLCheckerIf& xmlChecker)
  : m_xmlChecker(xmlChecker)
  {
  }
  //pozostałe metody

  private:
  const XMLCheckerIf& m_xmlChecker;

};

W tym przypadku klasa XMLEditor posiada tylko referencję do interfejsu klasy XMLCheckerIf. Na tym etapie może być widoczna raczej wada tego rozwiązania w postaci bardziej złożonego kodu. Natomiast zysk zaczyna być widoczny na etapie pisania testów. Dla takiej architektury można stworzyć mock klasy XMLCheckerIf.

Czym jest Mock? Mock to obiekt, który dziedziczy po interfejsie XMLCheckerIf i udaje klasę bazową poprzez implementacje metod czysto wirtualnych wedle sposobu dogodnego do testowania. Popularnym środowiskiem do testowania kodu napisanego w C++ jest biblioteka Google Test i to tam odsyłam czytelników, którzy chcą poszerzyć wiedzę dotyczącą aspektów technicznych testowania.

Powyższy przykład prezentował sytuację, w której istnieje możliwość napisania testów choć odbiegają one od idei testów jednostkowych. Natomiast nierzadko można spotkać sytuację, gdzie kod bez użycia odwróconego sterowania jest w ogóle nietestowalny. Do takiej sytuacji dochodzi np. w aplikacjach klient-serwer bądź też, gdy korzystamy z urządzeń peryferycznych.

Spójrzmy na przykład, w którym mamy klasę komunikującą się z innym procesem:

class TemperatureController
{
  public:
  void startToControlTemperature();

  private:
  void sendSignalToTurnOnAC();
  void sendSignalToTurnOffAC();
  Client m_client;
  Thermometer m_thermometer; 

};

Klasa TemperatureController służy do utrzymywania stałej temperatury w pomieszczeniu. W polach klasy znajduje się obiekt typu Client, który wysyła sygnał do procesu sterującego klimatyzatorem, oraz obiekt typu Thermometer, który pozwala na odczytywanie aktualnej temperatury. W tym przypadku nie istnieje możliwość napisania testów jednostkowych, ponieważ testowany kod najprawdopodobniej będzie uruchamiany na serwerze niewyposażonym ani w termometr, ani w klimatyzator.

Nawet jeśli stworzymy taki zestaw to i tak bez ręcznego podwyższania i obniżania temperatury nie będzie możliwości zaobserwowania poprawności działania kodu. Ponownie ten problem można rozwiązać tworząc odpowiednie interfejsy:

class ClientIf
{
  //definicje metod czysto wirtualnych
};

class ThermometerIf
{
  //definicje metod czysto wirtualnych
};

Które następnie zostaną przekazane do klasy sterującej:

class TemperatureController
{
  public:
  TemperatureController(const ClientIf& client, const ThermometerIf& thermometer)
  : m_client(client)
  , m_thermometer(thermometer)
  {
  }
  void startToControlTemperature();

  private:
  void sendSignalToTurnOnAC();
  void sendSignalToTurnOffAC();
  const ClientIf& m_client;
  const ThermometerIf& m_thermometer; 

};

Dzięki powyższemu zabiegowi można stworzyć mock klasy ClientIf oraz mock klasy ThermometerIf. Następnie w testach jednostkowych należy je przekazać do klasy testowanej. W efekcie klasa TemperatureController jest odseparowana od klas zewnętrznych i można przetestować wyłącznie jej logikę na dowolnej maszynie nie wyposażonej w zewnętrzne komponenty.

Typy wyliczeniowe

Dobrą praktyką jest używanie typu wyliczeniowego enum class. W szczególności powinien on być preferowany nad zwykłym typem wyliczeniowym enum. Główną zaletą typu enum class jest to, że jego wartości nie są konwertowane bezpośrednio na typ int i na etapie kompilacji istnieje możliwość wychwycenia błędów. Prezentuje to następujący przykład:

enum ColorPlain { red, green, blue };
enum AnimalPlain { cat, dog, bird }; 
 
enum class ColorClass { red, green, blue };    
enum class AnimalClass { cat, dog, bird }; 

int main()
{
  //przykład złego użycia typu enum
  ColorPlain colorp = ColorPlain::red;
  AnimalPlain animalp = AnimalPlain::cat;
  if(colorp == animalp) //kod skompiluje i wykona się
  {
    //dalsze wykonanie
  }

  //przykład dobrego użycia typu enum class
  ColorClass colorc = ColorClass::red;
  AnimalClass animalc = AnimalClass::cat;
  if(colorc == animalc) //na etapie kompilacji dostaniemy ostrzeżenie
  {
    //dalsze wykonanie
  }
  return 0;
}

Brakującą funkcjonalnością w C++ jest możliwość łatwej konwersji obiektu typu enum class na obiekt typu string. Taki problem najczęściej występuje w przypadku części kodu odpowiedzialnej za obsługę interfejsu użytkownika.

Załóżmy, że piszemy aplikację do operacji na plikach. Gdy użytkownik wybierze plik, chcemy mu wyświetlić menu z dostępnymi akcjami: usuń, zmień nazwę, edytuj i przenieś. Od strony kodu najwydajniej i najbezpieczniej jest operować na typach wyliczeniowych natomiast od strony interfejsu użytkownika oczywistym wyborem jest typ string. Ten problem można rozwiązać przy pomocy metody konwertujące jeden typ obiektu na drugi.

Najprostszym i najbardziej oczywistym podejściem jest wykorzystanie instrukcji switch:

enum class Actions { delete, rename, edit, move }; 

const char* convert(Actions a)
{
  switch(a)
  {
    case Actions::delete: return "usun";
    case Actions::rename: return "zmien nazwe";
    case Actions::edit:   return "edytuj";
    case Actions::move:   return "przenies";
    case default:         return "nieznana akcja";

  }
}

W powyższej implementacji można od razu zauważyć zysk z użycia typu enum class. W sytuacji, gdy mamy różne wyliczenia kompilator poinformuje nas, jeżeli zostanie użyty zły typ do konwersji (np. zostanie przekazany obiekt ColorClass::red. Niestety podejście to ma także pewne ograniczenia. Tego typu konwersję da się zastosować tylko w jedną stronę, enum class na string. Język C++ nie pozwala na wykorzystanie obiektu typu string w instrukcji sterującej swtich. Ten problem można rozwiązać pisząc klasę konwertującą zawierającą generyczne metody. Aby podejście działało w obie strony na początek zdefiniujmy obiekt, który przechowa informację co na co ma być konwertowane.

Można tutaj wykorzystać złożenie klas zdefiniowane w bibliotece standardowej std::pair i std::vector:

std::vector<std::pair<Actions, std::string>> conversionMap = 
  {{Actions::delete, "usun"},
   {Actions::rename, "zmien nazwe"}, 
   {Actions::edit,   "edytuj"}, 
   {Actions::move,   "przenies"}};

Implementacja klasy szablonowej wraz z szablonowymi metodami może wyglądać następująco:

template<class mappingType1, class mappingType2>
class Converter
{
  public:
  Converter(const std::vector<std::pair<mappingType1, mappingType2>>& map)
  : m_map{map}
  {}
    
  template <class FromState, class ToState>
  std::optional<ToState> convert(const FromState& toConvert) const
  {
    if (!isReversed<FromState, ToState>(m_map))
    {
      for (auto [fromState, to]: std::any_cast<std::vector<std::pair<FromState, ToState>>>(m_map))
      {
        if(fromState == toConvert)
        {
          return to;
        }
      }
      return std::nullopt;
    }
    for (auto [to, fromState] : std::any_cast<std::vector<std::pair<ToState, FromState>>>(m_map))
    {
      if(fromState == toConvert)
      {
        return to;
      }
    }
    return std::nullopt;
  }
    
    
   private:
   std::vector<std::pair<mappingType1, mappingType2>> m_map;
    
   template <class FromState, class ToState>
   bool isReversed(const std::any &configuration) const
   {
     return std::type_index(configuration.type()).name() != 
             std::type_index(typeid(std::vector<std::pair<FromState, ToState>>)).name();
   }
};

Przeanalizujmy krok po kroku, jak działa klasa Converter. Na samym początku mamy konstruktor, który kopiuje obiekt zawierający mapowanie do pola klasy m_map. Na tym etapie widać, że do powyższej klasy można przekazać mapowanie składających się z dowolnych obiektów. Jedynym wymogiem jest to, by te obiekty miały operator porównania. Konwersja jest dokonywana w metodzie convert, która w pierwszej kolejności sprawdza czy konwersja jest zgodna z przekazaną tablicą par (enum na string).

Jeżeli mamy do czynienia z odwrotną konwersją (string na enum) to wtedy metoda wyciąga elementy pary w odwrotnej kolejności. Po sprawdzeniu kierunku konwersji następuje przeszukiwanie wektora i zwrócenie właściwego obiektu. W przypadku gdy nie istnieje zadana konwersja, metoda zwraca std::nullopt.

Przykład użycia konwersji z enum na string:

std:string deleteActionString = converter.convert<Actions,std::string>(Actions::deleteFile).value();

oraz w odwrotnym kierunku:

Action actionType = converter.convert<std::string, Actions>("usun").value();

Wykorzystanie zdarzeń

W dużych projektach ważnym tematem jest kwestia podziału i pogrupowania klas w mniejsze bądź większe bloki. Rodzi się wtedy problem, jak takie bloki ze sobą skomunikować. Na wczesnych etapach projektu, gdzie nie mamy jeszcze dużej ilości klas, najczęstszą praktyką jest przekazanie do klasy obiektu innej klasy – czyli stworzenie zależności asocjacji. Jest to typowa komunikacja na poziomie klasy. Niestety, kiedy projekt się rozrasta zaczynamy mieć potrzebę komunikacji między blokami, a więc na poziomie wyższym od klas. Wtedy podejście używania asocjacji jest zdecydowanie złą praktyką, ponieważ bardzo szybko spowoduje stworzenie mnóstwa zależności i tak zwanego “kodu spaghetti”.

Dobrą praktyką w takiej sytuacji jest wykorzystanie mechanizmu zdarzeń (eventów) do skomunikowania ze sobą klas znajdujących się w różnych blokach. Najczęściej sprowadzi się do to napisania EventDispatcher – klasy sterującej całym mechanizmem obsługi zdarzeń. Taka klasa pozwoli nam wysyłać zdarzenie o określonym typie i z określonymi danymi. Do definicji danych warto pomyśleć o wykorzystaniu std::variant.

Każda klasa posiadająca obiekt EventDispatcher, może zasubskrybować się na dane zdarzenie, przekazując funkcję callback, która ma się wykonać w przypadku wystąpienia zdarzenia. Do przekazania funkcji można wykorzystać mechanizm bindowania (std::bind).

Poniżej znajduje się bardzo uproszczony przykład implementacji:

class EventDispatcher
{
  public:
    void raiseEvent(const Event&);
    void subscribe(const EventType&, std::function<void(const Event&)>);
    void unsubscribe(const EventType&);
};

class Event
{
  public:
    std::variant<int32_t, std::string> getEventData();
    
  private:
    EventType m_eventType;
    std::variant<int32_t, std::string> m_eventData;
};

enum class EventType
{
  TEMPERATURE_TOO_HIGH, 
  TEMPERATURE_TOO_LOW
};


class TemperatureController
{
  public:
    TemperatureController(const EventDispatcher& eventDsp): m_eventDsp{eventDsp}
    {
      m_eventDsp.subscribe(EventType::TEMPERATURE_TOO_LOW,
                           std::bind(TemperatureController::onTempTooLow, this));
      m_eventDsp.subscribe(EventType::TEMPERATURE_TOO_HIGH,
                           std::bind(TemperatureController::onTempTooHigh, this));

    }
    ~TemperatureController()
    {
      m_eventDsp.unsubscribe(EventType::TEMPERATURE_TOO_LOW);
      m_eventDsp.unsubscribe(EventType::TEMPERATURE_TOO_HIGH);
    }
    void onTempTooLow(const Event& event)
    {
     auto temperature = event.getEventData<int32_t>();
      //obsługa zbyt niskiej temperatury, na przykład zgłoszenie odpowiedniego alarmu
    }
    void onTempTooHigh(const Event& event)
    {
      auto temperature = event.getEventData<int32_t>();
      //obsługa zbyt wysokiej temperatury, na przykład zgłoszenie odpowiedniego alarmu
    }

  private:
    const EventDispatcher& m_eventDsp;
};

W powyższym przykładzie widzimy trzy klasy. Jedna z nich reprezentuje zdarzenie (Event). Każde zdarzenie musi mieć zdefiniowany typ oraz dane. Do definicji typu można wykorzystać typ wyliczeniowy. Do definicji danych możemy wykorzystać szablon, lub jak w powyższym przykładzie std::variant.

Drugą klasą jest EventDispatcher, który będzie obsługiwał zdarzenia. Posiada on metodę raiseEvent(), która przyjmuje konkretne zdarzenie i ogłasza, że ono nastąpiło. Klasa udostępnia także metody służące do subskrypcji i usunięcia subskrypcji na dany typ zdarzenia.

Klasą wykorzystującą zdarzenia jest TemperatureController. W klasie chcemy otrzymać informację, jeśli temperatura zostanie przekroczona (będzie zbyt niska lub zbyt wysoka). Subskrybujemy się zatem na interesujące nas typy zdarzeń (TEMPERATURE_TOO_LOW oraz TEMPERATURE_TOO_HIGH). W momencie, kiedy zostanie zgłoszone zdarzenie, zostanie uruchomiony odpowiedni callback z klasy TemperatureController.

Największą zaletą podejścia do komunikacji poprzez obsługę zdarzeń jest uniknięcie budowania złożonych zależności pomiędzy klasami. Kod staje się dużo czytelniejszy i co ważne, łatwy do dalszej rozbudowy. Dodanie nowego typu zdarzenia czy podpięcie nowej klasy do obsługi zdarzeń staje się bardzo łatwe.

Logowanie

Logowanie przebiegu programu jest nieodzownym elementem w każdym projekcie. Zazwyczaj w początkowych etapach projektu nie przykładamy do tego większej uwagi. Często programiści mają tendencję do nadużywania logów na wczesnym etapie projektu. Bo istotnie, na początku nie będzie stanowiło to większego problemu, jednak w miarę rozrastania się kodu, inni programiści widząc styl częstości logowania sami będą się do niego dostosowywać i już po kilku miesiącach okaże się, że plik z logami zebranymi z dwóch minut działania aplikacji zajmuje kilka megabajtów. Tym samym zebranie logów z dłuższego okresu staje się w zasadzie niemożliwe.

Warto przykładać dużą uwagę do logów oznaczających błąd w programie, sytuację, która nie powinna się wydarzyć. Tego typu logi powinny pojawiać się zawsze oraz zawierać jak najbardziej szczegółowe informacje, takie jak wartości zmiennych. Jeżeli dojdzie do takiej sytuacji w programie działającym u klienta, jest spora szansa, że uda się rozwikłać problem bez proszenia klienta o reprodukcję z dodatkowymi logami.

Złym przykładem często spotykanym w projekcie jest logowanie przebiegu tuż przed wywołaniem metody, która sama w sobie ma na wejściu log. Mamy tutaj przykład niepotrzebnego powielania logów. Warto zaimplementować swój własny logger, który pozwoli nam w łatwy sposób określać poziomy ważności logów – możemy na przykład zdefiniować grupę, która będzie wypisywana domyślnie – dobrym przykładem jest tutaj grupa logów oznaczających błąd w programie.

W logowaniu bardzo często potrzebujemy wypisać zawartość jakiejś struktury danych. Dobrą praktyką jest implementacja metody toString() w strukturach danych, która idealnie sprawdza się gdy zachodzi potrzeba wypisania zawartości struktury w logach. Można nawet pokusić się o wymuszenie implementacji takiej metody, definiując wspólny interfejs dla powiązanych struktur danych.

Modyfikacja zmiennej przez referencję

Złą praktyką, a niestety wciąż często spotykaną w projektach jest modyfikowanie zmiennej poprzez wysłanie jej przez referencję w argumencie funkcji. W argumentach funkcji powinniśmy używać wyłącznie stałych referencji, natomiast zmodyfikowana wartość powinna być zwrócona jako rezultat funkcji. Taki kod jest znacznie czytelniejszy, łatwiejszy do zrozumienia i mniej podatny na błędy.

Czasem wydaje się kuszące użyć mechanizmu modyfikacji atrybutu przez referencję, jeśli metoda ma zwrócić rezultat z kodem błędu. Popatrzmy na poniższy przykład:

enum class Result
{
  OK,
  ERROR_DEVICE_NOT_AVAILABLE,
  ERROR_TEMP_OUT_OF_RANGE
};

Result getTemperature(float& temperature);

W powyższym przypadku metoda getTemperature próbuje odczytać temperaturę i wpisuję ją do zmiennej wysłanej w argumencie. Jeśli odczyt się uda, metoda zwraca Result::OK. Jeśli odczyt się nie uda, metoda nie przypisze żadnej wartości do zmiennej oraz zwróci kod błędu.

Pierwsza pułapka czeka na nas już przy samym wywołaniu takiej metody, zwłaszcza jeśli użyjemy słowa kluczowego auto:

auto result = getTemperature(currentTemp);

Intuicyjnie pomyślimy, że result będzie zawierał wartość temperatury, natomiast bieżąca temperatura jest potrzebna metodzie do czegoś, na przykład do logowania, ale nie do modyfikacji.

W takiej sytuacji warto jest rozważyć skorzystanie z std::optional do zwrócenia wartości temperatury. Jeśli kod błędu potrzebny jest nam tylko po to, by go wypisać w logach, w takiej sytuacji skorzystanie z std::optional byłoby tutaj dobrą praktyką. Dzięki temu sprawdzimy, czy udało się pobrać temperaturę, natomiast kod błędu wypiszemy z ciała funkcji getTemperature(). Jeśli niezbędne jest zwrócenie kodu błędu, można pomyśleć o wykorzystaniu pary <temperatura, rezultat>.

Takie sytuacje są wyjątkowo przykre, jeśli korzystamy z zewnętrznej biblioteki i to właśnie ta biblioteka dostarcza nam taką metodę. Nie mamy możliwości modyfikacji kodu biblioteki, najczęściej nie mamy do niego nawet dostępu. Aby temu zaradzić, w takiej sytuacji dobrą praktyką jest napisanie własnego wrappera dla danej funkcjonalności biblioteki, żeby wymusić stosowanie dobrych praktyk na innych programistach w naszym projekcie.

Zagnieżdżanie kodu

Dobrym podejściem pozwalającym na uniknięcie nadmiarowego zagnieżdżania kodu jest stosowanie się do zasady negative if-statement. Polega ono na tym, że w instrukcji warunkowej if sprawdzamy negatywny scenariusz. Jeśli warunek negatywny zostanie spełniony, jest to dobry moment na wypisanie logu z informacją o błędzie czy rzucenie wyjątku. Następnie wychodzimy z metody. Podejście prezentuje poniższy przykład:

auto temperature = getTemperature(); //zwraca std::optional
if (!temperature) //negatywny scenariusz
{
  printErr(„Cannot read temperature!”);
  return;
}

if (!isTemperatureValid(*temperature)) //negatywny scenariusz
{
  printErr(„Temperature invalid!”);
  return;
}

if (*temperature < TEMP_MIN_LIMIT || *temperature > TEMP_MAX_LIMIT) 
{
  printErr(„Temperature out of range”);
  return;
}

printInfo(„Temperature correct”); //na tym etapie mamy już pewność 
// dalsze instrukcje

W powyższym przykładzie przeprowadzany jest odczyt i walidacja pomiaru temperatury. Mamy tutaj jeden poziom zagnieżdżenia. Gdybyśmy nie skorzystali z zasady negatywnego scenariusza, kod wyglądałby następująco:

auto temperature = getTemperature();
if(!temperature)
{
  if(isTemperatureValid(*temperature))
  {
    if (*temperature >= TEMP_MIN_LIMIT && *temperature <= TEMP_MAX_LIMIT)
    {
       printInfo(„Temperature correct”);
       //dalsze intrukcje
    }
    else
    {
      printErr(„Temperature out of range!”);
    }
  }
  else
  {
    printErr(„Temperature invalid!”);
  }
}
else
{
  printErr(„Cannot read temperature!”);
}

W tym przypadku dochodzimy do trzech poziomów zagnieżdżenia, natomiast kod wykonuje dokładnie to samo. Korzystając z negative scenario mamy więc znaczący zysk na czytelności kodu.

Dobre praktyki

Kusząca praktyką jest zwracanie „pustych” obiektów, gdy nie udało się wykonać danej akcji. Załóżmy, że piszemy metodę, która kontroluje liczbę klientów podłączonych do serwera. Przykładowa definicja mogłaby wyglądać następująco:

uint32_t getNumberOfConnectedClients();

Problem, który się rodzi to taki, że powyższa metoda zawsze musi zwrócić jakąś wartość. Doświadczenie pokazuje, że taka metoda zwróci 0, gdy nie uda nam się połączyć z serwerem i nie zostanie odczytana liczba klientów. Na tym etapie nie jest to problem, ale z pewnością z rodzi to problemy na dalszych etapach, gdzie ta liczba jest wykorzystywana. W przypadkach, gdy istnieje szansa, że metoda może z pewnych powodów nie zwrócić żądanej wartości należy to rozróżnić. Najprościej jest wykorzystać klasę std::optional (dostępną od standardu C++17), która pozwala na rozróżnienie tych dwóch możliwości.

Definicja poprawionej wersji powyższej metody będzie wyglądać następująco:

std::optional<uint32_t> getNumberOfConnectedClients();

To samo podejście można także stosować z klasami takimi jak std::string czy std::vector. W takich sytuacjach także nie jest polecane zwracać pustego obiektu, jeżeli dana metoda nie mogła wykonać w pełni swojego zadania.

Wysoka kompatybilność wsteczna, którą dostarcza C++ pomiędzy kolejnymi wersjami standardu, ma swoją zaletę w postaci możliwości wykorzystania starszych bibliotek. Natomiast niesie za sobą także problemy używania starszych rozwiązań, gdzie najczęstszym z nich jest operowanie na wskaźnikach. Obecnie wskaźniki nie powinny być używane bezpośrednio, ponieważ łatwo jest naruszyć wzorzec projektowy RAII (Resource Acquisition Is Initialization) i doprowadzić do wycieków pamięci.

Zamiast standardowych wskaźników powinno się używać inteligentne wskaźniki: std::unique_ptr dla obiektów z jednym właścicielem oraz std::shared_ptr dla obiektów współdzielonych. W każdym razie, jeżeli taka sytuacja się wydarzy to polecamy rozważyć użycie wzorcu projektowego adapter lub fasada (dla bardziej złożonych bibliotek). Wzorce te pozwalają stworzyć dodatkową warstwę oddzielającą nową implementację od starszej. Niezwykle ważne, by na wczesnym etapie pisania aplikacji rozdzielać części kodu, które są w pewien sposób niekompatybilne. Poprawienie tego na późniejszym etapie może wiązać się z dużym nakładem pracy.

W zbiorze dobrych praktyk nie mogło zabraknąć tematu nazewnictwa metod. Coś z czym każdy programista na przez cały czas do czynienia, niezależnie od tego w jakim języku pisze i jak duży projekt robi.

Najczęstszym błędem spotykanym zwłaszcza w dużych projektach jest stosowanie zbyt ogólnikowych nazw metod, przykładowo handleData. W miejscu wywołania takiej metody programista najczęściej będzie zmuszony do odnalezienia jej implementacji, aby zrozumieć intencje autora kodu. Co gorsza, taka metoda będzie miała tendencję do znacznego rozrastania się, ponieważ będzie miała zbyt wiele odpowiedzialności – będzie można „wpakować” do niej wszystko. Taka metoda dość szybko stanie się rozbudowana na kilkaset linii kodu.

Dobrą praktyką jest tworzenie metod o jak najmniejszej odpowiedzialności, najlepiej jednej. Metoda powinna być krótka i robić dokładnie to co ma w nazwie. Nie bójmy się używać dłuższych nazw w celu określenia przeznaczenia funkcji.

Przy nazewnictwie zmiennych warto zwrócić uwagę na jednostki. Doświadczenie pokazuje, że bardzo łatwo jest popełnić tutaj błąd. Jeśli mamy zmienną o nazwie m_timeout dopiszmy do niej jednostkę, na przykład m_timeoutMs. Kolejny przykład to dopisanie przyrostka _opt przy korzystaniu z std::optional. Jest to przydatne zwłaszcza przy korzystaniu ze słowa kluczowego auto:

auto currentTemperature_opt = getTemperature();

Należy oczywiście zawsze zwracać uwagę na zbiór zasad obowiązujących w danym projekcie / danej firmie i trzymać się konwencji. Przykładem może być dodawanie przedrostków do nazw zmiennych i argumentów.

Kolejnym ważnym zagadnieniem jest dodawanie komentarzy do kodu. Istnieje teoria mówiąca, że jeśli potrzebujemy napisać komentarz w kodzie to oznacza, że napisaliśmy zły kod, często zbyt zawiły i powinniśmy go od razu zmodyfikować. Jest w tym sporo racji. Przy stosowaniu odpowiednich nazw zmiennych, metod oraz zasady pojedynczej odpowiedzialności, kod powinien być na tyle czytelny, aby nie wymagał dodatkowych komentarzy. Czasem jednak napisanie komentarza staje się niezbędne, na przykład przy korzystaniu z zewnętrznych bibliotek, na których implementację nie mamy wpływu.

Zdjęcie główne artykułu pochodzi z unsplash.com.

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://geek.justjoin.it/jak-opracowac-strategie-testowania/" order_type="social" width="100%" count_of_comments="8" ]