Existe um padrão para lidar com parâmetros de função conflitantes?

38

Temos uma função de API que divide um valor total em valores mensais com base nas datas de início e término.

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

Recentemente, um consumidor dessa API desejou especificar o período de outras maneiras: 1) fornecendo o número de meses em vez da data final ou 2) fornecendo o pagamento mensal e calculando a data final. Em resposta a isso, a equipe da API alterou a função para o seguinte:

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

Eu sinto que essa mudança complica a API. Agora, o chamador precisa se preocupar com a heurística escondidas com a implementação da função na determinação de quais parâmetros têm preferência em ser usado para calcular o intervalo de datas (ou seja, por ordem de prioridade monthlyPayment, numMonths, endDate). Se um chamador não prestar atenção na assinatura da função, ele poderá enviar vários parâmetros opcionais e ficar confuso sobre o motivo pelo qual endDateestá sendo ignorado. Nós especificamos esse comportamento na documentação da função.

Além disso, acho que ele cria um precedente ruim e adiciona responsabilidades à API com as quais ela não deve se preocupar (ou seja, violar o SRP). Suponha que consumidores adicionais desejem que a função suporte mais casos de uso, como o cálculo totaldos parâmetros numMonthse monthlyPayment. Esta função se tornará cada vez mais complicada ao longo do tempo.

Minha preferência é manter a função como era e exigir que o chamador se calcule endDate. No entanto, posso estar errado e queria saber se as alterações feitas foram uma maneira aceitável de criar uma função de API.

Como alternativa, existe um padrão comum para lidar com cenários como este? Poderíamos fornecer funções adicionais de ordem superior em nossa API que envolvam a função original, mas isso incha a API. Talvez possamos adicionar um parâmetro de flag adicional especificando qual abordagem usar dentro da função.

CalMlynarczyk
fonte
79
"Recentemente, um consumidor dessa API queria [fornecer] o número de meses em vez da data de término" - essa é uma solicitação frívola. Eles podem transformar o número de meses em uma data final apropriada em uma linha ou duas de código no final.
Graham
12
que se parece com um anti-padrão de argumento de bandeira, e eu também recomendaria dividir em várias funções
njzk2
2
Como uma nota lateral, não são funções que podem aceitar o mesmo tipo e número de parâmetros e produzem resultados muito diferentes baseados naqueles - ver Date- você pode fornecer uma corda e ele pode ser analisado para determinar a data. No entanto, desta forma, oh parâmetros de manipulação também podem ser muito exigentes e podem produzir resultados não confiáveis. Veja Datenovamente. Não é impossível fazer o certo - o Moment lida com isso muito melhor, mas é muito chato de usar, independentemente.
VLAZ 25/09
Em uma ligeira tangente, você pode pensar em como lidar com o caso em que monthlyPaymenté dado, mas totalnão é um múltiplo inteiro dele. E também como lidar com possíveis erros de arredondamento de ponto flutuante se não for garantido que os valores sejam inteiros (por exemplo, tente com total = 0.3e monthlyPayment = 0.1).
Ilmari Karonen 25/09
@Graham Eu não reagi a isso ... Reagi à próxima afirmação "Em resposta a isso, a equipe da API mudou a função ..." - rola para a posição fetal e começa a balançar - Não importa onde essa linha ou duas de código são válidas, uma nova chamada de API com o formato diferente ou concluída no final da chamada. Apenas não altere uma chamada de API funcionando assim!
Baldrickk

Respostas:

99

Vendo a implementação, parece-me o que você realmente precisa aqui são três funções diferentes em vez de uma:

O original:

function getPaymentBreakdown(total, startDate, endDate) 

Aquele que fornece o número de meses em vez da data final:

function getPaymentBreakdownByNoOfMonths(total, startDate, noOfMonths) 

e aquele que fornece o pagamento mensal e calcula a data final:

function getPaymentBreakdownByMonthlyPayment(total, startDate, monthlyPayment) 

Agora, não há mais parâmetros opcionais, e deve ficar bem claro qual função é chamada como e para qual finalidade. Como mencionado nos comentários, em uma linguagem estritamente digitada, também é possível utilizar a sobrecarga de funções, distinguindo as três funções diferentes, não necessariamente pelo nome, mas pela assinatura, caso isso não oculte seu objetivo.

Observe que as diferentes funções não significam que você precise duplicar qualquer lógica - internamente, se essas funções compartilham um algoritmo comum, ele deve ser refatorado para uma função "privada".

existe um padrão comum para lidar com cenários como este

Eu não acho que exista um "padrão" (no sentido dos padrões de design do GoF) que descreve um bom design de API. O uso de nomes autoexplicativos, funções com menos parâmetros, funções com parâmetros ortogonais (= independentes), são apenas princípios básicos da criação de código legível, sustentável e evolutível. Nem toda boa ideia de programação é necessariamente um "padrão de design".

Doc Brown
fonte
24
Na verdade, a implementação "comum" do código poderia ser simplesmente getPaymentBreakdown(ou realmente qualquer uma dessas 3) e as outras duas funções apenas convertem os argumentos e chamam isso. Por que adicionar uma função privada que é uma cópia perfeita de um desses 3?
Giacomo Alzetta 25/09
@GiacomoAlzetta: isso é possível. Mas tenho certeza de que a implementação se tornará mais simples, fornecendo uma função comum que contém apenas a parte "return" da função OPs e permita que as funções públicas 3 chamem essa função com parâmetros innerNumMonths, totale startDate. Por que manter uma função complicada demais com 5 parâmetros, onde 3 são quase opcionais (exceto que um precisa ser definido), quando uma função de 3 parâmetros também faz o trabalho?
Doc Brown
3
Eu não quis dizer "mantenha a função de 5 argumentos". Só estou dizendo que, quando você tem uma lógica comum, essa lógica não precisa ser privada . Nesse caso, todas as três funções podem ser refatoradas para simplesmente transformar os parâmetros em datas de início, para que você possa usar a getPaymentBreakdown(total, startDate, endDate)função pública como implementação comum, a outra ferramenta simplesmente calculará as datas totais / de início / fim adequadas e a chamará.
Giacomo Alzetta 25/09
@ GiacomoAlzetta: ok, foi um mal-entendido, eu pensei que você estava falando sobre a segunda implementação da getPaymentBreakdownquestão.
Doc Brown
Eu chegaria ao ponto de adicionar uma nova versão do método original denominada explicitamente 'getPaymentBreakdownByStartAndEnd' e descontinuar o método original, se você quiser fornecer tudo isso.
Erik
20

Além disso, acho que ele cria um precedente ruim e adiciona responsabilidades à API com as quais ela não deve se preocupar (ou seja, violar o SRP). Suponha que consumidores adicionais desejem que a função suporte mais casos de uso, como o cálculo totaldos parâmetros numMonthse monthlyPayment. Esta função se tornará cada vez mais complicada ao longo do tempo.

Você está exatamente correto.

Minha preferência é manter a função como era e, em vez disso, exigir que o chamador calcule o endDate. No entanto, posso estar errado e queria saber se as alterações feitas foram uma maneira aceitável de criar uma função de API.

Isso também não é ideal, porque o código do chamador será poluído com uma placa de caldeira não relacionada.

Como alternativa, existe um padrão comum para lidar com cenários como este?

Introduzir um novo tipo, como DateInterval. Adicione o que os construtores fizerem sentido (data de início + data de término, data de início + num meses, qualquer que seja.). Adote isso como os tipos de moeda comum para expressar intervalos de datas / horas em todo o sistema.

Alexander - Restabelecer Monica
fonte
3
@DocBrown Yep. Nesses casos (Ruby, Python, JS), é habitual usar apenas métodos estáticos / de classe. Mas esse é um detalhe de implementação, que não acho particularmente relevante para o ponto de minha resposta ("use um tipo").
Alexander - Restabelecer Monica
2
Infelizmente, essa ideia atinge seus limites com o terceiro requisito: Data de início, Pagamento total e Pagamento mensal - e a função calculará o DateInterval a partir dos parâmetros monetários - e você não deve colocar os valores monetários em seu período ...
Falco
3
@DocBrown "apenas muda o problema da função existente para o construtor do tipo" Sim, ele está colocando o código de tempo onde o código de tempo deve ir, para que o código monetário possa estar onde o código monetário deve ir. É simples SRP, então não tenho certeza do que você está falando quando diz "apenas" muda o problema. É isso que todas as funções fazem. Eles não fazem o código desaparecer, eles o movem para lugares mais apropriados. Qual é o seu problema com isso? "mas meus parabéns, pelo menos cinco promotores morderam a isca" Isso soa muito mais idiota do que eu acho (espero) que você pretendia.
Alexander - Restabelecer Monica
@Falco Isso soa como um novo método para mim (nesta classe de calculadora de pagamentos, não DateInterval):calculatePayPeriod(startData, totalPayment, monthlyPayment)
Alexander - Reinstate Monica
7

Às vezes, expressões fluentes ajudam nisso:

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

Com tempo suficiente para projetar, você pode criar uma API sólida que age de maneira semelhante a uma linguagem específica de domínio.

A outra grande vantagem é que os IDEs com preenchimento automático tornam quase irrelevante a leitura da documentação da API, pois são intuitivos devido a seus recursos auto-detectáveis.

Existem recursos por aí, como https://nikas.praninskas.com/javascript/2015/04/26/fluent-javascript/ ou https://github.com/nikaspran/fluent.js sobre este tópico.

Exemplo (retirado do primeiro link do recurso):

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
fonte
8
A interface fluente por si só não facilita nem dificulta nenhuma tarefa específica. Isso parece mais com o padrão Builder.
VLAZ 25/09
8
A implementação seria bastante complicada, se você precisar evitar chamadas erradas, comoforTotalAmount(1234).breakIntoPayments().byPeriod(2).monthly().withPaymentsOf(12.34).byDateRange(saleStart, saleEnd);
Bergi em 25/09
4
Se os desenvolvedores realmente querem se divertir, existem maneiras mais fáceis de @Bergi. Ainda assim, o exemplo que você coloca é muito mais legível do queforTotalAmountAndBreakIntoPaymentsByPeriodThenMonthlyWithPaymentsOfButByDateRange(1234, 2, 12.34, saleStart, saleEnd);
DanielCuadra 25/09
5
@DanielCuadra O ponto que eu estava tentando enfatizar é que sua resposta realmente não resolve o problema dos OPs de ter três parâmetros mutuamente exclusivos. O uso do padrão do construtor pode tornar a chamada mais legível (e aumentar a probabilidade do usuário perceber que isso não faz sentido), mas o uso do padrão do construtor por si só não impede que eles ainda passem três valores ao mesmo tempo.
Bergi 25/09
2
@Falco Será? Sim, é possível, mas mais complicado, e a resposta não fez nenhuma menção a isso. Os construtores mais comuns que eu vi consistiram em apenas uma única classe. Se a resposta for editada para incluir o código do (s) construtor (es), eu vou apoiá-lo com prazer e remover meu voto negativo.
Bergi 25/09
2

Bem, em outros idiomas, você usaria parâmetros nomeados . Isso pode ser emulado no Javscript:

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

getPaymentBreakdown(100, today, {endDate: whatever});
getPaymentBreakdown(100, today, {noOfMonths: 4});
getPaymentBreakdown(100, today, {monthlyPayment: 20});
Gregory Currie
fonte
6
Como o padrão do construtor abaixo, isso torna a chamada mais legível (e aumenta a probabilidade do usuário perceber que não faz sentido), mas nomear os parâmetros não impede que o usuário ainda passe 3 valores de uma vez - por exemplo getPaymentBreakdown(100, today, {endDate: whatever, noOfMonths: 4, monthlyPayment: 20}).
Bergi 25/09
1
Não deveria ser em :vez de =?
Barmar 25/09
Eu acho que você pode verificar se apenas um dos parâmetros é não nulo (ou não está no dicionário).
Mateen Ulhaq 25/09
1
@ Bergi - A sintaxe em si não impede que os usuários passem parâmetros sem sentido, mas você pode simplesmente validar e
gerar
@ Bergi Eu não sou de modo algum um especialista em Javascript, mas acho que Destructuring Assignment in ES6 pode ajudar aqui, embora eu seja muito claro sobre o conhecimento disso.
Gregory Currie
1

Como alternativa, você também pode quebrar a responsabilidade de especificar o número do mês e deixá-lo fora de sua função:

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

E o getpaymentBreakdown receberia um objeto que forneceria o número base de meses

Essas funções de ordem superior retornariam, por exemplo, uma função.

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
fonte
O que aconteceu com os parâmetros totale startDate?
Bergi 26/09
Parece uma API agradável, mas você poderia adicionar como imaginaria essas quatro funções a serem implementadas? (Com tipos de variantes e uma interface comum, isso pode ser bastante elegante, mas não está claro o que você tinha em mente).
Bergi 26/09
@Bergi editou minha publicação
Vinz243 27/09
0

E se você estivesse trabalhando com um sistema com uniões discriminadas / tipos de dados algébricos, poderia repassá-lo como, digamos, a TimePeriodSpecification.

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

e então nenhum dos problemas ocorreria onde você poderia falhar na implementação de um e assim por diante.

NiklasJ
fonte
Definitivamente, é assim que eu abordaria isso em um idioma com tipos como estes disponíveis. Tentei manter minha pergunta independente da linguagem, mas talvez ela devesse levar em conta a linguagem usada porque abordagens como essa se tornam possíveis em alguns casos.
CalMlynarczyk 30/09