Zastanawiam się, czy duplikacja kodu jest złem koniecznym, jeśli chodzi o pisanie wspólnych struktur danych i ogólnie C?
W C, absolutnie dla mnie, jako ktoś, kto odbija się między C i C ++. Zdecydowanie powielam bardziej trywialne rzeczy na co dzień w C niż w C ++, ale celowo i niekoniecznie postrzegam to jako „zło”, ponieważ istnieją przynajmniej praktyczne korzyści - myślę, że błędem jest rozpatrywanie wszystkich rzeczy jako „dobro” lub „zło” - prawie wszystko jest kwestią kompromisów. Jasne zrozumienie tych kompromisów jest kluczem do uniknięcia niefortunnych decyzji z perspektywy czasu, a jedynie etykietowanie rzeczy jako „dobrych” lub „złych” na ogół ignoruje wszystkie takie subtelności.
Chociaż problem nie jest unikalny dla C, jak zauważyli inni, może być znacznie bardziej zaostrzony w C z powodu braku czegokolwiek bardziej eleganckiego niż makra lub pustych wskaźników dla ogólnych, niewygodnego nietrywialnego OOP i faktu, że Biblioteka standardowa C nie zawiera żadnych kontenerów. W C ++ osoba implementująca własną listę, do której prowadzi link, może mieć wściekły tłum ludzi pytających, dlaczego nie korzysta ze standardowej biblioteki, chyba że są studentami. W C zaprosiłbyś wściekły tłum, jeśli nie możesz śmiało wdrożyć eleganckiej implementacji połączonej listy we śnie, ponieważ często oczekuje się, że programista C będzie przynajmniej w stanie robić takie rzeczy codziennie. To' nie wynika z dziwnej obsesji na połączonych listach, że Linus Torvalds wykorzystał implementację wyszukiwania i usuwania SLL, stosując podwójną pośrednią rolę jako kryterium oceny programisty, który rozumie język i ma „dobry gust”. Jest tak, ponieważ programiści C mogą być zmuszeni do wprowadzenia takiej logiki tysiące razy w swojej karierze. W tym przypadku dla C jest to jak szef kuchni oceniający umiejętności nowego kucharza, zmuszając go do przygotowania kilku jajek, aby sprawdzić, czy przynajmniej opanowali podstawowe rzeczy, które będą musieli robić cały czas.
Na przykład prawdopodobnie zaimplementowałem tę podstawową strukturę danych „zindeksowanej listy swobodnej” kilkanaście razy w C lokalnie dla każdej witryny korzystającej z tej strategii alokacji (prawie wszystkie moje połączone struktury, aby uniknąć alokacji jednego węzła na raz i zmniejszyć o połowę pamięć koszty linków w wersji 64-bitowej):
Ale w C wystarczy realloc
pobrać niewielką ilość kodu do możliwej do powiększenia tablicy i zebrać z niej trochę pamięci, stosując indeksowane podejście do wolnej listy podczas implementacji nowej struktury danych, która korzysta z tej.
Teraz mam tę samą rzecz zaimplementowaną w C ++ i tam zaimplementowałem ją tylko raz jako szablon klasy. Ale jest to znacznie bardziej złożona implementacja po stronie C ++ z setkami linii kodu i pewnymi zewnętrznymi zależnościami, które obejmują również setki linii kodu. A głównym powodem, dla którego jest to znacznie bardziej skomplikowane, jest to, że muszę go zakodować pod kątem pomysłu, że T
może to być dowolny możliwy typ danych. Może rzucać w dowolnym momencie (z wyjątkiem niszczenia go, co muszę zrobić jawnie, jak w przypadku standardowych kontenerów bibliotecznych), musiałem pomyśleć o odpowiednim wyrównaniu, aby przydzielić pamięć dlaT
(chociaż na szczęście jest to łatwiejsze w C ++ 11 i późniejszych), może to być niełatwa do zbudowania / zniszczalna (wymagająca umieszczania nowych i ręcznych wywołań dtor), muszę dodać metody, że nie wszystko będzie potrzebne, ale niektóre rzeczy będą potrzebne, i muszę dodać iteratory, zarówno zmienne, jak i tylko do odczytu (stałe) iteratory itd. i tak dalej.
Growalne tablice nie są rakietami
W C ++ ludzie sprawiają, że brzmi to jak std::vector
praca naukowca zajmującego się rakietami, zoptymalizowana pod kątem śmierci, ale nie działa ona lepiej niż dynamiczna tablica C zakodowana względem określonego typu danych, który po prostu wykorzystuje realloc
do zwiększenia pojemności macierzy przy wypychaniu za pomocą kilkanaście linii kodu. Różnica polega na tym, że potrzeba bardzo złożonej implementacji, aby tylko rosnąca sekwencja losowego dostępu była w pełni zgodna ze standardem, unikaj przywoływania lekarzy na niewstawionych elementach, bezpieczna dla wyjątków, zapewniaj stałe i nietrwałe iteratory o swobodnym dostępie, użyj typu cechy jednoznacznego wypełnienia wskaźników z przedziałów dla określonych integralnych typówT
, potencjalnie traktuj POD POD różnymi cechami typu itp. itd. W tym momencie rzeczywiście potrzebujesz bardzo złożonej implementacji, aby stworzyć możliwą do uzyskania dynamiczną tablicę, ale tylko dlatego, że próbuje obsłużyć każdy możliwy przypadek użycia, jaki kiedykolwiek można sobie wyobrazić. Zaletą jest to, że możesz uzyskać cały przebieg z całego tego dodatkowego wysiłku, jeśli naprawdę potrzebujesz przechowywać zarówno POD, jak i nietrywialne UDT, używaj ogólnych algorytmów iteracyjnych, które działają na dowolnej zgodnej strukturze danych, skorzystaj z obsługi wyjątków i RAII, przynajmniej czasami nadpisuj std::allocator
swój własny niestandardowy alokator itp. Z pewnością opłaca się w standardowej bibliotece, jeśli weźmiesz pod uwagę, ile korzyścistd::vector
ma na całym świecie ludzi, którzy z niego korzystali, ale dotyczy to czegoś zaimplementowanego w standardowej bibliotece zaprojektowanej z myślą o potrzebach całego świata.
Prostsze wdrożenia Obsługa bardzo specyficznych przypadków użycia
W wyniku samej obsługi bardzo specyficznych przypadków użycia z moją „zindeksowaną listą darmową”, pomimo implementacji tej listy darmowej kilkanaście razy po stronie C i mając w wyniku powielony trywialny kod, prawdopodobnie napisałem mniej kodu łącznie w C, aby zaimplementować to kilkanaście razy, niż musiałem zaimplementować go tylko raz w C ++, i musiałem poświęcić mniej czasu na utrzymanie tych kilkunastu implementacji C, niż musiałem tę jedną implementację C ++. Jednym z głównych powodów, dla których strona C jest tak prosta, jest to, że zazwyczaj pracuję z POD w C za każdym razem, gdy używam tej techniki i generalnie nie potrzebuję więcej funkcji niż insert
ierase
w konkretnych witrynach, w których wdrażam to lokalnie. Zasadniczo mogę po prostu wdrożyć najmłodszy podzbiór funkcjonalności, jaką zapewnia wersja C ++, ponieważ mogę swobodnie podejmować o wiele więcej założeń na temat tego, co robię i nie potrzebuję projektu, gdy wdrażam go do bardzo konkretnego zastosowania walizka.
Teraz wersja C ++ jest o wiele ładniejsza i bezpieczniejsza w użyciu, ale nadal była główną PITA do wdrożenia i uczynienia bezpiecznym w wyjątkach i dwukierunkowym zgodnym z iteratorem, np. W taki sposób, że wymyślenie jednej ogólnej, wielokrotnego użytku implementacji prawdopodobnie kosztuje w tym przypadku więcej czasu niż faktycznie oszczędza. Wiele kosztów wdrożenia go w sposób uogólniony jest marnowane nie tylko z góry, ale wielokrotnie w postaci rzeczy, takich jak wydłużone czasy kompilacji, płacone w kółko każdego dnia.
To nie jest atak na C ++!
Ale to nie jest atak na C ++, ponieważ uwielbiam C ++, ale jeśli chodzi o struktury danych, spodobało mi się C ++ głównie dla naprawdę nietrywialnych struktur danych, które chcę poświęcić dużo więcej czasu na wdrożenie w bardzo uogólniony sposób, uczynienie wyjątkowym bezpiecznym dla wszystkich możliwych typów T
, uczynienie zgodnymi ze standardami i iterowalnymi itp., gdzie ten rodzaj kosztów początkowych naprawdę się opłaca w postaci ogromnego przebiegu.
Jednak promuje to również zupełnie inne podejście do projektowania. W C ++, jeśli chcę stworzyć oktree do wykrywania kolizji, mam tendencję do generalizowania go do n-tego stopnia. Nie chcę tylko, aby przechowywał indeksowane siatki trójkątów. Dlaczego powinienem ograniczyć go do tylko jednego typu danych, z którym może współpracować, gdy mam bardzo potężny mechanizm generowania kodu na wyciągnięcie ręki, który eliminuje wszystkie kary abstrakcyjne w czasie wykonywania? Chcę, aby przechowywał sfery proceduralne, kostki, woksele, powierzchnie NURB, chmury punktów itp. Itd. I starał się, aby wszystko dobrze nadawało, ponieważ kusi jest chęć zaprojektowania go w ten sposób, gdy masz szablony na wyciągnięcie ręki. Może nawet nie chcę ograniczać go do wykrywania kolizji - co powiesz na śledzenie promieni, zbieranie itp.? C ++ sprawia, że początkowo wygląda to „trochę łatwo” uogólnić strukturę danych do n-tego stopnia. I tak projektowałem takie indeksy przestrzenne w C ++. Próbowałem je zaprojektować tak, aby zaspokajały potrzeby głodowe całego świata, a to, co dostałem w zamian, to zazwyczaj „waluta wszystkich transakcji” z niezwykle złożonym kodem, aby zrównoważyć go ze wszystkimi możliwymi możliwymi przypadkami użycia.
Zabawne jest to, że przez wiele lat korzystałem z indeksów przestrzennych, które zaimplementowałem w C, i to bez winy C ++, ale tylko moje w tym, co kusi mnie język. Kiedy koduję coś w stylu oktetu w C, mam tendencję do tego, aby po prostu działał z punktami i cieszyłem się z tego, ponieważ język utrudnia nawet próbę uogólnienia go na n-ty stopień. Ale z powodu tych tendencji przez lata miałem tendencję do projektowania rzeczy, które są w rzeczywistości bardziej wydajne i niezawodne i naprawdę dobrze nadają się do określonych zadań, ponieważ nie zawracają sobie głowy byciem generalnym w stopniu n-tym. Stają się asami w jednej wyspecjalizowanej kategorii zamiast waleta wszystkich transakcji. Znowu nie wynika to z winy C ++, ale po prostu ludzkie tendencje, które mam, gdy go używam, w przeciwieństwie do C.
Ale i tak kocham oba języki, ale są różne tendencje. W CI mają tendencję do niewystarczającego uogólnienia. W C ++ mam tendencję do zbytniego generalizowania. Korzystanie z obu pomogło mi się zrównoważyć.
Czy implementacje ogólne są normą, czy piszesz różne implementacje dla każdego przypadku użycia?
W przypadku trywialnych rzeczy, takich jak pojedynczo połączone 32-bitowe indeksowane listy przy użyciu węzłów z tablicy lub tablicy, która dokonuje realokacji (analogiczny odpowiednik std::vector
w C ++) lub, powiedzmy, oktree, który po prostu przechowuje punkty i nie chce nic więcej robić, nie t starają się uogólnić do momentu przechowywania dowolnego typu danych. Implementuję je w celu przechowywania określonego typu danych (chociaż może to być abstrakcja i w niektórych przypadkach używam wskaźników funkcji, ale przynajmniej bardziej szczegółowych niż pisanie kaczką ze statycznym polimorfizmem).
I jestem całkowicie zadowolony z odrobiny redundancji w tych przypadkach, pod warunkiem , że dokładnie go przetestuję. Jeśli nie przeprowadzę testów jednostkowych, nadmiarowość zaczyna być o wiele bardziej niewygodna, ponieważ możesz mieć nadmiarowy kod, który może powielać błędy, np. Nawet jeśli typ kodu, który piszesz, prawdopodobnie nie będzie wymagać zmian projektowych, wciąż może wymagać zmian, ponieważ jest zepsuty. Staram się pisać dokładniejsze testy jednostkowe kodu C, który piszę jako przyczynę.
W przypadku rzeczy nietradycyjnych to zwykle sięgam po C ++, ale gdybym miał go zaimplementować w C, rozważyłbym użycie tylko void*
wskaźników, może zaakceptować rozmiar typu, aby wiedzieć, ile pamięci do przydzielenia dla każdego elementu, i ewentualnie copy/destroy
wskaźniki funkcji do głębokiego kopiowania i niszczenia danych, jeśli nie jest to łatwe do zbudowania / zniszczenia. Przez większość czasu nie zawracam sobie głowy i nie używam zbyt dużo C do tworzenia najbardziej złożonych struktur danych i algorytmów.
Jeśli używasz jednej struktury danych wystarczająco często z określonym typem danych, możesz również owinąć wersję bezpieczną dla tego typu, która po prostu działa z bitami i bajtami oraz wskaźnikami funkcji i void*
, na przykład, aby przywrócić bezpieczeństwo typu przez opakowanie C.
Mógłbym na przykład napisać ogólną implementację mapy skrótu, ale zawsze uważam, że wynik końcowy jest nieporządny. Mógłbym również napisać specjalną implementację tylko dla tego konkretnego przypadku użycia, zachować kod czytelny, łatwy do odczytania i debugowania. To ostatnie doprowadziłoby oczywiście do powielenia kodu.
Tabele skrótów są trochę niepewne, ponieważ wdrożenie lub naprawdę skomplikowane może być trywialne, w zależności od tego, jak złożone są twoje potrzeby w odniesieniu do skrótów, powtórzeń, jeśli chcesz automatycznie, aby tabela rozwijała się samodzielnie lub może przewidzieć rozmiar tabeli w z góry, bez względu na to, czy korzystasz z otwartego adresowania, czy oddzielnego tworzenia łańcuchów itp. Należy jednak pamiętać, że jeśli perfekcyjnie dostosujesz tabelę skrótów do potrzeb konkretnej witryny, jej wdrożenie często nie będzie tak skomplikowane i często wygrane nie będzie tak zbędny, jeśli będzie dokładnie dostosowany do tych potrzeb. Przynajmniej to usprawiedliwiam się, gdy wdrażam coś lokalnie. Jeśli nie, możesz po prostu użyć metody opisanej powyżej za pomocą void*
wskaźników i funkcji do kopiowania / niszczenia rzeczy i generalizowania ich.
Często pokonanie bardzo uogólnionej struktury danych nie wymaga dużego wysiłku ani kodu, jeśli twoja alternatywa ma bardzo wąskie zastosowanie do konkretnego przypadku użycia. Na przykład absolutnie trywialne jest pokonanie wydajności korzystania malloc
z każdego węzła (w przeciwieństwie do łączenia puli pamięci dla wielu węzłów) raz na zawsze za pomocą kodu, którego nigdy nie trzeba odwiedzać w bardzo, bardzo dokładnym przypadku użycia nawet gdy malloc
pojawiają się nowsze implementacje . Może zająć całe życie, aby go pokonać i kodować nie mniej skomplikowane, że musisz poświęcić ogromną część swojego życia na utrzymanie i aktualizowanie go, jeśli chcesz dopasować jego ogólność.
Jako kolejny przykład często stwierdziłem, że niezwykle łatwo jest wdrożyć rozwiązania, które są 10 razy szybsze lub więcej niż rozwiązania VFX oferowane przez Pixar lub Dreamworks. Mogę to zrobić we śnie. Ale to nie dlatego, że moje implementacje są lepsze - dalekie od tego. Są wręcz gorsze dla większości ludzi. Są lepsze tylko w moich bardzo specyficznych przypadkach użycia. Moje wersje mają znacznie, znacznie mniej ogólne zastosowanie, niż Pixar czy Dreamwork. To absurdalnie niesprawiedliwe porównanie, ponieważ ich rozwiązania są absolutnie genialne w porównaniu z moimi głupimi, prostymi rozwiązaniami, ale o to chodzi. Porównanie nie musi być uczciwe. Jeśli potrzebujesz tylko kilku bardzo konkretnych rzeczy, nie musisz tworzyć struktury danych obsługującej nieskończoną listę rzeczy, których nie potrzebujesz.
Jednorodne bity i bajty
Jedną rzeczą do wykorzystania w C, ponieważ ma tak nieodłączny brak bezpieczeństwa typu, jest pomysł jednorodnego przechowywania rzeczy w oparciu o cechy bitów i bajtów. Jest więcej rozmycia w wyniku między alokatorem pamięci a strukturą danych.
Ale przechowywanie wielu rzeczy o zmiennej wielkości, a nawet rzeczy, które po prostu mogłyby być o zmiennej wielkości, takich jak polimorficzne Dog
i Cat
jest trudne do wykonania skutecznie. Nie można założyć, że mogą one mieć zmienną wielkość i przechowywać je w sposób ciągły w prostym kontenerze o swobodnym dostępie, ponieważ krok do przejścia z jednego elementu do drugiego może być inny. W rezultacie do przechowywania listy zawierającej zarówno psy, jak i koty, może być konieczne użycie 3 oddzielnych instancji struktury / alokatora danych (jednej dla psów, jednej dla kotów i jednej dla polimorficznej listy wskaźników podstawowych lub inteligentnych, lub gorzej , alokuj każdego psa i kota względem alokatora ogólnego przeznaczenia i rozrzucaj je po całej pamięci), co staje się kosztowne i wiąże się z udziałem pomnożonych braków w pamięci podręcznej.
Tak więc jedną strategią do wykorzystania w C, chociaż wiąże się ze zmniejszonym bogactwem typów i bezpieczeństwem, jest uogólnienie na poziomie bitów i bajtów. Możesz być w stanie to założyć Dogs
i Cats
wymagać tej samej liczby bitów i bajtów, mieć te same pola, ten sam wskaźnik do tablicy wskaźników funkcji. Ale w zamian możesz następnie kodować mniej struktur danych, ale równie ważne jest przechowywanie wszystkich tych rzeczy w sposób wydajny i ciągły. W takim przypadku traktujesz psy i koty jak analogiczne związki (lub możesz po prostu użyć związku).
A to wiąże się z ogromnymi kosztami w zakresie bezpieczeństwa. Jeśli jest coś, za czym tęsknię bardziej niż cokolwiek innego w C, to bezpieczeństwo typu. Zbliża się do poziomu zestawu, gdzie struktury wskazują tylko, ile pamięci przydzielono i jak wyrównane jest każde pole danych. Ale to właściwie mój najważniejszy powód, aby używać C. Jeśli naprawdę próbujesz kontrolować układy pamięci i gdzie wszystko jest przydzielane i gdzie wszystko jest przechowywane względem siebie, często pomaga po prostu myśleć o rzeczach na poziomie bitów i bajty i ile bitów i bajtów potrzebujesz do rozwiązania określonego problemu. Tam głupota systemu typu C może stać się bardziej korzystna niż utrudnieniem. Zazwyczaj skutkuje to znacznie mniejszą liczbą typów danych, z którymi trzeba sobie poradzić,
Iluzoryczne / pozorne powielanie
Teraz używam „powielania” w luźnym znaczeniu dla rzeczy, które mogą nawet nie być zbędne. Widziałem, jak ludzie odróżniają terminy takie jak „przypadkowe / pozorne” powielanie od „rzeczywistego powielania”. Widzę, że w wielu przypadkach nie ma tak wyraźnego rozróżnienia. Uważam, że to rozróżnienie bardziej przypomina „potencjalną wyjątkowość” niż „potencjalną duplikację” i może iść w obie strony. Często zależy to od tego, jak chcesz, aby Twoje projekty i wdrożenia ewoluowały oraz od tego, jak idealnie będą dostosowane do konkretnego przypadku użycia. Ale często stwierdziłem, że to, co może wydawać się duplikacją kodu, później przestaje być zbędne po kilku iteracjach ulepszeń.
Weź prostą implementację możliwej do zastosowania tablicy, używając realloc
analogicznego odpowiednika std::vector<int>
. Początkowo może być zbędne, powiedzmy, używając std::vector<int>
w C ++. Ale może się okazać, poprzez pomiar, że korzystne może być wcześniejsze przydzielenie 64 bajtów z wyprzedzeniem, aby umożliwić wstawienie szesnastu 32-bitowych liczb całkowitych bez konieczności przydzielania sterty. Teraz nie jest już zbędny, a przynajmniej nie std::vector<int>
. A potem możesz powiedzieć: „Mógłbym po prostu uogólnić to na nowy SmallVector<int, 16>
, i możesz. Ale powiedzmy, że okaże się przydatny, ponieważ są one przeznaczone dla bardzo małych, krótkotrwałych macierzy, aby czterokrotnie zwiększyć pojemność macierzy przydziałów sterty zamiast zwiększając się o 1,5 (mniej więcej tylevector
implementacje używają), pracując przy założeniu, że pojemność macierzy jest zawsze potęgą dwóch. Teraz twój pojemnik jest naprawdę inny i prawdopodobnie nie ma takiego pojemnika. Być może możesz spróbować uogólnić takie zachowania, dodając coraz więcej parametrów szablonu, aby dostosować cięższe prealokacje, dostosować zachowanie realokacji itp. Itp., Ale w tym momencie możesz znaleźć coś naprawdę niewygodnego w użyciu w porównaniu do kilkunastu linii prostego C kod.
A może nawet dojdziesz do punktu, w którym potrzebujesz struktury danych, która przydziela 256-bitową wyrównaną i wypełnioną pamięć, przechowując wyłącznie POD dla instrukcji AVX 256, wstępnie przydziela 128 bajtów, aby uniknąć przydziału sterty dla małych rozmiarów wejściowych typowych przypadków, podwaja pojemność, gdy pełny i umożliwia bezpieczne zastępowanie końcowych elementów przekraczających rozmiar tablicy, ale nie przekraczających pojemności tablicy. W tym momencie, jeśli nadal próbujesz uogólnić rozwiązanie, aby uniknąć powielania niewielkiej ilości kodu C, niech bogowie programowania zlitują się nad twoją duszą.
Tak więc zdarzają się takie sytuacje, w których to, co początkowo zaczyna wyglądać na zbędne, zaczyna rosnąć, gdy dostosowujesz rozwiązanie, aby lepiej i lepiej i lepiej dopasować konkretny przypadek użycia, w coś całkowicie wyjątkowego i zupełnie niepotrzebnego. Ale to tylko w przypadku rzeczy, w których możesz sobie pozwolić na ich idealne dopasowanie do konkretnego przypadku użycia. Czasami potrzebujemy po prostu „przyzwoitej” rzeczy, która jest uogólniona do naszych celów i tam czerpię największe korzyści z bardzo ogólnych struktur danych. Ale w przypadku wyjątkowych rzeczy doskonale wykonanych dla konkretnego przypadku użycia idea „ogólnego przeznaczenia” i „wykonana idealnie dla mojego celu” zaczyna stać się zbyt niezgodna.
POD i prymitywy
Teraz w C często znajduję wymówki do przechowywania POD, a zwłaszcza prymitywów w strukturach danych, gdy tylko jest to możliwe. To może wydawać się anty-wzorcem, ale tak naprawdę okazało się, że jest nieumyślnie pomocne w poprawie możliwości utrzymania kodu w stosunku do rodzajów rzeczy, które robiłem częściej w C ++.
Prostym przykładem jest internowanie krótkich ciągów (jak to zwykle ma miejsce w przypadku ciągów używanych do kluczy wyszukiwania - zwykle są one bardzo krótkie). Po co męczyć się z tymi wszystkimi łańcuchami o zmiennej długości, których rozmiary różnią się w czasie wykonywania, implikując nietrywialną budowę i zniszczenie (skoro możemy potrzebować alokacji i uwolnienia sterty)? A może po prostu przechowuj te rzeczy w centralnej strukturze danych, na przykład w bezpiecznej dla wątku tabeli trie lub tabeli mieszającej zaprojektowanej tylko do internacjonalizacji ciągów, a następnie odnieś się do tych ciągów ze zwykłym starym int32_t
lub:
struct IternedString
{
int32_t index;
};
... w naszych tabelach skrótów, czerwono-czarnych drzewach, listach pominięć itp., jeśli nie potrzebujemy sortowania leksykograficznego? Teraz wszystkie nasze inne struktury danych, które zakodowaliśmy do pracy z 32-bitowymi liczbami całkowitymi, mogą teraz przechowywać te wbudowane klucze łańcuchowe, które są w rzeczywistości tylko 32-bitowe ints
. I przynajmniej znalazłem w moich przypadkach użycia (może to po prostu moja domena, ponieważ pracuję w takich obszarach, jak raytracing, przetwarzanie siatki, przetwarzanie obrazu, systemy cząstek, wiązanie z językami skryptowymi, implementacje zestawu wielowątkowego interfejsu GUI na niskim poziomie itp. rzeczy niskiego poziomu, ale nie tak niski jak system operacyjny), że kod przypadkiem staje się bardziej wydajny i prostszy po prostu przechowując indeksy dla takich rzeczy. To sprawia, że często pracuję, powiedzmy 75% czasu, z just int32_t
ifloat32
w moich nietrywialnych strukturach danych lub po prostu przechowując rzeczy o tym samym rozmiarze (prawie zawsze 32-bitowe).
I oczywiście, jeśli ma to zastosowanie w twoim przypadku, możesz uniknąć wielu implementacji struktury danych dla różnych typów danych, ponieważ w pierwszej kolejności będziesz pracować z tak małą liczbą.
Testowanie i niezawodność
Ostatnią rzeczą, którą zaoferuję i może nie być dla wszystkich, jest faworyzowanie pisania testów dla tych struktur danych. Spraw, aby byli naprawdę dobrzy w czymś. Upewnij się, że są wyjątkowo niezawodne.
Niektóre drobne duplikacje kodu stają się o wiele bardziej wybaczalne w tych przypadkach, ponieważ duplikacja kodu jest jedynie obciążeniem konserwacyjnym, jeśli trzeba wprowadzić kaskadowe zmiany w duplikowanym kodzie. Eliminujesz jeden z głównych powodów, dla których taki nadmiarowy kod się zmienia, upewniając się, że jest wyjątkowo niezawodny i naprawdę dobrze nadaje się do tego, czego nie chce.
Moje poczucie estetyki zmieniło się na przestrzeni lat. Nie denerwuję się już, ponieważ widzę, że jedna biblioteka implementuje produkt kropkowy lub jakąś banalną logikę SLL, która jest już zaimplementowana w innej. Denerwuje mnie tylko wtedy, gdy rzeczy są źle testowane i zawodne, i stwierdziłem, że jest to o wiele bardziej produktywne nastawienie. Naprawdę miałem do czynienia z bazami kodu, które duplikowały błędy poprzez zduplikowany kod, i widziałem najgorsze przypadki kodowania kopiuj-wklej, powodujące, że to, co powinno być banalną zmianą w jednym centralnym miejscu, zmienia się w podatną na błędy zmianę kaskadową dla wielu. Jednak wiele razy był to wynik złych testów, w wyniku których kod nie stał się wiarygodny i dobry w tym, co robił. Przedtem, kiedy pracowałem w błędnych bazach kodu, mój umysł kojarzył wszystkie formy powielania kodu z bardzo dużym prawdopodobieństwem powielania błędów i wymaganiem zmian kaskadowych. Jednak miniaturowa biblioteka, która robi jedną rzecz wyjątkowo dobrze i niezawodnie, znajdzie bardzo niewiele powodów do zmiany w przyszłości, nawet jeśli ma tu gdzieś jakiś zbędny kod. Moje priorytety były wtedy nieaktualne, kiedy duplikacja irytowała mnie bardziej niż zła jakość i brak testów. Te ostatnie rzeczy powinny być najwyższym priorytetem.
Powielanie kodu dla minimalizmu?
To zabawna myśl, która pojawiła się w mojej głowie, ale rozważmy przypadek, w którym możemy napotkać bibliotekę C i C ++, która z grubsza robi to samo: obie mają mniej więcej tę samą funkcjonalność, taką samą liczbę błędów, jedna nie jest znacząco bardziej wydajne niż inne itp. A co najważniejsze, oba są odpowiednio wdrożone, dobrze przetestowane i niezawodne. Niestety muszę tutaj mówić hipotetycznie, ponieważ nigdy nie znalazłem niczego, co byłoby bliskie doskonałemu porównaniu obok siebie. Ale najbliższe rzeczy, jakie kiedykolwiek znalazłem w tym porównaniu, często miały bibliotekę C o wiele, dużo mniejszą niż odpowiednik C ++ (czasami 1/10 wielkości kodu).
I uważam, że powodem tego jest to, że znowu ogólne rozwiązanie problemu, który obsługuje najszerszy zakres przypadków użycia zamiast jednego dokładnego przypadku użycia, może wymagać setek do tysięcy wierszy kodu, podczas gdy ten ostatni może wymagać tylko tuzin. Pomimo nadmiarowości i pomimo faktu, że biblioteka standardowa C jest fatalna, jeśli chodzi o dostarczanie standardowych struktur danych, często kończy się to produkowaniem mniejszej ilości kodu w ludzkich rękach, aby rozwiązać te same problemy, i myślę, że jest to przede wszystkim spowodowane na różnice w ludzkich skłonnościach między tymi dwoma językami. Jeden z nich promuje rozwiązywanie problemów w oparciu o bardzo konkretny przypadek użycia, drugi ma tendencję do promowania bardziej abstrakcyjnych i ogólnych rozwiązań w stosunku do najszerszego zakresu przypadków użycia, ale efekt końcowy nie
Kiedyś patrzyłem na czyjś raytracer na githubie i został on zaimplementowany w C ++ i wymagał tyle kodu dla zabawkowego raytracera. Nie spędzałem zbyt wiele czasu na przeglądaniu kodu, ale było tam mnóstwo struktur ogólnego przeznaczenia, które radziły sobie znacznie lepiej niż to, czego potrzebowałby raytracer. Znam ten styl kodowania, ponieważ używałem C ++ w ten sam sposób, w sposób bardzo oddolny, koncentrując się na tworzeniu pełnowymiarowej biblioteki struktur danych o bardzo ogólnym przeznaczeniu, które wykraczają daleko poza bezpośrednie problem pod ręką, a następnie rozwiązanie rzeczywistego problemu na sekundę. Ale chociaż te ogólne struktury mogą wyeliminować nadmiarowość tu i tam i cieszyć się dużym ponownym użyciem w nowych kontekstach, w zamian ogromnie nadmuchują projekt, wymieniając odrobinę redundancji z mnóstwem niepotrzebnego kodu / funkcjonalności, a to drugie niekoniecznie jest łatwiejsze do utrzymania niż poprzednie. Wręcz przeciwnie, często trudniej mnie utrzymać, ponieważ trudniej jest utrzymać projekt czegoś ogólnego, który musi ściśle dopasować decyzje projektowe do możliwie najszerszego zakresu potrzeb.