Sekrety profesjonalnego programowania

Sekrety profesjonalnego programowania

Niniejszy artykuł różni się od większości materiałów dotyczących programowania z którymi mogliście mieć styczność.

Nie wyniesiecie z niego żadnej wiedzy dotyczącej konkretnego procesora czy modułu. Co więcej, kodu będzie tutaj bardzo mało, omówione zostaną tylko techniki jego tworzenia.

Aspekty, które mam zamiar poruszyć są podstawą programowania.Odnoszą się nie tylko do języka C i systemów wbudowanych, ale do programowania w ogóle. Moim zdaniem każdy programista powinien je znać i stosować w praktyce.

Jak na ironię, prawie nikt nie mówi o tym osobom, które się uczą. Co więcej, w książkach, w internecie i na uczelniach bardzo często promuje się zupełnie inne koncepcje, które mogą być szkodliwe, każą się skupiać na nieistotnych elementach i straszą stopniem skomplikowania.

Jak rozpoznać dobrego programistę?

Jak ocenili byście umiejętności programisty, który napisał poniższy kod:

Wielu z was na pewno pomyśli: "Nieźle gość musi ogarniać, ja nic z tego nie rozumiem." Mówiąc coś w tym stylu pewnie macie na myśli kilka elementów, które od razu rzucają się w oczy. Autor wie które operatory w wyrażeniu są obliczane wcześniej, a które później. Dlatego może za jednym zamachem dokonać inkrementacji (++) i pobrać wartość spod wskaźnika (*), porównać wartości i dokonać przypisania (> i =) itp.

Potrafi używać wskaźniki do funkcji, rzutowanie bezpośredniego adresu, skrócony operator warunkowy. Pracuje na zaawansowanych strukturach danych - wielokrotnie zagnieżdżone struktury, podwójne wskaźniki. Optymalizuje wykonywanie programu poprzez użycie przesunięć bitowych zamiast zwykłego mnożenia przez 13.

Według niego dobry programista:

  • wykonuje jak najwięcej operacji w pojedynczej instrukcji dla zaoszczędzenia miejsca
  • w tym celu wykorzystuje zaawansowane funkcje języka
  • wie jaka jest kolejność działań i jakie jest domyślne zachowanie kompilatora, dlatego ogranicza zbędne wyrażenia, które i tak wykonają się domyślnie
  • wie jakie operacje zwiększają efektywność, zmniejszają zużycie zasobów i polepszają działanie programu
  • zna różne tricki takie jak skoki do adresów, wstawki assemblerowe, sprytne macra

Prawda jednak jest taka, że autor kodu może zna dobrze język, ale programistą jest beznadziejnym. Dlaczego? Właśnie dlatego, że czytelnik nic z tego nie rozumie. To czyni efekt jego pracy kompletnie bezużytecznym.

Nikt inny nie będzie mógł się połapać w tym co zostało napisane. Ciężko jest cokolwiek dodać lub zmienić. Wiele operacji wykonuje się na raz, więc ciężko jest debugować. W tym samym kodzie znajdują się odwołania do wysokopoziomowych funkcji (get_pid) i rejestrów hardware (TIM3->CR), a nawet konkretnych miejsc w pamięci.

Wymagania stawiane oprogramowaniu

Zastanówmy się teraz, jakie warunki powinien spełniać gotowy produkt. Nie ważne, czy robimy linefollowera na zawody, czy sterownik do elektrowni jądrowej, przed rozpoczęciem projektu mamy jakąś listę założeń, które trzeba spełnić. Mogą one obejmować obsługę jakiś urządzeń peryferyjnych, możliwość konfiguracji przez użytkownika, czy zapewnienie ram czasowych dla sygnałów sterujących.

Dlatego menu do zmiany parametrów lfa powinno być proste, żeby na zawodach nie pomylić się przy ustawianiu, przycisk +1 powinien mieć debouncing, żeby nie zmieniać wartości od razu o 50 itd. Są to funkcjonalności niejawne, z których często nie zdajemy sobie sprawy. Rozpoznajemy je zwykle po tym, że jeśli nie są spełnione, coś nam przeszkadza.

Kolejnym wymaganiem jest wyrobienie się z projektem w założonym czasie. Jeżeli chcieli byśmy wszystko robić perfekcyjnie, nie starczyło by nam życia na dokończenie programu. A w końcu robot musi wystartować w zawodach. Dlatego, żeby zdążyć na czas nieraz trzeba zrezygnować z jakiejś funkcji, inną okroić, jeszcze inną zmienić, żeby uniknąć buga.

Powyższe wymagania determinują, czy nasz projekt zakończył się sukcesem. Pozwalają łatwo sprawdzić, czy projekt jest już gotowy. Są znane dużo wcześniej, dlatego umożliwiają ocenę co jeszcze zostało do zrobienia i jasno wskazują cel do którego dążymy. Jest jeszcze drugi zestaw wymagań, które nazwę wymaganiami wewnętrznymi. Nie dotyczą one końcowego produktu, tylko samego procesu jego wytworzenia. Obejmują takie aspekty, jak efektywne wyszukiwanie i poprawianie błędów, dodawanie nowych funkcjonalności, portowanie, czy współpraca wielu osób przy tym samym projekcie.

Wymagania zewnętrzne:

  • zapewnienie określonej wcześniej funkcjonalności
  • zapewnienie komfortu użytkowania (wymagania niejawne)
  • zakończenie prac w odpowiednim czasie

Wymagania wewnętrzne:

  • możliwość szybkiego wyszukiwania i poprawiania błędów
  • łatwe dodawanie nowych funkcjonalności
  • dostosowywanie kodu do nowych projektów i innych konfiguracji sprzętu
  • współpraca wielu osób nad jednym programem
  • łatwe zrozumienie kodu po dłuższej przerwie w jego rozwijaniu

Zauważmy, że na powyższej liście nie ma ani słowa o optymalizacji, minimalizacji zużycia zasobów czy szybkości wykonywania się funkcji. Tak więc całe to "podejście akademickie" kompletnie mija się z rzeczywistością!

Co więcej, wymagania wewnętrzne często stoją w sprzeczności z klasycznymi miarami jakości kodu, czy kryteriami oceny umiejętności programisty. Dlatego celem jest sprowadzenie tych miar jakości do akceptowalnego poziomu (wymagania niejawne) przy jak najlepszym spełnieniu wymagań wewnętrznych.

codeLine

Specyfika języka C

Filozofia języka C zakłada, że programista to osoba kompetentna - zawsze wie co robi i jest świadomy konsekwencji swoich działań. Dzięki temu programy wynikowe szybko się wykonują i możliwe są niskopoziomowe operacje na sprzęcie i bezpośrednich adresach pamięci Inną ważną zaletą jest stosunkowo prosta implementacja kompilatorów, dzięki czemu możliwe jest pisanie w C kodu na wiele różnych platform.

Niestety istnieje również druga strona medalu. Człowiek nie jest nieomylny i nawet doświadczeni programiści popełniają błędy. W C te błędy są szczególnie bolesne, ponieważ pomyłka często skutkuje poprawnymi składniowo wyrażeniami, które nie zostają od razu zauważone (standarowy przykład to = zamiast ==, czy & zamiast &&). Efektem mogą być błędy, które ujawniają się w zupełnie innych miejscach kodu. Przez to ich poszukiwanie może być szczególnie uciążliwe. Na szczególną uwagę zasługują następujące aspekty:

  • Nie wszystkie błędy są wyłapywane przez kompilator. Pomocne może okazać się włączenie dodatkowych warningów kompilatora. Flagi -Wall i -Wextra powodują włączenie odpowiednio standardowego i rozszerzonego zestawu warningów.
  • Sposób w jaki kod został napisany zarówno pod względem użytej składni, jak i doboru nazw zmiennych, odstępów, wyrównań itp ma wpływ na czytelność i łatwość popełniania błędów.
  • Kod napisany w nieczytelny sposób jest trudny do zrozumienia, szczególnie jeśli wyrażenie jest długie, opiera się na domyślnych operacjach, zawiera dużo podobnych operatorów i nazw itp. Warto tutaj zwrócić uwagę, że nawet jeśli takie skomplikowane wyrażenie jest poprawne, za każdym razem kiedy je czytamy tracimy dodatkowy czas na interpretację.
  • Źle sformułowane wyrażenie może zawierać hazardy lub skutki uboczne.
  • Kompilatory czasem działają w inny sposób niż nam się wydaje. Niektóre błędy nie wynikają z przypadkowej pomyłki tylko z błędnej interpretacji. Ma to miejsce szczególnie podczas używania mniej popularnych konstrukcji.
  • W C nie ma wbudowanych mechanizmów zabezpieczania się przed run time errorami. Dlatego możliwe są takie błędy jak overflow, przepełnienie bufora, dzielenie przez 0, błędne działanie na wskaźnikach, wykorzystanie niezainicjowanych zmiennych, malloc fail, memory leak itd.

Nazwy zmiennych i funkcji powinny tłumaczyć do czego one służą. Jeżeli używasz zmiennej z pomiarem odległości napisz, że to pomiar odległości, a nie sensor5_data. Jeżeli wychodzisz z pętli kiedy zmienna równa się 0, napisz while (var != 0) a nie while(var). Jeżeli wykonujesz wiele operacji, rozbij je na kolejne linijki. Jeśli uważasz, że sam kod nie tłumaczy wystarczająco dobrze co chcesz osiągnąć, nie bój się dodać komentarza. Możesz się nawet rozpisać na kilka linijek. Teraz stracisz na to minutę, ale za to kiedyś może zyskasz 3 dni na debugowaniu.

Jak wykorzystać nasze naturalne zdolności

Ludzki mózg działa według pewnych schematów. Warto zdawać sobie z nich sprawę i jeśli to możliwe - wykorzystywać na swoją korzyść. Dzięki temu praca nad kodem może stać się o wiele bardziej efektywna. Aby sprawnie zapamiętywać i wyszukiwać informacje, posługujemy się pewnymi wzorcami.

Dlatego rozmieszczenie przestrzenne tekstu przyspiesza wyszukiwanie i zrozumienie informacji. Każdy z nas podświadomie próbuje to wykorzystywać i ma swoje ulubione sposoby stawiania nawiasów i spacji, nazywania funkcji, dzielenia programu na pliki itp. Z czasem oswajamy się z tym stylem i łatwo nam się z niego korzysta. Jeżeli nie daj Boże musimy wykorzystać cudzy kod, nagle okazuje się, że jest on napisany po chińsku. Żeby go zrozumieć musimy się dużo bardziej skupić, a i tak często popełniamy błędy. Z tego powodu bardzo ważne jest, aby w każdym projekcie mieć jasno określony styl i się go trzymać.

Podczas analizowania problemu wykorzystujemy pamięć krótkoterminową. Jest ona w stanie pomieścić jedynie kilka elementów. Dlatego często czytając kod musimy go scrollować, żeby znaleźć odpowiednie deklaracje zmiennych, wchodzimy do definicji funkcji, żeby ponownie sprawdzić co dokładnie robi, czytamy datasheety, żeby przypomnieć sobie do czego służył ten rejestr itp. Jeśli analizujemy jakiś problem i musimy się nagle skupić na czym innym, działamy w sposób podobny do procesora. Zawartość naszej pamięci krótkoterminowej odkładamy "na stos" aby można ją było w całości wykorzystać do analizy nowego problemu. Po jego zakończeniu próbujemy "przywrócić kontekst". Często się jednak okazuje, że nasz stos jest bardzo niedoskonały, informacje nam umykają, albo się zniekształcają. Musimy przez to zaczynać od nowa.

Dzięki temu nie musimy analizować rozbudowanego ifa na 3 linijki ze skomplikowaną kolejnością operatorów, a zamiast tego mamy proste wyrażenie:

Jeżeli mamy funkcję, która działa na pomiarze z czujnika analogowego, nie musimy się w niej przedzierać przez kod obsługujący ADC. Zamiast tego wystarczy funkcja:

obsługująca hardware gdzieś na boku. Poszczególne wyrażenia wewnątrz funkcji warto grupować według zadań po 5-10 linii i dodawać do nich zbiorczy komentarz. Wtedy czytelnik wie, że skończył się jakiś etap i część informacji z pamięci krótkoterminowej nie będzie już więcej potrzebna. Dzięki temu nie ma poczucia zawalenia natłokiem informacji.

Człowiek ma ograniczoną zdolność do operowania na złożonych strukturach zawierających wiele zależności. Kod z kolei ma praktycznie nieograniczone możliwości rozbudowania złożoności związane z ilością zmiennych i funkcji, poziomami zagnieżdżenia i zależnościami pomiędzy poszczególnymi modułami. Dlatego bardzo ważne jest, aby tą złożoność ograniczać. Z tego powodu moduły powinny dzielić się na warstwy. Moduł wyższego poziomu może wykorzystywać elementy niższego poziomu za pomocą dobrze zdefiniowanej listy globalnych funkcji. Zasięg zmiennych powinien być ograniczany, najlepiej do pojedynczego pliku. Dzięki temu programista nie musi pamiętać, że ta zmienna była jeszcze modyfikowana w tamtym pliku i jak ją teraz zmieniłem, to w jakimś zupełnie losowym miejscu program się wywala.

Rosnąca złożoność powoduje, że rozwijanie projektu zajmuje coraz więcej czasu, który nie przekłada się na nowe funkcjonalności. Jeżeli mamy do czynienia z taką sytuacją, stajemy się zdenerwowani i tracimy chęci do dalszego rozwoju projektu lub do programowania w ogóle.

Spójny standard kodu

Mając na uwadze wszystkie poruszone przeze mnie aspekty, w profesjonalnych projektach programistycznych wykorzystuje się standardy kodowania. Określają one jednolity standard nazywania plików, funkcji i zmiennych, sposób stawiania nawiasów i białych znaków itp. Dzięki temu łatwiej dodawać nowe elementy w miarę rozrastania się kodu, a wiele osób dodających zmiany jednocześnie może ze sobą współpracować nie robiąc bałaganu. W tego typu standardach często są również poruszane kwestie zakazanych konstrukcji językowych itp. Poniżej linki do kilku przykładowych standardów:

Podsumowanie

Informacje które tutaj zebrałem są efektem moich własnych doświadczeń, a także wiedzy znalezionej w książkach i w internecie. Być może momentami są dosyć subiektywne lub zbyt filozoficzne, ale mam nadzieję, że okażą się przydatne. Inspiracją do napisania artykułu była różnica pomiędzy tym czego się uczyłem na początku, a co okazało się naprawdę ważne w praktyce.

O tym, czy ktoś jest dobrym programistą nie świadczy ilość skomplikowanych wyrażeń, czy sprytnych tricków, które stosuje. Dobry programista potrafi realizować na czas stawiane cele unikając po drodze potencjalnych pułapek. Poza tym potrafi szybko znajdować błędy, a jego kod bez problemu mogą używać inni. Aby zostać dobrym programistą należy pracować nad swoim stylem, analizować napisany kod, wyciągać wnioski i szukać elementów które można poprawić.

arm, avr, C, programowanie, sekrety