Rodrigo Kumpera Weblog

Meus achados sobre tecnologia

Herança não funciona

November 13th, 2007 · 19 Comments

Por um bom tempo achei que eu não tinha entendido sobre o assunto, mas já posso afirmar categoricamente: Herança não funciona. Existe hoje substância e argumentos suficientes para sustentar essa afirmação. Antes de discutir o mérito do assunto já adianto óbvio, programação orientada-a-objetos é sim um paradigma que funciona – apenas que um de seus elementos não funciona como se esperava. No fundo quem já programou em Java, C#, Ruby ou Smalltalk já sentiu que existe algo de estranho nessas linguagens, algo que não parece certo, e herança é o denominador comum desse sentimento.

O problema que vou tentar aqui descrever já foi estudado pelo Luca Cardelli, que é a questão da co-variância e contra-variância dos tipos – um problema que só existe na presença de herança. Originalmente foi descoberto quando ele tentou formular um cálculo para linguagens OO, algo análogo ao cálculo lambda para linguagens funcionais, e somente conseguiu progredir quando tentava reduzir para calculo lambda sem tipagem. Abandono aqui qualquer tentativa de equacionar a questão e explico a seguir através de exemplos. Vou comentar sobre a consistencia de sistemas de tipos, parametricidade de tipos e transparência de refêrencia.

A principal característica da herança é poder substituir um tipo mais abstrato por um mais especializado, poder trocar object por string, e não estar assim violando o sistema de tipos. Dada uma variável do tipo A, definimos operações de leitura e escrita. Para a leitura podemos dizer que o tipo retornado é A ou um super tipo de A; e para a escrita podemos dizer que o tipo aceito é A ou um sub tipo de A*. Exemplificando, se temos C<B<A (C estende B que estende A), uma variável do tipo B pode ser atribuída o objeto de tipos B ou C, enquanto podemos considerar que os tipos lidos são sempre A ou B.

* Normalmente consideramos que A é super tipo e sub tipo de si próprio, logo leitura retorna um super tipo e escrita aceita um sub tipo.

Linguagens tipadas são assim e funcionam, então onde está o problema? O problema existe quando queremos ter referências para estas variáveis, C++ e C# são linguagens que suportam esse recurso. Vamos supor que referências a variaveis também funcionem com herança, isto é, uma referência à A pode ser substituída por uma à B. Vamos então examinar o pseudo-código abaixo:


C D

void foo (A &a) {
a = new D();
}

A a;
B b;

A &ra = &a; //ra é uma referencia à variável 'a'
B &rb = &b; //ra é uma referencia à variável 'b'

foo (ra); // #1
foo (rb); // #2

Em #1, tudo ocorre corretamente pois a C é um sub tipo de A, condição exigida pela operação de escrita. Porém em #2, isso não ocorre, já que C não é sub tipo de B. Nesta situação temos uma clara falha no sistema de tipos e o comportamento durante a execução é imprevisível. Poderíamos inverter a direção da herança de referências, isto é, A& é um sub tipo de B& e desta forma resolveríamos o problema do exemplo anterior. Porém não sem introduzir semelhante situação quando tivermos leitura. Este é o exemplo mais simples para demonstrar o problema da co-variância da leitura e da contra-variância da escrita.

Tendo visto esta questão, entender o problema com tipo paramétricos e simples. A grosso modo, tipos paramétricos é o termo que a comunidade de linguagens funcionais usa para generics. Um tipo paramétrico não consegue carregar para si o relacionamento de herança que existe entre seus parâmetros pois, como vimos antes, não é possível estabelecer uma relação de herança para as variáveis parametrizadas do tipo - por isso que List<String> não tem como ser um sub-tipo de List<Object>.

Uma solução parcial para é utilizar os wildcards do Java, que permite definir qual variância será utilizada. Solução similar é possível utilizando métodos genéricos em C# ou Java. Em ambos os caso, porém, não é possível definir código que ao mesmo tempo tem papel de escrita e leitura - além de criar assinaturas de métodos completamente assustadoras como as encontradas em java.util.Collections.

Por fim venho com a questão da consistencia do sistema de tipos, que deveria garantir que todas operações sobre tipos não deveriam falhar ou gerar estado inconsistente, salvo aquelas que testam explicitamente por um tipo como casts, is/as (C#) e instanceof (Java). Infelizmente não existe consistencia no sistema de tipos em linguagens como Java ou C#, que admitem herança entre arrays. Um atribuição a uma posição de um array pode falhar com ArrayStoreException/ArrayTypeMismatchException. Isso ocorre pois um array não é nada além que um vetor de referências e já sabemos que referências e herança não casam.

O mais curioso deste assunto é que não falo de um assunto que já não sabíamos, sinais claros existem vários: recomenda-se utilizar composição no lugar de estensão, frameworks baseados em herança são notoriamente mais difíceis de usar, a maioria dos sistemas hoje são modelados utilizando interfaces explicitas ou meta interfaces*. Quanto a solução, se ela se resume a apenas eliminarmos herança, não clamo por sabê-la. Digo, entretanto, que existem várias técnicas que compensam sua ausência e pretendo explorá-las em artigos futuros.

*Chamo aqui de meta interfaces aquelas que são frutos de conversão (JavaBeans) ou anotação (EJB3).

Update: apenas pequenas correções no texto.

Tags: Programming

19 responses so far ↓

  • 1 Rafael de F. Ferreira // Nov 14, 2007 at 3:02 am

    Sonoridade é soundness? Nesse caso acho que a tradução é “correção”.

    Em relação ao tema do blog, eu acho que herança é difícil de tratar mesmo, mas eu não diria que “não funciona”. Em parte pq ainda não conheço alternativa melhor — qdo eu crescer quero entender as type classes do Haskell…

    A cagada de fazer arrays covariantes não é argumento para dizer que herança é unsound. Como vc sabe, uma alternativa é declarar tipos parametrizados como invariantes por padrão. Referências podem ser relaxadas usando bounds (wildcards) e limitando o uso à locais seguros. Por exemplo uma declaração com upper bound como em X limita às chamadas com A em posição covariante (i.e. retorno de método). Lower bounds são simétricos.

    Uma alternativa que eu acho um pouco mais fácil que wildcards é Scala que usa definition-site variance. O princípio é o mesmo, só que a definição da direção da variância é feita na declaração do parâmetro de tipo. O Burak Emir fala um pouco sobre isso durante a discussão sobre erasure aqui: http://lamp.epfl.ch/~emir/bqbase/2006/10/16/erasure.html .

  • 2 kumpera // Nov 14, 2007 at 10:14 am

    Rafael, verdade, não dá para usar sonoro como tradução de soundness, apesar de que apenas correção não carregar todo o significado.

    Como eu falei no artigo, na presença de Herança não é possivel criar um calculo OO tipado. Isso é mais uma falta de resultado que propriamente um.

    O Luca Cardelli estudou isso por um década e não chegou em lugar algum. Outros tentaram a mesma coisa e chegaram em resultados parecidos.

    Tanto Java quanto Scala possuem um modelo falho de tipos parametrizados. Scala, assim como a CLR, permite informar a variância de cada parâmetro, porém isso nem sempre é possivel.

    Com containers isso é claramente impossível, já que sua interface admite operações com variância nas duas direções. Poderíamos dividir em duas interfaces com anotações diferentes, mas isso tornaria tudo muito mais difícil.

    Quanto a arrays serem covariantes, caso não fossem, seria necessário que todas funções que lidam com eles fossem parametrizadas. Isso não é de maneira alguma prática.

    Usar bounds com referências resolve o problema da mesma maneira que resolve para collections. Porém isso complica bastante na hora de definir a variância da assinaturas de métodos.

    Quanto a uma alternativa. A um bom tempo o James Gosling disse que preferiria se Java não tivesse herança e usasse apenas de composição e interfaces. Os problemas a época eram a falta de pesquisa de como construir uma linguagem baseada em composição e, principalmente, o mindset do público alvo da linguagem.

    Uma linguagem que use apenas interfaces, structural/union/algebraic types e composição vai ter um sistema de tipos compatível com outro que suporte herança – um pode ser reduzido ao outro. Vai ser também mais simples de ser tipado estaticamente já que não vai exigir anotação de variancia para tudo quanto é lado.

    Talvez uma linguagem no qual todos métodos sejam implicitamente paramétricos no tipo de suas variáveis também funcione, mas aqui já estou especulando.

  • 3 Fernando Boaglio // Nov 14, 2007 at 1:44 pm

    Um dos princípios da OO é representar o mundo real, coisa que ela consegue fazer bem até. O grande problema que eu vejo é que a herança no mundo real é bem restrita e a herança OO é bem ampla, com a possibilidade de fazer besteiras monstruosas, como a clássica Properties extends Hashtable =)

  • 4 Rafael de F. Ferreira // Nov 14, 2007 at 2:42 pm

    “O Luca Cardelli estudou isso por um década e não chegou em lugar algum. Outros tentaram a mesma coisa e chegaram em resultados parecidos.”
    Agora estou beeem out of my depth (to com o TAPL estacionado na minha estante faz uns meses), mas e o Featherweight Java ou o vObj ou o próprio Object Calculus do Cardelli ou o cálculo do Drossopoulou e Eisenbach? Estou perguntando mesmo, pq nunca li nenhum desses sistemas.

    “Com containers isso é claramente impossível, já que sua interface admite operações com variância nas duas direções.”
    Estruturas de dados funcionais se misturam muito bem com defition site variance.

    Dado tipos A<B x2:X[+B]

    Para isso funcionar o método add precisa declarar
    que recebe um supertipo qualquer de T, ou seja, usar T como lower bound:
    X[+T] { def +[R >: T](obj:R): X[R] = …. }

    Ou seja, o problema mesmo é a mutabilidade.

    “Quanto a arrays serem covariantes, caso não fossem, seria necessário que todas funções que lidam com eles fossem parametrizadas. Isso não é de maneira alguma prática.”
    IMHO arrays só devem ser usados por motivo de performance, tipo em rotinas numéricas. Nesse caso não vejo problema em deixá-los invariantes. Em todos os demais casos dá para usar uma coleção parametrizada qualquer.

    “Quanto a uma alternativa. A um bom tempo o James Gosling disse que preferiria se Java não tivesse herança e usasse apenas de composição e interfaces.”
    Hmmm. Interessante, mas muitos dos problemas que vc levantou são oriundos da relação de subtipo em linguagens OO, e interfaces tb sofrem deles.
    Se vc remover estado mutável (destructive assignments) acho que simplificaria muita coisa, mas daí fica difícil chamar de OO.

  • 5 Bruno // Nov 14, 2007 at 2:43 pm

    Eu traduziria ’sound’ como ‘consistente’… Fica claro, não fica?

  • 6 Rafael de F. Ferreira // Nov 14, 2007 at 2:44 pm

    O wordpress comeu o código. Vou tentar de novo…

    Dado tipos A<B<C e X[+T]
    Um container x:X[A]
    Voce pode adicionar um b:B para obter um x2:X[B]
    (x:X[+A] add b:B) —-> x2:X[+B]

    Para isso funcionar o método add precisa declarar
    que recebe um supertipo qualquer de T, ou seja, usar T como lower bound:
    X[+T] { def +[R >: T](obj:R): X[R] = …. }

  • 7 kumpera // Nov 14, 2007 at 4:19 pm

    Rafael, é sim possivel construir containers com suporte a herança, mas somente se eles forem imutaveis, caso contrário você fica meio que preso na armadilha que é precisar de variancia para todo lado.

    Como você mesmo falou, mutabilidade é o que torna herança problemática e OOP sem destructuring assignment seria uma novidade para mim.

    Quanto a eliminar herança. Com interfaces o problema quase desaparece pois elas seriam definidas apenas em termos de composição (sem subtipagem entre elas). Elimine o root object e pronto.

  • 8 kumpera // Nov 14, 2007 at 4:49 pm

    Aproveitando, o Cardelli conseguiu sim construir um cálculo OO depois de mais de 1 década pesquisando sobre o assunto. O paper dele de 95 detalha isso.

    Porém acho muito mais interessante ler aquilo que ele escreveu no final dos anos 80, mostrando a dificuldade de se provar soundness em sistemas com sub-tipos – problema que o sistema de tipos do Haskel de certa forma contorna.

    Mas voltando ao calculo imperativo que ele propos, são impostas uma séries de restrições a sub-tipagem de maneira não só a simplificar, mas também tornar possivel o modelo.

    Curiosamente boa parte do embrólio se resume a sub tipagem nominal ou estrutural. Que é exatamente o problema que ainda não tem uma resposta satisfatoria.

  • 9 Proteu Alcebidiano // Nov 16, 2007 at 2:01 am

    Rodrigo, o que acha de classes abertas (comumente usadas no ruby)? Parece que o conceito fica a favor do texto que você escreveu, além da escolha de usar composição ao invés de herança.

    T+

    P.S: Eu curto esse tema do wordpress, também uso no meu blog! Até

  • 10 kumpera // Nov 16, 2007 at 8:41 am

    Proteu, open classes do ruby são uma ótima solução, mas eu acredito que é um recurso desnecessário na presença de um bom sistema de meta-classes.

  • 11 Proteu Alcebidiano // Nov 18, 2007 at 11:28 pm

    Rodrigo, outra coisa que me fez lembrar indiretamente de sua postagem mais recente:

    http://www.infoq.com/news/2007/11/qi4j-intro

    T+

  • 12 Marcos Silva Pereira // Nov 19, 2007 at 11:06 pm

    Rodrigo, classes abertas não são uma maneira de implementar um sistema de meta-classes? Ou vc fala de algo como o MOP de Groovy (onde há “class” e “metaclass”)?

    E mais, o que vc acha de protótipos como alternativa?

    valeuz…

  • 13 Christiano Milfont // Nov 21, 2007 at 9:15 am

    Isso, e quanto ao prototype-based OO?
    No javascript vários frameworks implementaram facilmente a herança múltipla pelas características da linguagem.
    Louds, e qual seria a melhor alternativa para substituir a herança? Exemplo prático, tenho uma classe Documento em um sistema onde extendo para suas especializações como por exemplo RequisicaoVeiculo, que tem propriedades distintas a esse tipo de documento. Seria uma abordagem de composição melhor do que Herança nesse caso?

  • 14 Andrei // Nov 22, 2007 at 2:03 am

    Quanto a uma tradução para “type soundness”, eu não sei. Vi um dia desses em francês, uma “proof of type soundness” é “preuve de preservation de types”, que captura bem a idéia. Uma prova de type soundness é uma garantia que os tipos são preservados em cada passo da avaliação dos termos, não? (sendo que a “preservação” inclui a idéia de subsumption — tb não sei como traduzir — de sistemas com sub-tipos).

    Sobre herança não funcionar, talvez seja um pouco extremo, mas certamente a herança sempre foi superestimada. Hoje em dia conselhos como “prefira composição ao invés de herança” são comuns, e pra mim é difícil achar bom um projeto com hierarquias profundas. Acredito que em alguns domínios a herança seja bem útil, embora seja possível se virar sem ela. Bem, eu tenho programado quase que exclusivamente em linguagens funcionais nos últimos tempos, e não sinto falta de nada OO. Já quando eu uso uma linguagem OO, por outro lado…

    E adicione outra dificuldade causada por sistemas com subtipos: a inferência global de tipos fica indecidível. É por isso que Scala, por exemplo, usa inferência de tipos local, e é o mesmo motivo que ninguém conseguiu integrar muito bem subtipos com o sistema Hindley-Milner, até hoje. As type classes em Haskell são bem legais e podem simular herança de interfaces, mas não cria problemas de variância — e, mesmo assim, torna a inferência indecidível em geral, requerendo um maior uso de anotações de tipos no código. Eu acho interessante que, em ML (OCaml, SML) é muito raro ver código com anotações de tipos explícitas, enquanto que em Haskell isso é bem comum. Um dos motivos são as type classes.

  • 15 Andrei // Nov 22, 2007 at 2:23 am

    Ah, faltou uma coisa: sobre os problemas teóricos de variância mencionados no post, eles são causados pela combinação da herança com outras características das linguagens OO, como mutabilidade de variáveis. Então em resumo teria duas soluções: remove a mutabilidade de variáveis ou remove a herança. Claro que para quem programa em linguagens OO é provavelmente mais simples tirar a herança do que a mutabilidade, mas não é necessariamente a herança que é a raiz do problema.

    No capítulo sobre sub-tipos do TAPL ele formaliza arrays só para leitura e só para escrita que são corretas com relação à variância. Se isso seria utilizável em uma linguagem real, não sei. O interessante é que marcar uma variável como mutável (como acontece na família ML) poderia ter outros usos também. Mas isso já são questões de language design, divago.

  • 16 kumpera // Nov 24, 2007 at 4:27 pm

    Marcos, classes abertas são uma limitada de meta-programação, pretendo escrever sobre isso em um artigo futuro.

    Andrei, a interpretação de “type soundness” esta relacionada a correta preservação e dos tipos mediante a qualquer operação, porem não deixa de ser importante possuir um termo curto para descreve-la, ou do contrario teríamos textos de demasia dificuldade de leitura.

    Mutabilidade definitivamente entra em conflito com herança. Pessoalmente, fico com mutabilidade, mesmo em vista de todas as facilidades advindo dela. Herança pode ser substituída por composição e anotação explicita de variância no caso de variáveis de referencia, tuplas ou arrays – por sinal, tuplas mutáveis são uma coisa meio bizarra.

  • 17 problemas da herança « TJRN Developers // Dec 7, 2007 at 9:10 am

    [...] post desse blog aborda sobre [...]

  • 18 Herança não funciona, parte 2 | Kumpera.net delírios sobre linguagens de programação // Dec 31, 2007 at 1:27 am

    [...] escrevi anteriormente que herança não funciona meu objetivo não era realmente provar isso, mas atentar ao fato de que [...]

  • 19 Herança não funciona, parte III | Rodrigo Kumpera Weblog // Dec 1, 2008 at 11:35 am

    [...] artigos anteriores eu descrevi algum dos problemas associados com herança, ou subtipagem, e questões [...]

Leave a Comment