Jak mogę _ odczytać funkcjonalny kod JavaScript?

9

Wierzę, że nauczyłem się niektórych / wielu / większości podstawowych pojęć leżących u podstaw programowania funkcjonalnego w JavaScript. Mam jednak problemy z odczytaniem kodu funkcjonalnego, nawet kodu, który napisałem, i zastanawiam się, czy ktoś może dać mi jakieś wskazówki, porady, najlepsze praktyki, terminologię itp., Które mogą pomóc.

Weź poniższy kod. Napisałem ten kod. Ma na celu przypisanie procentowego podobieństwa między dwoma obiektami, między powiedzmy {a:1, b:2, c:3, d:3}i {a:1, b:1, e:2, f:2, g:3, h:5}. Kod powstał w odpowiedzi na to pytanie dotyczące przepełnienia stosu . Ponieważ nie byłem pewien, o jaki procent podobieństwa pytał plakat, podałem cztery różne rodzaje:

  • procent kluczy w 1. obiekcie, który można znaleźć w 2.,
  • procent wartości w pierwszym obiekcie, który można znaleźć w drugim, w tym duplikaty,
  • procent wartości w pierwszym obiekcie, który można znaleźć w drugim, bez dozwolonych duplikatów, oraz
  • procent par {klucz: wartość} w 1. obiekcie, który można znaleźć w 2. obiekcie.

Zacząłem od rozsądnego kodu, ale szybko zdałem sobie sprawę, że jest to problem dobrze dostosowany do programowania funkcjonalnego. W szczególności zdałem sobie sprawę, że jeśli uda mi się wyodrębnić funkcję lub trzy dla każdej z czterech powyższych strategii, które określają rodzaj funkcji, którą chciałem porównać (np. Klucze lub wartości itp.), To mogę być w stanie zredukować (ułaskawić grę słów) resztę kodu do powtarzalnych jednostek. Wiesz, utrzymując to na sucho. Więc przeszedłem do programowania funkcjonalnego. Jestem bardzo dumny z wyniku, myślę, że jest dość elegancki i myślę, że rozumiem, co zrobiłem całkiem dobrze.

Jednak nawet po napisaniu kodu i zrozumieniu każdej jego części podczas budowy, kiedy teraz na niego patrzę, nadal jestem nieco zaskoczony zarówno tym, jak czytać poszczególne półtony, jak i jak „grok”, co właściwie robi konkretna półwiersz kodu. Robię mentalne strzały, by łączyć różne części, które szybko rozkładają się w bałagan spaghetti.

Czy ktoś może mi więc powiedzieć, jak „odczytać” bardziej skomplikowane fragmenty kodu w sposób zwięzły i przyczyniający się do zrozumienia tego, co czytam? Sądzę, że części, które mnie najbardziej doceniają, to te, które mają kilka grubych strzałek z rzędu i / lub części, które mają kilka nawiasów z rzędu. Ponownie, u ich podstaw, w końcu mogę zrozumieć logikę, ale (mam nadzieję) istnieje lepszy sposób na szybkie i jasne i bezpośrednie „przyjęcie” szeregu funkcjonalnych programów JavaScript.

Możesz użyć dowolnej linii kodu od dołu, a nawet innych przykładów. Jeśli jednak chcesz ode mnie kilku wstępnych sugestii, oto kilka. Zacznij od dość prostej. Od blisko końca kodu, jest to, że jest przekazywana jako parametr do funkcji: obj => key => obj[key]. Jak to przeczytać i zrozumieć? Dłuższy przykładem jest jeden pełny funkcji od blisko początku: const getXs = (obj, getX) => Object.keys(obj).map(key => getX(obj)(key));. Ostatnia mapczęść mnie szczególnie.

Należy pamiętać, że w tym momencie ja nie szukają odniesień do Haskell lub symbolicznej abstrakcyjnej notacji lub podstaw zmiękczania itp Co ja jestem szukasz jest zdania po angielsku, że mogę cicho usta patrząc na linię kodu. Jeśli masz referencje, które dokładnie to dotyczą, świetnie, ale nie szukam również odpowiedzi, które mówią, że powinienem przeczytać kilka podstawowych podręczników. Zrobiłem to i dostaję (przynajmniej znaczną część) logikę. Zauważ też, że nie potrzebuję wyczerpujących odpowiedzi (chociaż takie próby byłyby mile widziane): mile widziane byłyby nawet krótkie odpowiedzi, które zapewniają elegancki sposób odczytu pojedynczej linii w innym przypadku kłopotliwego kodu.

Przypuszczam, że część tego pytania brzmi: czy mogę nawet czytać kod funkcjonalny liniowo, od lewej do prawej i od góry do dołu? A może ktoś jest zmuszony stworzyć na stronie kodu obraz przypominający spaghetti, który nie jest liniowy? A jeśli trzeba to zrobić, wciąż musimy przeczytać kod, więc jak wziąć liniowy tekst i połączyć spaghetti?

Wszelkie wskazówki będą mile widziane.

const obj1 = { a:1, b:2, c:3, d:3 };
const obj2 = { a:1, b:1, e:2, f:2, g:3, h:5 };

// x or X is key or value or key/value pair

const getXs = (obj, getX) =>
  Object.keys(obj).map(key => getX(obj)(key));

const getPctSameXs = (getX, filter = vals => vals) =>
  (objA, objB) =>
    filter(getXs(objB, getX))
      .reduce(
        (numSame, x) =>
          getXs(objA, getX).indexOf(x) > -1 ? numSame + 1 : numSame,
        0
      ) / Object.keys(objA).length * 100;

const pctSameKeys       = getPctSameXs(obj => key => key);
const pctSameValsDups   = getPctSameXs(obj => key => obj[key]);
const pctSameValsNoDups = getPctSameXs(obj => key => obj[key], vals => [...new Set(vals)]);
const pctSameProps      = getPctSameXs(obj => key => JSON.stringify( {[key]: obj[key]} ));

console.log('obj1:', JSON.stringify(obj1));
console.log('obj2:', JSON.stringify(obj2));
console.log('% same keys:                   ', pctSameKeys      (obj1, obj2));
console.log('% same values, incl duplicates:', pctSameValsDups  (obj1, obj2));
console.log('% same values, no duplicates:  ', pctSameValsNoDups(obj1, obj2));
console.log('% same properties (k/v pairs): ', pctSameProps     (obj1, obj2));

// output:
// obj1: {"a":1,"b":2,"c":3,"d":3}
// obj2: {"a":1,"b":1,"e":2,"f":2,"g":3,"h":5}
// % same keys:                    50
// % same values, incl duplicates: 125
// % same values, no duplicates:   75
// % same properties (k/v pairs):  25
Andrew Willems
źródło

Odpowiedzi:

18

Przeważnie masz trudności z jego odczytaniem, ponieważ ten konkretny przykład nie jest zbyt czytelny. Bez zamiaru przestępstwa, zniechęcająco duża część próbek, które można znaleźć w Internecie, również nie jest. Wiele osób bawi się programowaniem funkcjonalnym tylko w weekendy i tak naprawdę nigdy nie musi zajmować się utrzymywaniem produkcyjnego kodu funkcjonalnego w dłuższej perspektywie. Chciałbym napisać to bardziej tak:

function mapObj(obj, f) {
  return Object.keys(obj).map(key => f(obj, key));
}

function getPctSameXs(obj1, obj2, f) {
  const mapped1 = mapObj(obj1, f);
  const mapped2 = mapObj(obj2, f);
  const same = mapped1.filter(x => mapped2.indexOf(x) != -1);
  const percent = same.length / mapped1.length * 100;
  return percent;
}

const getValues = (obj, key) => obj[key];
const valuesWithDupsPercent = getPctSameXs(obj1, obj2, getValues);

Z jakiegoś powodu wiele osób ma w głowie tę myśl, że kod funkcjonalny powinien mieć pewien estetyczny „wygląd” dużego zagnieżdżonego wyrażenia. Zauważ, że chociaż moja wersja przypomina nieco kod rozkazujący ze wszystkimi średnikami, wszystko jest niezmienne, więc możesz zastąpić wszystkie zmienne i uzyskać jedno duże wyrażenie, jeśli chcesz. Jest rzeczywiście tak „funkcjonalny” jak wersja spaghetti, ale z większą czytelnością.

Wyrażenia są tutaj podzielone na bardzo małe kawałki i nadane im nazwy, które mają znaczenie dla domeny. Zagnieżdżania można uniknąć, przeciągając typową funkcję, np. Do funkcji mapObjo nazwie. Lambdy są zarezerwowane dla bardzo krótkich funkcji z wyraźnym celem w kontekście.

Jeśli natrafisz na kod, który jest trudny do odczytania, refaktoryzuj go, aż będzie łatwiejszy. To wymaga trochę praktyki, ale warto. Kod funkcjonalny może być tak samo czytelny jak konieczny. W rzeczywistości często więcej, ponieważ zwykle jest bardziej zwięzły.

Karl Bielefeldt
źródło
Zdecydowanie bez przestępstwa! Chociaż nadal będę utrzymywał, że wiem kilka rzeczy na temat programowania funkcjonalnego, być może moje stwierdzenia w pytaniu o to, ile wiem, były nieco przesadzone. Jestem naprawdę względnym początkującym. Więc zobaczenie, jak tę konkretną moją próbę można przepisać w tak zwięzły, jasny, ale wciąż funkcjonalny sposób, wydaje się złotem ... dziękuję. Będę uważnie studiował twój przepis.
Andrew Willems,
1
Słyszałem, że powiedział, że posiadanie długich łańcuchów i / lub zagnieżdżanie metod eliminuje niepotrzebne zmienne pośrednie. W przeciwieństwie do tego, twoja odpowiedź dzieli moje łańcuchy / zagnieżdżanie na pośrednie samodzielne instrukcje przy użyciu dobrze nazwanych zmiennych pośrednich. W tym przypadku twój kod jest bardziej czytelny, ale zastanawiam się, jak ogólny chcesz być. Czy mówisz, że długie łańcuchy metod i / lub głębokie zagnieżdżanie są często lub nawet zawsze anty-wzorcem, którego należy unikać, czy też są chwile, kiedy przynoszą znaczącą korzyść? Czy odpowiedź na to pytanie jest inna w przypadku kodowania funkcjonalnego i kodowania imperatywnego?
Andrew Willems,
3
Istnieją pewne sytuacje, w których wyeliminowanie zmiennych pośrednich może zwiększyć przejrzystość. Na przykład w FP prawie nigdy nie chcesz indeksu w tablicy. Czasami też nie ma doskonałej nazwy dla wyniku pośredniego. Z mojego doświadczenia wynika jednak, że większość ludzi ma tendencję do zbyt daleko idących błędów w drugą stronę.
Karl Bielefeldt
6

Nie wykonałem dużo wysoce funkcjonalnej pracy w Javascript (co powiedziałbym, że tak jest - większość ludzi mówiących o funkcjonalnym Javascript może używać map, filtrów i redukcji, ale twój kod definiuje własne funkcje wyższego poziomu , które są nieco bardziej zaawansowany), ale zrobiłem to w Haskell i myślę, że przynajmniej część tego doświadczenia tłumaczy. Dam ci kilka wskazówek do rzeczy, których się nauczyłem:

Określenie typów funkcji jest naprawdę ważne. Haskell nie wymaga określenia typu funkcji, ale włączenie typu do definicji znacznie ułatwia czytanie. Chociaż Javascript nie obsługuje jawnego pisania w ten sam sposób, nie ma powodu, aby nie dołączać definicji typu w komentarzu, np .:

// getXs :: forall O, F . O -> (O -> String -> F) -> [F]
const getXs = (obj, getX) =>
    Object.keys(obj).map(key => getX(obj)(key));

Przy odrobinie wprawy w pracy z takimi definicjami typów, wyjaśniają znaczenie funkcji.

Nazewnictwo jest ważne, być może nawet bardziej niż w programowaniu proceduralnym. Wiele programów funkcjonalnych jest napisanych w bardzo zwięzłym stylu, który jest ciężki w konwencji (np. Konwencja, że ​​„xs” jest listą / tablicą i że „x” jest w niej elementem, jest bardzo wszechobecna), ale chyba, że ​​rozumiesz ten styl łatwo zasugerowałbym bardziej pełne nazewnictwo. Patrząc na konkretne nazwy, których używałeś, „getX” jest niejasne, dlatego też „getXs” tak naprawdę niewiele pomaga. Nazwałbym „getXs” czymś w rodzaju „ApplyToProperties”, a „getX” prawdopodobnie byłoby „propertyMapper”. „getPctSameXs” byłoby wówczas „percentPropertiesSameWith” („z”).

Kolejną ważną rzeczą jest napisanie kodu idiomatycznego . Zauważam, że używasz składni a => b => some-expression-involving-a-and-bdo tworzenia funkcji curry. Jest to interesujące i może być przydatne w niektórych sytuacjach, ale nie robisz tutaj nic, co by korzystało z funkcji curry, i byłoby bardziej idiomatyczne JavaScript, aby zamiast tego używać tradycyjnych funkcji wielu argumentów. Takie postępowanie może ułatwić sprawdzenie, co się dzieje na pierwszy rzut oka. Używasz także const name = lambda-expressiondo definiowania funkcji, których użycie byłoby bardziej idiomatyczne function name (args) { ... }. Wiem, że są semantycznie nieco różne, ale jeśli nie polegasz na tych różnicach, sugeruję użycie bardziej powszechnego wariantu, jeśli to możliwe.

Jules
źródło
5
+1 za typy! To, że język ich nie ma, nie oznacza, że ​​nie musisz o nich myśleć . Kilka systemów dokumentacji dla ECMAScript ma język typów do rejestrowania typów funkcji. Kilka IDE ECMAScript ma również język typów (i zazwyczaj rozumieją również języki typów dla głównych systemów dokumentacji), a nawet mogą wykonywać podstawowe sprawdzanie typów i heurystyczne wskazówki za pomocą tych adnotacji typu .
Jörg W Mittag
Dałeś mi wiele do żucia: definicje typów, znaczące nazwy, używanie idiomów ... dziękuję! Tylko kilka z wielu możliwych komentarzy: niekoniecznie zamierzałem pisać pewne części jako funkcje curry; po prostu ewoluowały w ten sposób, gdy ponownie pisałem kod podczas pisania. Widzę teraz, jak to nie było potrzebne, a nawet połączenie parametrów z tych dwóch funkcji w dwa parametry dla jednej funkcji nie tylko ma większy sens, ale natychmiast sprawia, że ​​ten krótki bit jest co najmniej bardziej czytelny.
Andrew Willems,
@ JörgWMittag, dziękuję za komentarze na temat znaczenia typów i link do tej innej odpowiedzi, którą napisałeś. Korzystam z WebStorm i nie zdawałem sobie sprawy, że zgodnie z tym, jak przeczytałem tę twoją odpowiedź, WebStorm wie, jak interpretować adnotacje podobne do jsdoc. Z twojego komentarza zakładam, że jsdoc i WebStorm mogą być używane razem do opisywania funkcjonalnego, nie tylko koniecznego kodu, ale musiałbym zagłębić się dalej, aby naprawdę to wiedzieć. Grałem z jsdoc wcześniej i teraz, gdy wiem, że WebStorm i ja możemy tam współpracować, spodziewam się, że będę więcej używał tej funkcji / podejścia.
Andrew Willems,
@Jules, aby wyjaśnić, o której funkcji curry mówiłem w moim komentarzu powyżej: Jak sugerowałeś, każde wystąpienie obj => key => ...można uprościć, (obj, key) => ...ponieważ później getX(obj)(key)można je również uprościć get(obj, key). W przeciwieństwie do innej funkcji curry, (getX, filter = vals => vals) => (objA, objB) => ...nie można jej łatwo uprościć, przynajmniej w kontekście napisanej reszty kodu.
Andrew Willems,