Duży projekt wykorzystujący proceduralny paradygmat programowania — potrzebne opinie

0

Czy ktoś z was pracował kiedyś nad dużym projektem (tak 100k+ LoC), w którym kod tworzony był w pełni proceduralnie (bez wsparcia OOP) i może co nieco napisać na ten temat, głównie jeśli chodzi o łatwość utrzymania, przejrzystość kodu, modułowość, podział na niezależne warstwy itd.?

Mam tutaj na myśli głównie gry, w których proceduralność nie jest szczególnie dziwna, ale równie dobrze można wziąć pod uwagę okienkowe aplikacje narzędziowe, tworzone np. w WinAPI — struktura kodu oraz obsługa komunikatów jest bardzo podobna do proceduralnych gier.

Chciałbym zasięgnąć opinii w tym temacie, dlatego że stoję obecnie się nad doborem paradygmatu do własnej gry. Dla mnie nie ma wielkiej różnicy czy skorzystam z OOP czy programowania strukturalno-proceduralnego, operowanie na wskaźnikach w małym paluszku i nie mam żadnych problemów z zarządzaniem pamięcią oraz unikaniem wycieków.

Widze tutaj dużo plusów, jeśli chodzi o proceduralny kod, przy czym mógłbym stworzyć silnik gry w formie lekkiej i wydajnej oraz być w stanie łatwo wydzielić go do .dll, co na pewno mi się w przyszłości przyda. Nie musiałbym też obsługiwać wyjątków (których nienawidzę) — mógłbym wszystko oprzeć na kodach błędów, co jest wydajnym i przyjemnym dla mnie rozwiązaniem. No i nie musiałbym SDL-a opakowywać w obiekty, co też byłoby plusem.

Minusem natomiast byłaby konieczność operowania na niskopoziomowych elementach, a więc trochę więcej kodu trzeba będzie wyprodukować. Choć to akurat nie powinno stanowić żadnego problemu, bo ilość kodu nie ma dla mnie żadnego znaczenia, jeśli jest on sensownie zmodularyzowany i rozwarstwiony.

Jeśli ktoś ma doświadczenie w temacie opisanym w pierwszym zdaniu i ma ochotę coś napisać (choćby w skrócie), to będę wdzięczny.

3

Sam nie zrobiłem gry od zera, która by urosła do 100k+. Musiałem pomodzić kiedyś openssla dla jednego projektu (300k+) i proceduralność nie stanowiła problemu. Wyzwaniem przy grach jest to, i wychodzi to nawet w prostych projektach, że każdy system najlepiej "chciałby" wiedzieć wszystko o innych systemach, czasem ciężko o twardą separację, nie sądzę by istniał paradygmat, który sam z siebie pomógłby w tym specyficznym przypadku (pisaniu gry). Z drugiej strony, ~100k to wcale nie tak dużo, nie sądzę by proceduralny paradygmat miałby w czymś przeszkadzać. Sam oddalam się ostatnio od OOP i odkryłem na nowo jak dużo ficzerów można zawrzeć w pojedyńczym zawołaniu funkcji nie tracąc nic na testowalności. Prosty, proceduralny kod stał się dla mnie przyjemną odskocznią po latach poruszania się w "przeinżynierowanym" korpo OOPie.

Jako, że sam nie napisałem żadnej znaczącej gry proceduralnie, wydaje mi się właściwe wrzucenie odnośników do materiałów gdzie ludzie właśnie tak działali, to może sam oceń czy to dla Ciebie. Niestety wszystko to C albo C++, nie działam w "pascalach".

Źródła starszych silników wypuszczonych przez ID Software są bezcennym źródłem wiedzy. Polecam serię code review jaką zrobił Fabien Sanglard, gdzie omawia ich architekturę, np. code review quake'a 3 https://fabiensanglard.net/quake3/

Handmade Hero Gra pisana od zera bez żadnych bibliotek. Progres w całości udokumentowany na kanale Molly Rocket. Autor ma bardzo silną opinię o OOP więc kod w całości proceduralny, co nie znaczy, że nie uświadczysz w nim obiektów. Gra nie jest skończona i raczej nie dobiła do 100k, tym nie mniej posiada już kilka nietrywialnych systemów. Największy minus to dostęp do źródeł kosztuje 15 dolków.

Eskil Steenberg, autor gry LOVE (polecam wyszukać sobie też inne jego prezentacje, np. o wizualnym debugerze, albo o jego narzędziach do LOVE), pisze w czystym C i zrobił filmik w jaki sposób go używa How I program C. W niektórych momentach kręciłem oczami na pewne oczywistości, ale jest tam kilka słów jak organizuje kod. Wypuścił też swój 3D GUI toolkit jako opensource http://www.gamepipeline.org/, tutorial do niego TUTAJ

Milton to program do rysowania z nieskończonym płótnem, kilka platform, własna arena do zarządzania pamięcią, minimum zależności i przyjemnie się czyta - wszystko w C.

3

W Gamedev nie siedzę, ale pracowałem z systemami proceduralnymi 2 mln+ LOC

Kilka pomysłów ode mnie (lub z tego co zapamiętałem z książek):

  • w języku proceduralnym możesz pisać obiektowo (np. pierwszy argument to struktura którą nazywasz "this", procedury grupujesz przedrostkami i/lub unitami, w Pascalu unity mają część publiczną i prywatną). Główny problem w takim podejściu to dziedziczenie atrybutów. Istniejące rozwiązania - GObject
  • w języku proceduralnym możesz mieć nie-mutujące funkcje (alternatywnie dla OOP)
  • Pascalowe pakiety mogą służyć za UML-owe moduły, czyli niezależne jednostki kodu bez cyklicznych zależności
  • jeśli masz "bardzo ciekawskie" kawałki kodu które chcą wiedzieć coś o wszystkim, możesz wdrożyć wewnętrzną kolejkę eventów, dzięki temu odbiorca nie musi mieć wskazania na nadawcę a jedynie musi otrzymać komunikat (stosowałem w Delphi i C++)
  • łatwość utrzymania stosujesz trzymając się jednej konwencji nazewniczej, formatowania i najlepiej jakiegoś publicznego standardu kodowania dla wybranego języka
  • przydadzą się unit testy

Z nowszych książek mogę polecić "Data Oriented Programming - Unlearning Objects". Nie czytałem, ale jest o tym czego szukasz.
Ze starszych ludzie polecają "Game Programming Patterns", w której znajdziesz np. rozdział... "Event Queue".

2

Poczytałem linkowane źródła, pooglądałem filmy i jestem pod wrażeniem tego, jak łatwo się taki proceduralny kod czyta. Co prawda konwencja nazewnictwa z C nie jest przeze mnie lubiana, ale to nie jest istotne. Obejrzałem How I program C — z większością się zgadzam, ale nie ze wszystkim.

Chodzi o stosowaną przez Eskila hermetyzację, bazującą na nietypowanych wskaźnikach — w mojej opinii jest to złe rozwiązanie, być może nawet najgorsze. NIetypowany wskaźnik na strukturkę co prawda nie pokazuje jej zawartości, ale pozwala przekazać dane dowolnego typu (a więc również nieprawidłowe dane), co nie będzie wyłapane podczas kompilacji i może nawet nie być wyłapane w runtime. Co z tego, że nie daje się wglądu do danych, skoro łatwo można wpaść w pułapkę i narobić sobie bugów bardzo trudnych do namierzenia. Lepiej mieć więc wskaźniki typowane i wiedzieć co dana funkcja powinna przyjąć (bez czytania dokumentacji czy wręcz implmenetacji funkcji), a jeśli dany system ma być hermetyczny, to znacznie lepszym rozwiązaniem są uchwyty, tak jak to robi Windowsowe API.

To co będzie widoczne a co nie, może być kontrolowane w sposób łatwy i całkowicie bezpieczny tylko dzięki uchwytom liczbowym, które ani nie pozwalają przekazać nieprawidłowych danych, ani też nie dają dostępu do danych, bo nie są adresami. Reszta zależy już od struktury projektu, co jest po części wymuszane przez sam język programowania i tutaj też ciężko o zminimalizowanie widoczności. IMO nie jest problemem to, że jakiś moduł i jego zawartość jest widoczna tam gdzie nie powinna. Istotne jest to aby nie używać tego, czego się używać nie powinno.

Podoba mi się podejscie, w którym dane są całkowicie odseparowane od kodu, tak jak w kodzie Eskila — proste strukturki przechowujące dane, a wszelkie operacje wykorzystujące dane struktury są zewnętrznymi funkcjami. Dziedziczenie, choć nieistniejące w przypadku prostych struktur, da się łatwo zaimplementować — wystarczy strukturkę bazową osadzić na początku tej ”dziedziczącej” i voilà. Dodanie czegokolwiek do struktury bazowej nie zepsuje drzewka takiego dziedziczenia.

Jeśli chodzi o wspomnianą przez Piotra konwencję nazewnictwa — będę się trzymał tego pascalowego, bo jest dla mnie naturalne. Projekt podzielę na dużo małych modułów, tak aby ograniczać widoczność do minimum.Jednak zamiast stosować nazewnictwo SDL-owe (à la C), raczej nazwy zrobię krótkie, a źródło określę przestrzeniami nazw. Łatwiej mi pisać i rozumieć Game.Window.GetPosition lub Game_GetWindowPosition (trzymając konwencję SDL-a), bo nazewnictwo typu Game_Window_Position_Get wygląda mi nienaturalnie. Kwestia gustu.

Z list kierunkowych też nie ma sensu korzystać — proste tablice wystarczą. Typy i funkcje generyczne na pewno się przydadzą. No i makra też będą pomocne, bo fajnie można kod debugowania osadzić. W połączeniu z kompilacją warunkową powinno wyjść nieźle.

I to chyba tyle — prezentacja bardzo fajna, sporo ciekawych rzeczy można się dowiedzieć. Oglądnąłem trochę filmów Eskila z jego narzędziami i robią niesamowite wrażenie. Z proponowanych innych źródeł też pewnie skorzystam, tak że wielkie dzięki za odpowiedzi. Jeśli ktoś chciałby coś jeszcze dodać to z chęcią poczytam.

1

hermetyzację, bazującą na nietypowanych wskaźnikach — w mojej opinii jest to złe rozwiązanie

Nie tylko w Twojej. void* w API jest konieczny jak potrzebujesz czegoś co działa jak generyk, np. jakiś customowy kontener. Nie wynosiłbym go ponad bo bardzo łatwo o błąd, bardzo słabo się czyta i debuguje.

1

W sumie to jest w obiektowych Pascalach sposób na to, aby pogodzić te wszystkie wymagania, czyli mieć typowany wskaźnik (a więc bezpieczny), ukryć jego całą zawartość poza modułem źródłowym oraz trzymać logikę w osobnych funkcjach. Wystarczy dane trzymać w dowolnej strukturze obsługującej hermetyzację (czyli w klasach, starych obiektach lub zaawansowanych rekordach) i wszystkie je umieścić w sekcji private/protected.

Dzięki temu, że dane będą prywatne, użytkownik API nie będzie miał do nich bezpośredniego dostępu, ale dostęp będą miały funkcje przeznaczone do ich odczytu i modyfikacji (à la settery i gettery). Z tego też powodu sekcja strict private odpada. Plusem jest bezpieczeństwo oraz możliwość inline'owania wszystkich tych funkcji, więc jest szansa na utrzymanie wysokiej efektywności. A jeśli skorzystać z klas lub starych obiektów, to w gratisie (prócz hermetyzacji) dostaniemy dziedziczenie i plombowanie. Dzięki dziedziczeniu nie trzeba będzie pamiętać o deklarowaniu pierwszego pola jako nagłówka o typie takim jak struktura dziedziczona (tak jak to musi robić Eskil Steenberg, bo korzysta ze zwykłych struktur).

Póki co jeszcze eksperymentuję i taki sposób tworzenia API wygląda dość obiecująco. Ale muszę przyznać, że ciężko mi się przestawić na pisanie globalnych funkcji zamiast metod w tych klasach. Ni to programowanie obiektowe, ni proceduralne. Choć walka z przyzwyczajeniami to akurat nie jest problem — im więcej praktyki tym mniej poczucia dziwności.

1

Ciężko mi tak technicznie się do tego odnieść, bo nie znam w ogóle pascala poza podstawami podstaw. Pokazałem palcem Eskila bo robi fantastycznie wyglądające rzeczy w czystym proceduralnym C bez żadnych prób emulacji poprze ręcznie napisane VTABLE czy temu podobne, więc na pewno warto przynajmniej wysłuchać co ma do powiedzenia. Tym nie mniej nie mam pojęcia jak jego rady przekładają się na pascala. Z około gierkowych, nietrywialnych rzeczy w pascalu znalazłem Castle Game Engine, ale nie jestem nawet w stanie stwierdzić czy jest napisany obiektowo czy proceduralnie :D Znalazłem też klony wormsów podobno napisane w pascalu http://www.hedgewars.org/download.html

3

Polecam nie mieszać nomenklatury. Programowanie proceduralne to nie anty OOP, tylko paradygmat, gdzie dzielisz kod na procedury, czyli 100% języków wobec czego ten termin jest tak naprawdę nic nie znaczący w dzisiejszych czasach. Polecam ten film , bo fajnie opisuje, że pojęcie OOP jest rozmyte i trudne do zdefiniowania.

Co do OOP to wydaje mi się, że każdy imperatywny kod będzie w jakimś stopniu OOP, bo ścisłe połączenie danych i logiki jest konieczne w celu zapobiegania błędów (np. kontenery).
Jeśli za proceduralny-strukturalny paradygmat uznajesz taki, gdzie nie tworzy się obiektu do każdej pierdoły to polecam dowolny duży projekt w C (np. git) albo Go (choć tutaj przeciętna baza kodowa będzie bardziej OOP niż w C)

1

lko paradygmat, gdzie dzielisz kod na procedury, czyli 100% języków wobec czego ten termin jest tak naprawdę nic nie znaczący

Meanwhile return w srodku funkcji XD.
Mea culpa, pomyslalem o strukturalnym. Faktycznie masz racje

1

Programowanie proceduralne to nie anty OOP

True. Za to OOP to anty programowanie proceduralne i to z samej nazwy - "Object Oriented Programming". Dlatego często stawia się te dwie definicje na przeciw siebie. W OOP to nie obiekty są problemem, a właśnie ta orientacja, która przerodziła się już w fiksację.

czyli 100% języków wobec czego ten termin jest tak naprawdę nic nie znaczący

To, że w javie można programować proceduralnie nie znaczy, że termin jest bez znaczenia. Zaraz Ci znajdę jakiś open source'owy system w tym języku i zobaczmy czy z czystym sumieniem nazwiesz go proceduralnym.

EDIT

wydaje mi się, że każdy imperatywny kod będzie w jakimś stopniu OOP, bo ścisłe połączenie danych i logiki jest konieczne w celu zapobiegania błędów (np. kontenery)

To, że masz obiekty w systemie, to nie znaczy, że system jest OOP. Tak jak napisałem to Oriented w samej definicji robi różnicę.

1

To czym jest, czym może być i czym nie może być programowanie proceduralne i obiektowe to widzę kwestia interpretacji. To nad czym sam się zastanawiam to kod typowo proceduralny, w którym logika jest odseparowana od danych. Czyli mamy zestaw zadeklarowanych w module procedur (sekcja interface, więc widocznych na zewnątrz), do których przekazuje się dane — typy proste oraz paczki danych (instancje klas, obiekty lub rekordy).

Dla mnie OOP nie jest programowaniem stricte proceduralnym, bo zamieniamy (globalne w modułach) procedury na metody, będące częścią np. klas. To nie procedurami kontroluje się przepływ sterowania — to paczki danych same wykonują na nich logikę. I to właśnie jest coś, co nie jest zbyt logiczne, ale też coś, do czego jestem przyzwyczajony, bo tak narzucają mi konstrukcje np. klas (nadźgać pól, metod i właściwości, niech klasa stanowi sama o sobie).

Z jednej strony mogę bez problemu pisać proceduralnie i dane trzymać w prostych strukturach (bez logiki), jednak to wymaga więcej pisania. Z drugiej strony, mogę wykorzystać klasy lub ”stare” obiekty do trzymania danych (dostaję dziedziczenie), a logikę trzymać w formie globalnych procedur i funkcji. Wygląda
na pierwszy rzut okna w porządku. Ale…

Tutaj pojawia się problem, bo nie da się poprawnie zarządzać pamięcią instancji klas, jeśli skorzystamy z listy generycznej — z poziomu kodu listy nie mamy pojęcia jakie dane do niej zostaną wrzucone, więc nie wiemy czy coś mamy w tej klasie alokować czy nie (i dealokować). Dlatego też każda klasa siłą rzeczy musi mieć przynajmniej konstruktor i destruktor, które zajmą się alokacją i dealokacją pamięci dla pól o typach niezarządzanych.

Z jednej strony trzymanie danych w klasach bez logiki daje sporo korzyści, bo mamy:

  • możliwość wrzucenia wszystkiego tego co do rekordów (bądź ile danych),
  • hermetyzację, więc można ukryć zawartość przed użytkownikiem i wymusić uzywanie dedykowanych procedur i funkcji,
  • dziedziczenie, więc automatycznie dane klas bazowych są dostępne w tych dziedziczących (brak kombinowania z nagłówkami),
  • plombowanie, blokując dziedziczenie danej klasy.
  • alokowanie danych na stercie z automatu,
  • możliwość pisania logiki operującej na abstrakcjach (klasy dziedziczą dane, więc procedury mogą operować na klasach bazowych).

Niestety są też minusy:

  • logikę i tak trzeba zaimplementować w takiej klasie — przynajmniej konstruktor i destruktor,
  • klasa posiadająca wyłącznie prywatne/chronione dane brzmi jak smutny żart,
  • brak wsparcia interfejsów, skoro odrzucamy opcję implementacji logiki w klasach.

Jest dużo zalet i mało wad, ale wychodzi z tego totalny miszmasz i nie wiem czy na dłuższą metę ma to w ogóle jakiś sens. Bawię się tym póki jeszcze mało kodu jest w projekcie, ale im dłużej się bawię, tym większy WTF mi z tego wychodzi. :D

0

Może jest to kwestia języka programowania, a nie paradygmatu? Mam wrażenie, że piszesz mocno pod kątem konkretnego języka (C++? Pascal?). A w innych językach może być to inaczej rozwiązane. Może lepiej.

1
furious programming napisał(a):

To czym jest, czym może być i czym nie może być programowanie proceduralne i obiektowe to widzę kwestia interpretacji. To nad czym sam się zastanawiam to kod typowo proceduralny, w którym logika jest odseparowana od danych. Czyli mamy zestaw zadeklarowanych w module procedur (sekcja interface, więc widocznych na zewnątrz), do których przekazuje się dane — typy proste oraz paczki danych (instancje klas, obiekty lub rekordy).

A zastanawiałeś się nad tym żeby wziąć jakiś projekt funkcyjny? Tylko że nie w jakieś hybrydzie/hydrze/chimerze obiektowo funkcyjnej, tylko w języku 100% funkcyjnym z separacją danych od logiki? Nie wiem, np kompilator Haskella napisany w Haskellu?

2
LukeJL napisał(a):

Może jest to kwestia języka programowania, a nie paradygmatu? Mam wrażenie, że piszesz mocno pod kątem konkretnego języka (C++? Pascal?). A w innych językach może być to inaczej rozwiązane. Może lepiej.

Docelowo i tak użyję Free Pascala, więc tak — wszystko piszę w kontekście jego i C/C++ (ze względu na tę samą półkę). Nawet jeśli w innych językach jest to lepiej rozwiązane, to i tak nic mi z tego, bo nie będę mógł tego przenieść do Pascala.

KamilAdam napisał(a):

Nie wiem, np kompilator Haskella napisany w Haskellu?

Nie zrozumiałbym z tego absolutnie nic — dla mnie programowanie funkcyjne to czarna magia. Bardziej mnie interesuje jak sobie wygodnie oddzielić dane od logiki używając Pascala/C/C++ i podstawową (domyślną) opcją są po prostu struktury i zestawy funkcji na nich operujące. To jest dosyć proste, łatwo się taki kod pisze (nawet z ręczną alokacją pamięci i wskaźnikami) i przy okazji jest zgodne z budową SDL-a.

Z innymi sposobami eksperymentuję, ale efekt końcowy jest bardzo dziwny — mam wrażenie, że nie byłbym w stanie utrzymać większego projektu, stworzonego w takim stylu. Pascale po prostu nie są stworzone do takich technik, więc koniec końców pewnie zostanie mi do wyboru albo programowanie ”proceduralno-strukturalne” (zwykłe rekordy i globalne procedurki), albo zwyczajne OOP.

Choć od generyków we Free Pascalu wolę już jałowe wskaźniki — przynajmniej kompilator nie będzie się krzaczył. :D

1
furious programming napisał(a):

Czy ktoś z was pracował kiedyś nad dużym projektem (tak 100k+ LoC), w którym kod tworzony był w pełni proceduralnie (bez wsparcia OOP) i może co nieco napisać na ten temat, głównie jeśli chodzi o łatwość utrzymania, przejrzystość kodu, modułowość, podział na niezależne warstwy itd.?

A co masz na myśli "bez OOP"? Chodzi Ci o to że bez struktur, bez klas, bez polimorfizmu, czy o co kaman?

0

Bez klas i trzymania logiki w obiektach. Czyli prostre struktury z danymi, a do nich osobne zestawy funkcji, przyjmujące w jednym z parametrów wskaźnik na strukturkę. Czyli coś co jest normą np. w C.

0

Dobra panowie, decyzja podjęta — wybieram podejście ”proceduralno-strukturalne”, czyli proste strukturki danych bez logiki, zestawy funkcji i procedur do manipulowania danymi, pointery do generyczności, hermetyzacji i polimorfizmu oraz niskopoziomowe zarządzanie pamięcią na podstawie AllocMem, ReallocMem i FreeMem.

Łatwiej mi w ten sposób będzie pogodzić własny kod z API SDL-a — będę miał jeden styl/paradygmat do całego projektu. Tak więc kończę z testami i zabieram się za pisanie API. Dzięki wszystkim za odpowiedzi, sugestie i materiały.

Edit: chyba że wymięknę, to wtedy wrócę do klas. :D

1 użytkowników online, w tym zalogowanych: 0, gości: 1