Dzięki dostępnym obecnie darmowym zasobom, książkom, zajęciom online i bootcampom, każdy może nauczyć się kodować. Jednak nadal istnieje luka jakościowa między kodowaniem a inżynierią oprogramowania. Czy musi ona istnieć?
Swoje pierwsze "Hello World" napisałem ponad dwadzieścia lat temu - takiej odpowiedzi udzielam, gdy ktoś pyta mnie, jak długo jestem programistą. Przez ostatnie dziesięć lat cieszyłem się karierą, w której mogłem dotknąć kod Prawie codziennie - to odpowiedź, której udzielam na pytanie, jak długo jestem profesjonalnym programistą.
Jak długo jestem inżynier oprogramowania? Powiedziałbym, że około pięciu lat. Chwila, te liczby nie wydają się zgadzać! Więc co się zmieniło? Kogo mógłbym uznać za inżyniera oprogramowania, a kogo "tylko" za programistę?
Definicja inżyniera oprogramowania
Kodowanie jest stosunkowo łatwe. To już nie tylko mnemoniki asemblerowe na absurdalnie ograniczonych systemach. A jeśli używasz czegoś tak ekspresyjnego i potężnego jak Ruby, jest to jeszcze łatwiejsze.
Po prostu podnosisz bilet, znajdujesz miejsce, w którym musisz wstawić kod, wymyślasz logikę, którą musisz tam umieścić, i bum - gotowe. Jeśli jesteś nieco bardziej zaawansowany, upewnij się, że twój kod jest ładny. Logicznie podzielony na metody. Ma przyzwoite specyfikacje, które testują nie tylko szczęśliwą ścieżkę. To właśnie robi dobry programista.
Inżynier oprogramowania nie myśli już metodami i klasami, przynajmniej nie przede wszystkim. Z mojego doświadczenia wynika, że inżynierowie oprogramowania myślą przepływami. Przede wszystkim widzą grzmiącą, szalejącą rzekę danych i interakcji przepływającą przez system. Myślą o tym, co muszą zrobić, aby odwrócić lub zmienić ten przepływ. Ładny kod, logiczne metody i świetne specyfikacje przychodzą niemal po namyśle.
To żółwie przez całą drogę w dół
Ludzie generalnie myślą w określony sposób o większości interakcji z rzeczywistością. Z braku lepszego określenia nazwijmy to perspektywą "top-down". Jeśli mój mózg pracuje nad przygotowaniem sobie filiżanki herbaty, to w pierwszej kolejności określi ogólne kroki: idę do kuchni, nastawiam czajnik, przygotowuję herbatę, nalewam wodę, wracam do biurka.
Nie będzie najpierw zastanawiać się, którego kubka użyć, gdy stoję zdezorientowany przy biurku; to zdezorientowanie nastąpi później, gdy stanę przed szafką. Nie weźmie pod uwagę, że herbata może się skończyć (a przynajmniej, że może się skończyć dobry rzeczy). Jest szeroki, reaktywny i podatny na błędy. Podsumowując - bardzo człowiek w przyrodzie.
Gdy inżynier oprogramowania rozważa zmiany w nieco skomplikowanym przepływie danych, naturalnie zrobi to w podobny sposób. Rozważmy tę przykładową historię użytkownika:
Klient zamawia widżet. Przy wycenie zamówienia należy wziąć pod uwagę następujące kwestie:
- Cena bazowa widżetu w lokalizacji użytkownika
- Kształt widżetu (modyfikator ceny)
- Czy jest to zamówienie pospieszne (modyfikator ceny)
- Czy dostawa zamówienia odbywa się w dniu wolnym od pracy w lokalizacji użytkownika (modyfikator ceny)
To wszystko może wydawać się wymyślone (i oczywiście takie jest), ale nie jest to dalekie od niektórych rzeczywistych historii użytkowników, które miałem ostatnio przyjemność zmiażdżyć.
Przejdźmy teraz przez proces myślowy, który inżynier oprogramowania może zastosować, aby sobie z tym poradzić:
"Musimy poznać użytkownika i jego zamówienie. Następnie zaczniemy obliczać sumę. Zaczniemy od zera. Następnie zastosujemy modyfikator kształtu widżetu. Następnie opłata za pośpiech. Następnie sprawdzamy, czy jest święto, bum, gotowe przed lunchem!".
Ach, ten pęd, który może przynieść prosta historia użytkownika. Ale inżynier oprogramowania jest tylko człowiekiem, a nie idealną wielowątkową maszyną, a powyższy przepis jest ogólny. Inżynier musi więc myśleć głębiej:
"Modyfikator kształtu widżetu jest... och, to bardzo zależy od widżetu, prawda? I mogą się one różnić w zależności od lokalizacji, nawet jeśli nie teraz, to w przyszłości". ich zdaniem, wcześniej spalonych przez zmieniające się wymagania biznesowe, "może być również opłata za pośpiech. A święta są również bardzo specyficzne dla lokalizacji, augh, i strefy czasowe będą zaangażowane! Miałem tutaj artykuł o radzeniu sobie z czasami w różnych strefach czasowych w Railsach... ooh, zastanawiam się, czy czas zamówienia jest przechowywany ze strefą w bazie danych! Lepiej sprawdzić schemat."
W porządku, inżynierze oprogramowania. Stop. Powinieneś parzyć herbatę, ale siedzisz przed szafką i zastanawiasz się, czy kwiecista filiżanka w ogóle pasuje do twojego problemu z herbatą.
Widżet do parzenia idealnego kubka
Ale tak właśnie może się stać, gdy próbujesz zrobić coś tak nienaturalnego dla ludzkiego mózgu, jak myślenie na kilku poziomach szczegółowości jednocześnie.
Po krótkim przeszukaniu obszernego arsenału linków dotyczących obsługi strefy czasowej nasz inżynier zbiera się w sobie i zaczyna rozkładać to na rzeczywisty kod. Gdyby spróbowali naiwnego podejścia, mogłoby to wyglądać mniej więcej tak:
def calculate_price(user, order)
order.price = 0
order.price = WidgetPrices.find_by(widget_type: order.widget.type).price
order.price = WidgetShapes.find_by(widget_shape: order.widget.shape).modifier
...
end
I tak dalej i dalej, w ten rozkosznie proceduralny sposób, tylko po to, by zostać mocno zablokowanym przy pierwszym przeglądzie kodu. Bo jeśli się nad tym zastanowić, to całkowicie normalne jest myślenie w ten sposób: najpierw szerokie pociągnięcia, a szczegóły znacznie później. Na początku nawet nie myślałeś, że nie masz dobrej herbaty, prawda?
Nasz inżynier jest jednak dobrze wyszkolony i nie jest mu obcy Service Object, więc oto, co zaczyna się dziać:
class BaseOrderService
def self.call(user, order)
new(user, order).call
end
def initialize(user, order)
@user = użytkownik
@order = zamówienie
end
def call
puts "[WARN] Zaimplementuj niedomyślne wywołanie dla #{self.class.name}!"
user, order
end
end
class WidgetPriceService < BaseOrderService; end
class ShapePriceModifier < BaseOrderService; end
class RushPriceModifier < BaseOrderService; end
class HolidayDeliveryPriceModifier < BaseOrderService; end
class OrderPriceCalculator < BaseOrderService
def call
user, order = WidgetPriceService.call(user, order)
user, order = ShapePriceModifier.call(user, order)
user, order = RushPriceModifier.call(user, order)
user, order = HolidayDeliveryPriceModifier.call(user, order)
user, order
end
end
```
Dobrze! Teraz możemy zastosować dobre TDD, napisać dla niego przypadek testowy i dopracować klasy, aż wszystkie elementy znajdą się na swoim miejscu. I to też będzie piękne.
Jak również całkowicie niemożliwe do uzasadnienia.
Wrogiem jest państwo
Oczywiście, są to dobrze oddzielone obiekty z pojedynczymi obowiązkami. Problem polega jednak na tym, że wciąż są to obiekty. Wzorzec obiektu usługowego z jego "wymuszonym udawaniem, że ten obiekt jest funkcją" jest tak naprawdę kulą u nogi. Nic nie stoi na przeszkodzie, by wywołać HolidayDeliveryPriceModifier.new(user, order).something_else_entirely
. Nic nie stoi na przeszkodzie, aby dodać wewnętrzny stan do tych obiektów.
Nie wspominając o użytkownik
i porządek
są również obiektami, a manipulowanie nimi jest tak proste, jak szybkie zakradnięcie się do nich order.save
gdzieś w tych skądinąd "czystych" obiektach funkcjonalnych, zmieniając podstawowe źródło prawdy, czyli bazę danych, stan. W tym wymyślonym przykładzie nie jest to wielka sprawa, ale z pewnością może wrócić, aby cię ugryźć, jeśli ten system stanie się bardziej złożony i rozszerzy się na dodatkowe, często asynchroniczne części.
Inżynier miał dobry pomysł. I użył bardzo naturalnego sposobu wyrażenia tego pomysłu. Ale wiedza o tym, jak wyrazić ten pomysł - w piękny i łatwy do zrozumienia sposób - została prawie uniemożliwiona przez podstawowy paradygmat OOP. A jeśli ktoś, kto jeszcze nie dokonał skoku w kierunku wyrażania swoich myśli jako przekierowań przepływu danych, spróbuje mniej umiejętnie zmienić podstawowy kod, wydarzy się coś złego.
Stawanie się funkcjonalnie czystym
Gdyby tylko istniał paradygmat, w którym wyrażanie swoich pomysłów w postaci przepływów danych byłoby nie tylko łatwe, ale wręcz konieczne. Gdyby rozumowanie mogło być proste, bez możliwości wprowadzenia niepożądanych efektów ubocznych. Gdyby dane mogły być niezmienne, tak jak kwiecisty kubek, w którym parzysz herbatę.
Tak, oczywiście żartuję. Taki paradygmat istnieje i nazywa się programowaniem funkcjonalnym.
Zastanówmy się, jak powyższy przykład mógłby wyglądać w ulubionym przeze mnie Elixirze.
defmodule WidgetPrices do
def priceorder([użytkownik, zamówienie]) do
[użytkownik, zamówienie]
|> widgetprice
|> shapepricemodifier
|> rushpricemodifier
|> holidaypricemodifier
koniec
defp widgetprice([user, order]) do
%{widget: widget} = zamówienie
price = WidgetRepo.getbase_price(widget)
[user, %{order | price: price }]
end
defp shapepricemodifier([user, order]) do
%{widget: widget, price: currentprice} = order
modifier = WidgetRepo.getshapeprice(widget)
[user, %{order | price: currentprice * modifier} ]
end
defp rushpricemodifier([user, order]) do
%{rush: rush, price: currentprice} = order
if rush do
[user, %{order | price: currentprice * 1.75} ]
else
[user, %{order | price: current_price} ]
end
end
defp holidaypricemodifier([user, order]) do
%{date: date, price: currentprice} = order
modifier = HolidayRepo.getholidaymodifier(user, date)
[user, %{order | price: currentprice * modifier}]
end
end
```
Można zauważyć, że jest to w pełni rozwinięty przykład tego, jak faktycznie można osiągnąć historię użytkownika. To dlatego, że jest ona mniej skomplikowana niż w Rubim. Używamy kilku kluczowych funkcji unikalnych dla Elixira (ale ogólnie dostępnych w językach funkcyjnych):
Czyste funkcje. W rzeczywistości nie zmieniamy przychodzących porządek
w ogóle, po prostu tworzymy nowe kopie - nowe iteracje stanu początkowego. Nie przeskakujemy też na bok, by cokolwiek zmienić. A nawet gdybyśmy chcieli, porządek
jest tylko "głupią" mapą, nie możemy wywołać order.save
ponieważ po prostu nie wie, co to jest.
Dopasowywanie wzorców. Raczej podobnie do destrukturyzacji ES6, pozwala nam to na wyrwanie cena
i widżet
z zamówienia i przekazać je dalej, zamiast zmuszać naszych kumpli WidgetRepo
i HolidayRepo
wiedzieć, jak radzić sobie z pełnym porządek
.
Operator rur. Widziane w price_order
, pozwala nam przekazywać dane przez funkcje w swego rodzaju "potoku" - koncepcja natychmiast znana każdemu, kto kiedykolwiek uruchomił ps aux | grep postgres
aby sprawdzić, czy to cholerstwo nadal działa.
Tak właśnie myślisz
Efekty uboczne nie są tak naprawdę podstawową częścią naszego procesu myślowego. Po nalaniu wody do filiżanki zazwyczaj nie martwimy się, że błąd w czajniku może spowodować jego przegrzanie i eksplozję - przynajmniej nie na tyle, by grzebać w jego wnętrzu w celu sprawdzenia, czy ktoś przypadkiem go nie zostawił. explode_after_pouring
odwrócony wysoko.
Droga od programisty do inżyniera oprogramowania - wykraczająca poza martwienie się o obiekty i stany, do martwienia się o przepływy danych - może w niektórych przypadkach zająć lata. Z pewnością tak było w przypadku mojej osoby wychowanej na OOP. Dzięki językom funkcjonalnym zaczynasz myśleć o przepływach pierwszej nocy.
Zrobiliśmy inżynieria oprogramowania skomplikowane dla nas samych i dla każdego nowicjusza w tej dziedzinie. Programowanie nie musi być trudne i wymagające. Może być łatwe i naturalne.
Nie komplikujmy tego i przejdźmy już do funkcjonalności. Ponieważ tak właśnie myślimy.
Czytaj także: