Back-End

6 out, 2015

Abordagem funcional versus orientada a objetos para validação

Publicidade

A abordagem funcional é muitas vezes descrita nos termos de seu contraste com programas orientados a objeto; ou seja, você escreve funções que agem sobre os dados em vez de agir sobre os objetos que envolvem dados e usam métodos para agir sobre si mesmos. Experts da abordagem funcional (como eu) irão lhe dizer que o código escrito dessa forma é geralmente melhor do que o código OO, mas eu não quero fazer isso (agora).

No entanto, neste artigo, não estou aqui para discutir um ou outro lado. Hoje, vou demonstrar somente algumas abordagens equivalentes para o mesmo problema: a validação de dados.

Digamos que queremos escrever uma rotina para validação e limpeza de dados em um formulário. Nos é dada uma estrutura de dados de entrada, e devemos aplicar nela um conjunto de regras, devolvendo uma estrutura de dados limpa, se tudo correu bem, e uma lista detalhada de erros, caso não. Nós não queremos curto-circuito em nosso código; se dois campos estiverem incorretos, queremos saber sobre ambos.

Como estamos trabalhando em uma linguagem que espera exceções, vamos permitir que nossa interface externa as utilize. Assim, para todos os exemplos, vamos definir uma função validate_form que aceita os nossos dados e lista validadores, gera uma exceção contendo todos os erros, se houver algum, e retorna os dados de outra forma.

Implementação de OO

Vamos começar com o exemplo de uma abordagem orientada a objeto, o que eu acho que acontece quando o trabalho está ok:

class ValidationError(Exception):
    def __init__(self, message, errors={}):
        self.errors = errors


class Validator(object):
    def __init__(self, field_name):
        self.field_name = field_name

    def validate(self, data):
        raise Exception('Please implement validate')


class ValidatorSuite(Validator):
    def __init__(self):
        self.validators = []

    def add_validator(self, validator):
        self.validators.append(validator)

    def validate(self, data):
        errors = {}
        for validator in self.validators:
            try:
                data = validator.validate(data)
            except ValidationError as e:
                errors[validator.field_name] = e.message

        if len(errors) > 0:
            raise ValidationError("Validation failed", errors=errors)

        return data


# Specific Validators

class NonBlankValidator(Validator):
    def validate(self, data):
        s = data.get(self.field_name)
        if not isinstance(s, str) or len(s) == 0:
            raise ValidationError(
                "Field '{}' must not be blank".format(self.field_name))

        return data

class DefaultValidator(Validator):
    def __init__(self, field_name, default):
        self.field_name = field_name
        self.default = default

    def validate(self, data):
        data[self.field_name] = data.get(self.field_name, self.default)
        return data


# Our public API

def validate_form(data, validators):
    suite = ValidatorSuite()
    for v in validators:
        suite.add_validator(v)
    return suite.validate(data)

Isso é muito bom; vamos ver se reescrever esse código em um estilo funcional nos salva de ter algum problema, e então poderemos ter alguma discussão comparativa.

A razão pela qual as pessoas que utilizam a abordagem funcional não gostam de exceções é que elas realmente causam estragos no fluxo de execução do código. Nós preferimos montar nosso programa de funções definidas que aceitam alguns valores e retornam outros valores, e nunca pulam esse fluxo. Djikstra tinha algumas palavras fortes para o uso de blocos goto em C, porque eles tornam mais difícil do que o necessário seguir o fluxo de dados através do seu programa; o mesmo é válido para as exceções.

class Failure(object):
    def __init__(self, errors):
        self.errors = errors


# Specific Validators

def non_blank_validator(field_name):
    def validate(data):
        if not isinstance(data, str) or len(data) == 0:
            return Failure({
                field_name: "Field '{}' must not be blank".format(field_name)
            })
        return data
    return validate


def default_validator(field_name, default_val):
    def validate(data):
        data[field_name] = data.get(field_name, default_val)
        return data
    return validate


def validation_suite(validators):
    def validate(data):
        errors = {}
        for v in validators:
            val = v(data)
            if isinstance(val, Failure):
                errors = dict(val.errors, **errors)
            else:
                data = val
        if len(errors) > 0:
            return Failure(errors)
        return data
    return validate


# Our public API

def validate_form(data, validators, return_error=False):
    val = validation_suite(validators)(data)
    if isinstance(val, Failure):
        if return_error:
            return val
        else:
            raise ValidationError("Validation Failed", errors=val.errors)
    return val

No exemplo OO, os validadores eram classes com um construtor que aceitava os valores necessários e um método validate que realmente executava a validação. Na versão funcional, validate é um tipo de encapsulamento em torno dos valores necessários, com uma assinatura consistente. Se você não está acostumado a escrever código com algum tipo de encapsulamento, pode não gostar do estilo que escolhi para os validadores, mas esse tipo de código não é difícil de ler ou compreender.

A principal coisa que nós introduzimos aqui é Failure, uma espécie de parâmetro (ou flag). Nossa única restrição nas funções de validação é que elas devem retornar uma instância Failure no caso de falharem. Isso elimina a necessidade de utilizar exceções. No entanto, podemos tomar isso ainda mais útil.

Outro estilo funcional

Este outro estilo de abordagem funcional possui uma distinção, mas vou guardá-la e revelar depois. Aqui está o código:

class ValidatedData(dict):
    def __init__(self, data=None, errors=None):
        self['data'] = data or {}
        self['errors'] = errors or {}

    def run(self, *validator_fns):
        result = self
        for fn in validator_fns:
            result = result.merge(fn(result['data']))
        return result

    def merge(self, other):
        self['data'] = dict(self['data'], **other['data'])
        self['errors'] = dict(self['errors'], **other['errors'])
        return self


def success(data):
    return ValidatedData(data=data)


def fail(field_name, error):
    return ValidatedData(errors={field_name: error})


def non_blank_validator(field_name):
    def validate(data):
        s = data.get(field_name)
        if not isinstance(s, str) or len(s) == 0:
            return fail(field_name, "Field '{}' must not be blank".format(field_name))
        return success(data)
    return validate


def default_validator(field_name, default_val):
    def _inner(data):
        data[field_name] = data.get(field_name, default_val)
        return success(data)
    return validate


# Public API

class ValidationError(Exception):
    def __init__(self, message, errors={}):
        self.errors = errors


def validate_form(data, validators):
    result = ValidatedData(data).run(*validators)
    if len(result['errors']) > 0:
        raise ValidationError("Validation Failed", errors=result['errors'])
    return result['data']

Nesse exemplo, os nossos validadores aceitam um dicionário cru como antes, mas retornam um objeto encapsulado que chamamos de ValidatedData. O ValidatedData é (efetivamente) uma mônada, com funções que retornam valores monádicos e run que é utilizado para o preenchimento de bind (não sinto a menor necessidade de ser rigoroso sobre a semântica em Python). Mas não se preocupe, o código ainda funciona se você não souber disso.

Eu prefiro a forma como a mônada funciona através de ambas as funções privadas de validação que mostrei anteriormente. Temos abstraído tudo que o negócio precisa em prol de algo mais genérico. Eu também achei inteligente herdar valores de dict, mas isso não é realmente necessário.

No geral, esse código saiu um pouco mais longo do que a outra versão funcional. A maioria dos códigos que utiliza a explicitamente as funções success e fail se tornaram necessários, assim como o valor de retorno esperado se tornou mais complexo.

Comparação

Falar é muito bom, mas vamos comparar algumas situações em que queremos trabalhar com nosso código.

Escrevendo um novo validador

Vamos verificar o que é preciso para adicionar um validador. Vamos pular a implementação real dos bits complicados para que possamos olhar para os padrões e as diferenças de um e de outro lado.

# Common functions. TODO: implement

def email_valid(email):
    return True


def email_domain_equals(email, domain):
    return True


# OO-Style

class EmailValidator(Validator):
    def __init__(self, field_name, domain):
        self.field_name = field_name
        self.domain = domain

    def validate(self, data):
        email = data.get(self.field_name)
        if not email_valid(email):
            raise ValidationError("Invalid email address.")
        elif not email_domain_equals(email, self.domain):
            raise ValidationError("Email must have domain {}".format(self.domain))
        return data


# Functional Style

def email_validator(field_name, domain):
    def validate(data):
        email = data.get(field_name)
        if not email_valid(email):
            return Failure({field_name: "Invalid email address."})
        elif not email_domain_equals(email, domain):
            raise Failure({field_name: "Email must have domain {}".format(domain)})
        return data
    return validate


# Monadic Style

def email_validator(field_name, domain):
    def validate(data):
        email = data.get(field_name)
        if not email_valid(email):
            fail(field_name, "Invalid email address.")
        elif not email_domain_equals(email, domain):
            fail(field_name, "Email must have domain {}".format(domain)})
        return data
    return validate

Nada mudou muito aqui. A versão monádica beneficia-se da inclusão da função fail, mas é basicamente equivalente à versão OO. O validador baseado em classe deve se lembrar de armazenar os valores de entrada no constructor, que é algo com que os outros dois validadores não precisam se preocupar – dessa forma, acho que as versões funcionais são um pouco mais simples (desde que você esteja confortável com funções de primeira classe, é claro).

Executando a suíte (sem a função externa)

Vamos dar uma olhada neste código side-by-side:

# OO Version
def validate_form(data, validators):
    suite = ValidatorSuite()
    for v in validators:
        suite.add_validator(v)
    return suite.validate(data)


# Functional Version
def validate_form(data, validators):
    val = validation_suite(validators)(data)
    if isinstance(val, Failure):
        if return_error:
            return val
        else:
            raise ValidationError("Validation Failed", errors=val.errors)
        return val


# Monadic Version
def validate_form(data, validators):
    result = ValidatedData(data).run(*validators)
    if len(result['errors']) > 0:
        raise ValidationError("Validation Failed", errors=result['errors'])
    return result['data']

Eu poderia reclamar sobre a forma como o conjunto de códigos usa o padrão add_validator, mas isso seria muito ingênuo dado que eu o escrevi. Honestamente, uma vez que a versão OO corresponde à especificação que aqui nos propusemos a fazer funcionar a partir do get-go, eu teria que dar-lhe uma chance. Mas espere!

Validação aninhada

Isso deve ser divertido. Vamos dizer que queremos validar data[‘person’][‘name’] e que o campo não está em branco.

# Object-Oriented

class SuiteValidator(Validator):
    def __init__(self, field_name, suite):
        self.field_name = field_name
        self.suite = suite

    def validate(self, data):
        try:
            data[self.field_name] = self.suite.validate(data.get(self.field_name, {}))
        except ValidationError as e:
            raise ValidationError(errors=e.errors)
        return data

suite = ValidatorSuite()
suite.add_validator(NonBlankValidator('name'))
suite.add_validator(NonBlankValidator('email'))

outerSuite = ValidatorSuite()
outerSuite.add_validator(SuiteValidator('person', suite))

try:
    print outerSuite.validate({'person': {'email': 'test@test.com'}})
except Exception as e:
    print e.errors


# Functional

def nested_validator(field_name):
    def validate(data):
        suite = validation_suite([
            non_blank_validator('email'),
            non_blank_validator('name'),
        ])
        result = suite(data.get(field_name, {}))
        if isinstance(result, Failure):
            return Failure({field_name: result.errors})
        return result
    return validate

validation_suite([nested_validator('person')])({'person': {'email': 'test@test.com'}})
# => Failure(errors={'person': {'name': "Field 'name' must not be blank."}})


# Monadic

def nested_validator(field_name, validators):
    def validate(data):
        result = ValidatedData(data.get('person')).run(*validators)
        if len(result['errors']) > 0:
            return fail(field_name, result['errors'])
        return success({field_name: result['data']})
    return validate

ValidatedData({'person': {'email': 'test@test.com'}}).run(
    nested_validator('person', [
        non_blank_validator('name'),
        non_blank_validator('email')
    ]))

# {'errors': {'person': {'name': "Field 'name' must not be blank"}}, 'data': {'person': {'email': 'test@test.com'}}}

Eu gosto de usar a mônada novamente – tudo o que o validador aninhado tem que fazer é descompactar a mônada que foi retornada e construir um resultado.

Note que o código OO não poderia ser feito para fazer isso sem alterar a aplicação do ValidationSuite para recolher os erros adequadamente. Isso tem um pouco de autosserviço, e você deve usá-lo como quiser, mas acho que isso mostra que as opções funcionais são um pouco mais genéricas e flexíveis (mesmo se a versão OO pudesse ser reformulada facilmente). Não foi de propósito, honestamente! Então, agora, a versão OO só se lembra de um erro para cada campo aninhado.

Palavras de advertência

Todas essas técnicas irão funcionar em qualquer linguagem com as seguintes características:

  • Classes (ou tipos de classes e objetos)
  • Exceções
  • Funções de primeira classe

Então, isso funciona em Python, Ruby, JavaScript e seus amigos Java 8, Scala, Clojure (naturalmente), C#, F#, Caml e muitas outras.

No entanto, como é sempre o caso, ao utilizar técnicas funcionais ou linguagens-não-necessariamente-funcionais, você deve ter cautela. Se você está escrevendo um projeto de código aberto ou trabalhando em uma equipe, precisa ter certeza de que seu código se encaixa na definição contextualmente apropriada como “idiomática”. E se você estiver escrevendo uma biblioteca, no mínimo você deverá garantir que ela possa ser usada na forma comum – é por isso que todos os itens acima contêm uma interface que gera uma exceção.

Não acredito que qualquer uma das implementações acima sejam demasiado estranhas para se qualificar como idiomáticas em Python, mas sua estrutura pode variar. Você tem alguma ideia do choque cultural provocado pelos estilos mais distintos?

***

Adam Bard faz parte do time de colunistas internacionais do iMasters. A tradução do artigo é feita pela redação iMasters, com autorização do autor, e você pode acompanhar o artigo em inglês no link: http://adambard.com/blog/oo-vs-functional-form-validation/