¿Hay algún patrón para manejar parámetros de funciones en conflicto?

38

Tenemos una función API que desglosa un monto total en montos mensuales en función de las fechas de inicio y finalización.

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

Recientemente, un consumidor de esta API quería especificar el rango de fechas de otras maneras: 1) proporcionando el número de meses en lugar de la fecha de finalización, o 2) proporcionando el pago mensual y calculando la fecha de finalización. En respuesta a esto, el equipo de API cambió la función a la siguiente:

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

Siento que este cambio complica la API. Ahora la persona que llama tiene que preocuparse por la heurística ocultos en la implementación de la función en la determinación de los parámetros que tienen preferencia en la que se utiliza para calcular el intervalo de fechas (es decir, por orden de prioridad monthlyPayment, numMonths, endDate). Si una persona que llama no presta atención a la firma de la función, puede enviar varios de los parámetros opcionales y confundirse sobre por qué endDatese ignora. Sí especificamos este comportamiento en la documentación de la función.

Además, creo que sienta un mal precedente y agrega responsabilidades a la API con las que no debería preocuparse (es decir, violar SRP). Suponga que consumidores adicionales desean que la función admita más casos de uso, como el cálculo a totalpartir de los parámetros numMonthsy monthlyPayment. Esta función se volverá cada vez más complicada con el tiempo.

Mi preferencia es mantener la función como estaba y, en cambio, exigirle a la persona que llama que se calcule endDate. Sin embargo, puedo estar equivocado y me preguntaba si los cambios que hicieron fueron una forma aceptable de diseñar una función API.

Alternativamente, ¿hay un patrón común para manejar escenarios como este? Podríamos proporcionar funciones adicionales de orden superior en nuestra API que envuelvan la función original, pero esto aumenta la API. Tal vez podríamos agregar un parámetro de indicador adicional que especifique qué enfoque usar dentro de la función.

CalMlynarczyk
fuente
79
"Recientemente, un consumidor para esta API quería [proporcionar] el número de meses en lugar de la fecha de finalización" - Esta es una solicitud frívola. Pueden transformar el número de meses en una fecha de finalización adecuada en una o dos líneas de código en su final.
Graham
12
que parece un antipatrón de Flag Argument, y también recomendaría dividir en varias funciones
njzk2
2
Como nota al margen, no son funciones que pueden aceptar el mismo tipo y número de parámetros y producir resultados muy diferentes en base a los - ver Date- se puede suministrar una cadena y que se pueden analizar para determinar la fecha. Sin embargo, de esta manera, los parámetros de manejo también pueden ser muy delicados y pueden producir resultados poco confiables. Ver de Datenuevo. No es imposible hacerlo bien: Moment lo maneja mucho mejor, pero es muy molesto de usar de todos modos.
VLAZ
En una ligera tangente, es posible que desee pensar en cómo manejar el caso donde monthlyPaymentse da pero totalno es un múltiplo entero. Y también cómo lidiar con posibles errores de redondeo de punto flotante si no se garantiza que los valores sean enteros (por ejemplo, pruébelo con total = 0.3y monthlyPayment = 0.1).
Ilmari Karonen
@Graham No reaccioné a eso ... reaccioné a la siguiente declaración "En respuesta a esto, el equipo de API cambió la función ..." - se enrolla en posición fetal y comienza a balancearse - No importa dónde esa línea o dos de código van, ya sea una nueva llamada de API con el formato diferente, o se realiza en el extremo del llamante. ¡Simplemente no cambie una llamada API que funcione como esta!
Baldrickk

Respuestas:

99

Al ver la implementación, me parece que lo que realmente necesita aquí son 3 funciones diferentes en lugar de una:

El original:

function getPaymentBreakdown(total, startDate, endDate) 

El que proporciona el número de meses en lugar de la fecha de finalización:

function getPaymentBreakdownByNoOfMonths(total, startDate, noOfMonths) 

y el que proporciona el pago mensual y calcula la fecha de finalización:

function getPaymentBreakdownByMonthlyPayment(total, startDate, monthlyPayment) 

Ahora, ya no hay parámetros opcionales, y debería quedar bastante claro qué función se llama cómo y para qué propósito. Como se menciona en los comentarios, en un lenguaje estrictamente tipado, uno también podría utilizar la sobrecarga de funciones, distinguiendo las 3 funciones diferentes no necesariamente por su nombre, sino por su firma, en caso de que esto no ofusque su propósito.

Tenga en cuenta que las diferentes funciones no significan que tenga que duplicar ninguna lógica: internamente, si estas funciones comparten un algoritmo común, se debe refactorizar a una función "privada".

¿Existe un patrón común para manejar escenarios como este?

No creo que haya un "patrón" (en el sentido de los patrones de diseño de GoF) que describa un buen diseño de API. El uso de nombres autodescriptivos, funciones con menos parámetros, funciones con parámetros ortogonales (= independientes), son solo principios básicos para crear código legible, mantenible y evolutivo. No toda buena idea en programación es necesariamente un "patrón de diseño".

Doc Brown
fuente
24
En realidad, la implementación "común" del código podría ser simplemente getPaymentBreakdown(o realmente cualquiera de esos 3) y las otras dos funciones simplemente convierten los argumentos y llaman a eso. ¿Por qué agregar una función privada que sea una copia perfecta de uno de estos 3?
Giacomo Alzetta
@GiacomoAlzetta: eso es posible. Pero estoy bastante seguro de que la implementación se simplificará al proporcionar una función común que contenga solo la parte de "retorno" de la función OP, y deje que las funciones públicas 3 llamen a esta función con parámetros innerNumMonths, totaly startDate. ¿Por qué mantener una función demasiado complicada con 5 parámetros, donde 3 son casi opcionales (excepto que se debe configurar uno), cuando una función de 3 parámetros también hará el trabajo?
Doc Brown
3
No quise decir "mantener la función de 5 argumentos". Solo digo que cuando tienes una lógica común, esta lógica no necesita ser privada . En este caso, las 3 funciones se pueden refactorizar para transformar simplemente los parámetros a las fechas de inicio y finalización, para que pueda usar la getPaymentBreakdown(total, startDate, endDate)función pública como implementación común, la otra herramienta simplemente calculará las fechas totales / de inicio / finalización adecuadas y lo llamará.
Giacomo Alzetta
@GiacomoAlzetta: ok, fue un malentendido, pensé que estabas hablando de la segunda implementación de getPaymentBreakdownla pregunta.
Doc Brown
Llegaría al extremo de agregar una nueva versión del método original que se llama explícitamente 'getPaymentBreakdownByStartAndEnd' y descartar el método original, si desea proporcionar todo esto.
Erik
20

Además, creo que sienta un mal precedente y agrega responsabilidades a la API con las que no debería preocuparse (es decir, violar SRP). Suponga que consumidores adicionales desean que la función admita más casos de uso, como el cálculo a totalpartir de los parámetros numMonthsy monthlyPayment. Esta función se volverá cada vez más complicada con el tiempo.

Estás exactamente en lo correcto.

Mi preferencia es mantener la función como estaba y, en cambio, pedirle a la persona que llama que calcule endDate por sí misma. Sin embargo, puedo estar equivocado y me preguntaba si los cambios que hicieron fueron una forma aceptable de diseñar una función API.

Esto tampoco es ideal, porque el código de la persona que llama estará contaminado con una placa de caldera no relacionada.

Alternativamente, ¿hay un patrón común para manejar escenarios como este?

Introducir un nuevo tipo, como DateInterval. Agregue los constructores que tengan sentido (fecha de inicio + fecha de finalización, fecha de inicio + número de meses, lo que sea). Adopte esto como los tipos de moneda común para expresar intervalos de fechas / horas en todo su sistema.

Alexander - Restablece a Monica
fuente
3
@DocBrown Sí. En tales casos (Ruby, Python, JS), es costumbre usar solo métodos estáticos / de clase. Pero ese es un detalle de implementación, que no creo que sea particularmente relevante para el punto de mi respuesta ("use un tipo").
Alexander - Restablece a Monica el
2
Y esta idea llega tristemente sus límites con el tercer requisito: Fecha de inicio, el pago total y un pago mensual - y la función calculará el DateInterval partir de los parámetros de dinero - y usted no debe poner los importes monetarios en el intervalo de fechas ...
Falco
3
@DocBrown "solo cambia el problema de la función existente al constructor del tipo" Sí, está colocando el código de tiempo donde debe ir el código de tiempo, de modo que el código de dinero pueda ser donde debe ir el código de dinero. Es un SRP simple, por lo que no estoy seguro de a qué te refieres cuando dices que "solo" cambia el problema. Eso es lo que hacen todas las funciones. No hacen que el código desaparezca, lo mueven a lugares más apropiados. ¿Cuál es tu problema con eso? "pero mis felicitaciones, al menos 5 votantes votaron por el anzuelo" Esto suena mucho más idiota de lo que creo (espero) que pretendías.
Alexander - Restablece a Monica el
@Falco Eso me parece un método nuevo (en esta clase de calculadora de pagos, no DateInterval):calculatePayPeriod(startData, totalPayment, monthlyPayment)
Alexander - Restablece a Monica el
7

A veces, las expresiones fluidas ayudan en esto:

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

Con tiempo suficiente para diseñar, puede llegar a una API sólida que actúe de manera similar a un lenguaje específico de dominio.

La otra gran ventaja es que los IDE con autocompletado hacen que sea casi irreverente leer la documentación de la API, como es intuitivo debido a sus capacidades de autodescubrimiento.

Existen recursos como https://nikas.praninskas.com/javascript/2015/04/26/fluent-javascript/ o https://github.com/nikaspran/fluent.js sobre este tema.

Ejemplo (tomado del primer enlace de recursos):

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
fuente
8
La interfaz fluida por sí sola no hace que ninguna tarea en particular sea más fácil o más difícil. Esto se parece más al patrón Builder.
VLAZ
8
Sin embargo, la implementación sería bastante complicada si necesita evitar llamadas erróneas comoforTotalAmount(1234).breakIntoPayments().byPeriod(2).monthly().withPaymentsOf(12.34).byDateRange(saleStart, saleEnd);
Bergi
44
Si los desarrolladores realmente quieren disparar sobre sus pies, hay formas más fáciles de @Bergi. Aún así, el ejemplo que pones es mucho más legible queforTotalAmountAndBreakIntoPaymentsByPeriodThenMonthlyWithPaymentsOfButByDateRange(1234, 2, 12.34, saleStart, saleEnd);
DanielCuadra
55
@DanielCuadra El punto que estaba tratando de hacer es que su respuesta realmente no resuelve el problema de los OP de tener 3 parámetros mutuamente excluyentes. El uso del patrón de construcción puede hacer que la llamada sea más legible (y aumentar la probabilidad de que el usuario se dé cuenta de que no tiene sentido), pero el uso del patrón de construcción por sí solo no evita que sigan pasando 3 valores a la vez.
Bergi
2
@Falco lo hará? Sí, es posible, pero más complicado, y la respuesta no mencionó esto. Los constructores más comunes que he visto consistían en una sola clase. Si la respuesta se edita para incluir el código de los constructores, lo aprobaré y eliminaré mi voto negativo.
Bergi
2

Bueno, en otros idiomas, usarías parámetros con nombre . Esto se puede emular en Javscript:

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

getPaymentBreakdown(100, today, {endDate: whatever});
getPaymentBreakdown(100, today, {noOfMonths: 4});
getPaymentBreakdown(100, today, {monthlyPayment: 20});
Gregory Currie
fuente
66
Al igual que el patrón de construcción a continuación, esto hace que la llamada sea más legible (y aumenta la probabilidad de que el usuario se dé cuenta de que no tiene sentido), pero nombrar los parámetros no impide que el usuario siga pasando 3 valores a la vez, por ejemplo getPaymentBreakdown(100, today, {endDate: whatever, noOfMonths: 4, monthlyPayment: 20}).
Bergi
1
¿No debería ser en :lugar de =?
Barmar
Supongo que podría verificar que solo uno de los parámetros no sea nulo (o no esté en el diccionario).
Mateen Ulhaq
1
@Bergi: la sintaxis en sí misma no impide que los usuarios pasen parámetros sin sentido, pero simplemente puede hacer una validación y arrojar errores
slebetman
@ Bergi De ninguna manera soy un experto en Javascript, pero creo que Destructuring Assignment en ES6 puede ayudar aquí, aunque tengo muy poco conocimiento sobre esto.
Gregory Currie
1

Como alternativa, también puede romper la responsabilidad de especificar el número de meses y dejarlo fuera de su función:

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

Y getpaymentBreakdown recibiría un objeto que proporcionaría el número base de meses

Esas funciones de orden superior devolverían, por ejemplo, una función.

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
fuente
¿Qué pasó con los parámetros totaly startDate?
Bergi
Esto parece una buena API, pero ¿podría agregar cómo imagina que se implementarán esas cuatro funciones? (Con tipos de variantes y una interfaz común, esto podría ser bastante elegante, pero no está claro lo que tenía en mente).
Bergi
@Bergi editó mi publicación
Vinz243
0

Y si estuviera trabajando con un sistema con uniones discriminadas / tipos de datos algebraicos, podría pasarlo como, por ejemplo, a TimePeriodSpecification.

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

y luego ninguno de los problemas ocurriría cuando no pudieras implementar uno y así sucesivamente.

NiklasJ
fuente
Definitivamente, así es como abordaría esto en un lenguaje que tuviera tipos como estos disponibles. Traté de mantener mi pregunta independiente del lenguaje, pero tal vez debería tener en cuenta el lenguaje utilizado porque enfoques como este se vuelven posibles en algunos casos.
CalMlynarczyk