Design de interface em que funções precisam ser chamadas em uma sequência específica

24

A tarefa é configurar um pedaço de hardware dentro do dispositivo, de acordo com algumas especificações de entrada. Isso deve ser alcançado da seguinte maneira:

1) Colete as informações de configuração. Isso pode acontecer em diferentes momentos e lugares. Por exemplo, o módulo A e o módulo B podem solicitar (em momentos diferentes) alguns recursos do meu módulo. Esses 'recursos' são, na verdade, qual é a configuração.

2) Depois que ficar claro que não haverá mais solicitações, um comando de inicialização, fornecendo um resumo dos recursos solicitados, precisa ser enviado ao hardware.

3) Somente depois disso, é possível (e deve) a configuração detalhada dos referidos recursos.

4) Além disso, somente após 2), pode (e deve) o roteamento dos recursos selecionados para os chamadores declarados.


Uma causa comum de bugs, mesmo para mim, que escrevi a coisa, está confundindo essa ordem. Quais convenções, designs ou mecanismos de nomenclatura posso empregar para tornar a interface utilizável por alguém que vê o código pela primeira vez?

Vorac
fonte
Fase 1 é melhor chamado discoveryou handshake?
Rwong
11
O acoplamento temporal é um antipadrão e deve ser evitado.
11
O título da pergunta me faz pensar que você pode estar interessado no padrão do construtor de etapas .
Joshua Taylor

Respostas:

45

É uma reformulação, mas você pode impedir o uso indevido de muitas APIs, mas sem ter disponível nenhum método que não deva ser chamado.

Por exemplo, em vez de first you init, then you start, then you stop

Seu construtor inité um objeto que pode ser iniciado e startcria uma sessão que pode ser parada.

Obviamente, se você tiver uma restrição para uma sessão de cada vez, precisará lidar com o caso em que alguém tenta criar uma com uma já ativa.

Agora aplique essa técnica ao seu próprio caso.

CashCow
fonte
zlibe jpeglibsão dois exemplos que seguem esse padrão para inicialização. Ainda assim, muitas documentações são necessárias para ensinar o conceito aos desenvolvedores.
rwong
5
Esta é exatamente a resposta certa: se a ordem importa, cada função retorna um resultado que pode ser chamado para executar o próximo passo. O próprio compilador é capaz de impor as restrições de design.
2
Isso é semelhante ao padrão do construtor de etapas ; apenas apresente a interface que faz sentido em uma determinada fase.
Joshua Taylor
@JoshuaTaylor minha resposta é um passo construtor de implementação padrão :)
Silviu Burcea
@SilviuBurcea Sua resposta não é uma implementação de construtor de etapas, mas vou comentar sobre isso em vez de aqui.
27414 Joshua Taylor
19

Você pode fazer com que o método de inicialização retorne um objeto que seja um parâmetro necessário para a configuração:

Recurso * MyModule :: GetResource ();
MySession * MyModule :: Startup ();
void Resource :: Configure (sessão MySession *);

Mesmo se você MySessionfor apenas uma estrutura vazia, isso aplicará através da segurança de tipo que nenhum Configure()método pode ser chamado antes da inicialização.

jpa
fonte
O que impede alguém de fazer module->GetResource()->Configure(nullptr)?
svick
@ Rick: Nada, mas você deve fazer isso explicitamente. Essa abordagem informa o que espera e contornar essa expectativa é uma decisão consciente. Como na maioria das linguagens de programação, ninguém o impede de dar um tiro no próprio pé. Mas é sempre bom por um API para indicar claramente que você está fazendo isso;)
Michael Klement
O +1 parece ótimo e simples. No entanto, vejo um problema. Se eu tiver objetos a, b, c, d, posso começar ae usá-lo MySessionpara tentar usá-lo bcomo um objeto já iniciado, enquanto na realidade não é.
Vorac
8

Com base na resposta da Cashcow - por que você tem que apresentar um novo objeto ao chamador, quando você pode apenas apresentar uma nova interface? Rebrand-Padrão:

class IStartable     { public: virtual IRunnable      start()     = 0; };
class IRunnable      { public: virtual ITerminateable run()       = 0; };
class ITerminateable { public: virtual void           terminate() = 0; };

Você também pode permitir que ITerminateable implemente IRunnable, se uma sessão puder ser executada várias vezes.

Seu objeto:

class Service : IStartable, IRunnable, ITerminateable
{
  public:
    IRunnable      start()     { ...; return this; }
    ITerminateable run()       { ...; return this; }
    void           terminate() { ...; }
}

// And use it like this:
IStartable myService = Service();

// Now you can only call start() via the interface
IRunnable configuredService = myService.start();

// Now you can also call run(), because it is wrapped in the new interface...

Dessa maneira, você só pode chamar os métodos certos, já que você possui apenas a Interface IStartable no início e obterá o método run () acessível apenas quando você chama start (); Do lado de fora, parece um padrão com várias classes e objetos, mas a classe subjacente permanece uma classe, sempre referenciada.

Falco
fonte
11
Qual é a vantagem de ter apenas uma classe subjacente em vez de várias? Como essa é a única diferença com a solução que propus, eu estaria interessado nesse ponto em particular.
Michael Le Barbier Grünewald
11
@ MichaelGrünewald Não é necessário implementar todas as interfaces com uma classe, mas para um objeto de tipo de configuração, pode ser a técnica de implementação mais simples compartilhar os dados entre instâncias das interfaces (ou seja, porque eles são compartilhados em virtude de serem os mesmos objeto).
Joshua Taylor
11
Esse é essencialmente o padrão do construtor de etapas .
Joshua Taylor
@JoshuaTaylor Compartilhar dados entre instâncias da interface é duplo: embora possa ser mais fácil de implementar, precisamos ter cuidado para não acessar o "estado indefinido" (como acessar o endereço do cliente de um servidor não conectado). Como o OP enfatiza a usabilidade da interface, podemos julgar as duas abordagens iguais. Obrigado por citar o "padrão do construtor de etapas".
Michael Le Barbier Grünewald
11
@ MichaelGrünewald Se você interagir apenas com o objeto por meio de uma interface específica especificada em um determinado momento, não deve haver nenhuma maneira (sem vazamento, etc.) de acessar esse estado.
Joshua Taylor
2

Existem muitas abordagens válidas para resolver seu problema. Basile Starynkevitch propôs uma abordagem de “burocracia zero” que deixa você com uma interface simples e depende do programador usar adequadamente a interface. Enquanto eu gosto dessa abordagem, apresentarei outra que tem mais engenharia, mas permite ao compilador detectar alguns erros.

  1. Identificar os vários estados o dispositivo pode ser, como Uninitialised, Started, Configurede assim por diante. A lista deve ser finita.¹

  2. Para cada estado, defina structmantendo as informações adicionais necessárias relevantes para esse estado, por exemplo DeviceUninitialised, DeviceStartede assim por diante.

  3. Empacote todos os tratamentos em um objeto em DeviceStrategyque os métodos usem estruturas definidas em 2. como entradas e saídas. Assim, você pode ter um DeviceStarted DeviceStrategy::start (DeviceUninitalised dev)método (ou qualquer que seja o equivalente de acordo com as convenções do seu projeto).

Com essa abordagem, um programa válido deve chamar alguns métodos na sequência imposta pelos protótipos do método.

Os vários estados são objetos não relacionados, isso ocorre por causa do princípio da substituição. Se for útil que essas estruturas compartilhem um ancestral comum, lembre-se de que o padrão de visitante pode ser usado para recuperar o tipo concreto da instância de uma classe abstrata.

Enquanto eu descrevi em 3. uma DeviceStrategyclasse única , há situações em que você pode querer dividir a funcionalidade que ela fornece em várias classes.

Para resumir, os pontos principais do design que descrevi são:

  1. Por causa do princípio da substituição, os objetos que representam estados do dispositivo devem ser distintos e não ter relações de herança especiais.

  2. Empacote tratamentos de dispositivos em objetos iniciais, e não nos objetos que representam os próprios dispositivos, para que cada dispositivo ou estado do dispositivo veja apenas a si mesmo, e a estratégia os veja e expresse possíveis transições entre eles.

Juro que vi uma vez uma descrição de uma implementação de cliente de telnet seguindo estas linhas, mas não consegui encontrá-la novamente. Teria sido uma referência muito útil!

Nota: Para isso, siga sua intuição ou encontre as classes de equivalência de métodos em sua implementação real para a relação “method₁ ~ method₂ iff. é válido usá-los no mesmo objeto ”- supondo que você tenha um grande objeto encapsulando todos os tratamentos no seu dispositivo. Ambos os métodos de listagem de estados fornecem resultados fantásticos.

Michael Le Barbier Grünewald
fonte
11
Em vez de definir estruturas separadas, pode ser suficiente definir as interfaces necessárias que um objeto em cada fase deve apresentar. Então é o padrão do construtor de etapas .
21414 Joshua Taylor
2

Use um padrão de construtor.

Tenha um objeto que possua métodos para todas as operações mencionadas acima. No entanto, ele não realiza essas operações imediatamente. Apenas lembra de cada operação para mais tarde. Como as operações não são executadas imediatamente, a ordem na qual você as passa para o construtor não importa.

Depois de definir todas as operações no construtor, você chama um executemétodo Quando esse método é chamado, ele executa todas as etapas listadas acima na ordem correta com as operações armazenadas acima. Esse método também é um bom lugar para executar algumas verificações de sanidade que abrangem a operação (como tentar configurar um recurso que ainda não foi configurado) antes de gravá-las no hardware. Isso pode evitar que você danifique o hardware com uma configuração sem sentido (caso o seu hardware seja suscetível a isso).

Philipp
fonte
1

Você só precisa documentar corretamente como a interface é usada e dar um exemplo de tutorial.

Você também pode ter uma variante da biblioteca de depuração que faz algumas verificações em tempo de execução.

Talvez definir e documentar correctamente algumas convenções de nomenclatura (por exemplo preconfigure*, startup*, postconfigure*, run*....)

BTW, muitas interfaces existentes seguem um padrão semelhante (por exemplo, kits de ferramentas X11).

Basile Starynkevitch
fonte
Um diagrama de transição de estado, semelhante ao ciclo de vida da atividade de aplicativos Android , pode ser necessário para transmitir as informações.
rwong
1

Esse é realmente um tipo de erro comum e insidioso, porque os compiladores só podem impor condições de sintaxe, enquanto você precisa que seus programas clientes estejam "gramaticalmente" corretos.

Infelizmente, as convenções de nomenclatura são quase totalmente ineficazes contra esse tipo de erro. Se você realmente deseja incentivar as pessoas a não fazer coisas não gramaticais, deve distribuir um objeto de comando de algum tipo que deve ser inicializado com valores para as pré-condições, para que elas não possam executar as etapas fora de ordem.

Kilian Foth
fonte
Você quer dizer algo como este ?
Vorac
1
public class Executor {

private Executor() {} // helper class

  public void execute(MyStepsRunnable r) {
    r.step1();
    r.step2();
    r.step3();
  }
}

interface MyStepsRunnable {

  void step1();
  void step2();
  void step3();
}

Usando esse padrão, você tem certeza de que qualquer implementador será executado nesta ordem exata. Você pode dar um passo adiante e criar um ExecutorFactory que criará Executors com caminhos de execução personalizados.

Silviu Burcea
fonte
Em outro comentário, você chamou isso de implementação do construtor de etapas, mas não é. Se você tiver uma instância do MyStepsRunnable, poderá chamar a etapa 3 antes da etapa 1. Uma implementação do construtor de etapas seria mais parecida com ideone.com/UDECgY . A idéia é conseguir apenas algo com um step2 executando o step1. Assim, você é forçado a chamar métodos na ordem certa. Por exemplo, consulte stackoverflow.com/q/17256627/1281433 .
Joshua Taylor
Você pode convertê-lo em uma classe abstrata com métodos protegidos (ou mesmo padrão) para restringir a maneira como ele pode ser usado. Você será forçado a usar o executor, mas eu tenho que pode haver uma falha ou duas na implementação atual.
Silviu Burcea
Isso ainda não o torna um construtor de etapas. No seu código, não há nada que um usuário possa fazer para executar o código entre as diferentes etapas. A ideia não é apenas sequenciar o código (independentemente de ser público ou privado ou encapsulado). Como mostra o seu código, isso é fácil o suficiente step1(); step2(); step3();. O objetivo do construtor de etapas é fornecer uma API que exponha algumas etapas e impor a sequência na qual elas são chamadas. Não deve impedir que um programador faça outras coisas entre as etapas.
Joshua Taylor