Usar a ordem de resolução de método do Python para injeção de dependência - isso é ruim?

11

Eu assisti o Pycon de Raymond Hettinger falar "Super Considerado Super" e aprendi um pouco sobre o MRO (Method Resolution Order) do Python, que lineariza as classes "parentais" de uma maneira determinística. Podemos usar isso para nossa vantagem, como no código abaixo, para fazer injeção de dependência. Então agora, naturalmente, quero usar superpara tudo!

No exemplo abaixo, a Userclasse declara suas dependências herdando de ambos LoggingServicee UserService. Isso não é particularmente especial. A parte interessante é que podemos usar a Ordem de Resolução de Método e simular dependências durante o teste de unidade. O código abaixo cria um MockUserServiceque herda UserServicee fornece uma implementação dos métodos que queremos zombar. No exemplo abaixo, fornecemos uma implementação de validate_credentials. Para podermos MockUserServicelidar com todas as chamadas validate_credentials, precisamos posicioná-lo antes UserServiceno MRO. Isso é feito criando uma classe de wrapper Userchamada MockUsere herdando-a de Usere MockUserService.

Agora, quando o fizermos MockUser.authenticate, por sua vez, chama para super().validate_credentials() MockUserServiceestá UserServicena Ordem de Resolução de Método e, uma vez que oferece uma implementação concreta validate_credentialsdessa implementação, será usado. Sim - nós zombamos com sucesso UserServiceem nossos testes de unidade. Considere que isso UserServicepode fazer algumas chamadas caras de rede ou banco de dados - acabamos de remover o fator de latência disso. Também não há risco de UserServicetocar em dados ao vivo / prod.

class LoggingService(object):
    """
    Just a contrived logging class for demonstration purposes
    """
    def log_error(self, error):
        pass


class UserService(object):
    """
    Provide a method to authenticate the user by performing some expensive DB or network operation.
    """
    def validate_credentials(self, username, password):
        print('> UserService::validate_credentials')
        return username == 'iainjames88' and password == 'secret'


class User(LoggingService, UserService):
    """
    A User model class for demonstration purposes. In production, this code authenticates user credentials by calling
    super().validate_credentials and having the MRO resolve which class should handle this call.
    """
    def __init__(self, username, password):
        self.username = username
        self.password = password

    def authenticate(self):
        if super().validate_credentials(self.username, self.password):
            return True
        super().log_error('Incorrect username/password combination')
        return False

class MockUserService(UserService):
    """
    Provide an implementation for validate_credentials() method. Now, calls from super() stop here when part of MRO.
    """
    def validate_credentials(self, username, password):
        print('> MockUserService::validate_credentials')
        return True


class MockUser(User, MockUserService):
    """
    A wrapper class around User to change it's MRO so that MockUserService is injected before UserService.
    """
    pass

if __name__ == '__main__':
    # Normal useage of the User class which uses UserService to resolve super().validate_credentials() calls.
    user = User('iainjames88', 'secret')
    print(user.authenticate())

    # Use the wrapper class MockUser which positions the MockUserService before UserService in the MRO. Since the class
    # MockUserService provides an implementation for validate_credentials() calls to super().validate_credentials() from
    # MockUser class will be resolved by MockUserService and not passed to the next in line.
    mock_user = MockUser('iainjames88', 'secret')
    print(mock_user.authenticate())

Isso parece bastante inteligente, mas esse é um uso bom e válido da herança múltipla do Python e da Ordem de resolução de métodos? Quando penso em herança da maneira que aprendi OOP com Java, isso parece completamente errado, porque não podemos dizer que Useré um UserServiceou Useré um LoggingService. Pensando dessa maneira, usar herança da maneira que o código acima usa, não faz muito sentido. Ou é? Se usarmos a herança apenas para fornecer reutilização de código, e não pensar em termos de relacionamento entre pais e filhos, isso não parecerá tão ruim.

Estou fazendo errado?

Iain
fonte
Parece que há duas perguntas diferentes aqui: "Esse tipo de manipulação de MRO é seguro / estável?" e "É impreciso dizer que a herança do Python modela um relacionamento" é-um "?" Você está tentando perguntar a ambos, ou apenas a um deles? (ambos são boas perguntas, só quero ter certeza de que responder a mais acertada, ou dividir isso em duas perguntas, se você não quer ambas)
Ixrec
Eu lidei com as perguntas ao ler, deixei alguma coisa de fora?
Aaron Hall
@lxrec Eu acho que você está absolutamente certo. Estou tentando fazer duas perguntas diferentes. Acho que a razão pela qual isso não parece "certo" é porque estou pensando no estilo de herança "é-um" (então, o GoldenRetriever "é" um cão e um cachorro "é um" animal) em vez desse tipo de abordagem composicional. Acho que isso é algo que eu poderia abrir outra pergunta para :)
Iain
Isso também me confunde significativamente. Se a composição é preferível à herança, por que não passar instâncias de LoggingService e UserService para o construtor de User e defini-las como membros? Em seguida, você pode usar a digitação de pato para injeção de dependência e passar uma instância do MockUserService para o construtor User. Por que é preferível usar super para DI?
Jake Spracher

Respostas:

7

Usar a ordem de resolução de método do Python para injeção de dependência - isso é ruim?

Não. Este é um uso teórico pretendido do algoritmo de linearização C3. Isso vai contra o seu familiar é um relacionamento, mas alguns consideram que a composição é preferida à herança. Nesse caso, você compôs alguns relacionamentos has-a. Parece que você está no caminho certo (embora o Python tenha um módulo de registro, a semântica é um pouco questionável, mas, como exercício acadêmico, está perfeitamente bem).

Não acho que zombar ou aplicar patches seja ruim, mas se você puder evitá-los com esse método, será bom para você - com mais complexidade, você evitou modificar as definições de classe de produção.

Estou fazendo errado?

Isso parece bom. Você substituiu um método potencialmente caro, sem aplicar patches em macacos ou usar um patch simulado, o que novamente significa que você nem modificou diretamente as definições de classe de produção.

Se a intenção era exercitar a funcionalidade sem realmente ter credenciais no teste, você provavelmente deveria fazer algo como:

>>> print(MockUser('foo', 'bar').authenticate())
> MockUserService::validate_credentials
True

em vez de usar suas credenciais reais e verifique se os parâmetros foram recebidos corretamente, talvez com afirmações (afinal, esse é o código de teste):

def validate_credentials(self, username, password):
    print('> MockUserService::validate_credentials')
    assert username_ok(username), 'username expected to be ok'
    assert password_ok(password), 'password expected to be ok'
    return True

Caso contrário, parece que você já descobriu. Você pode verificar o MRO assim:

>>> MockUser.mro()
[<class '__main__.MockUser'>, 
 <class '__main__.User'>, 
 <class '__main__.LoggingService'>, 
 <class '__main__.MockUserService'>, 
 <class '__main__.UserService'>, 
 <class 'object'>]

E você pode verificar se o MockUserServicetem precedência sobre o UserService.

Aaron Hall
fonte