Czy jestem zbyt „sprytny”, aby być czytelnym dla Jr. devs? Za dużo programowania funkcjonalnego w moim JS? [Zamknięte]

133

Jestem programistą front-end Sr., koduję w Babel ES6. Część naszej aplikacji wykonuje wywołanie API, a na podstawie modelu danych, który otrzymujemy z wywołania API, niektóre formularze muszą zostać wypełnione.

Te formularze są przechowywane na podwójnie połączonej liście (jeśli zaplecze mówi, że niektóre dane są nieprawidłowe, możemy szybko przywrócić użytkownika do jednej strony, którą pomieszali, a następnie przywrócić je do celu, po prostu modyfikując lista.)

W każdym razie istnieje wiele funkcji służących do dodawania stron i zastanawiam się, czy nie jestem zbyt sprytny. To tylko podstawowy przegląd - faktyczny algorytm jest znacznie bardziej złożony, z mnóstwem różnych stron i typów stron, ale to da ci przykład.

Myślę, że tak poradziłby sobie początkujący programista.

export const addPages = (apiData) => {
   let pagesList = new PagesList(); 

   if(apiData.pages.foo){
     pagesList.add('foo', apiData.pages.foo){
   }

   if (apiData.pages.arrayOfBars){
      let bars = apiData.pages.arrayOfBars;
      bars.forEach((bar) => {
         pagesList.add(bar.name, bar.data);
      })
   }

   if (apiData.pages.customBazes) {
      let bazes = apiData.pages.customBazes;
      bazes.forEach((baz) => {
         pagesList.add(customBazParser(baz)); 
      })
   } 

   return pagesList;
}

Teraz, aby być bardziej testowalnym, wziąłem wszystkie te instrukcje if i rozdzieliłem je, samodzielne funkcje, a następnie mapuję je.

Teraz testowalność to jedna rzecz, ale tak też jest czytelna i zastanawiam się, czy nie sprawiam, że rzeczy są mniej czytelne.

// file: '../util/functor.js'

export const Identity = (x) => ({
  value: x,
  map: (f) => Identity(f(x)),
})

// file 'addPages.js' 

import { Identity } from '../util/functor'; 

export const parseFoo = (data) => (list) => {
   list.add('foo', data); 
}

export const parseBar = (data) => (list) => {
   data.forEach((bar) => {
     list.add(bar.name, bar.data)
   }); 
   return list; 
} 

export const parseBaz = (data) => (list) => {
   data.forEach((baz) => {
      list.add(customBazParser(baz)); 
   })
   return list;
}


export const addPages = (apiData) => {
   let pagesList = new PagesList(); 
   let { foo, arrayOfBars: bars, customBazes: bazes } = apiData.pages; 

   let pages = Identity(pagesList); 

   return pages.map(foo ? parseFoo(foo) : x => x)
               .map(bars ? parseBar(bars) : x => x)
               .map(bazes ? parseBaz(bazes) : x => x)
               .value

}

Oto moja troska. Dla mnie dno jest bardziej zorganizowane. Sam kod jest podzielony na mniejsze części, które można testować w izolacji. ALE myślę: gdybym musiał to przeczytać jako młodszy programista, nieprzyzwyczajony do takich pojęć, jak używanie funktorów tożsamości, curry lub potrójne stwierdzenia, czy byłbym w stanie zrozumieć, co robi to drugie rozwiązanie? Czy czasem lepiej jest robić rzeczy w „zły, łatwiejszy” sposób?

Brian Bojko
źródło
13
jako ktoś, kto ma tylko 10 lat samokształcenia w JS, Babel ES6
uważałbym
26
OMG - działasz w branży od 1999 roku i kodujesz od 1983 roku, a ty jesteś najbardziej szkodliwym rodzajem programisty. To, co uważasz za „sprytne”, nazywa się naprawdę „drogim” i „trudnym w utrzymaniu” oraz „źródłem błędów” i nie ma miejsca w środowisku biznesowym. Pierwszy przykład jest prosty, łatwy do zrozumienia i działa, podczas gdy drugi przykład jest złożony, trudny do zrozumienia i niepoprawnie poprawny. Proszę przestańcie robić takie rzeczy. To NIE jest lepsze, z wyjątkiem może w jakimś akademickim sensie, który nie dotyczy prawdziwego świata.
user1068,
15
Chciałbym tylko zacytować tutaj Briana Kerninghana: „Wszyscy wiedzą, że debugowanie jest dwa razy trudniejsze niż pisanie programu. Jeśli więc jesteś tak sprytny, jak tylko potrafisz, pisząc go, jak to możliwe? „ - en.wikiquote.org/wiki/Brian_Kernighan / „The Elements of Programming Style”, wydanie drugie, rozdział 2
MarkusSchaber
7
@Logister Coolness nie jest bardziej podstawowym celem niż prostota. Zarzut ten dotyczy nieuzasadnionej złożoności, która jest wrogiem poprawności (z pewnością najważniejsza kwestia), ponieważ utrudnia to rozumowanie kodu i może zawierać nieoczekiwane przypadki narożne. Biorąc pod uwagę mój wcześniejszy sceptycyzm do twierdzeń, że faktycznie łatwiej jest przetestować, nie widziałem żadnych przekonujących argumentów za tym stylem. Analogicznie do zasady najmniejszego uprzywilejowanego bezpieczeństwa, może istnieć ogólna zasada, która mówi, że należy być ostrożnym w używaniu zaawansowanych funkcji językowych do robienia prostych rzeczy.
sdenham
6
Twój kod wygląda jak młodszy kod. Spodziewałbym się, że senior napisze pierwszy przykład.
sed

Odpowiedzi:

322

W swoim kodzie wprowadziłeś wiele zmian:

  • przypisanie destrukcyjne dostępu do pól w pagesjest dobrą zmianą.
  • wyodrębnienie parseFoo()funkcji itp. jest prawdopodobnie dobrą zmianą.
  • wprowadzenie funktora jest… bardzo mylące.

Jedną z najbardziej zagmatwanych części jest tutaj mieszanie programowania funkcjonalnego i imperatywnego. Za pomocą funktora tak naprawdę nie transformujesz danych, używasz go do przepuszczania listy zmiennych przez różne funkcje. To nie wydaje się bardzo przydatną abstrakcją, mamy już do tego zmienne. To, co prawdopodobnie powinno być wyabstrahowane - analizowanie tylko tego elementu, jeśli istnieje - jest nadal wyraźnie w twoim kodzie, ale teraz musimy pomyśleć za rogiem. Na przykład, nie jest oczywiste, że parseFoo(foo)funkcja zwróci. JavaScript nie ma systemu typu statycznego, który powiadamiałby cię, czy jest to legalne, więc taki kod jest naprawdę podatny na błędy bez lepszej nazwy ( makeFooParser(foo)?). Nie widzę żadnej korzyści z tego zaciemnienia.

Co zamiast tego spodziewałbym się zobaczyć:

if (foo) parseFoo(pages, foo);
if (bars) parseBar(pages, bars);
if (bazes) parseBaz(pages, bazes);
return pages;

Ale to też nie jest idealne, ponieważ ze strony połączeń nie jest jasne, że elementy zostaną dodane do listy stron. Jeśli zamiast tego funkcje analizujące są czyste i zwracają (ewentualnie pustą) listę, którą możemy jawnie dodać do stron, może być lepiej:

pages.addAll(parseFoo(foo));
pages.addAll(parseBar(bars));
pages.addAll(parseBaz(bazes));
return pages;

Dodatkowa korzyść: logika dotycząca tego, co zrobić, gdy element jest pusty, została teraz przeniesiona do poszczególnych funkcji parsowania. Jeśli nie jest to właściwe, nadal możesz wprowadzić warunki warunkowe. Zmienność pageslisty jest teraz połączona w jedną funkcję, zamiast rozłożyć ją na wiele wywołań. Unikanie mutacji nielokalnych jest znacznie większą częścią programowania funkcjonalnego niż abstrakcje o śmiesznych nazwach Monad.

Więc tak, twój kod był zbyt sprytny. Proszę, zastosujcie swoją spryt, aby nie pisać sprytnego kodu, ale aby znaleźć sprytne sposoby uniknięcia potrzeby rażącej sprytności. Najlepsze projekty nie wyglądają fantazyjnie, ale są oczywiste dla każdego, kto je zobaczy. A dobre abstrakcje mają uprościć programowanie, a nie dodawać dodatkowych warstw, które muszę najpierw rozplątać w umyśle (tutaj, stwierdzając, że funktor jest równoważny zmiennej i może być skutecznie pomijany).

Proszę: w razie wątpliwości utrzymuj swój kod prosty i głupi (zasada KISS).

amon
źródło
2
Z punktu widzenia symetrii, czym let pages = Identity(pagesList)się różni parseFoo(foo)? Biorąc pod uwagę, że to pewnie mają ... {Identity(pagesList), parseFoo(foo), parseBar(bar)}.flatMap(x -> x).
ArTs,
8
Dziękuję za wyjaśnienie, że posiadanie trzech zagnieżdżonych wyrażeń lambda do zbierania zmapowanej listy (do mojego niewprawnego oka) może być zbyt sprytne.
Thorbjørn Ravn Andersen
2
Komentarze nie są przeznaczone do rozszerzonej dyskusji; ta rozmowa została przeniesiona do czatu .
yannis
Może Twój drugi przykład dobrze sprawdzi się w płynnym stylu?
user1068,
225

Jeśli masz wątpliwości, prawdopodobnie jest to zbyt sprytne! Drugi przykład wprowadza przypadkową złożoność z podobnymi wyrażeniami foo ? parseFoo(foo) : x => x, a ogólnie kod jest bardziej złożony, co oznacza, że ​​trudniej jest go śledzić.

Rzekoma korzyść polegająca na tym, że można testować poszczególne porcje, można osiągnąć w prostszy sposób, po prostu dzieląc się na poszczególne funkcje. W drugim przykładzie łączysz oddzielne iteracje, dzięki czemu masz mniej izolacji.

Bez względu na to, jakie są twoje odczucia dotyczące funkcjonalnego stylu, jest to wyraźnie przykład, w którym kod jest bardziej złożony.

Trochę ostrzegam, że kojarzysz prosty i bezpośredni kod z „nowicjuszami”. To niebezpieczna mentalność. Z mojego doświadczenia wynika, że ​​jest odwrotnie: nowi programiści są podatni na zbyt skomplikowany i sprytny kod, ponieważ potrzeba więcej doświadczenia, aby móc zobaczyć najprostsze i najczystsze rozwiązanie.

Odradzanie „sprytnego kodu” tak naprawdę nie dotyczy tego, czy kod wykorzystuje zaawansowane koncepcje, których nowicjusz może nie zrozumieć. Chodzi raczej o pisanie kodu, który jest bardziej złożony lub skomplikowany niż to konieczne . To sprawia, że ​​kod jest trudniejszy do naśladowania dla wszystkich , zarówno dla nowicjuszy, jak i ekspertów, a prawdopodobnie również dla ciebie kilka miesięcy później.

JacquesB
źródło
156
„Początkujący programiści są podatni na zbyt skomplikowany i sprytny kod, ponieważ wymaga większego doświadczenia, aby zobaczyć najprostsze i najczystsze rozwiązanie” nie może się z tobą więcej zgodzić. Doskonała odpowiedź!
Bonifacio
23
Zbyt skomplikowany kod jest również dość pasywno-agresywny. Celowo tworzysz kod, który niewielu innych może łatwo odczytać lub debugować ... co oznacza dla ciebie bezpieczeństwo pracy i piekło dla wszystkich innych pod Twoją nieobecność. Równie dobrze możesz napisać dokumentację techniczną w całości po łacinie.
Ivan
14
Nie sądzę, żeby sprytny kod zawsze był czymś popisującym się. Czasami wydaje się to naturalne i po drugiej kontroli wygląda śmiesznie.
5
Usunąłem sformułowanie o „popisywaniu się”, ponieważ zabrzmiało to bardziej osądowo niż zamierzano.
JacquesB
11
@BaileyS - Myślę, że to podkreśla znaczenie przeglądu kodu; to, co dla programisty wydaje się naturalne i proste, szczególnie gdy stopniowo rozwija się w ten sposób, może łatwo wydawać się skręcone dla recenzenta. Kod nie przechodzi następnie recenzji do czasu ponownego przetworzenia / przepisania w celu usunięcia splotu.
Myles,
21

Moja odpowiedź jest nieco spóźniona, ale nadal chcę wejść. Tylko dlatego, że używasz najnowszych technik ES6 lub najbardziej popularnego paradygmatu programistycznego, niekoniecznie oznacza, że ​​twój kod jest poprawniejszy lub kod tego młodszego jest źle. Funkcjonalne programowanie (lub dowolną inną technikę) powinno być stosowane wtedy, gdy jest rzeczywiście potrzebne. Jeśli spróbujesz znaleźć najmniejszą szansę na wbicie najnowszych technik programowania w każdy problem, zawsze znajdziesz rozwiązanie o zbyt wysokim poziomie inżynierii.

Cofnij się o krok i spróbuj zwerbalizować problem, który próbujesz rozwiązać przez sekundę. W skrócie, po prostu chcesz, addPagesaby funkcja przekształciła różne części apiDataw zestaw par klucz-wartość, a następnie dodaj je wszystkie PagesList.

A jeśli to wszystko jest do niego, dlaczego przeszkadza korzystania identity functionz ternary operator, lub używając functordo wejścia parsowania? Poza tym, dlaczego uważasz, że jest to właściwe podejście, functional programmingktóre powoduje skutki uboczne (poprzez mutowanie listy)? Dlaczego wszystkie te rzeczy, kiedy wszystko czego potrzebujesz to tylko:

const processFooPages = (foo) => foo ? [['foo', foo]] : [];
const processBarPages = (bar) => bar ? bar.map(page => [page.name, page.data]) : [];
const processBazPages = (baz) => baz ? baz.map(page => [page.id, page.content]) : [];

const addPages = (apiData) => {
  const list = new PagesList();
  const pages = [].concat(
    processFooPages(apiData.pages.foo),
    processBarPages(apiData.pages.arrayOfBars),
    processBazPages(apiData.pages.customBazes)
  );
  pages.forEach(([pageName, pageContent]) => list.addPage(pageName, pageContent));

  return list;
}

( tutaj można uruchomić jsfiddle )

Jak widać, to podejście nadal stosuje functional programming, ale z umiarem. Należy również pamiętać, że ponieważ wszystkie 3 funkcje transformacji nie powodują żadnych skutków ubocznych, są one łatwe do przetestowania. Kod addPagesjest również trywialny i niepozorny, który nowicjusze lub eksperci mogą zrozumieć tylko jednym spojrzeniem.

Teraz porównaj ten kod z tym, co wymyśliłeś powyżej, czy widzisz różnicę? Niewątpliwie, functional programminga składnie ES6 są potężne, ale jeśli rozwiążesz problem w niewłaściwy sposób za pomocą tych technik, skończysz z jeszcze bardziej nieporządnym kodem.

Jeśli nie wpadniesz w problem i nie zastosujesz odpowiednich technik we właściwych miejscach, możesz mieć kod, który ma funkcjonalny charakter, a jednocześnie jest bardzo zorganizowany i łatwy do utrzymania dla wszystkich członków zespołu. Te cechy nie wykluczają się wzajemnie.

b0nyb0y
źródło
2
+1 za zwrócenie uwagi na to szerokie podejście (niekoniecznie dotyczy OP): „To, że używasz najnowszych technik ES6 lub najpopularniejszego paradygmatu programistycznego, niekoniecznie oznacza, że ​​kod jest poprawniejszy, lub kod tego juniora jest nieprawidłowy. ”.
Giorgio,
+1. Tylko mała uwaga pedantyczna, możesz użyć operatora spread (...) zamiast _.concat, aby usunąć tę zależność.
YoTengoUnLCD
1
@YoTengoUnLCD Ah, dobry chwyt. Teraz już wiesz, że ja i mój zespół wciąż jesteśmy w podróży, aby poznać niektóre z naszych lodashzastosowań. Tego kodu można użyć spread operator, a nawet [].concat()jeśli chcemy zachować nienaruszony kształt kodu.
b0nyb0y
Przepraszamy, ale ta lista kodów jest dla mnie o wiele mniej oczywista niż oryginalny „młodszy kod” w poście OP. Zasadniczo: Nigdy nie używaj operatora trójskładnikowego, jeśli możesz tego uniknąć. To jest zbyt napięte. W „prawdziwym” języku funkcjonalnym instrukcje if byłyby wyrażeniami, a nie instrukcjami, a zatem byłyby bardziej czytelne.
Olle Härstedt
@ OlleHärstedt Umm, to interesujące stwierdzenie. Chodzi o to, że paradygmat programowania funkcjonalnego lub jakikolwiek inny paradygmat nigdy nie jest powiązany z żadnym konkretnym „prawdziwym” językiem funkcjonalnym, a tym bardziej z jego składnią. Zatem dyktowanie, jakie konstrukcje warunkowe powinny być lub nigdy nie powinny być używane, po prostu nie ma sensu. A ternary operatorjest tak samo ważne jak zwykłe ifstwierdzenie, czy ci się to podoba, czy nie. Debata na temat czytelności między if-elsei ?:obozem nigdy się nie kończy, więc nie będę się w to angażował. Powiem tylko, że przy wyćwiczonych oczach takie linie nie są zbyt „zbyt napięte”.
b0nyb0y
5

Drugi fragment kodu nie jest bardziej testowalny niż pierwszy. Zrozumiałe byłoby skonfigurowanie wszystkich niezbędnych testów dla jednego z dwóch fragmentów.

Prawdziwą różnicą między tymi dwoma fragmentami jest zrozumiałość. Mogę dość szybko przeczytać pierwszy fragment i zrozumieć, co się dzieje. Drugi fragment, nie tyle. Jest o wiele mniej intuicyjny, a także znacznie dłuższy.

Dzięki temu pierwszy fragment kodu jest łatwiejszy w utrzymaniu, co stanowi cenną jakość kodu. W drugim fragmencie znajduję niewiele wartości.

Dawood ibn Kareem
źródło
3

TD; DR

  1. Czy potrafisz wyjaśnić swój kod Junior Developer w 10 minut lub krócej?
  2. Za dwa miesiące możesz zrozumieć swój kod?

Szczegółowa analiza

Jasność i czytelność

Oryginalny kod jest imponująco przejrzysty i łatwy do zrozumienia dla każdego poziomu programisty. Jest w stylu znanym wszystkim .

Czytelność opiera się w dużej mierze na znajomości, a nie na matematycznym liczeniu tokenów . IMO, na tym etapie masz za dużo ES6 w przepisywaniu. Może za kilka lat zmienię tę część mojej odpowiedzi. :-) BTW, podoba mi się również odpowiedź @ b0nyb0y jako rozsądny i jasny kompromis.

Testowalność

if(apiData.pages.foo){
   pagesList.add('foo', apiData.pages.foo){
}

Zakładając, że PagesList.add () ma testy, które powinny, jest to całkowicie prosty kod i nie ma oczywistego powodu, aby ta sekcja wymagała specjalnego oddzielnego testowania.

if (apiData.pages.arrayOfBars){
      let bars = apiData.pages.arrayOfBars;
      bars.forEach((bar) => {
         pagesList.add(bar.name, bar.data);
      })
   }

Ponownie nie widzę potrzeby natychmiastowego przeprowadzania specjalnych oddzielnych testów tej sekcji. Chyba że PagesList.add () ma nietypowe problemy z zerami lub duplikatami lub innymi danymi wejściowymi.

if (apiData.pages.customBazes) {
      let bazes = apiData.pages.customBazes;
      bazes.forEach((baz) => {
         pagesList.add(customBazParser(baz)); 
      })
   } 

Ten kod jest również bardzo prosty. Zakładając, że customBazParserjest przetestowany i nie zwraca zbyt wielu „specjalnych” wyników. Więc znowu, chyba że występują trudne sytuacje z `PagesList.add (), (które mogą być, ponieważ nie znam twojej domeny). Nie rozumiem, dlaczego ta sekcja wymaga specjalnych testów.

Ogólnie rzecz biorąc, testowanie całej funkcji powinno działać dobrze.

Zastrzeżenie : Jeśli istnieją specjalne powody, aby przetestować wszystkie 8 możliwości trzech if()instrukcji, to tak, podziel testy. Lub, jeśli PagesList.add()jest wrażliwy, tak, podziel testy.

Struktura: Czy warto podzielić na trzy części (jak Gal)

Tutaj masz najlepszy argument. Osobiście nie sądzę, aby oryginalny kod był „zbyt długi” (nie jestem fanatykiem SRP). Ale gdyby było jeszcze kilka if (apiData.pages.blah)sekcji, SRP zbiera brzydką głowę i warto byłoby podzielić. Zwłaszcza jeśli ma zastosowanie DRY, a funkcji można użyć w innych miejscach kodu.

Moja jedna sugestia

YMMV. Aby zapisać wiersz kodu i logikę, mógłbym połączyć if i pozwolić na jedną linię: np

let bars = apiData.pages.arrayOfBars || [];
bars.forEach((bar) => {
   pagesList.add(bar.name, bar.data);
})

To się nie powiedzie, jeśli apiData.pages.arrayOfBars jest liczbą lub łańcuchem, ale również oryginalny kod. I dla mnie jest to wyraźniejsze (i nadużywany idiom).

użytkownik949300
źródło