Kiedy w projektowaniu API należy stosować / unikać polimorfizmu ad hoc?

14

Sue jest projektowanie biblioteki JavaScript, Magician.js. Jego zawleczka jest funkcją, która wyciąga Rabbitz przekazanego argumentu.

Wie, że jego użytkownicy mogą chcieć wyciągnąć królika z String, a Number, a Functionmoże nawet a HTMLElement. Mając to na uwadze, mogłaby zaprojektować swój interfejs API w następujący sposób:

Surowy interfejs

Magician.pullRabbitOutOfString = function(str) //...
Magician.pullRabbitOutOfHTMLElement = function(htmlEl) //...

Każda funkcja w powyższym przykładzie wiedziałaby, jak obsługiwać argument typu określonego w nazwie funkcji / nazwie parametru.

Lub mogłaby to zaprojektować tak:

Interfejs „ad hoc”

Magician.pullRabbit = function(anything) //...

pullRabbitmusiałby uwzględnić różnorodność różnych oczekiwanych typów, którymi anythingmoże być argument, a także (oczywiście) nieoczekiwany typ:

Magician.pullRabbit = function(anything) {
  if (anything === undefined) {
    return new Rabbit(); // out of thin air
  } else if (isString(anything)) {
    // more
  } else if (isNumber(anything)) {
    // more
  }
  // etc.
};

Pierwsza (ścisła) wydaje się bardziej wyraźna, być może bezpieczniejsza i być może bardziej wydajna - ponieważ istnieje niewiele lub nie ma żadnych kosztów ogólnych związanych z sprawdzaniem lub konwersją typów. Ale ten drugi (ad hoc) wydaje się łatwiejszy, patrząc na to z zewnątrz, ponieważ „po prostu działa” z dowolnym argumentem, który konsument API uzna za dogodny do przekazania.

Aby uzyskać odpowiedź na to pytanie , chciałbym zobaczyć konkretne zalety i wady podejścia (lub innego podejścia, jeśli żadne z nich nie jest idealne), które Sue powinna wiedzieć, jakie podejście zastosować przy projektowaniu interfejsu API swojej biblioteki.

GladstoneKeep
źródło
2
Pisanie

Odpowiedzi:

7

Niektóre zalety i wady

Plusy dla polimorficznych:

  • Mniejszy interfejs polimorficzny jest łatwiejszy do odczytania. Muszę zapamiętać tylko jedną metodę.
  • Jest zgodny ze sposobem, w jaki ma być używany język - pisanie kaczek.
  • Jeśli jest jasne, z których obiektów chcę wyciągnąć królika, nie powinno być dwuznaczności.
  • Wykonywanie wielu sprawdzeń typu jest uważane za złe nawet w statycznych językach, takich jak Java, gdzie posiadanie dużej liczby sprawdzeń typu dla typu obiektu sprawia, że ​​kod jest brzydki, gdyby mag naprawdę musiał rozróżniać rodzaje obiektów, z których wyciąga królika ?

Plusy dla ad-hoc:

  • Jest to mniej wyraźne, czy mogę wyciągnąć ciąg z Catinstancji? Czy to po prostu zadziała? jeśli nie, jakie jest zachowanie? Jeśli nie ograniczę tego typu, muszę to zrobić w dokumentacji lub w testach, które mogłyby pogorszyć kontrakt.
  • Masz do czynienia z ciągnięciem królika w jednym miejscu, Maga (niektórzy mogą uznać to za oszustwo)
  • Nowoczesne optymalizatory JS rozróżniają funkcje monomorficzne (działa tylko na jednym typie) i funkcje polimorficzne. Wiedzą, jak znacznie lepiej zoptymalizować monomorficzne, więc pullRabbitOutOfStringwersja będzie prawdopodobnie znacznie szybsza w silnikach takich jak V8. Zobacz ten film, aby uzyskać więcej informacji. Edycja: Sam napisałem perf, okazuje się, że w praktyce nie zawsze tak jest .

Niektóre alternatywne rozwiązania:

Moim zdaniem ten rodzaj projektu nie jest zbyt „Java-Scripty” na początek. JavaScript to inny język z różnymi idiomami niż języki takie jak C #, Java czy Python. Te idiomy powstały w latach, gdy programiści próbowali zrozumieć słabe i mocne strony języka, staram się trzymać tych idiomów.

Są dwa fajne rozwiązania, o których mogę myśleć:

  • Podnoszenie obiektów, dzięki czemu obiekty stają się „pullable”, dostosowując je do interfejsu w czasie wykonywania, a następnie zlecając Magowi pracę na obiektach pullable.
  • Wykorzystując wzorzec strategii, dynamicznie uczymy Maga jak posługiwać się różnego rodzaju obiektami.

Rozwiązanie 1: Podnoszenie obiektów

Jednym z powszechnych rozwiązań tego problemu jest „podnoszenie” obiektów z możliwością wyciągnięcia z nich królików.

Oznacza to, że ma funkcję, która bierze jakiś obiekt i dodaje do tego wyciągnięcie kapelusza. Coś jak:

function makePullable(obj){
   obj.pullOfHat = function(){
       return new Rabbit(obj.toString());
   }
}

Mogę tworzyć takie makePullablefunkcje dla innych obiektów, mógłbym stworzyć makePullableStringitp. Definiuję konwersję dla każdego typu. Jednak po podniesieniu obiektów nie mam typu, aby używać ich w ogólny sposób. Interfejs w JavaScript jest określony przez wpisanie kaczki, jeśli ma pullOfHatmetodę, mogę pobrać za pomocą metody Magika.

Wtedy Magik mógłby zrobić:

Magician.pullRabbit = function(pullable) {
    var rabbit = obj.pullOfHat();
    return {rabbit:rabbit,text:"Tada, I pulled a rabbit out of "+pullable};
}

Unoszenie obiektów, użycie pewnego rodzaju wzoru mieszania wydaje się być bardziej rzeczą JS do zrobienia. (Zauważ, że jest to problematyczne w przypadku typów wartości w języku, które są łańcuchem, liczbą, wartością null, niezdefiniowaną i wartością logiczną, ale wszystkie mają możliwość wstawiania pól)

Oto przykład, jak może wyglądać taki kod

Rozwiązanie 2: Wzór strategii

Omawiając to pytanie na czacie JS w StackOverflow, mój przyjaciel fenomnomnominal zasugerował użycie wzorca strategii .

Umożliwiłoby to dodanie umiejętności wyciągania królików z różnych obiektów w czasie wykonywania i tworzyłby bardzo kod JavaScript. Mag może nauczyć się wyciągać z kapeluszy przedmioty różnych typów i wyciąga je na podstawie tej wiedzy.

Oto jak może to wyglądać w CoffeeScript:

class Magician
  constructor: ()-> # A new Magician can't pull anything
     @pullFunctions = {}

  pullRabbit: (obj) -> # Pull a rabbit, handler based on type
    func = pullFunctions[obj.constructor.name]
    if func? then func(obj) else "Don't know how to pull that out of my hat!"

  learnToPull: (obj, handler) -> # Learns to pull a rabbit out of a type
    pullFunctions[obj.constructor.name] = handler

Odpowiedni kod JS można zobaczyć tutaj .

W ten sposób czerpiesz korzyści z obu światów, akcja jak ciągnąć nie jest ściśle związana ani z przedmiotami, ani z Magiem i myślę, że jest to bardzo miłe rozwiązanie.

Użycie byłoby coś takiego:

var m = new Magician();//create a new Magician
//Teach the Magician
m.learnToPull("",function(){
   return "Pulled a rabbit out of a string";
});
m.learnToPull({},function(){
   return "Pulled a rabbit out of a Object";
});

m.pullRabbit(" Str");
Benjamin Gruenbaum
źródło
1
Bardzo polecam również tę darmową książkę z wzorami do projektowania JavaScript
Benjamin Gruenbaum,
2
Dałbym to +10 za bardzo dokładną odpowiedź, z której wiele się nauczyłem, ale zgodnie z zasadami SE musisz zadowolić się +1 ... :-)
Marjan Venema
@MarjanVenema Inne odpowiedzi są również dobre, koniecznie je też przeczytaj. Cieszę się, że ci się podobało. Zachęcamy do zatrzymania się i zadawania dodatkowych pytań projektowych.
Benjamin Gruenbaum
4

Problem polega na tym, że próbujesz zaimplementować rodzaj polimorfizmu, który nie istnieje w JavaScript - JavaScript jest prawie powszechnie najlepiej traktowany jako język typu kaczego, nawet jeśli obsługuje niektóre typy czcionek.

Aby stworzyć najlepszy interfejs API, odpowiedzią jest, że powinieneś wdrożyć oba. To trochę więcej pisania, ale na dłuższą metę zaoszczędzi dużo pracy użytkownikom interfejsu API.

pullRabbitpowinna być po prostu metodą arbitrażową, która sprawdza typy i wywołuje odpowiednią funkcję związaną z danym typem obiektu (np pullRabbitOutOfHtmlElement.).

W ten sposób użytkownicy prototypów mogą korzystać pullRabbit, ale jeśli zauważą spowolnienie, mogą wdrożyć kontrolę typu na swoim końcu (prawdopodobnie szybciej) i po prostu zadzwonić pullRabbitOutOfHtmlElementbezpośrednio.

Jonathan Rich
źródło
2

To jest JavaScript. Gdy stajesz się lepszy, zauważysz, że często znajduje się środkowa droga, która pomaga negować takie dylematy. Poza tym naprawdę nie ma znaczenia, czy jakiś nieobsługiwany „typ” zostanie złapany przez coś, czy ulegnie awarii, gdy ktoś spróbuje go użyć, ponieważ nie ma kompilacji w czasie wykonywania. Jeśli użyjesz go źle, psuje się. Próba ukrycia, że ​​się zepsuła lub sprawiła, że ​​działała w połowie, kiedy się zepsuła, nie zmienia faktu, że coś jest zepsute.

Więc zjedz swoje ciasto i zjedz je, a także naucz się unikać pomyłek typu i niepotrzebnego łamania, utrzymując wszystko naprawdę, naprawdę oczywiste, jak w dobrze nazwanym i ze wszystkimi właściwymi szczegółami we właściwych miejscach.

Przede wszystkim gorąco zachęcam do przyzwyczajenia się do ustawiania kaczek z rzędu, zanim będzie trzeba sprawdzać typy. Najsmuklejszym i najbardziej wydajnym (ale nie zawsze najlepszym w przypadku rodzimych konstruktorów) jest najpierw uderzenie w prototypy, aby twoja metoda nie musiała nawet przejmować się obsługiwanym typem.

String.prototype.pullRabbit = function(){
    //do something string-relevant
}

HTMLElement.prototype.pullRabbit = function(){
    //do something HTMLElement-relevant
}

Magician.pullRabbitFrom = function(someThingy){
    return someThingy.pullRabbit();
}

Uwaga: Powszechnie uważa się za złą formę robienia tego z Object, ponieważ wszystko dziedziczy po Object. Osobiście też unikałbym Funkcji. Niektórzy mogą odczuwać zaniepokojenie dotykaniem prototypu dowolnego natywnego konstruktora, co może nie być złą polityką, ale przykład może nadal służyć podczas pracy z własnymi konstruktorami obiektów.

Nie martwiłbym się tym podejściem w przypadku metody o konkretnym zastosowaniu, która prawdopodobnie nie zablokuje czegoś z innej biblioteki w mniej skomplikowanej aplikacji, ale dobrym instynktem jest unikanie twierdzenia, że ​​zbyt wiele metod rodzimych w JavaScript, jeśli nie musisz, chyba że normalizujesz nowsze metody w nieaktualnych przeglądarkach.

Na szczęście zawsze możesz po prostu wstępnie odwzorować typy lub nazwy konstruktorów na metody (strzeż się IE <= 8, który nie ma <object> .constructor.name, wymagając, abyś go przeanalizował z wyników toString z właściwości konstruktora). Wciąż sprawdzasz nazwę konstruktora (typeof jest dość bezużyteczny w JS podczas porównywania obiektów), ale przynajmniej czyta o wiele ładniej niż gigantyczna instrukcja switch lub łańcuch / / else w każdym wywołaniu metody do czegoś, co może być szerokie różnorodność obiektów.

var rabbitPullMap = {
    String: ( function pullRabbitFromString(){
        //do stuff here
    } ),
    //parens so we can assign named functions if we want for helpful debug
    //yes, I've been inconsistent. It's just a nice unrelated trick
    //when you want a named inline function assignment

    HTMLElement: ( function pullRabitFromHTMLElement(){
        //do stuff here
    } )
}

Magician.pullRabbitFrom = function(someThingy){
    return rabbitPullMap[someThingy.constructor.name]();
}

Lub stosując tę ​​samą metodę mapowania, jeśli chcesz uzyskać dostęp do komponentu „ten” różnych typów obiektów, aby korzystać z nich tak, jakby były metodami bez dotykania odziedziczonych prototypów:

var rabbitPullMap = {
    String: ( function(obj){

    //yes the anon wrapping funcs would make more sense in one spot elsewhere.

        return ( function pullRabbitFromString(obj){
            var rabbitReach = this.match(/rabbit/g);
            return rabbitReach.length;
        } ).call(obj);
    } ),

    HTMLElement: ( function(obj){
        return ( function pullRabitFromHTMLElement(obj){
            return this.querySelectorAll('.rabbit').length;
        } ).call(obj);
    } )
}

Magician.pullRabbitFrom = function(someThingy){

    var
        constructorName = someThingy.constructor.name,
        rabbitCnt = rabbitPullMap[constructorName](someThingy);

    console.log(
        [
            'The magician pulls ' + rabbitCnt,
            rabbitCnt === 1 ? 'rabbit' : 'rabbits',
            'out of her ' + constructorName + '.',
            rabbitCnt === 0 ? 'Boo!' : 'Yay!'
        ].join(' ');
    );
}

Dobrą ogólną zasadą w każdym języku IMO jest próba uporządkowania szczegółów rozgałęzienia, zanim dojdziesz do kodu, który faktycznie pociąga za spust. W ten sposób można łatwo zobaczyć wszystkich graczy zaangażowanych w ten najwyższy poziom API, aby uzyskać przyjemny przegląd, ale także znacznie łatwiej jest ustalić, gdzie można znaleźć szczegóły, które mogą być dla kogoś ważne.

Uwaga: wszystko to nie zostało przetestowane, ponieważ zakładam, że nikt tak naprawdę nie ma do tego zastosowania RL. Jestem pewien, że są literówki / błędy.

Erik Reppen
źródło
1

To (dla mnie) jest interesujące i skomplikowane pytanie, na które należy odpowiedzieć. Naprawdę podoba mi się to pytanie, więc postaram się odpowiedzieć. Jeśli w ogóle przeprowadzisz badania dotyczące standardów programowania w javascript, znajdziesz tyle „właściwych” sposobów na zrobienie tego, co ludzie, którzy reklamują „właściwy” sposób.

Ale skoro szukasz opinii, która droga jest lepsza. Tutaj nic nie idzie.

Osobiście wolałbym podejście „adhoc”. Pochodzę z tła c ++ / C #, to bardziej mój styl rozwoju. Możesz utworzyć jedno żądanie pullRabbit i sprawić, aby ten jeden typ żądania sprawdził przekazany argument i zrobił coś. Oznacza to, że nie musisz się martwić, jaki typ argumentu jest przekazywany w dowolnym momencie. Jeśli zastosujesz podejście ścisłe, nadal będziesz musiał sprawdzić, jaki typ jest zmienna, ale zamiast tego zrobiłbyś to przed wywołaniem metody. Tak więc ostatecznie pytanie brzmi: czy chcesz sprawdzić typ przed wykonaniem połączenia czy po nim?

Mam nadzieję, że to pomoże, proszę zadawać więcej pytań w związku z tą odpowiedzią, dołożę wszelkich starań, aby wyjaśnić moje stanowisko.

Kenneth Garza
źródło
0

Kiedy piszesz, Magician.pullRabbitOutOfInt, dokumentuje to, o czym myślałeś podczas pisania metody. Dzwoniący będzie oczekiwać, że to zadziała, jeśli przekaże dowolną liczbę całkowitą. Kiedy piszesz, Magician.pullRabbitOutOfAnything, dzwoniący nie wie, co myśleć i musi zagłębić się w kod i eksperymentować. Może działać dla Int, ale czy będzie działał przez długi czas? Pływak? Podwójny? Jeśli piszesz ten kod, jak daleko chcesz się posunąć? Jakie argumenty chcesz poprzeć?

  • Smyczki?
  • Tablice?
  • Mapy?
  • Strumienie?
  • Funkcje?
  • Bazy danych?

Niejednoznaczność wymaga czasu na zrozumienie. Nie jestem nawet przekonany, że szybciej jest pisać:

Magician.pullRabbit = function(anything) {
  if (anything === undefined) {
    return new Rabbit(); // out of thin air
  } else if (isString(anything)) {
    // more
  } else if (isNumber(anything)) {
    // more
  } else {
      throw new Exception("You can't pull a rabbit out of that!");
  }
  // etc.
};

Vs:

Magician.pullRabbitFromAir = fromAir() {
    return new Rabbit(); // out of thin air
}
Magician.pullRabbitFromStr = fromString(str)) {
    // more
}
Magician.pullRabbitFromInt = fromInt(int)) {
    // more
};

OK, więc dodałem wyjątek do twojego kodu (który bardzo polecam), aby powiedzieć dzwoniącemu, że nigdy nie wyobrażałeś sobie, że przekażą ci to, co zrobili. Ale pisanie konkretnych metod (nie wiem, czy JavaScript to umożliwia) nie jest już kodem i jest znacznie łatwiejsze do zrozumienia jako wywołującego. Ustanawia realistyczne założenia na temat tego, o czym myślał autor tego kodu, i sprawia, że ​​kod jest łatwy w użyciu.

GlenPeterson
źródło
Po prostu daję ci znać, że JavaScript pozwala ci to zrobić :)
Benjamin Gruenbaum
Gdyby hiper-wyraźne były łatwiejsze do odczytania / zrozumienia, książki instruktażowe brzmiałyby jak legalne. Ponadto metody dla poszczególnych typów, które w przybliżeniu robią to samo, są poważnym faulem SUCHYM dla typowego dewelopera JS. Nazwa zamiaru, a nie typ. To, czego wymaga argument, powinno być oczywiste lub bardzo łatwe do sprawdzenia, sprawdzając w jednym miejscu w kodzie lub na liście akceptowanych argumentów pod jedną nazwą metody w dokumencie.
Erik Reppen