Czy powielanie kodu jest złem koniecznym w C?

16

Jestem raczej nowy w C i zastanawiam się, czy duplikacja kodu jest koniecznym złem, jeśli chodzi o pisanie wspólnych struktur danych i ogólnie C?

Mógłbym na przykład napisać ogólną implementację hash map, 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.

Czy implementacje ogólne są normą, czy piszesz różne implementacje dla każdego przypadku użycia?

Gerstmann
źródło
11
Moralny problem, który opisujesz, nie jest unikalny dla C. Anegdotycznie stwierdzam, że poprzeczka do stworzenia ogólnej implementacji jest dość wysoka, chyba że masz do tego publiczność. Ilość wysiłku wymaganego do stworzenia dobrej ogólnej implementacji znacznie przewyższa punktowe rozwiązanie, IME.
Robert Harvey
3
@RobertHarvey z mojego doświadczenia wynika, że kod wielokrotnego użytku wymaga 3-4 razy więcej wysiłku, aby napisać
komentuj
1
@kevincline: Cóż, Java ma generyczne. To prawda, że ​​to rodzaj ogólny „nie zwracaj uwagi na człowieka za zasłoną”.
Robert Harvey
1
A przed ogólnymi, mógłbyś przekazać odpowiednio Object i Float, Double, Integer i Long.
Daniel Kaplan
3
Deja vu to sprawdzanie zapachu kodu, a preferencje dla DRY są podstawą wszystkich najlepszych praktyk wartych zwrócenia uwagi na IMO. Ale postrzeliłem się w stopy w językach zorientowanych na OOP, próbując zbyt mocno uniknąć powielania dla samego siebie. Jeśli nie wiążesz się ze sobą niepowiązanymi ze sobą niepotrzebnymi obawami ani nie niszczysz czytelności ze względu na to, że dwie podobne funkcje stają się jednością, podążałbym instynktem w prawie każdym języku.
Erik Reppen

Odpowiedzi:

27

C utrudnia pisanie kodu ogólnego. W przeciwieństwie do C ++, który udostępnia szablony i funkcje wirtualne, C ma tylko 3 mechanizmy do pisania kodu ogólnego:

  1. void* wskaźniki
  2. Makra preprocesora
  3. Wskaźniki funkcji

void* wskaźniki są dalekie od ideału, ponieważ tracisz wszelkie bezpieczeństwo typu zapewniane przez kompilator, co może powodować trudne do debugowania niezdefiniowane zachowanie wynikające z rzutowania typu nieprawidłowego.

Makra preprocesora mają dobrze zauważone wady - rozszerzenie preprocesora jest w zasadzie tylko mechanizmem znajdowania / zastępowania, który ma miejsce przed fazą kompilacji, co ponownie może powodować trudne do debugowania błędy. Archetypowy przykład to coś w rodzaju:, #define add(x) (x+x)gdzie xmożna dwukrotnie zwiększyć, jeśli zadzwonisz add(i++). Państwo może napisać szablon w stylu kodu rodzajowe w całości za pomocą makra typu C, ale wynik jest naprawdę ohydne i trudne do utrzymania .

Wskaźniki funkcji zapewniają dobry sposób na pisanie kodu ogólnego, ale niestety nie zapewniają one ogólnego rodzaju tekstu - zapewniają jedynie możliwość polimorfizmu w czasie wykonywania (dlatego na przykład standardowa biblioteka qsortnadal wymaga funkcji, która przyjmuje void*wskaźniki).

Możesz także zaimplementować hierarchie klas w C za pomocą struktur, tak jak dzieje się to w bibliotece GLib, która zapewnia ogólną GObjectklasę bazową. Ale cierpi to z powodu podobnych problemów jak używanie void*wskaźników, ponieważ nadal musisz polegać na potencjalnie niebezpiecznym ręcznym rzutowaniu do przesyłania w górę i w dół.

Tak, C utrudnia pisanie kodu, który jest zarówno ogólny ORAZ bezpieczny / łatwy w utrzymaniu, co niestety może skutkować duplikacją kodu. Duże projekty C często używają języków skryptowych do generowania powtarzalnego kodu podczas procesu kompilacji.

Charles Salvia
źródło
1
Można również wygenerować powtarzalny kod C za pomocą zewnętrznych języków / narzędzi szablonów lub skryptu powłoki lub używając ant lub ...
Job
Jaki jest ten zaciemniony przez Google rosyjski URL, który umieściłeś w swojej odpowiedzi? Kliknięcie powoduje, że w mojej przeglądarce pojawia się monit „otwórz”. Czy to jest bezpieczne?
Robert Harvey
2
@RobertHarvey Moje programy antywirusowe i skanowania bezpieczeństwa uważają, że jest w porządku. Jest to plik nagłówkowy w języku C, rozszerzenie .h. Wydaje się legit.
wałek klonowy
@maple_shaft: OK. Usunąłem Googlization wokół linku.
Robert Harvey
To tylko zwykły plik .h (nagłówek C)
Charles Salvia
15

Nie mogę mówić w imieniu innych, ale z własnego doświadczenia z C duplikacja kodu nie była dużym problemem. Czy jest to spowodowane rozmiarami projektu, czy uroczym zestawem próbek, nie mogę powiedzieć. Istnieją jednak trzy ogólne zasady, które według mnie mają zastosowanie. W żadnej kolejności nie są ...

  1. Napisz, co jest potrzebne. Ogólność może przyjść później, jeśli będzie to potrzebne.
  2. Jeśli potrzebna jest ogólność, puste wskaźniki, wskaźniki funkcji i rozmiar struktury mogą być nieocenione. Na przykład rutynowa funkcja qsort () korzysta ze wszystkich trzech.
  3. Podaj wagę, aby Twój kod był przejrzysty.
iskrzący
źródło
5

Warto zauważyć, że może nie być potrzebne w pełni ogólne rozwiązanie w celu ograniczenia duplikacji kodu. Czasami wystarcza zwykłe refaktoryzacja i trochę uogólnienia metody.

Pisząc ogólne rozwiązanie dla szerokiego grona odbiorców, należy wziąć pod uwagę:

  1. Jakie nowe sposoby wykorzystania kodu przez konsumenta i jak go dostosować?
  2. Jakie błędy muszę wychwycić? Jakie błędy mam nie złapać?
  3. Jak silny musi być interfejs API? Ile przeciążeń muszę zapewnić?
  4. Jakie środki bezpieczeństwa muszę wprowadzić, aby kod nie mógł być wykorzystywany do złych celów?

Eric Lippert napisał cały artykuł o pojedynczej decyzji projektowej w kodzie platformy .NET. Podsumowując, refaktoryzacja jest często prostsza.

Robert Harvey
źródło
3

Zwykle używam ogólnej implementacji, takiej jak glib , a następnie, jeśli rzutowanie jest zbyt denerwujące, stwórz dla niego małe opakowanie specyficzne dla typu. Jednak w C można się spodziewać dużo rzutowania, podobnie jak użycie void*typu ogólnego, więc to, co można by uznać za „niechlujne” w innym języku, jest po prostu typowe C. Dość przerażająco, będzie wyglądać bardziej naturalnie, gdy będziesz zdobyć więcej doświadczenia w języku.

Karl Bielefeldt
źródło
2
„To, co byłoby uważane za„ niechlujne ”w innym języku, jest po prostu typowe dla C. Jest to dość przerażające, będzie wyglądać o wiele bardziej naturalnie, gdy zdobędziesz więcej doświadczenia w tym języku. Hahaha! C zawsze był jednym z moich ulubionych języków, ale to wciąż dość zabawne!
GlenPeterson
0

Kwestia wielokrotnego użytku nie jest unikalna dla C. Jednak istnieją pewne doskonałe i bardzo przydatne implementacje HashMap, na których możesz oprzeć swoje. Ten, który istnieje od dekady lub dwóch, jest w Javie: http://docs.oracle.com/javase/7/docs/api/java/util/HashMap.html

Wszystkie kolekcje Java, w tym HashMap, są przepisywane na Javę 8, aby pozbyć się iteratora, i zamiast tego wymagają przekazania funkcji do kolekcji, aby mogła iterować wewnętrznie. To wielka wygrana za pisanie współbieżnego kodu - kolekcja może wewnętrznie zarządzać współbieżnością, więc klient nie musi. To powiedziawszy, większość programistów nie jest przyzwyczajona do przekazywania wskaźników funkcji. To robi dobrą robotę, wyjaśniając, dokąd zmierza HashMap: http://cr.openjdk.java.net/~briangoetz/lambda/collections-overview.html

Składnia Scali może utrudniać przetłumaczenie czegoś takiego do C, ale oto przykład kolekcji, która już działa tak, jak kolekcje Java 8 - domyślnie niezmienna, oferująca różne metody transformacji, wykorzystująca wskaźniki funkcji i inne fajne rzeczy, takie jak częściowe zastosowanie: http://www.scala-lang.org/api/current/index.html#scala.collection.Map

Jeśli robisz wiele rzeczy z HashMaps i innymi bardziej zaawansowanymi strukturami danych, możesz chcieć spojrzeć na język, który je obsługuje.

Wracając do ogólnej koncepcji powielania kodu, możesz naprawdę szybko kodować, wycinając i wklejając, ale produkuje on okropny kod. Powiedziałbym, że jest to dopuszczalne w przypadku wyrzucanego kodu, takiego jak jednorazowe raporty. Czasami jest to również OK, gdy nie ma kodu źródłowego, takiego jak kod interfejsu użytkownika, który tworzy ekran, od którego nie zależy nic innego, ale tak naprawdę jest to w najlepszym razie bardzo szara strefa.

Nie sądzę, aby zbytnim uproszczeniem było mówienie, że za każdym razem, gdy zdublujesz kod, powinieneś wykonać funkcję. Same błędy, które naprawiasz w jednej kopii kodu, a nie w drugiej, są wystarczającym powodem do uruchomienia funkcji.

GlenPeterson
źródło
1
- „Nie sądzę, aby zbytnim uproszczeniem było stwierdzenie, że za każdym razem, gdy zdublujesz kod, powinieneś stworzyć funkcję” - W C jest to nadmierne uproszczenie. Spróbuj utworzyć stos w C dla liczb całkowitych, podwójnych i ciągów bez powielania funkcji push i pop dla każdego typu danych.
mike30
@mike Czy nie jest to możliwe przy użyciu pustych wskaźników?
sakisk
@mike - dobra uwaga. Kontrapunktem byłoby jednak to, że można zaimplementować stos dla każdego typu danych w taki sposób, że nie trzeba ponownie wdrażać stosu. Więc celem nie jest liczbaOfImplementations = 1, ale bardziej jak 5. Być może uda Ci się sprawić, by intimplementacja działała dla chars ... Minęło tak dużo czasu, odkąd użyłem C, że nie pamiętam.
GlenPeterson
1
@faif. Tak, ale potrzeba użycia pustych wskaźników sprawia, że ​​punkt C. Rzutowanie wskaźników jest wolniejsze i mniej eleganckie niż alternatywy znalezione w C ++. Powielanie kodu zajmuje nieco wyższą pozycję na liście możliwych opcji podczas kodowania w C.
mike30
@ mike30 Czy są jakieś dowody / referencje na temat: „Rzucanie wskaźników w C jest wolniejsze niż alternatywy znalezione w C ++?”
sakisk
0

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):

wprowadź opis zdjęcia tutaj

Ale w C wystarczy reallocpobrać 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 Tmoż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::vectorpraca 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 reallocdo 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::allocatorswó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ż insertierasew 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::vectorw 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/destroywskaź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 mallocz 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 mallocpojawiają 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 Dogi Catjest 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ć Dogsi Catswymagać 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 reallocanalogicznego 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 tylevectorimplementacje 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_tlub:

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_tifloat32 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.


źródło