terça-feira, 18 de fevereiro de 2014

Aspectos – parte II: mixins

Na parte I demos uma passada geral no conceito de aspectos. Aqui veremos os mixins.

Mixins são classes incompletas que apenas atribuem determinado comportamento às classes herdeiras.

Vamos a um exemplo bem esdrúxulo, mas suficiente: um objeto que armazena notas de alunos em um arquivo.
from cPickle import dumps, loads
import gdbm

__all__ = ['Banco', 'Notas']


class Banco(object):

    def __init__(self, file=None):
        if file is None:
            file = 'notas.db'
        if isinstance(file, basestring):
            file = gdbm.open(file, 'c')
        self.file = file

    def close(self):
        if self.file:
            self.file.close()
            self.file = None


class Notas(object):

    def __init__(self, matricula, db):
        self.matricula = matricula

        if not isinstance(db, Banco):
            raise TypeError(
                'expected Banco instance, got {}'
                .format(type(db).__name__)
            )

        self.db = db

        try:
            self.notas = loads(db[str(matricula)])
        except KeyError:
            self.notas = {}


    def save(self):
        db[str(self.matricula)] = dumps(self.notas)


    @property
    def primeiro_bimestre(self):
        return self.notas.get('1bim')

    @primeiro_bimestre.setter
    def primeiro_bimestre(self, value):
        self.notas['1bim'] = float(value)

    @property
    def segundo_bimestre(self):
        return self.notas.get('2bim')

    @segundo_bimestre.setter
    def segundo_bimestre(self, value):
        self.notas['2bim'] = float(value)

    @property
    def terceiro_bimestre(self):
        return self.notas.get('3bim')

    @terceiro_bimestre.setter
    def terceiro_bimestre(self, value):
        self.notas['3bim'] = float(value)

    @property
    def quarto_bimestre(self):
        return self.notas.get('4bim')

    @quarto_bimestre.setter
    def quarto_bimestre(self, value):
        self.notas['4bim'] = float(value)

    @property
    def recuperacao(self):
        return self.notas.get('rec')

    @recuperacao.setter
    def recuperacao(self, value):
        return self.notas['rec'] = float(value)

    @property
    def media(self):
        soma = sum(
            self.primeiro_bimestre or 0,
            self.segundo_bimestre or 0,
            self.terceiro_bimestre or 0,
            self.quarto_bimestre or 0,
        )
        m = soma / 4.

        rec = self.recuperacao
        if rec is not None:
            m = (m + rec) / 2.

        return m

Repare que temos o mesmo problema apresentando na parte I: está tudo misturado em uma única classe!

Podemos separar as partes de gerência de banco e serialização em classes diferentes, dedicadas a seu próprio aspecto, chamadas mixins.

A classe de faz serialização pode ser apenas isso:
class NotaSerializavelMixin(object):
    def load(self):
        s = self.retrieve()
        self.notas = loads(s) if s else {}

    def __str__(self):
        return dumps(self.notas)

A gerência de banco vai para outro mixin:
class PersistenciaMixin(object):
    def retrieve(self):
        try:
            return self.db[str(self.matricula)]
        except KeyError:
            return None

    def save(self):
        db[str(self.matricula)] = str(self)

Preferindo, é possível separar a gerência de notas em um mixin também:
class NotasMixin(object):

    @property
    def primeiro_bimestre(self):
        return self.notas.get('1bim')

    @primeiro_bimestre.setter
    def primeiro_bimestre(self, value):
        self.notas['1bim'] = float(value)

    ...

    @property
    def media(self):
        soma = sum(
            self.primeiro_bimestre or 0,
            self.segundo_bimestre or 0,
            self.terceiro_bimestre or 0,
            self.quarto_bimestre or 0,
        )
        m = soma / 4.

        rec = self.recuperacao
        if rec is not None:
            m = (m + rec) / 2.

        return m

Ao final, a classe principal será apenas uma cola dos mixins:
class Notas(NotaSerializavelMixin, PersistenciaMixin, MotasMixin):

    def __init__(self, matricula, db):
        self.matricula = matricula

        if not isinstance(db, Banco):
            raise TypeError(
                'expected Banco instance, got {}'
                .format(type(db).__name__)
            )

        self.db = db
        self.load()

A API da classe continua idêntica: recebe o número da matrícula e o banco na instanciação, propriedades para acessar as notas e método save() para salvá-las em arquivo, porém agora cada aspecto está isolado e encapsulado em seu próprio mixin.

[]’s
Cacilhας, La Batalema

Aspectos – parte I

Atualizado no blog novo.

Um paradigma muito útil é a Programação orientada a Aspectos.

Consiste em separar e encapsular as funcionalidades de um código conforme sua importância.

Nesta primeira parte, abordaremos de forma simples tal separação e deixaremos o conceito de mixins para a parte II.

Vamos começar com um exemplo: imagine uma view que modifica o estado de um objeto, retornando um hash do novo estado:
@app.route('/people/<uuid>/', methods=['PATCH'])
def update_person(uuid):
    person = db.person.find({ '_id': uuid }).first()
    if not person:
        raise Http404

    try:
        data = json.loads(request.data)
    except ValueError:
        return json.dumps({ 'error': 'invalid request' }), \
               400, \
               { 'Content-Type': 'application/json' }

    person.update(data)
    db.person.save(person)

    r = [(str(k), repr(v)) for k, v in person.iteritems()]
    r.sort()
    s = ';'.join('{}:{}'.format(k, v) for k, v in r)

    return json.dumps({ 'etag': md5(s).hexdigest() }), \
           200, \
           { 'Content-Type': 'application/json' }

A solução atende, mas é de difícil manutenção. Perceba que a função chamada update_person (atualiza pessoa) faz muito mais do que simplesmente atualizar os dados:
  • Recupera o documento do banco, retornando 404 se não existir;
  • Faz os parsing dos dados recebidos, retornando 400 em caso de erro;
  • Efetivamente atualiza o documento;
  • Serializa o objeto para a resposta;
  • Gera um hash da serialização;
  • Responde a requisição com formato conveniente.

Cada um desses passos é um aspecto do processo e pode ser isolado do restante.

Vamos então separar o primeiro aspecto: recuperação do documento.
def retrieve_person_aspect(view):
    @wraps(view)
    def wrapper(uuid):
        person = db.person.find({ '_id': uuid }).first()
        if not person:
            raise Http404

        return view(person)
    return wrapper

@app.route('/people/<uuid>/', methods=['PATCH'])
@retrieve_person_aspect
def update_person(person):
    try:
        data = json.loads(request.data)
    except ValueError:
        return json.dumps({ 'error': 'invalid request' }), \
               400, \
               { 'Content-Type': 'application/json' }

    person.update(data)
    db.person.save(person)

    r = [(str(k), repr(v)) for k, v in person.iteritems()]
    r.sort()
    s = ';'.join('{}:{}'.format(k, v) for k, v in r)

    return json.dumps({ 'etag': md5(s).hexdigest() }), \
           200, \
           { 'Content-Type': 'application/json' }

Agora a recuperação do documento está isolada, podendo inclusive ser usada em outras views. Nossa view já recebe o documento recuperado e não precisa lidar com o fato dele existir ou não.

Porém ainda temos muita coisa misturada. Por exemplo, a obtenção e parsing dos dados recebidos: isso caracteriza outro aspecto do código, que não a atualização do documento.

Podemos portanto, separá-los:
def parse_data_aspect(view):
    @wraps(view)
    def wrapper(person):
        try:
            data = json.loads(request.data)
        except ValueError:
            return json.dumps({ 'error': 'invalid request' }), \
                   400, \
                   { 'Content-Type': 'application/json' }

        return view(person, data)
    return wrapper

def retrieve_person_aspect(view):
    ...

@app.route('/people/<uuid>/', methods=['PATCH'])
@retrieve_person_aspect
@parse_data_aspect
def update_person(person, data):
    person.update(data)
    db.person.save(person)

    r = [(str(k), repr(v)) for k, v in person.iteritems()]
    r.sort()
    s = ';'.join('{}:{}'.format(k, v) for k, v in r)

    return json.dumps({ 'etag': md5(s).hexdigest() }), \
           200, \
           { 'Content-Type': 'application/json' }

A função update_person já está muito mais limpa: atualiza o documento, serializa e retorna o hash, mas ainda faz coisas demais. Vamos separar o tratamento do retorno:
def respond_etag_aspect(view):
    @wraps(view)
    def wrapper(person, data):
        response = view(person, data)
        return json.dumps({ 'etag': md5(response).hexdigest() }), \
               200, \
               { 'Content-Type': 'application/json' }
    return wrapper

def parse_data_aspect(view):
    ...

def retrieve_person_aspect(view):
    ...

@app.route('/people/<uuid>/', methods=['PATCH'])
@retrieve_person_aspect
@parse_data_aspect
@respond_etag_aspect
def update_person(person, data):
    person.update(data)
    db.person.save(person)

    r = [(str(k), repr(v)) for k, v in person.iteritems()]
    r.sort()
    return ';'.join('{}:{}'.format(k, v) for k, v in r)

As coisas estão ficando cada vez mais separadas. A única coisa que a função update_person faz agora além de atualizar o documento é serializá-lo. Isso também pode ser isolado:
def serialize_person_aspect(view):
    @wraps(view)
    def wrapper(person, data):
        response = view(person, data)
        r = [(str(k), repr(v)) for k, v in response.iteritems()]
        r.sort()
        return ';'.join('{}:{}'.format(k,v) for k, v in r)

def respond_etag_aspect(view):
    ...

def parse_data_aspect(view):
    ...

def retrieve_person_aspect(view):
    ...

@app.route('/people/<uuid>/', methods=['PATCH'])
@retrieve_person_aspect
@parse_data_aspect
@respond_etag_aspect
@serialize_person_aspect
def update_person(person, data):
    person.update(data)
    db.person.save(person)
    return person

Perceba que, com a separação dos aspectos em funções distintas, o código ficou muito mais semântico:
  • retrive_person_aspect apenas recupera o documento do banco;
  • parse_data_aspect apenas faz o parsing dos dados recebidos;
  • respond_etag_aspect apenas gera o formato correto da resposta;
  • serialize_person_aspect apenas serializa o documento;
  • finalmente, update_person apenas atualiza o documento.

Observações

  • db é um objeto de banco de dados MongoDB, apenas para fim de exemplo.
  • app é uma aplicação Flask, apenas para fim de exemplo.
  • A ordem dos decoradores é importante.
  • Os imports foram omitidos:
import json
from functools import wraps
from hashlib import md5

Na parte II abordaremos mixins.

[]’s
Cacilhας, La Batalema