Por que as linguagens OOP estáticas strongs mainstream impedem a herança de primitivas?

53

Por que isso é OK e principalmente esperado:

abstract type Shape
{
   abstract number Area();
}

concrete type Triangle : Shape
{
   concrete number Area()
   {
      //...
   }
}

... enquanto isso não está certo e ninguém reclama:

concrete type Name : string
{
}

concrete type Index : int
{
}

concrete type Quantity : int
{
}

Minha motivação é maximizar o uso do sistema de tipos para verificação de correção em tempo de compilação.

PS: sim, eu li este e o wrapping é uma forma de trabalho hacky.

    
por Den 10.08.2016 / 11:37
fonte

10 respostas

83

Eu presumo que você esteja pensando em linguagens como Java e C #?

Nesses idiomas, os primitivos (como int ) são basicamente um compromisso para o desempenho. Eles não suportam todos os recursos de objetos, mas são mais rápidos e com menos sobrecarga.

Para que os objetos suportem herança, cada instância precisa "saber" em tempo de execução de qual classe é uma instância. Caso contrário, os métodos substituídos não podem ser resolvidos no tempo de execução. Para objetos, isso significa que os dados da instância são armazenados na memória junto com um ponteiro para o objeto de classe. Se essas informações também devem ser armazenadas junto com valores primitivos, os requisitos de memória aumentariam. Um valor inteiro de 16 bits exigiria seus 16 bits para o valor e, adicionalmente, 32 ou 64 bits de memória para um ponteiro para sua classe.

Além da sobrecarga de memória, você também esperaria poder substituir operações comuns em primitivos como operadores aritméticos. Sem a subtipagem, operadores como + podem ser compilados em uma instrução de código de máquina simples. Se pudesse ser substituído, você precisaria resolver métodos em tempo de execução, uma operação muito mais custosa. (Você deve saber que o C # suporta a sobrecarga do operador - mas isso não é o mesmo. A sobrecarga do operador é resolvida em tempo de compilação, portanto, não há nenhuma penalidade de tempo de execução padrão.)

As cordas não são primitivas, mas ainda são "especiais" na forma como são representadas na memória. Por exemplo, eles são "internados", o que significa que duas cadeias de caracteres literais que são iguais podem ser otimizadas para a mesma referência. Isso não seria possível (ou, pelo menos, muito menos efetivo) se as instâncias de string também mantivessem o controle da classe.

O que você descreve certamente seria útil, mas o suporte exigiria uma sobrecarga de desempenho para cada uso de primitivos e cadeias de caracteres, mesmo quando eles não tirassem proveito da herança.

A linguagem Smalltalk faz (acredito) permitir a subclasse de inteiros. Mas quando o Java foi projetado, o Smalltalk foi considerado muito lento, e a sobrecarga de ter tudo como um objeto foi considerada uma das principais razões. Java sacrificou um pouco de elegância e pureza conceitual para obter melhor desempenho.

    
por 10.08.2016 / 13:24
fonte
20

O que alguns idiomas propõem não é subclassificação, mas subtipagem . Por exemplo, Ada permite criar tipos derivados ou . A seção Sistema de Programação Ada / Tipo vale a pena ser lida para entender todos os detalhes. Você pode restringir o intervalo de valores, que é o que você deseja na maior parte do tempo:

 type Angle is range -10 .. 10;
 type Hours is range 0 .. 23; 

Você pode usar os dois tipos como Inteiros se os converter explicitamente. Note também que você não pode usar um no lugar de outro, mesmo quando os intervalos são estruturalmente equivalentes (os tipos são verificados por nomes).

 type Reference is Integer;
 type Count is Integer;

Os tipos acima são incompatíveis, mesmo que representem o mesmo intervalo de valores.

(Mas você pode usar o Unchecked_Conversion; não diga às pessoas que eu lhe contei isso)

    
por 10.08.2016 / 12:20
fonte
16

Acho que isso pode muito bem ser uma pergunta X / Y. Pontos importantes, da pergunta ...

My motivation is maximising the use of type system for compile-time correctness verification.

... e de seu comentário elaborando:

I don't want to be able to substitute one for another implicitly.

Desculpe-me se estou perdendo alguma coisa, mas ... Se estes são seus objetivos, então por que você está falando sobre herança? Substituibilidade implícita é ... como ... toda a sua coisa. Sabe, o Princípio da Substituição Liskov?

O que você parece querer, na realidade, é o conceito de um "typedef strong" - em que algo "é", e. um int em termos de intervalo e representação mas não pode ser substituído em contextos que esperam um int e vice-versa. Eu sugeriria pesquisar por informações sobre esse termo e qualquer que seja o idioma escolhido. Novamente, é praticamente o oposto da herança.

E para aqueles que podem não gostar de uma resposta X / Y, acho que o título ainda pode ser respondido com referência ao LSP. Os tipos primitivos são primitivos porque fazem algo muito simples, e é tudo o que fazem . Permitir que eles sejam herdados e, assim, tornar infinitos seus possíveis efeitos levaria a uma grande surpresa, na melhor das hipóteses, e à violação fatal de LSP, na pior das hipóteses. Se eu puder assumir de forma otimista que o Thales Pereira não se importará em citar este comentário fenomenal:

There is the added problem that If someone was able to inherit from Int, you would have innocent code like "int x = y + 2" (where Y is the derived class) that now writes a log to the Database, opens a URL and somehow resurrect Elvis. Primitive types are supposed to be safe and with more or less guaranteed, well-defined behavior.

Se alguém vê um tipo primitivo, em uma linguagem sã, eles corretamente presumem que sempre farão sua pequena coisa, muito bem, sem surpresas. Os tipos primitivos não têm declarações de classe disponíveis que sinalizem se podem ou não ser herdados e ter seus métodos ignorados. Se eles fossem, seria realmente surpreendente (e totalmente quebrar a compatibilidade, mas estou ciente de que é uma resposta para 'porque X não foi projetado com Y').

... embora, como Mooing Duck apontou em resposta, as linguagens que permitem a sobrecarga do operador permitem que o usuário se confunda em uma extensão semelhante ou igual, se realmente quiser, então é duvidoso se esse último argumento vale a pena. E vou parar de resumir os comentários de outras pessoas agora, heh.

    
por 10.08.2016 / 22:19
fonte
4

Para permitir a herança com o despacho virtual 8, o que geralmente é considerado desejável no design do aplicativo, é necessário ter informações sobre o tipo de tempo de execução. Para cada objeto, alguns dados referentes ao tipo de objeto precisam ser armazenados. Um primitivo, por definição, não tem essa informação.

Existem duas linguagens OOP principais (gerenciadas, executadas em uma VM) que apresentam primitivas: C # e Java. Muitas outras linguagens não têm primitivas em primeiro lugar, ou usam raciocínio similar para permitir / usar elas.

Primitivos são um compromisso para o desempenho. Para cada objeto, você precisa de espaço para seu cabeçalho de objeto (em Java, normalmente 2 * 8 bytes em VMs de 64 bits), além de seus campos, além de eventuais preenchimentos (no Hotspot, cada objeto ocupa um número de bytes que é um múltiplo de 8). Portanto, um objeto int as precisaria de pelo menos 24 bytes de memória para serem mantidos, em vez de apenas 4 bytes (em Java).

Assim, tipos primitivos foram adicionados para melhorar o desempenho. Eles fazem um monte de coisas mais fáceis. O que significa a + b se ambos forem subtipos de int ? Algum tipo de dispathcing tem que ser adicionado para escolher a adição correta. Isso significa envio virtual. Ter a capacidade de usar um opcode muito simples para a adição é muito, muito mais rápido e permite otimizações em tempo de compilação.

String é outro caso. Tanto em Java quanto em C #, String é um objeto. Mas em C # está selado, e em Java é final. Isso porque as bibliotecas padrão Java e C # exigem que String s seja imutável, e subclassificá-las quebraria essa imutabilidade.

No caso do Java, a VM pode (e faz) estagiar Strings e "pool", permitindo um melhor desempenho. Isso só funciona quando Strings são verdadeiramente imutáveis.

Além disso, um raramente precisa criar subclasses de tipos primitivos. Enquanto os primitivos não puderem ser subclassificados, há muitas coisas legais que a matemática nos diz sobre elas. Por exemplo, podemos ter certeza de que a adição é comutativa e associativa. Isso é algo que a definição matemática de números inteiros nos diz. Além disso, podemos facilmente criar invariantes sobre loops por indução em muitos casos. Se permitirmos a subclassificação de int , perderemos as ferramentas que a matemática nos fornece, porque não podemos mais garantir que certas propriedades sejam mantidas. Assim, eu diria que a capacidade não de poder subclassificar tipos primitivos é realmente uma coisa boa. Menos coisas que alguém pode quebrar, além de um compilador, podem frequentemente provar que ele tem permissão para fazer certas otimizações.

    
por 10.08.2016 / 23:03
fonte
4

Em linguagens OOP estáticas strongs, a sub-digitação é vista principalmente como uma maneira de estender um tipo e sobrescrever os métodos atuais do tipo.

Para fazer isso, 'objetos' contém um ponteiro para o seu tipo. Isso é uma sobrecarga: o código em um método que usa uma instância Shape primeiro tem que acessar as informações de tipo dessa instância, antes de saber o método correto Area() para chamar.

Uma primitiva tende a permitir apenas operações nela que podem se traduzir em instruções de linguagem de máquina única e não carregam nenhuma informação de tipo com elas. Fazer um número inteiro mais lento para que alguém pudesse criar uma subclasse não era atraente o bastante para impedir que qualquer idioma que o fizesse se tornasse mainstream.

Então a resposta para:

Why do mainstream strong static OOP languages prevent inheriting primitives?

é:

  • Houve pouca demanda
  • E isso teria tornado a linguagem muito lenta
  • A subdivisão foi vista principalmente como uma maneira de estender um tipo, em vez de uma maneira de obter uma verificação de tipo estática melhor (definida pelo usuário).

No entanto, estamos começando a obter linguagens que permitem a verificação estática com base nas propriedades de variáveis diferentes de 'type', por exemplo, F # tem "dimensão" e "unidade" para que você não possa, por exemplo, adicionar um comprimento para uma área.

Existem também linguagens que permitem 'tipos definidos pelo usuário' que não mudam (ou trocam) o que um tipo faz, mas apenas ajudam na verificação do tipo estático; veja a resposta do coredump.

    
por 10.08.2016 / 13:39
fonte
3

Não tenho certeza se estou ignorando algo aqui, mas a resposta é bastante simples:

  1. A definição de primitivos é: valores primitivos não são objetos, tipos primitivos não são tipos de objetos, primitivos não fazem parte do sistema de objetos.
  2. Herança é uma característica do sistema de objetos.
  3. Ergo, os primitivos não podem participar da herança.

Note que existem apenas duas linguagens OOP estáticas strongs, as quais possuem primitivas , AFAIK: Java e C ++. (Na verdade, eu nem tenho certeza sobre o último, eu não sei muito sobre o C ++, e o que eu achei na pesquisa foi confuso.)

Em C ++, as primitivas são basicamente herdadas (trocadilhos intencionais) de C. Portanto, elas não participam do sistema de objetos (e, portanto, da herança) porque C não possui um sistema de objetos nem herança.

Em Java, as primitivas são o resultado de uma tentativa equivocada de melhorar o desempenho. As primitivas também são os únicos tipos de valor no sistema; na verdade, é impossível escrever tipos de valor em Java, e é impossível que os objetos sejam tipos de valor. Então, além do fato de que as primitivas não participam do sistema de objetos e, portanto, a idéia de "herança" nem faz sentido, mesmo se você pudesse herdar delas, você não o faria t ser capaz de manter o "valor-ness". Isto é diferente de, e. C♯ que faz tem tipos de valor ( struct s), que, no entanto, são objetos.

Outra coisa é que não poder herdar também não é exclusivo de primitivos. Em C♯, struct s implicitamente herdam de System.Object e podem implementar interface s, mas não podem herdar nem herdar class es ou struct s. Além disso, sealed class es não pode ser herdado de. Em Java, final class es não pode ser herdado de.

tl; dr :

Why do mainstream strong static OOP languages prevent inheriting primitives?

  1. primitivos não fazem parte do sistema de objetos (por definição, se fossem, eles não seriam primitivos), a idéia de herança está ligada ao sistema de objetos, a herança ergo-primitiva é uma contradição em termos
  2. primitivos não são exclusivos, muitos outros tipos não podem ser herdados também ( final ou sealed em Java ou C♯, struct s em C♯, case class es em Scala)
por 10.08.2016 / 22:07
fonte
2

Joshua Bloch em “Effective Java” recomenda projetar explicitamente para herança ou proibi-lo. As classes primitivas não são projetadas para herança porque são projetadas para serem imutáveis e permitir que a herança mude isso nas subclasses, quebrando assim o Princípio de Liskov e seria uma fonte de muitos bugs.

De qualquer forma, por que este é um hacky solução alternativa? Você deve preferir composição sobre herança. Se a razão é desempenho do que você tem um ponto e a resposta à sua pergunta é que não é possível colocar todos os recursos em Java, porque leva tempo para analisar todos os diferentes aspectos da adição de um recurso. Por exemplo, Java não tem Generics antes de 1.5.

Se você tiver muita paciência, então você tem sorte porque existe um plano para adicionar classes de valor para Java, que lhe permitirá criar as suas classes de valor que o ajudarão a aumentar o desempenho e, ao mesmo tempo, lhe dará mais flexibilidade.

    
por 10.08.2016 / 17:42
fonte
2

No nível abstrato, você pode incluir o que quiser em um idioma que está criando.

No nível de implementação, é inevitável que algumas dessas coisas sejam mais simples de implementar, algumas sejam complicadas, algumas possam ser feitas rapidamente, algumas sejam mais lentas e assim por diante. Para dar conta disso, os designers muitas vezes precisam tomar decisões difíceis e compromissos.

No nível da implementação, uma das maneiras mais rápidas de acessar uma variável é descobrir seu endereço e carregar o conteúdo desse endereço. Existem instruções específicas na maioria das CPUs para carregar dados de endereços e essas instruções geralmente precisam saber quantos bytes eles precisam carregar (um, dois, quatro, oito, etc) e onde colocar os dados que eles carregam (registrador único, registrador par, registro estendido, outra memória, etc). Conhecendo o tamanho de uma variável, o compilador pode saber exatamente qual instrução emitir para usos dessa variável. Por não saber o tamanho de uma variável, o compilador precisaria recorrer a algo mais complicado e provavelmente mais lento.

No nível abstrato, o ponto de subtipagem é poder usar instâncias de um tipo onde um tipo igual ou mais geral é esperado. Em outras palavras, o código pode ser escrito esperando que um objeto de um tipo particular ou algo mais derivado, sem saber antes do tempo o que exatamente isso seria. E claramente, como tipos mais derivados podem adicionar mais membros de dados, um tipo derivado não tem necessariamente os mesmos requisitos de memória que seus tipos de base.

No nível da implementação, não há uma maneira simples para uma variável de tamanho predeterminado manter uma instância de tamanho desconhecido e ser acessada de uma forma que você normalmente chamaria de eficiente. Mas há uma maneira de mover as coisas um pouco e usar uma variável para não armazenar o objeto, mas para identificar o objeto e deixar que esse objeto seja armazenado em outro lugar. Dessa forma, é uma referência (por exemplo, um endereço de memória) - um nível extra de indireção que garante que uma variável só precisa conter algum tipo de informação de tamanho fixo, contanto que possamos encontrar o objeto através dessa informação. Para conseguir isso, precisamos apenas carregar o endereço (tamanho fixo) e, então, podemos trabalhar normalmente usando esses deslocamentos do objeto que sabemos serem válidos, mesmo que esse objeto tenha mais dados em deslocamentos que não conhecemos. Podemos fazer isso porque não nos preocupamos com seus requisitos de armazenamento ao acessá-lo mais.

No nível abstrato, esse método permite que você armazene uma (referência a) string em uma variável object sem perder as informações que a tornam string . É bom para todos os tipos de trabalhar assim e você também pode dizer que é elegante em muitos aspectos.

Ainda assim, no nível de implementação, o nível extra de indireção envolve mais instruções e, na maioria das arquiteturas, isso torna cada acesso ao objeto um pouco mais lento. Você pode permitir que o compilador extraia mais desempenho de um programa se você incluir em sua linguagem alguns tipos comumente usados que não tenham esse nível extra de indireção (a referência). Mas, removendo esse nível de indireção, o compilador não pode mais permitir que você subtipule de maneira segura a memória. Isso porque, se você adicionar mais membros de dados ao seu tipo e atribuir a um tipo mais geral, todos os membros de dados extras que não couberem no espaço alocado para a variável de destino serão cortados.

    
por 11.08.2016 / 18:43
fonte
1

Em geral

Se uma classe é abstrata (metáfora: uma caixa com furo (s)), tudo bem (até mesmo necessário para algo útil!) para "preencher o furo (s)", é por isso que subclassificamos classes abstratas.

Se uma classe é concreta (metáfora: uma caixa cheia), não há problema em alterar a existente porque, se estiver cheia, está cheia. Não temos espaço para adicionar algo mais dentro da caixa, por isso não devemos subclassificar classes de concreto.

Com primitivos

Primitivos são classes concretas por design. Eles representam algo que é bem conhecido, totalmente definido (eu nunca vi um tipo primitivo com algo abstrato, senão não é mais um primitivo) e amplamente usado através do sistema. Permitir subclasses de um tipo primitivo e fornecer sua própria implementação a outras pessoas que dependem do comportamento projetado de primitivos pode causar muitos efeitos colaterais e enormes danos!

    
por 10.08.2016 / 13:53
fonte
1

Normalmente, a herança não é a semântica desejada, porque você não pode substituir seu tipo especial em qualquer lugar em que uma primitiva é esperada. Para emprestar do seu exemplo, um Quantity + Index não faz sentido semanticamente, portanto, um relacionamento de herança é o relacionamento errado.

No entanto, várias linguagens têm o conceito de um tipo de valor que expressa o tipo de relacionamento que você está descrevendo. Scala é um exemplo. Um tipo de valor usa uma primitiva como representação subjacente, mas possui uma identidade de classe e operações diferentes no exterior. Isso tem o efeito de estender um tipo primitivo, mas é mais uma composição do que um relacionamento de herança.

    
por 12.08.2016 / 18:10
fonte