Gibt es ein Muster für den Umgang mit widersprüchlichen Funktionsparametern?

38

Wir haben eine API-Funktion, die einen Gesamtbetrag basierend auf den angegebenen Start- und Enddaten in monatliche Beträge aufteilt.

// 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,
  };
}

Kürzlich wollte ein Verbraucher für diese API den Datumsbereich auf andere Weise angeben: 1) durch Angabe der Anzahl der Monate anstelle des Enddatums oder 2) durch Angabe der monatlichen Zahlung und Berechnung des Enddatums. In Reaktion darauf hat das API-Team die Funktion folgendermaßen geändert:

// 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),
  };
}

Ich glaube, diese Änderung erschwert die API. Nun muss der Anrufer Sorge um die versteckte Heuristik mit der Umsetzung der Funktion bei der Bestimmung , welche Parameter nehmen Präferenz verwendet wird , um den Datumsbereich (dh nach der Reihenfolge ihrer Priorität zu berechnen monthlyPayment, numMonths, endDate). Wenn ein Aufrufer die Funktionssignatur nicht beachtet, sendet er möglicherweise mehrere der optionalen Parameter und ist verwirrt, warum endDatediese ignoriert werden. Dieses Verhalten legen wir in der Funktionsdokumentation fest.

Außerdem habe ich das Gefühl, dass dies einen schlechten Präzedenzfall darstellt und der API Verantwortlichkeiten hinzufügt, mit denen sie sich nicht befassen sollte (dh die SRP verletzen). Angenommen, zusätzliche Verbraucher möchten, dass die Funktion mehr Anwendungsfälle unterstützt, z. B. das Berechnen totalaus den Parametern numMonthsund monthlyPayment. Diese Funktion wird mit der Zeit immer komplizierter.

Ich bevorzuge es, die Funktion so zu belassen, wie sie war, und stattdessen den Anrufer aufzufordern, sich endDateselbst zu berechnen . Ich kann mich jedoch irren und habe mich gefragt, ob die vorgenommenen Änderungen eine akzeptable Möglichkeit zum Entwerfen einer API-Funktion darstellen.

Gibt es alternativ ein allgemeines Muster für den Umgang mit solchen Szenarien? Wir könnten zusätzliche Funktionen höherer Ordnung in unserer API bereitstellen, die die ursprüngliche Funktion umschließen, aber dies bläht die API auf. Möglicherweise könnten wir einen zusätzlichen Flag-Parameter hinzufügen, der angibt, welcher Ansatz innerhalb der Funktion verwendet werden soll.

CalMlynarczyk
quelle
79
"Kürzlich wollte ein Verbraucher für diese API die Anzahl der Monate anstelle des Enddatums [angeben]" - Dies ist eine leichtfertige Anfrage. Sie können die Anzahl der Monate in ein geeignetes Enddatum in ein oder zwei Zeilen Code am Ende umwandeln.
Graham
12
Das sieht aus wie ein Flag-Argument-Anti-Pattern, und ich würde auch empfehlen, es in mehrere Funktionen
aufzuteilen
2
Als Randnotiz gibt es Funktionen, die den gleichen Typ und die gleiche Anzahl von Parametern akzeptieren und sehr unterschiedliche Ergebnisse Dateliefern können. Sie können eine Zeichenfolge angeben und sie kann analysiert werden, um das Datum zu bestimmen. Auf diese Weise können die Handhabungsparameter jedoch auch sehr schwierig sein und zu unzuverlässigen Ergebnissen führen. Sehen Sie noch Dateeinmal. Es ist nicht unmöglich, das Richtige zu tun - Moment handhabt es viel besser, aber es ist sehr ärgerlich, es trotzdem zu benutzen.
VLAZ
Auf eine leichte Tangente möchten Sie vielleicht darüber nachdenken, wie Sie mit dem monthlyPaymentgegebenen Fall umgehen sollen, der jedoch totalkein ganzzahliges Vielfaches davon ist. Und auch, wie man mit möglichen Gleitkomma-Rundungsfehlern umgeht, wenn die Werte nicht garantiert ganze Zahlen sind (z. B. probieren Sie es mit total = 0.3und aus monthlyPayment = 0.1).
Ilmari Karonen
@Graham Ich habe nicht darauf reagiert ... Ich habe auf die nächste Aussage reagiert "Als Reaktion darauf hat das API-Team die Funktion geändert ..." - rollt sich in die fötale Position und fängt an zu rocken - Es spielt keine Rolle, wo Die eine oder andere Codezeile wird entweder durch einen neuen API-Aufruf mit dem anderen Format oder auf der Anruferseite ausgeführt. Ändern Sie einen funktionierenden API-Aufruf einfach nicht so!
Baldrickk

Antworten:

99

In Anbetracht der Implementierung scheint es mir, dass Sie hier wirklich drei verschiedene Funktionen anstelle von einer benötigen:

Das Original:

function getPaymentBreakdown(total, startDate, endDate) 

Derjenige, der die Anzahl der Monate anstelle des Enddatums angibt:

function getPaymentBreakdownByNoOfMonths(total, startDate, noOfMonths) 

und derjenige, der die monatliche Zahlung vornimmt und das Enddatum berechnet:

function getPaymentBreakdownByMonthlyPayment(total, startDate, monthlyPayment) 

Jetzt gibt es keine optionalen Parameter mehr und es sollte ziemlich klar sein, welche Funktion wie und zu welchem ​​Zweck aufgerufen wird. Wie in den Kommentaren erwähnt, könnte man in einer streng getippten Sprache auch eine Funktionsüberladung verwenden, die die drei verschiedenen Funktionen nicht unbedingt anhand ihres Namens, sondern anhand ihrer Signatur unterscheidet, falls dies ihren Zweck nicht verschleiert.

Beachten Sie, dass die verschiedenen Funktionen nicht bedeuten, dass Sie eine Logik duplizieren müssen. Wenn diese Funktionen intern einen gemeinsamen Algorithmus verwenden, sollte dieser in eine "private" Funktion umgestaltet werden.

Gibt es ein allgemeines Muster für den Umgang mit solchen Szenarien?

Ich glaube nicht, dass es ein "Muster" (im Sinne der GoF-Designmuster) gibt, das ein gutes API-Design beschreibt. Die Verwendung selbstbeschreibender Namen, Funktionen mit weniger Parametern und Funktionen mit orthogonalen (= unabhängigen) Parametern sind nur Grundprinzipien für die Erstellung von lesbarem, wartbarem und entwickelbarem Code. Nicht jede gute Idee in der Programmierung ist notwendigerweise ein "Entwurfsmuster".

Doc Brown
quelle
24
Eigentlich könnte die "gemeinsame" Implementierung des Codes einfach sein getPaymentBreakdown(oder wirklich eine dieser 3) und die anderen beiden Funktionen konvertieren einfach die Argumente und nennen das. Warum sollte eine private Funktion hinzugefügt werden, die eine perfekte Kopie dieser drei Funktionen ist?
Giacomo Alzetta
@ GiacomoAlzetta: das ist möglich. Aber ich bin ziemlich sicher , wird die Umsetzung einfacher werden durch eine gemeinsame Funktion bereitstellt , die nur die „Rückkehr“ Teil der OPs Funktion enthält, und lassen Sie die Öffentlichkeit 3 Funktionen dieser Funktion mit Parametern aufrufen innerNumMonths, totalund startDate. Warum sollte eine überkomplizierte Funktion mit 5 Parametern beibehalten werden, wobei 3 fast optional sind (außer einer muss eingestellt werden), wenn auch eine 3-Parameter-Funktion die Aufgabe übernimmt?
Doc Brown
3
Ich wollte nicht sagen "Behalte die 5-Argument-Funktion". Ich sage nur, wenn Sie eine gemeinsame Logik haben, muss diese Logik nicht privat sein . In diesem Fall können alle drei Funktionen überarbeitet werden, um die Parameter einfach in Start- und Enddaten umzuwandeln. Sie können also die öffentliche getPaymentBreakdown(total, startDate, endDate)Funktion als allgemeine Implementierung verwenden. Das andere Tool berechnet einfach die geeigneten Gesamt- / Start- / Enddaten und ruft sie auf.
Giacomo Alzetta
@GiacomoAlzetta: ok, war ein missverständnis, ich dachte du sprichst über die zweite umsetzung von getPaymentBreakdownin der frage .
Doc Brown
Ich würde so weit gehen, eine neue Version der ursprünglichen Methode namens 'getPaymentBreakdownByStartAndEnd' hinzuzufügen und die ursprüngliche Methode zu verwerfen, wenn Sie alle diese Methoden bereitstellen möchten.
Erik
20

Außerdem habe ich das Gefühl, dass dies einen schlechten Präzedenzfall darstellt und der API Verantwortlichkeiten hinzufügt, mit denen sie sich nicht befassen sollte (dh die SRP verletzen). Angenommen, zusätzliche Verbraucher möchten, dass die Funktion mehr Anwendungsfälle unterstützt, z. B. das Berechnen totalaus den Parametern numMonthsund monthlyPayment. Diese Funktion wird mit der Zeit immer komplizierter.

Du bist genau richtig.

Ich bevorzuge es, die Funktion so zu belassen, wie sie war, und stattdessen den Aufrufer aufzufordern, das Enddatum selbst zu berechnen. Ich kann mich jedoch irren und habe mich gefragt, ob die vorgenommenen Änderungen eine akzeptable Möglichkeit zum Entwerfen einer API-Funktion darstellen.

Dies ist auch nicht ideal, da der Anrufercode durch nicht zugehörige Kesselschilder verschmutzt wird.

Gibt es alternativ ein allgemeines Muster für den Umgang mit solchen Szenarien?

Führe einen neuen Typ ein, wie DateInterval. Fügen Sie die Konstruktoren hinzu, die Sinn machen (Startdatum + Enddatum, Startdatum + Anzahl Monate, was auch immer). Übernehmen Sie dies als die gängigen Währungstypen für die Angabe von Datums- / Uhrzeitintervallen in Ihrem System.

Alexander
quelle
3
@ DocBrown Yep. In solchen Fällen (Ruby, Python, JS) ist es üblich, nur statische Methoden / Klassenmethoden zu verwenden. Aber das ist ein Implementierungsdetail, das meiner Meinung nach für den Punkt meiner Antwort nicht besonders relevant ist ("benutze einen Typ").
Alexander
2
Und diese Idee stößt leider mit der dritten Anforderung an ihre Grenzen: Startdatum, Gesamtzahlung und eine monatliche Zahlung - und die Funktion berechnet das DateInterval aus den Geldparametern - und Sie sollten die Geldbeträge nicht in Ihren Datumsbereich eintragen ...
Falco
3
@DocBrown "verschiebt das Problem nur von der vorhandenen Funktion zum Konstruktor des Typs" Ja, es setzt den Zeitcode dahin, wohin der Zeitcode gehen soll, sodass der Geldcode dahin gehen kann, wohin der Geldcode gehen soll. Es ist einfaches SRP, also bin ich mir nicht sicher, was Sie erreichen, wenn Sie sagen, dass es "nur" das Problem verschiebt. Das machen alle Funktionen. Sie lassen den Code nicht verschwinden, sondern verschieben ihn an einen geeigneteren Ort. Was ist Ihr Problem damit? "aber meine glückwünsche, mindestens 5 upvoter haben den köder genommen" Das klingt viel mehr arschloch, als ich denke (hoffe), dass du es beabsichtigt hast.
Alexander
@ Falco Das klingt für mich nach einer neuen Methode (für diese Klasse von Zahlungsrechnern nicht DateInterval):calculatePayPeriod(startData, totalPayment, monthlyPayment)
Alexander
7

Manchmal helfen dabei fließende Ausdrücke:

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();

Wenn Sie genügend Zeit zum Entwerfen haben, können Sie eine solide API entwickeln, die einer domänenspezifischen Sprache ähnelt.

Der andere große Vorteil besteht darin, dass IDEs mit automatischer Vervollständigung das Lesen der API-Dokumentation fast unangenehm machen , da sie aufgrund ihrer selbsterkennbaren Funktionen intuitiv sind.

Es gibt Ressourcen wie https://nikas.praninskas.com/javascript/2015/04/26/fluent-javascript/ oder https://github.com/nikaspran/fluent.js zu diesem Thema.

Beispiel (entnommen aus dem ersten Ressourcenlink):

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
quelle
8
Fluent Interface selbst macht keine bestimmte Aufgabe leichter oder schwerer. Dies scheint eher das Builder-Muster zu sein.
VLAZ
8
Die Implementierung wäre allerdings ziemlich kompliziert, wenn Sie versehentliche Anrufe wieforTotalAmount(1234).breakIntoPayments().byPeriod(2).monthly().withPaymentsOf(12.34).byDateRange(saleStart, saleEnd);
Bergi
4
Wenn Entwickler wirklich auf die Beine kommen wollen, gibt es einfachere Möglichkeiten, @Bergi. Das Beispiel, das Sie angegeben haben, ist jedoch weitaus lesbarer alsforTotalAmountAndBreakIntoPaymentsByPeriodThenMonthlyWithPaymentsOfButByDateRange(1234, 2, 12.34, saleStart, saleEnd);
DanielCuadra,
5
@DanielCuadra Der Punkt, den ich ansprechen wollte, ist, dass Ihre Antwort das OP-Problem der drei sich gegenseitig ausschließenden Parameter nicht wirklich löst. Die Verwendung des Builder-Musters kann den Aufruf lesbarer machen (und die Wahrscheinlichkeit erhöhen, dass der Benutzer merkt, dass dies keinen Sinn ergibt). Die Verwendung des Builder-Musters allein hindert ihn jedoch nicht daran, immer noch 3 Werte gleichzeitig zu übergeben.
Bergi
2
@ Falco Wird es? Ja, es ist möglich, aber komplizierter, und die Antwort erwähnte dies nicht. Die allgemeineren Erbauer, die ich gesehen habe, bestanden nur aus einer einzelnen Klasse. Wenn die Antwort so bearbeitet wird, dass sie den Code des Erstellers enthält, unterstütze ich sie gerne und entferne meine Ablehnung.
Bergi
2

In anderen Sprachen würden Sie benannte Parameter verwenden . Dies kann in Javscript emuliert werden:

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

getPaymentBreakdown(100, today, {endDate: whatever});
getPaymentBreakdown(100, today, {noOfMonths: 4});
getPaymentBreakdown(100, today, {monthlyPayment: 20});
Gregory Currie
quelle
6
Wie das folgende Builder-Muster macht dies den Aufruf lesbarer (und erhöht die Wahrscheinlichkeit, dass der Benutzer bemerkt, dass dies keinen Sinn ergibt), aber die Benennung der Parameter hindert den Benutzer nicht daran, immer noch 3 Werte gleichzeitig zu übergeben - z getPaymentBreakdown(100, today, {endDate: whatever, noOfMonths: 4, monthlyPayment: 20}).
Bergi
1
Sollte es nicht :statt sein =?
Barmar
Ich vermute, Sie könnten überprüfen, ob nur einer der Parameter nicht null ist (oder nicht im Wörterbuch enthalten ist).
Mateen Ulhaq
1
@Bergi - Die Syntax selbst hindert Benutzer nicht daran, unsinnige Parameter zu übergeben, aber Sie können einfach eine Überprüfung durchführen und Fehler
auslösen
@Bergi Ich bin auf keinen Fall ein Javascript-Experte, aber ich denke, Destructuring Assignment in ES6 kann hier Abhilfe schaffen, obwohl ich sehr wenig darüber weiß.
Gregory Currie
1

Alternativ können Sie auch die Verantwortung für die Angabe der Monatsanzahl aufheben und diese aus Ihrer Funktion ausschließen:

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

Und getpaymentBreakdown würde ein Objekt erhalten, das die Basisanzahl der Monate liefert

Diese würden eine Funktion höherer Ordnung zurückgeben, zum Beispiel eine Funktion.

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
quelle
Was ist mit den Parametern totalund passiert startDate?
Bergi,
Dies scheint eine nette API zu sein, aber könnten Sie bitte hinzufügen, wie Sie sich die Implementierung dieser vier Funktionen vorstellen würden? (Bei Variantentypen und einer gemeinsamen Oberfläche könnte dies recht elegant sein, aber es ist unklar, was Sie vorhatten).
Bergi,
@Bergi hat meinen Beitrag bearbeitet
Vinz243
0

Und wenn Sie mit einem System mit diskriminierten Gewerkschaften / algebraischen Datentypen arbeiten würden, könnten Sie es wie folgt übergeben: a TimePeriodSpecification.

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

und dann würde keines der Probleme auftreten, wenn Sie eines nicht implementieren könnten und so weiter.

NiklasJ
quelle
Dies ist definitiv, wie ich dies in einer Sprache angehen würde, die Typen wie diese zur Verfügung hatte. Ich habe versucht, meine Frage sprachunabhängig zu halten, aber vielleicht sollte sie die verwendete Sprache berücksichtigen, da in einigen Fällen solche Ansätze möglich werden.
CalMlynarczyk