Cześć,
Jest taka gra planszowa której fabuła rozgrywa się na łodzi podwodnej i gracze muszą współpracować przy jej naprawach, żeby statek wytrzymał w jednym kawałku aż do momentu kiedy przybędzie pomoc. Najważniejsza akcja którą gracz może podejmować w tej grze to próba naprawy. Jeżeli wykonamy próbę naprawy trwającą 10 minut (można powiedzieć - 10 tur), to mamy pewność, że naprawa się powiedzie. Jeżeli nie mamy aż tyle czasu i chcemy zaryzykować, to możemy podejmować krótsze próby naprawy - ale każda minuta odjęta od tego podstawowego czasu dziesięciu minut zmniejsza szanse powodzenia o 10% - więc jeśli podejmiemy próbę naprawy 5-minutową, to mamy tylko 50% szansy na to że się uda, a jeżeli spróbujemy naprawić w minutę, to szansa na powodzenie wynosi tylko 10%.
Chodziło mi ostatnio po głowie pytanie, czy któraś strategia naprawy (czyli naprawianie zawsze przez 10 minut, albo zawsze tylko przez minutę itd.) opłaca się bardziej od innych. Jakoś nie byłem w stanie znaleźć konkretnych wzorów matematycznych na takie sumowanie prawdopodbieństwa, więc po prostu napisałem skrypt, który zasymuluje tę sytuację i poda uśrednione wyniki:
// function that executes a single repair trial and returns its result
const repairTrial = (threshold: number, maxValue: number) => {
const successChance = threshold / maxValue;
return Math.random() <= successChance;
};
// function that executes a strategy - repair trials in a sequence until success and returns accumulated time elapsed in minutes
export const executeSingleStrategy = (threshold: number, maxValue: number) => {
let minutesElapsed = 0;
while (true) {
const repaired = repairTrial(threshold, maxValue);
minutesElapsed += threshold;
if (repaired) break;
}
return minutesElapsed;
};
// math helper function
function getStandardDeviation(array: number[], mean: number) {
const n = array.length;
return Math.sqrt(
array.map((x) => (x - mean) ** 2).reduce((a, b) => a + b) / n
);
}
// genrate given amount of samples and calculate their mean value and standard deviation
export const createStatisticalAnalysisOfSamples = (
samplingAmount: number,
threshold: number,
maxValue: number
) => {
const results = Array.from({ length: samplingAmount }, () =>
executeSingleStrategy(threshold, maxValue)
);
const mean = results.reduce((p, c) => p + c, 0) / results.length;
const standardDeviation = getStandardDeviation(results, mean);
return {
MeanValue: mean.toPrecision(3),
standardDeviation: standardDeviation.toPrecision(3),
};
};
// evaluate mutliple repairing strategies by sampling (getting time elapsed until successful repair) each of them multiple times to see differences in statistical properties between them
export const CompareRepairingStrategies = () => {
return [
createStatisticalAnalysisOfSamples(1000, 1, 10),
createStatisticalAnalysisOfSamples(1000, 10, 10),
];
};
Łatwiej było mi myśleć o tym jako o serii kroków niż o wszystkim naraz, więc napisałem kod podzielony na funkcje które wywołują siebie nawzajem - to jest przykład prostego programowania proceduralnego. Na diagramie poniżej widać jak funkcje wywołują siebie nawzajem i przekazują sobie dane:
I w konsoli pojawił się wynik - każda strategia jest tak samo opłacalna. Jeżeli będziemy robili naprawy trwające jedną minutę, ale mające 10% szans powodzenia, to średnio będzie nam to zajmowało i tak 10 minut. Tutaj jest screen z konsoli REPL z wynikami:
Chociaż, w grze planszowej jest jeszcze taka możliwość, żeby użyć do naprawy narzędzia (jeżeli się je posiada w ekwipunku) - wtedy szansa na powodzenie naprawy zwiększa się o 40% - a więc jednominutowa naprawa ma 50% szans na powodzenie, a 6 minut wystarczy, żeby mieć pewność że się uda.
Też chciałbym to sprawdzić, ale żeby zmodyfikować mój skrypt tak, żeby można było w parametrach wejściowych podawać informację o tym, czy posiadamy narzędzie czy nie, to musiałbym zmodyfikować kod w prawie każdej funkcji, wszędzie powstawiać if’y które zmieniają wykonywanie zależnie od tego którą funkcjonalnośc realizujemy, dodać dużo boilerplate’u (np. argument drilling).
To nie jest kod który można łatwo rozwijać.
Object oriented programming
Wiele osób jest przekonanych, że jedynym sposobem na sprawienie, że program komputerowy będzie “na poważnym poziomie” jest programowanie obiektowe.
Programowanie obiektowe jest faktycznie w trakcie nauki programowania krokiem w dobrą stronę po najprostszym programowaniu proceduralnym, ale wcale nie jest jedynym i uniwersalnym sposobem programowania. Z każdym problemem można sobie spokojnie poradzić bez sięgania po programowanie obiektowe, czego przykład będzie można zobaczyć w tym artykule.
Oddzielaj od siebie rzeczy, które zmieniają się z osobnych powodów
Jestem w sytuacji, w której chcę dodać nową funkcjonalność i nie podoba mi się to, że muszę przebrnąć przez cały dotychczas napisany kod źródłowy i wszędzie wstawiać ify oraz zmieniać sygnatury funkcji. Chciałbym, żeby dodanie opcji naprawiania z narzędziem wymagało zmiany kodu źródłowego w jednym tylko miejscu, a nie we wszystkich istniejących.
Oddzielaj ‘co’ od ‘w jaki sposób’ - czyli programowanie deklaratywne
Jeżeli masz dwa kawałki kodu źródłowego co do których wiesz, że będą się zmieniały osobno - w takiej sytuacji lepiej trzymaj je osobno. Tak, żebyś przy zmienianiu jednego nie musiał w ogóle myśleć o drugim.
Bardzo wartościowym kryterium jest oddzielanie kawałków kodu które mówią ‘co chcemy osiągnąć’ od tych które będą dopiero opisywać ‘w jaki sposób’. Można to kryterium wprowadzić zawsze, niezależnie od tego co robi twój program. Przykład z mojego skryptu:
- Co? - “Chcę porównać średni czas trwania wykonywania kilku różnych strategii naprawy”
- W jaki sposób - “Porównanie średnich czasów naprawy robi się tak - wykonamy 500 razy symulację naprawiania wg każdej ze strategii, następnie zsumujemy ich czasy trwania i podzielimy przez 500…”, “Symulację strategii robi się tak - w pętli while dodajemy czas trwania naprawy do akumulatora i robimy test na to czy naprawa się powiodła…”
Nowe rzeczy których chcę się dowiedzieć będą pojawiały się często - dzisiaj jest to znalezienie najlepszej strategii naprawiania, jutro może być np. znalezienie najlepszej strategii poruszania się po statku. Konkretne kawałki programu, które np. symulują próbę naprawy statku będą zmieniać się rzadziej (np. trzeba będzie to zmienić gdy wydawnictwo wyda dodatek do gry planszowej i zmodyfikuje zasady).
A to, w jaki sposób oblicza się średnią wartość dla zbioru liczb - nie będzie się zmieniać nigdy, a pewnie będzie wykorzystywane w wielu miejscach w moim programie.
Dzięki temu, gdy będę chciał np. zmienić kolejność dwóch kroków w tym co chcę osiągnąć, to zmienię tylko dwie linijki kodu, bez potrzeby każdorazowego wracania do szczegółów tych kroków żeby sprawdzić czy tam też nie musisz czegoś zamienić.
To też da możliwość programiście który w przyszłości będzie czytał twój kod żeby zawsze móc sobie powiedzieć “Ok, widzę co jest tutaj uzyskiwane, ale szukam czegoś innego - idę szukać dalej”. Bez tego rozdzielenia programista będzie musiał przeczytać o tym co uzyskujesz, przeczytać w jaki sposób to uzyskujesz i wtedy dopiero będzie mógł przejść dalej. Taki kod dużo trudniej zrozumieć, bo trzeba zrozumieć wszystko jednocześnie.
Czasem słyszy się określenie “Programowanie deklaratywne” żeby opisać taką sytuację, w któ©ej w twoim kodzie źródłowym jest wydzielona osobna, rozbudowana część, która zawiera wyłącznie kod opisujący efekt końcowy który ma być osiągnięty, a inna część programu (np. nazywana silnikiem) zajmuje się samodzielnie doprowadzeniem do wymaganego stanu.
Wzorzec Komponent - Kompozycja
Żeby więc uniknąć problemów które niesie ze sobą programowanie proceduralne, sięgnijmy po wzorzec, który rozwiązuje problem na który natrafiliśmy. Zróbmy refactoring kodu:
import { createStatisticalAnalysisOfSamples } from "./mathTestUtils";
// create a function that executes a single repair trial and returns its result
const createRepairTrial = (threshold: number, maxValue: number) => () => {
const successChance = threshold / maxValue;
return Math.random() <= successChance;
};
type CreateSingleStrategyExecutionArgs = {
repairTrial: ReturnType<typeof createRepairTrial>;
trialDuration: number;
};
// create a function that executes a strategy - repair trials in a sequence until success and returns accumulated time elapsed in minutes
export const createSingleStrategyExecution = ({
repairTrial,
trialDuration,
}: CreateSingleStrategyExecutionArgs) => () => {
let minutesElapsed = 0;
while (true) {
const repaired = repairTrial();
minutesElapsed += trialDuration;
if (repaired) break;
}
return minutesElapsed;
};
export type TestEnvironment = {
maxRepairingTime: number;
samplingAmount: number;
repairStrategies: number[];
};
// Composition - evaluate mutliple repairing strategies by sampling (getting time elapsed until successful repair) each of them multiple times to see differences in statistical properties between them
export const CompareRepairingStrategies = ({
maxRepairingTime,
samplingAmount,
repairStrategies,
}: TestEnvironment) => {
const strategyTests = repairStrategies.map((repairDuration) =>
createStatisticalAnalysisOfSamples({
samplingAmount,
getSample: createSingleStrategyExecution({
repairTrial: createRepairTrial(repairDuration, maxRepairingTime),
trialDuration: repairDuration,
}),
})
);
const strategyResults = strategyTests.map((test) => test());
return strategyResults;
};
Przez zmianę funkcji z mojego skryptu w taki sposób, żeby wszystkie funkcje których używają w środku były im przekazywane jako argumenty, uzyskałem możliwość ‘wymieniania’ pojedynczych funkcji w przyszłości, pomimo że data flow i typy danych przekazywanych pomiędzy nimi się nie zmieniły. Najważniejsze zmiania w kodzie źródłowym polega na tym, że wszystkie decyzje o tym jaką konkretnie implementację wybierzemy dla każdej z funkcji zapadają w jednym miejscu - w funkcji najbardziej zewnętrznej, która staje się naszą “kompozycją”. Pojedyncze funkcje które są definiowane w niej stają się “komponentami”.
Żeby rozdzielić ‘co’ od ‘w jaki sposób’ w moim kodzie podzieliłem go na ‘komponenty’ i ‘kompozycje’. Komponent to pojedynczy kawałek kodu który może być składany z innymi (przez argumenty wejściowe i zwracaną wartość), a kompozycja to miejsce w którym składam te komponenty ze sobą. Dzięki temu każdy komponent może być pisany i czytany bez konieczności czytania o tym co dzieje się w innych, a całe podejmowanie decyzji na temat tego jaką implementację wybierzemy dla poszczególnych z nich jest zgrupowana w jednym miejscu - kompozycji.
W przypadku naszego języka programowania komponent to po prostu czysta funkcja (taka która operuje tylko na argumentach z którymi została wywołana i wszystko co robi to zwraca wynik), a kompozycja to funkcja, która utworzy ‘docelowe’ komponenty i złączy je ze sobą.
Dodanie jakiejkolwiek nowej funkcjonalności do tego programu - na jakimkolwiek poziomie by nie była, od mechanizmu obliczania średniej wartości, czy symulowania rzutu kostką, aż po symulowanie strategii - zmiana nie będzie już polegała na modyfikacji istniejących szczegółów implementacyjnych, tylko na napisaniu nowej kompozycji która opisze ‘co chcemy osiągnąć’ w nowej funkcjonalności i ewentualnie napisaniu nowego komponentu jeżeli obecne nie wystarczą żeby to osiągnąć.
W moim skrypcie będzie wyglądać to w ten sposób:
// new component
export const createSingleStrategyExecutionWithTool = ({
repairTrial,
repairTrialWithTool,
trialDuration,
}: CreateSingleStrategyExecutionWithToolArgs) => () => {
let minutesElapsed = 0;
let toolAvailable = true;
while (true) {
const repaired = toolAvailable ? repairTrialWithTool() : repairTrial();
// tool is always single-use
toolAvailable = false;
minutesElapsed += trialDuration;
if (repaired) break;
}
return minutesElapsed;
};
// new composition
export const CompareRepairingStrategiesWithTool = ({
maxRepairingTime,
samplingAmount,
repairStrategies,
}: TestEnvironment) => {
const strategyTests = repairStrategies.map((repairDuration) =>
createStatisticalAnalysisOfSamples({
samplingAmount,
getSample: createSingleStrategyExecutionWithTool({
repairTrial: createRepairTrial(repairDuration, maxRepairingTime),
repairTrialWithTool: createRepairTrial(
repairDuration + 4,
maxRepairingTime
),
trialDuration: repairDuration,
}),
})
);
const strategyResults = strategyTests.map((test) => test());
return strategyResults;
};
Diagram pokazujący obok siebie dwie kompozycje które teraz będziemy mieć w kodzie:
taki program czytającemu go programiście pozwala na przejrzyste czytanie go jednocześnie i pod kątem tego ‘co chce uzyskać’ jak i ‘w jaki sposób jest to uzyskiwane’. Za całą odpowiedź na pytanie ‘co chcę uzyskać’ odpowiada jedno miejsce - kompozycja, a na pytania ‘w jaki sposób’ odpowiadają osobno komponenty. Dzięki temu każde zagadnienie można zrozumieć osobno poprzez przeczytanie jednego spójnego kawałka kodu.
W pierwszym przykładzie ostateczny wynik zwracany był przez funkcję CompareRepairingStrategies
- ona wywoływała createSingleStrategyExecution
… itd. Żeby dowiedzieć się w jaki sposób działa testowanie pojedynczej strategii musieliśmy wejść do środka. I tak w kółko aż nie przeczytaliśmy całego programu od początku do końca. Taki program, po osiągnięciu pewnej długości kodu źródłowego nigdy nie da nam zrozumieć w 100% “Co chcemy uzyskać”, bo nie wsadzimy całego długiego programu naraz do naszej pamięci.
Z drugiej strony, informacja o tym ‘w jaki sposób’ też nie powinna być schowana za bardzo. W systemie komponent-kompozycja “w jaki sposób” jest oddalone tylko o jedno przejście do implementacji funkcji. Gdyby programista nie mógł w prosty sposób przejść do implementacji funkcji która jest używana w kompozycji, bo np. konfiguracja która ostatecznie o tym decyduje nie byłaby łatwo dostępna i zrozumiała, albo w trakcie działania programu może wykonać się w danym kawałku kodu zbyt wiele różnych możliwości, to nawigowanie po kodzie też nie będzie możliwe.
Open-closed principle
Właśnie dlatego mówi się, że elementy systemu powinny być otwarte na rozszerzenie, ale zamknięte na modyfikacje. Oznacza to, że można zmienić zachowanie systemu za pomocą samego dodawania nowego kodu źródłowego i zmiany kompozycji, bez modyfikowania istniejących już komponentów. Dzięki temu:
Unikamy potrzeby modyfikowania istniejącego kodu źródłowego, a co za tym idzie, trudności oraz ryzyka które z tym się wiążą - nie mamy wszystkich informacji które były znane w czasie kiedy kod był pisany, możemy wprowadzić regresję, bugi itd.
Możemy rozwijać aplikację w każdym wymiarze i dodawać dowolnie wiele wariantów działania na każdym poziomie systemu bez strachu, że zmiany będą trudne w zakodowaniu (bo nie trzeba zrozumieć całego splątanego systemu zanim się doda coś nowego), albo utrudnią utrzymywanie systemu w przyszłości (gdyby z czasem powstało za dużo rozgałęzień powsadzanych w każde miejsce systemu).
Kod źródłowy jest łatwiejszy do czytania i zrozumienia przez programistów.
Czy przypadkiem nie czytałem już o tym gdzieś wcześniej?
To nie jest nowy pomysł. React (framework frontendowy) opiera się na tych samych zasadach i to jest to co powoduje że tak dobrze się w nim pracuje.
W react’cie komponenty też mogą być kompozycjami i posiadać ‘w środku’ inne komponenty. Bez transpilacji JSX (trójkątnych nawiasów na powyższym screenie) reactowy kod źródłowy wyglądałby praktycznie tak samo jak kod źródłowy z moich przykładów.
Czy da się to jeszcze poprawić?
To mimo wszystko nadal jest prosty skrypt jednorazowego użytku. Kod źródłowy w dużych aplikacjach nie będzie tak wyglądał, będzie miał więcej standaryzacji, prawdopodobnie jakiś bardziej rozbudowany mechanizm do zarządzania tym jakich komponentów możemy używać. Gdyby dziedzina była bardziej rozbudowana pod względem przekształcania danych, możnaby ten przykład też zmienić, na przykład zmieniając funkcje komponentów w taki sposób, żeby przyjmowały i zwracały wyłącznie proste dane, a data flow (przekazywanie danych od jednego komponentu do drugiego) odbywałby się całkowicie w kodzie źródłowym kompozycji.
Dodatkowo - programowanie funkcyjne korzysta z wielu narzędzi, które zwiększają komfort programisty w tworzeniu kompozycji - poprzez korzystanie ze struktur matematycznych które pozwalają zrozumieć, a nawet dać gwarancję co do tego jak będzie się zachowywał dany komponent, jak dane komponenty będą do siebie pasować, jak będzie przekazywany stan pomiędzy nimi, jak ułatwić pracę z obszernym i skomplikowanym stanem itd. Na każde wyzwanie “Dobrze, ale jak to będzie działało w dużym programie pod kątem X” istnieją narzędzia które starają się uprościć pracę w skali. Takie jak React.
pozdrawiam,
~Marek