Czy istnieje wzorzec obsługi sprzecznych parametrów funkcji?

38

Mamy funkcję API, która dzieli całkowitą kwotę na kwoty miesięczne na podstawie danych dat rozpoczęcia i zakończenia.

// JavaScript

function convertToMonths(timePeriod) {
  // ... returns the given time period converted to months
}

function getPaymentBreakdown(total, startDate, endDate) {
  const numMonths = convertToMonths(endDate - startDate);

  return {
    numMonths,
    monthlyPayment: total / numMonths,
  };
}

Ostatnio konsument tego interfejsu API chciał określić zakres dat na inne sposoby: 1) podając liczbę miesięcy zamiast daty końcowej, lub 2) podając miesięczną płatność i obliczając datę końcową. W odpowiedzi zespół API zmienił funkcję na:

// JavaScript

function addMonths(date, numMonths) {
  // ... returns a new date numMonths after date
}

function getPaymentBreakdown(
  total,
  startDate,
  endDate /* optional */,
  numMonths /* optional */,
  monthlyPayment /* optional */,
) {
  let innerNumMonths;

  if (monthlyPayment) {
    innerNumMonths = total / monthlyPayment;
  } else if (numMonths) {
    innerNumMonths = numMonths;
  } else {
    innerNumMonths = convertToMonths(endDate - startDate);
  }

  return {
    numMonths: innerNumMonths,
    monthlyPayment: total / innerNumMonths,
    endDate: addMonths(startDate, innerNumMonths),
  };
}

Wydaje mi się, że ta zmiana komplikuje interfejs API. Teraz rozmówca musi się martwić o ukryte heurystyki z realizacją danej funkcji w celu ustalenia, które parametry mają pierwszeństwo w wykorzystywane do obliczenia zakres dat (czyli w kolejności priorytetów monthlyPayment, numMonths, endDate). Jeśli osoba dzwoniąca nie zwraca uwagi na podpis funkcji, może wysłać wiele opcjonalnych parametrów i pomylić się, dlaczego endDatejest ignorowana. Określamy to zachowanie w dokumentacji funkcji.

Dodatkowo uważam, że stanowi zły precedens i dodaje obowiązki do API, którym nie powinien się zajmować (tj. Naruszać SRP). Załóżmy, że dodatkowi klienci chcą, aby funkcja obsługiwała więcej przypadków użycia, takich jak obliczanie totalna podstawie parametrów numMonthsi monthlyPayment. Ta funkcja z czasem będzie się komplikować.

Preferuję, aby funkcja pozostała taka, jaka była, a zamiast tego wymagać od osoby dzwoniącej obliczenia endDatesiebie. Jednak mogę się mylić i zastanawiałem się, czy wprowadzone przez nich zmiany były akceptowalnym sposobem zaprojektowania funkcji API.

Alternatywnie, czy istnieje wspólny wzorzec obsługi takich scenariuszy? Możemy zapewnić dodatkowe funkcje wyższego rzędu w naszym interfejsie API, które zawijają oryginalną funkcję, ale powoduje to powiększenie interfejsu API. Być może moglibyśmy dodać dodatkowy parametr flagi określający, które podejście należy zastosować w funkcji.

CalMlynarczyk
źródło
79
„Ostatnio konsument tego interfejsu API chciał [podać] liczbę miesięcy zamiast daty końcowej” - to niepoważna prośba. Mogą przekształcić liczbę miesięcy w odpowiednią datę końcową w linii lub dwóch kodach na końcu.
Graham
12
który wygląda jak anty-wzorzec Argument flagowy, a także poleciłbym podział na kilka funkcji
njzk2
2
Na marginesie, nie funkcje, które może zaakceptować ten sam typ i liczbę parametrów i produkują bardzo różne wyniki w oparciu o te - patrz Date- można dostarczyć łańcuch i może być analizowany w celu ustalenia terminu. Jednak w ten sposób parametry obsługi mogą być również bardzo wybredne i mogą dawać niewiarygodne wyniki. Zobacz Datejeszcze raz. Właściwie nie jest to niemożliwe - Moment radzi sobie z tym lepiej, ale korzystanie z niego jest bardzo denerwujące.
VLAZ
W przypadku niewielkiej stycznej możesz zastanowić się, jak poradzić sobie z przypadkiem, w którym monthlyPaymentjest podany, ale totalnie jest jego całkowitą wielokrotnością. A także, jak radzić sobie z możliwymi błędami zaokrąglania zmiennoprzecinkowego, jeśli nie można zagwarantować, że wartości są liczbami całkowitymi (np. Spróbuj za pomocą total = 0.3i monthlyPayment = 0.1).
Ilmari Karonen
@Graham nie zareagowałem na to ... zareagowałem na następne stwierdzenie „W odpowiedzi zespół API zmienił funkcję ...” - podchodzi do pozycji embrionalnej i zaczyna się kołysać - nie ważne gdzie przechodzi ten wiersz lub dwa kody, albo nowe wywołanie API w innym formacie, albo wykonane po stronie wywołującej. Po prostu nie zmieniaj działającego wywołania API w ten sposób!
Baldrickk

Odpowiedzi:

99

Widząc implementację, wydaje mi się, że tak naprawdę potrzebujesz tutaj 3 różnych funkcji zamiast jednej:

Oryginalny:

function getPaymentBreakdown(total, startDate, endDate) 

Ten, który podaje liczbę miesięcy zamiast daty końcowej:

function getPaymentBreakdownByNoOfMonths(total, startDate, noOfMonths) 

i ten zapewniający miesięczną płatność i obliczający datę końcową:

function getPaymentBreakdownByMonthlyPayment(total, startDate, monthlyPayment) 

Teraz nie ma już żadnych parametrów opcjonalnych i powinno być całkiem jasne, która funkcja nazywa się w jaki sposób i w jakim celu. Jak wspomniano w komentarzach, w ściśle typowanym języku można również wykorzystać przeciążenie funkcji, rozróżniając 3 różne funkcje niekoniecznie według nazwy, ale według ich podpisu, w przypadku, gdy nie zaciemnia to ich celu.

Zauważ, że różne funkcje nie oznaczają, że musisz zduplikować dowolną logikę - wewnętrznie, jeśli funkcje te mają wspólny algorytm, należy przekształcić je w funkcję „prywatną”.

czy istnieje wspólny wzorzec obsługi takich scenariuszy

Nie sądzę, że istnieje „wzorzec” (w sensie wzorców projektowych GoF), który opisuje dobry projekt API. Używanie samoopisujących nazw, funkcje z mniejszą liczbą parametrów, funkcje z ortogonalnymi (= niezależnymi) parametrami, to tylko podstawowe zasady tworzenia czytelnego, łatwego do utrzymania i rozwijalnego kodu. Nie każdy dobry pomysł w programowaniu jest koniecznie „wzorcem projektowym”.

Doktor Brown
źródło
24
W rzeczywistości „powszechną” implementacją kodu może być po prostu getPaymentBreakdown(a właściwie dowolna z tych 3), a pozostałe dwie funkcje po prostu konwertują argumenty i wywołują to. Po co dodawać prywatną funkcję, która jest idealną kopią jednego z tych 3?
Giacomo Alzetta
@GiacomoAlzetta: to jest możliwe. Ale jestem całkiem pewny realizacja stanie się prostsze, zapewniając wspólną funkcję, która zawiera tylko „powrót” część funkcji OPS, i niech publiczne 3 funkcje wywołać tę funkcję z parametrami innerNumMonths, totala startDate. Po co utrzymywać nadmiernie skomplikowaną funkcję z 5 parametrami, gdzie 3 są prawie opcjonalne (z wyjątkiem tego, że jeden musi być ustawiony), gdy funkcja 3-parametrowa również wykona zadanie?
Doc Brown,
3
Nie chciałem powiedzieć „zachowaj funkcję 5 argumentów”. Mówię tylko, że kiedy masz jakąś wspólną logikę, ta logika nie musi być prywatna . W takim przypadku wszystkie 3 funkcje mogą być refaktoryzowane, aby po prostu przekształcić parametry do dat początkowych, dzięki czemu można użyć getPaymentBreakdown(total, startDate, endDate)funkcji publicznej jako wspólnej implementacji, drugie narzędzie po prostu obliczy odpowiednią całkowitą / początkową / końcową datę i ją wywoła.
Giacomo Alzetta
@GiacomoAlzetta: ok, było nieporozumienie, myślałem, że mówisz o drugiej realizacji getPaymentBreakdownpytania.
Doc Brown,
Chciałbym posunąć się nawet do dodania nowej wersji oryginalnej metody, która jest jawnie nazwana „getPaymentBreakdownByStartAndEnd” i wycofania oryginalnej metody, jeśli chcesz podać wszystkie z nich.
Erik
20

Dodatkowo uważam, że stanowi zły precedens i dodaje obowiązki do API, którym nie powinien się zajmować (tj. Naruszać SRP). Załóżmy, że dodatkowi klienci chcą, aby funkcja obsługiwała więcej przypadków użycia, takich jak obliczanie totalna podstawie parametrów numMonthsi monthlyPayment. Ta funkcja z czasem będzie się komplikować.

Masz dokładnie rację.

Preferuję, aby funkcja pozostała taka, jaka była, a zamiast tego wymagać od osoby dzwoniącej obliczenia samej daty końcowej. Jednak mogę się mylić i zastanawiałem się, czy wprowadzone przez nich zmiany były akceptowalnym sposobem zaprojektowania funkcji API.

Nie jest to również idealne, ponieważ kod wywołujący zostanie zanieczyszczony niepowiązaną płytą kotła.

Alternatywnie, czy istnieje wspólny wzorzec obsługi takich scenariuszy?

Wprowadź nowy typ, np DateInterval. Dodaj dowolne konstruktory, które mają sens (data początkowa + data końcowa, data początkowa + liczba miesięcy, cokolwiek.) Zaadoptuj to jako typ wspólnej waluty do wyrażania przedziałów dat / godzin w całym systemie.

Alexander
źródło
3
@DocBrown Tak. W takich przypadkach (Ruby, Python, JS) zwyczajowo używa się tylko metod static / class. Ale to szczegół implementacji, który nie wydaje mi się szczególnie istotny w kontekście mojej odpowiedzi („użyj typu”).
Alexander
2
I ten pomysł niestety osiąga swoje granice z trzeciego wymogu: Start Date, całkowitej płatności oraz miesięczne płatności - a funkcja obliczy DateInterval od parametrów pieniądze - i nie należy umieszczać kwot pieniężnych w zakresie dat ...
Falco
3
@DocBrown ”tylko przenosi problem z istniejącej funkcji do konstruktora typu„ Tak, umieszcza kod czasu tam, gdzie powinien iść kod czasu, aby kod pieniędzy mógł być tam, gdzie powinien iść kod pieniędzy. To proste SRP, więc nie jestem pewien, do czego zmierzasz, kiedy mówisz, że „tylko” przesuwa problem. Tak właśnie działają wszystkie funkcje. Nie usuwają kodu, przenoszą go w bardziej odpowiednie miejsca. Z czym masz problem? „ale moje gratulacje, przynajmniej 5 zwolenników wzięcia przynęty” To brzmi o wiele bardziej dupsko, niż myślę (mam nadzieję), że zamierzałeś.
Alexander
@Falco To brzmi dla mnie jak nowa metoda (w tej klasie kalkulatorów płatności, nie DateInterval):calculatePayPeriod(startData, totalPayment, monthlyPayment)
Alexander
7

Czasami pomagają w tym płynne wyrażenia:

let payment1 = forTotalAmount(1234)
                  .breakIntoPayments()
                  .byPeriod(months(2));

let payment2 = forTotalAmount(1234)
                  .breakIntoPayments()
                  .byDateRange(saleStart, saleEnd);

let monthsDue = forTotalAmount(1234)
                  .calculatePeriod()
                  .withPaymentsOf(12.34)
                  .monthly();

Mając wystarczająco dużo czasu na zaprojektowanie, możesz opracować solidny interfejs API, który działa podobnie do języka specyficznego dla domeny.

Inną dużą zaletą jest to, że środowiska IDE z funkcją autouzupełniania sprawiają, że czytanie dokumentacji API jest prawie nierealne, ponieważ jest intuicyjne ze względu na możliwości samodzielnego odkrywania.

Istnieją zasoby, takie jak https://nikas.praninskas.com/javascript/2015/04/26/fluent-javascript/ lub https://github.com/nikaspran/fluent.js na ten temat.

Przykład (wzięty z pierwszego łącza do zasobów):

let insert = (value) => ({into: (array) => ({after: (afterValue) => {
  array.splice(array.indexOf(afterValue) + 1, 0, value);
  return array;
}})});

insert(2).into([1, 3]).after(1); //[1, 2, 3]
DanielCuadra
źródło
8
Płynny interfejs sam w sobie nie czyni żadnego konkretnego zadania łatwiejszym ani trudniejszym. To bardziej przypomina wzorzec Konstruktora.
VLAZ
8
Wdrożenie byłoby raczej skomplikowane, jeśli chcesz zapobiec błędnym połączeniom, takim jakforTotalAmount(1234).breakIntoPayments().byPeriod(2).monthly().withPaymentsOf(12.34).byDateRange(saleStart, saleEnd);
Bergi
4
Jeśli programiści naprawdę chcą strzelać na nogi, istnieją łatwiejsze sposoby na @Bergi. Mimo to forTotalAmountAndBreakIntoPaymentsByPeriodThenMonthlyWithPaymentsOfButByDateRange(1234, 2, 12.34, saleStart, saleEnd);
podany
5
@DanielCuadra Chciałem zwrócić uwagę na to, że twoja odpowiedź tak naprawdę nie rozwiązuje problemu PO związanego z 3 wzajemnie wykluczającymi się parametrami. Użycie wzorca konstruktora może sprawić, że wywołanie będzie bardziej czytelne (i zwiększy prawdopodobieństwo, że użytkownik zauważy, że nie ma to sensu), ale samo użycie wzorca konstruktora nie zapobiegnie przekazywaniu 3 wartości jednocześnie.
Bergi
2
@Falco Will it? Tak, jest to możliwe, ale bardziej skomplikowane, a w odpowiedzi nie wspomniano o tym. Bardziej powszechni twórcy, których widziałem, składali się tylko z jednej klasy. Jeśli odpowiedź zostanie zredagowana w celu włączenia kodu konstruktora (ów), chętnie poprę ją i usunę głos negatywny.
Bergi
2

W innych językach używasz nazwanych parametrów . Można to emulować w Javscript:

function getPaymentBreakdown(total, startDate, durationSpec) { ... }

getPaymentBreakdown(100, today, {endDate: whatever});
getPaymentBreakdown(100, today, {noOfMonths: 4});
getPaymentBreakdown(100, today, {monthlyPayment: 20});
Gregory Currie
źródło
6
Podobnie jak poniższy wzorzec konstruktora, to sprawia, że ​​wywołanie jest bardziej czytelne (i zwiększa prawdopodobieństwo, że użytkownik zauważy, że nie ma to sensu), ale nazywanie parametrów nie zapobiega przekazywaniu przez użytkownika 3 wartości naraz - np getPaymentBreakdown(100, today, {endDate: whatever, noOfMonths: 4, monthlyPayment: 20}).
Bergi
1
Nie powinno być :zamiast =?
Barmar
Wydaje mi się, że można sprawdzić, czy tylko jeden z parametrów jest inny niż null (lub nie ma go w słowniku).
Mateen Ulhaq
1
@Bergi - Sama składnia nie uniemożliwia użytkownikom przekazywania niedorzecznych parametrów, ale możesz po prostu dokonać weryfikacji i wyrzucić błędy
slebetman
@Bergi W żadnym wypadku nie jestem ekspertem w dziedzinie Javascript, ale myślę, że Destr restrukturyzacja zadań w ES6 może tu pomóc, choć jestem bardzo lekka w tym zakresie.
Gregory Currie,
1

Alternatywnie możesz również zerwać odpowiedzialność za określenie liczby miesięcy i pominąć tę funkcję:

getPaymentBreakdown(420, numberOfMonths(3))
getPaymentBreakdown(420, dateRage(a, b))
getPaymentBreakdown(420, paymentAmount(350))

A getpaymentBreakdown otrzyma obiekt, który zapewni podstawową liczbę miesięcy

Byłyby to funkcje wyższego rzędu zwracające na przykład funkcję.

function numberOfMonths(months) {
  return {months: (total) => months};
}

function dateRange(startDate, endDate) {
  return {months: (total) => convertToMonths(endDate - startDate)}
}

function monthlyPayment(amount) {
  return {months: (total) => total / amount}
}


function getPaymentBreakdown(total, {months}) {
  const numMonths= months(total);
  return {
    numMonths, 
    monthlyPayment: total / numMonths,
    endDate: addMonths(startDate, numMonths)
  };
}
Vinz243
źródło
Co się stało z parametrami totali startDate?
Bergi
Wygląda to na niezły interfejs API, ale czy mógłbyś dodać, jak wyobrażasz sobie te cztery funkcje do wdrożenia? (W przypadku typów wariantów i wspólnego interfejsu może to być dość eleganckie, ale nie jest jasne, co miałeś na myśli).
Bergi
@Bergi zredagował mój post
Vinz243
0

A jeśli miałbyś pracować z systemem z dyskryminowanymi związkami / algebraicznymi typami danych, możesz przekazać to jak, powiedzmy, a TimePeriodSpecification.

type TimePeriodSpecification =
    | DateRange of startDate : DateTime * endDate : DateTime
    | MonthCount of startDate : DateTime * monthCount : int
    | MonthlyPayment of startDate : DateTime * monthlyAmount : float

i wtedy nie wystąpiłby żaden problem, w którym można nie wdrożyć jednego i tak dalej.

NiklasJ
źródło
Tak zdecydowanie podchodziłbym do tego w języku, w którym dostępne były tego rodzaju typy. Starałem się zachować moje pytanie bez względu na język, ale być może powinno ono uwzględniać używany język, ponieważ takie podejścia stają się możliwe w niektórych przypadkach.
CalMlynarczyk