Quais são as advertências da implementação de tipos fundamentais (como int) como classes?

27

Ao projetar e implantar uma linguagem de programação orientada a objetos, em algum momento é preciso fazer uma escolha sobre a implementação de tipos fundamentais (como int , float, doubleou equivalentes) como classes ou outra coisa. Claramente, as linguagens da família C tendem a não defini-las como classes (Java possui tipos primitivos especiais, o C # as implementa como estruturas imutáveis, etc.).

Posso pensar em uma vantagem muito importante quando tipos fundamentais são implementados como classes (em um sistema de tipos com uma hierarquia unificada): esses tipos podem ser subtipos apropriados de Liskov do tipo raiz. Assim, evitamos complicar o idioma com boxe / unboxing (explícito ou implícito), tipos de wrapper, regras especiais de variação, comportamento especial etc.

Claro, eu posso entender parcialmente por que os designers de linguagem decidem da maneira que fazem: as instâncias de classe tendem a ter uma sobrecarga espacial (porque as instâncias podem conter uma tabela de dados ou outros metadados em seu layout de memória), que as primitivas / estruturas não precisam have (se o idioma não permitir herança nesses).

A eficiência espacial (e a localização espacial aprimorada, especialmente em matrizes grandes) são a única razão pela qual tipos fundamentais geralmente não são classes?

Geralmente, eu suponho que a resposta seja sim, mas os compiladores têm algoritmos de análise de escape e, portanto, podem deduzir se podem (seletivamente) omitir a sobrecarga espacial quando uma instância (qualquer instância, não apenas um tipo fundamental) é estritamente estrita. local.

O que foi dito acima está errado ou há algo que esteja faltando?

Theodoros Chatzigiannakis
fonte

Respostas:

19

Sim, tudo se resume à eficiência. Mas você parece estar subestimando o impacto (ou superestimando o quão diversas otimizações funcionam).

Primeiro, não é apenas "sobrecarga espacial". Fazer primitivas encaixotadas / alocadas em heap também tem custos de desempenho. Há uma pressão adicional no GC para alocar e coletar esses objetos. Isso vale duplamente se os "objetos primitivos" são imutáveis, como deveriam ser. Depois, há mais falhas de cache (por causa da indireção e porque menos dados se encaixam em uma determinada quantidade de cache). Além disso, o simples fato de "carregar o endereço de um objeto e carregar o valor real a partir desse endereço" requer mais instruções do que "carregar o valor diretamente".

Segundo, a análise de escape não é um pó de fada mais rápido. Isso se aplica apenas a valores que, bem, não escapam. Certamente, é bom otimizar cálculos locais (como contadores de loop e resultados intermediários de cálculos) e isso trará benefícios mensuráveis. Mas uma maioria muito maior de valores vive nos campos de objetos e matrizes. É verdade que eles podem estar sujeitos à análise de escape, mas como geralmente são tipos de referência mutáveis, qualquer apelido deles representa um desafio significativo à análise de escape, que agora precisa provar que esses apelidos (1) também não escapam. , e (2) não fazem diferença com o objetivo de eliminar alocações.

Dado que chamar qualquer método (incluindo getters) ou passar um objeto como argumento para qualquer outro método pode ajudar o objeto a escapar, você precisará de uma análise interprocedural em todos os casos, exceto nos mais triviais. Isso é muito mais caro e complicado.

E há casos em que as coisas realmente escapam e não podem ser razoavelmente otimizadas. Muitos deles, na verdade, se você considerar com que freqüência os programadores C enfrentam o problema de alocar coisas de pilha. Quando um objeto que contém um int escapa, a análise de escape deixa de se aplicar ao int também. Diga adeus aos campos primitivos eficientes .

Isso se vincula a outro ponto: as análises e otimizações necessárias são seriamente complicadas e uma área ativa de pesquisa. É discutível se alguma implementação de linguagem alcançou o grau de otimização sugerido e, mesmo assim, tem sido um esforço raro e hercúlea. Certamente, ficar sobre os ombros desses gigantes é mais fácil do que ser você mesmo um gigante, mas ainda está longe de ser trivial. Não espere desempenho competitivo a qualquer momento nos primeiros anos, se é que alguma vez.

Isso não quer dizer que essas línguas não possam ser viáveis. Claramente eles são. Apenas não assuma que será linha por linha tão rápido quanto os idiomas com primitivas dedicadas. Em outras palavras, não se iluda com visões de um compilador suficientemente inteligente .


fonte
Ao falar sobre análise de escape, também quis dizer alocar para armazenamento automático (não resolve tudo, mas, como você diz, resolve algumas coisas). Também admito que subestimei até que ponto os campos e aliases poderiam fazer com que a análise de escape falhasse com mais frequência. Perdas em cache são as coisas que mais me preocuparam quando falamos em eficiência espacial, então obrigado por abordar isso.
Theodoros Chatzigiannakis 01/01
@TheodorosChatzigiannakis Incluo mudanças na estratégia de alocação na análise de escape (porque, honestamente, essa parece ser a única coisa para a qual é usada).
No seu segundo parágrafo: os objetos nem sempre precisam ser alocados em heap ou ser tipos de referência. De fato, quando não são, isso torna as otimizações necessárias comparativamente fáceis. Veja os objetos alocados por pilha do C ++ para um exemplo anterior, e o sistema de propriedade da Rust para uma maneira de fazer análises de escape diretamente na linguagem.
amon
@ amon eu sei, e talvez eu devesse ter deixado isso mais claro, mas parece que o OP está interessado apenas em linguagens Java e C #, onde a alocação de heap é quase obrigatória (e implícita) por causa da semântica de referência e dos lançamentos sem perdas entre subtipos. Bom ponto sobre Rust usando o que equivale a escapar da análise!
@delnan É verdade que estou interessado principalmente em idiomas que abstraem os detalhes de armazenamento, mas sinta-se à vontade para incluir qualquer coisa que considere relevante, mesmo que não seja aplicável nesses idiomas.
Theodoros Chatzigiannakis 01/01
27

A eficiência espacial (e a localização espacial aprimorada, especialmente em matrizes grandes) são a única razão pela qual tipos fundamentais geralmente não são classes?

Não.

A outra questão é que tipos fundamentais tendem a ser usados ​​por operações fundamentais. O compilador precisa saber que int + intnão será compilado em uma chamada de função, mas em alguma instrução elementar da CPU (ou código de byte equivalente). Nesse ponto, se você tiver o intobjeto como normal, terá que efetivamente desmarcar a coisa de qualquer maneira.

Esse tipo de operação também não funciona muito bem com subtipagem. Você não pode enviar para uma instrução de CPU. Você não pode enviar a partir de uma instrução de CPU. Quero dizer, todo o ponto de subtipagem é para que você possa usar um Donde você pode a B. As instruções da CPU não são polimórficas. Para que os primitivos façam isso, é necessário agrupar suas operações com uma lógica de despacho que custa várias vezes a quantidade de operações como uma adição simples (ou qualquer outra coisa). O benefício de intfazer parte da hierarquia de tipos se torna um pouco discutível quando é selado / final. E isso é ignorar todas as dores de cabeça com lógica de despacho para operadores binários ...

Basicamente, os tipos primitivos precisariam ter muitas regras especiais sobre como o compilador lida com eles e o que o usuário pode fazer com seus tipos de qualquer maneira ; portanto, muitas vezes é mais simples tratá-los como completamente distintos.

Telastyn
fonte
4
Confira a implementação de qualquer uma das linguagens de tipo dinâmico que tratam números inteiros e objetos. A instrução final primitiva da CPU pode muito bem estar oculta em um método (sobrecarga do operador) na implementação de classe apenas um pouco privilegiada na biblioteca de tempo de execução. Os detalhes pareceriam diferentes com um sistema de tipo estático e compilador, mas não é um problema fundamental. Na pior das hipóteses, isso torna as coisas ainda mais lentas.
3
int + intpode ser um operador regular no nível do idioma que chama uma instrução intrínseca que é garantida para compilar (ou se comportar como) a adição de número inteiro de CPU nativa op. O benefício de intherdar objectnão é apenas a possibilidade de herdar outro tipo int, mas também a possibilidade de um intcomportamento como objectsem boxe. Considere os genéricos de C #: você pode ter covariância e contravariância, mas elas são aplicáveis ​​apenas aos tipos de classe - os tipos de estrutura são excluídos automaticamente, porque só podem se tornar objectatravés de boxe (implícito, gerado pelo compilador).
Theodoros Chatzigiannakis 01/01
3
@delnan - claro, embora na minha experiência com implementações estaticamente tipadas, uma vez que todas as chamadas não pertencentes ao sistema se resumem às operações primitivas, a sobrecarga causa um impacto dramático no desempenho - que, por sua vez, tem um efeito ainda mais dramático na adoção.
Telastyn 01/01
@TheodorosChatzigiannakis - ótimo, para que você possa obter variação e contravariância em tipos que não têm sub / supertipo útil ... E implementar esse operador especial para chamar a instrução da CPU ainda o torna especial. Não estou discordando da idéia - fiz coisas muito semelhantes em minhas linguagens de brinquedos, mas descobri que existem dicas práticas durante a implementação que não tornam as coisas tão limpas como você esperaria.
Telastyn 01/01
1
@TheodorosChatzigiannakis É certamente possível fazer uma linha cruzada entre os limites da biblioteca, embora seja outro item da lista de compras "Otimizações de ponta que eu gostaria de ter". Sinto-me obrigado a salientar que é notoriamente complicado acertar completamente sem ser tão conservador a ponto de ser inútil.
4

Existem muito poucos casos em que você precisa que “tipos fundamentais” sejam objetos completos (aqui, um objeto são dados que contêm um ponteiro para um mecanismo de despacho ou são marcados com um tipo que pode ser usado por um mecanismo de despacho):

  • Você deseja que tipos definidos pelo usuário possam herdar de tipos fundamentais. Isso geralmente não é desejado, pois introduz dores de cabeça relacionadas a desempenho e segurança. É um problema de desempenho porque a compilação não pode assumir que um intterá um tamanho fixo específico ou que nenhum método foi substituído e é um problema de segurança porque a semântica de ints pode ser subvertida (considere um número inteiro igual a qualquer número ou que muda seu valor em vez de ser imutável).

  • Seus tipos primitivos têm supertipos e você deseja ter variáveis ​​com o tipo de um supertipo de um tipo primitivo. Por exemplo, suponha que ints seja Hashablee você deseja declarar uma função que aceita um Hashableparâmetro que pode receber objetos regulares, mas também ints.

    Isso pode ser “resolvido” tornando esses tipos ilegais: livre-se das subtipagens e decida que as interfaces não são tipos, mas restrições de tipo. Obviamente, isso reduz a expressividade do seu sistema de tipos, e esse tipo de sistema não seria mais chamado de orientado a objetos. Veja Haskell para uma linguagem que usa essa estratégia. C ++ está no meio do caminho, porque tipos primitivos não têm supertipos.

    A alternativa é boxe total ou parcial de tipos fundamentais. O tipo de boxe não precisa ser visível pelo usuário. Essencialmente, você define um tipo de caixa interno para cada tipo fundamental e conversões implícitas entre o tipo de caixa e o tipo fundamental. Isso pode ficar estranho se os tipos de caixa tiverem semânticas diferentes. Java apresenta dois problemas: os tipos em caixa têm um conceito de identidade, enquanto os primitivos têm apenas um conceito de equivalência de valor, e os tipos em caixa são anuláveis, enquanto os primitivos são sempre válidos. Esses problemas são completamente evitáveis ​​ao não oferecer um conceito de identidade para tipos de valor, sobrecarregar o operador e não tornar todos os objetos anuláveis ​​por padrão.

  • Você não possui digitação estática. Uma variável pode conter qualquer valor, incluindo tipos ou objetos primitivos. Portanto, todos os tipos primitivos precisam estar sempre em caixas para garantir uma digitação forte.

Os idiomas que possuem digitação estática fazem bem em usar tipos primitivos sempre que possível e só retornam aos tipos em caixas como último recurso. Embora muitos programas não sejam tremendamente sensíveis ao desempenho, há casos em que o tamanho e a composição dos tipos primitivos são extremamente relevantes: Pense em um processamento de números em larga escala em que você precisa encaixar bilhões de pontos de dados na memória. Mudando dedouble parafloatpode ser uma estratégia viável de otimização de espaço em C, mas não terá quase nenhum efeito se todos os tipos numéricos estiverem sempre em caixa (e, portanto, desperdiçar pelo menos metade da memória para um ponteiro de mecanismo de despacho). Quando tipos primitivos in a box são usados ​​localmente, é bastante simples removê-lo através do uso de intrínsecas ao compilador, mas seria míope apostar o desempenho geral do seu idioma em um "compilador suficientemente avançado".

amon
fonte
Um intdificilmente é imutável em todas as línguas.
Scott Whitlock 01/01
6
@ScottWhitlock Entendo por que você pode pensar isso, mas em geral os tipos primitivos são tipos de valor imutáveis. Nenhum idioma são permite alterar o valor do número sete. No entanto, muitos idiomas permitem reatribuir uma variável que contém um valor de um tipo primitivo para um valor diferente. Em idiomas do tipo C, uma variável é um local de memória nomeado e atua como um ponteiro. Uma variável não é igual ao valor para o qual aponta. Um intvalor é imutável, mas uma intvariável não.
amon
1
@ amon: nenhum idioma são; apenas Java: thedailywtf.com/articles/Disgruntled-Bomb-Java-Edition
Mason Wheeler
get rid of subtyping and decide that interfaces aren't types but type constraints.... such a type system wouldn't be called object-oriented any longer mas isso soa como programação baseada em protótipo, que é definitivamente OOP.
Michael
1
@ScottWhitlock, a pergunta é se, se você tem int b = a, pode fazer algo para b que mudará o valor de a. Houve algumas implementações de linguagem onde isso é possível, mas geralmente é considerado patológico e indesejado, ao contrário de fazer o mesmo para uma matriz.
precisa saber é o seguinte
2

A maioria das implementações que conheço impõe três restrições a essas classes que permitem que o compilador use com eficiência os tipos primitivos como representação subjacente na grande maioria das vezes. Essas restrições são:

  • Imutabilidade
  • Finalidade (impossível de derivar)
  • Digitação estática

As situações em que um compilador precisa colocar uma primitiva em um objeto na representação subjacente são relativamente raras, como quando uma Objectreferência está apontando para ele.

Isso adiciona um pouco de tratamento especial de caso no compilador, mas não se limita apenas a algum compilador mítico super avançado. Essa otimização está em compiladores de produção reais nos principais idiomas. Scala ainda permite que você defina suas próprias classes de valor.

Karl Bielefeldt
fonte
1

No Smalltalk, todos eles (int, float etc.) são objetos de primeira classe. O único caso especial é que SmallIntegers são codificados e tratados de forma diferente pela Máquina Virtual por uma questão de eficiência e, portanto, a classe SmallInteger não admitirá subclasses (o que não é uma limitação prática). Observe que isso não requer nenhuma consideração especial por parte do programador, já que a distinção é limitada a rotinas automáticas como geração de código ou coleta de lixo.

O compilador Smalltalk (código fonte -> bytecodes da VM) e o nativizador da VM (bytecodes -> código da máquina) otimizam o código gerado (JIT) para reduzir a penalidade de operações elementares com esses objetos básicos.

Leandro Caniglia
fonte
1

Eu estava projetando uma linguagem OO e tempo de execução (isso falhou por um conjunto de razões completamente diferente).

Não há nada inerentemente errado em criar coisas como int true classes; na verdade, isso facilita o design do GC, pois agora existem apenas 2 tipos de cabeçalhos de heap (classe e matriz) em vez de 3 (classe, matriz e primitivo) [o fato de podermos mesclar classe e matriz depois que isso não for relevante ]

O caso realmente importante em que os tipos primitivos devem ter métodos final / selados (+ realmente importa, ToString não muito). Isso permite que o compilador resolva estática quase todas as chamadas para as próprias funções e as inline. Na maioria dos casos, isso não importa como comportamento de cópia (optei por disponibilizar a incorporação no nível do idioma [o mesmo fez o .NET]), mas em alguns casos, se os métodos não forem selados, o compilador será forçado a gerar a chamada para a função usada para implementar int + int.

Joshua
fonte