¿Cuáles son las advertencias de implementar tipos fundamentales (como int) como clases?

27

Al diseñar y implenting un lenguaje de programación orientado a objetos, en algún momento uno debe tomar una decisión sobre la aplicación de los tipos fundamentales (como int, float, doubleo equivalentes) como clases o algo más. Claramente, los lenguajes en la familia C tienden a no definirlos como clases (Java tiene tipos primitivos especiales, C # los implementa como estructuras inmutables, etc.).

Puedo pensar en una ventaja muy importante cuando los tipos fundamentales se implementan como clases (en un sistema de tipos con una jerarquía unificada): estos tipos pueden ser subtipos de Liskov adecuados del tipo raíz. Por lo tanto, evitamos complicar el lenguaje con boxing / unboxing (ya sea explícito o implícito), tipos de envoltura, reglas de variación especiales, comportamiento especial, etc.

Por supuesto, puedo entender parcialmente por qué los diseñadores de idiomas deciden la forma en que lo hacen: las instancias de clase tienden a tener una sobrecarga espacial (porque las instancias pueden contener una vtable u otros metadatos en su diseño de memoria), que las primitivas / estructuras no necesitan tener (si el idioma no permite la herencia en esos).

¿Es la eficiencia espacial (y la localidad espacial mejorada, especialmente en matrices grandes) la única razón por la cual los tipos fundamentales a menudo no son clases?

En general, he asumido que la respuesta es sí, pero los compiladores tienen algoritmos de análisis de escape y, por lo tanto, pueden deducir si pueden omitir (selectivamente) la sobrecarga espacial cuando se demuestra que una instancia (cualquier instancia, no solo un tipo fundamental) es estrictamente local.

¿Está mal lo anterior o hay algo más que me falta?

Theodoros Chatzigiannakis
fuente

Respuestas:

19

Sí, todo se reduce a la eficiencia. Pero parece estar subestimando el impacto (o sobreestimando qué tan bien funcionan varias optimizaciones).

Primero, no es solo "sobrecarga espacial". Hacer primitivas en caja / asignadas al montón también tiene costos de rendimiento. Existe la presión adicional sobre el GC para asignar y recoger esos objetos. Esto va doblemente si los "objetos primitivos" son inmutables, como deberían ser. Luego hay más errores de caché (tanto por la indirección como porque hay menos datos en una cantidad dada de caché). Además, el simple hecho de que "cargar la dirección de un objeto y luego cargar el valor real desde esa dirección" requiere más instrucciones que "cargar el valor directamente".

En segundo lugar, el análisis de escape no es un polvo de hadas más rápido. Solo se aplica a valores que, bueno, no se escapan. Sin duda, es bueno optimizar los cálculos locales (como los contadores de bucle y los resultados intermedios de los cálculos) y proporcionará beneficios medibles. Pero una mayoría mucho mayor de valores vive en los campos de objetos y matrices. Por supuesto, estos pueden estar sujetos al análisis de escape, pero como suelen ser tipos de referencia mutables, cualquier alias de ellos presenta un desafío significativo para el análisis de escape, que ahora tiene que demostrar que esos alias (1) tampoco escapan , y (2) no marcan la diferencia con el propósito de eliminar asignaciones.

Dado que llamar a cualquier método (incluidos los captadores) o pasar un objeto como argumento a cualquier otro método puede ayudar al objeto a escapar, necesitará un análisis interprocedial en todos los casos, excepto en los más triviales. Esto es mucho más costoso y complicado.

Y luego hay casos en los que las cosas realmente escapan y no se pueden optimizar de manera razonable. Muchos de ellos, en realidad, si consideras con qué frecuencia los programadores de C pasan por la molestia de asignar cosas al montón. Cuando un objeto que contiene un int escapa, el análisis de escape deja de aplicarse también al int. Diga adiós a los campos primitivos eficientes .

Esto se relaciona con otro punto: los análisis y optimizaciones requeridos son muy complicados y un área activa de investigación. Es discutible si alguna implementación de lenguaje alguna vez logró el grado de optimización que sugieres, e incluso si es así, ha sido un esfuerzo raro y hercúleo. Seguramente pararse sobre los hombros de estos gigantes es más fácil que ser un gigante, pero aún está lejos de ser trivial. No espere un rendimiento competitivo en ningún momento en los primeros años, si es que lo hace.

Eso no quiere decir que tales lenguajes no puedan ser viables. Claramente lo son. Simplemente no asuma que será línea por línea tan rápido como los idiomas con primitivas dedicadas. En otras palabras, no se engañe con visiones de un compilador suficientemente inteligente .


fuente
Cuando hablaba del análisis de escape, también me refería a la asignación al almacenamiento automático (no resuelve todo, pero como usted dice, resuelve algunas cosas). También admito que había subestimado la medida en que los campos y los alias podrían hacer que el análisis de escape falle más a menudo. Las fallas de caché son lo que más me preocupaba cuando hablaba de eficiencia espacial, así que gracias por abordar eso.
Theodoros Chatzigiannakis
@TheodorosChatzigiannakis Incluyo el cambio de estrategia de asignación en el análisis de escape (porque, sinceramente, eso parece ser lo único para lo que se ha utilizado).
Vuelva a su segundo párrafo: los objetos no siempre tienen que estar asignados al montón o ser tipos de referencia. De hecho, cuando no lo son, esto hace que las optimizaciones necesarias sean relativamente fáciles. Vea los objetos asignados a la pila de C ++ para un ejemplo temprano, y el sistema de propiedad de Rust para una forma de hornear el análisis de escape directamente en el lenguaje.
amon
@amon Lo sé, y tal vez debería haberlo aclarado, pero parece que OP solo está interesado en los lenguajes tipo Java y C #, donde la asignación del montón es casi obligatoria (e implícita) debido a la semántica de referencia y los cambios sin pérdida entre subtipos. ¡Buen punto sobre Rust usando lo que equivale a escapar del análisis!
@delnan Es cierto, estoy interesado principalmente en los idiomas que abstraen los detalles de almacenamiento, pero no dude en incluir cualquier cosa que considere relevante, incluso si no es aplicable en esos idiomas.
Theodoros Chatzigiannakis
27

¿Es la eficiencia espacial (y la localidad espacial mejorada, especialmente en matrices grandes) la única razón por la cual los tipos fundamentales a menudo no son clases?

No.

El otro problema es que los tipos fundamentales tienden a ser utilizados por las operaciones fundamentales. El compilador necesita saber que int + intno se compilará para una llamada de función, sino para alguna instrucción de CPU elemental (o código de bytes equivalente). En ese punto, si tiene el intobjeto regular, tendrá que desempaquetar efectivamente la cosa de todos modos.

Ese tipo de operaciones tampoco son agradables con los subtipos. No puede enviar a una instrucción de CPU. No puede enviar desde una instrucción de CPU. Quiero decir que todo el punto de subtipo es para que puedas usar un Ddonde puedas a B. Las instrucciones de la CPU no son polimórficas. Para obtener primitivas para hacer eso, debe ajustar sus operaciones con una lógica de envío que cuesta varias veces la cantidad de operaciones como la simple adición (o lo que sea). El beneficio de intser parte de la jerarquía de tipos se vuelve un poco discutible cuando está sellado / final. Y eso ignora todos los dolores de cabeza con la lógica de despacho para operadores binarios ...

Básicamente, los tipos primitivos necesitarían tener muchas reglas especiales sobre cómo los maneja el compilador y qué puede hacer el usuario con sus tipos de todos modos , por lo que a menudo es más simple tratarlos como completamente distintos.

Telastyn
fuente
44
Consulte la implementación de cualquiera de los lenguajes de tipo dinámico que tratan enteros y, por ejemplo, objetos. La instrucción de CPU primitiva final puede muy bien ocultarse en un método (sobrecarga del operador) en la implementación de la clase con privilegios únicos en la biblioteca de tiempo de ejecución. Los detalles se verían diferentes con un sistema de tipo estático y un compilador, pero no es un problema fundamental. En el peor de los casos, solo hace las cosas aún más lentas.
3
int + intpuede ser un operador de nivel de lenguaje normal que invoca una instrucción intrínseca que se garantiza que compilará (o se comportará como) la operación de adición de enteros de CPU nativa. El beneficio de intheredar de objectno es solo la posibilidad de heredar otro tipo de int, sino también la posibilidad de intcomportarse como un objectsin boxeo. Considere los genéricos de C #: puede tener covarianza y contravarianza, pero solo son aplicables a los tipos de clase: los tipos de estructura se excluyen automáticamente, porque solo pueden convertirse a objecttravés del boxeo (implícito, generado por el compilador).
Theodoros Chatzigiannakis
3
@delnan: claro, aunque en mi experiencia con implementaciones estáticamente tipadas, ya que cada llamada que no es del sistema se reduce a las operaciones primitivas, tener gastos generales allí tiene un impacto dramático en el rendimiento, lo que a su vez tiene un efecto aún más dramático en la adopción.
Telastyn
@TheodorosChatzigiannakis: excelente, por lo que puede obtener varianza y contravarianza en tipos que no tienen un subtipo / supertipo útil ... Y la implementación de ese operador especial para llamar a la instrucción de la CPU todavía lo hace especial. No estoy en desacuerdo con la idea: he hecho cosas muy similares en los lenguajes de mis juguetes, pero he descubierto que hay problemas prácticos durante la implementación que no hacen que las cosas sean tan limpias como cabría esperar.
Telastyn
1
@TheodorosChatzigiannakis Sin duda es posible alinearse a través de los límites de la biblioteca, aunque es otro elemento más en la lista de compras "optimizaciones de alta gama que me gustaría tener". Sin embargo, me siento obligado a señalar que es notoriamente complicado acertar por completo sin ser tan conservador como inútil.
4

Solo hay muy pocos casos en los que necesite "tipos fundamentales" para ser objetos completos (aquí, un objeto son datos que contienen un puntero a un mecanismo de despacho o están etiquetados con un tipo que puede ser usado por un mecanismo de despacho):

  • Desea que los tipos definidos por el usuario puedan heredar de los tipos fundamentales. Esto generalmente no es deseable ya que introduce dolores de cabeza relacionados con el rendimiento y la seguridad. Es un problema de rendimiento porque la compilación no puede suponer que un inttendrá un tamaño fijo específico o que no se han anulado ningún método, y es un problema de seguridad porque la semántica de ints podría subvertirse (considere un número entero que sea igual a cualquier número, o eso cambia su valor en lugar de ser inmutable).

  • Sus tipos primitivos tienen supertipos y desea tener variables con el tipo de un supertipo de un tipo primitivo. Por ejemplo, suponga que sus ints son Hashable, y desea declarar una función que toma un Hashableparámetro que podría recibir objetos regulares pero también ints.

    Esto puede "resolverse" haciendo que tales tipos sean ilegales: deshacerse de los subtipos y decidir que las interfaces no son tipos sino restricciones de tipo. Obviamente, eso reduce la expresividad de su sistema de tipos, y dicho sistema de tipos ya no se llamaría orientado a objetos. Ver Haskell para un lenguaje que utiliza esta estrategia. C ++ está a medio camino porque los tipos primitivos no tienen supertipos.

    La alternativa es el boxeo total o parcial de los tipos fundamentales. El tipo de boxeo no necesita ser visible para el usuario. Esencialmente, usted define un tipo de cuadro interno para cada tipo fundamental y las conversiones implícitas entre el tipo de cuadro y fundamental. Esto puede ser incómodo si los tipos en caja tienen una semántica diferente. Java presenta dos problemas: los tipos en caja tienen un concepto de identidad, mientras que las primitivas solo tienen un concepto de equivalencia de valor, y los tipos en caja son anulables, mientras que las primitivas siempre son válidas. Estos problemas son completamente evitables al no ofrecer un concepto de identidad para los tipos de valor, al ofrecer una sobrecarga del operador y al no hacer que todos los objetos sean anulables por defecto.

  • No tiene mecanografía estática. Una variable puede contener cualquier valor, incluidos tipos u objetos primitivos. Por lo tanto, todos los tipos primitivos deben estar siempre encuadrados para garantizar una escritura fuerte.

Los idiomas que tienen escritura estática hacen bien en usar tipos primitivos siempre que sea posible y solo recurren a los tipos en caja como último recurso. Si bien muchos programas no son tremendamente sensibles al rendimiento, hay casos en los que el tamaño y la composición de los tipos primitivos es extremadamente relevante: piense en la reducción de números a gran escala donde necesita colocar miles de millones de puntos de datos en la memoria. Cambiar de doubleafloatpodría ser una estrategia de optimización de espacio viable en C, pero casi no tendría ningún efecto si todos los tipos numéricos siempre están encuadrados (y, por lo tanto, desperdician al menos la mitad de su memoria para un puntero del mecanismo de envío). Cuando los tipos primitivos en caja se usan localmente, es bastante sencillo eliminar el boxeo mediante el uso de intrínsecos del compilador, pero sería miope apostar el rendimiento general de su idioma en un "compilador suficientemente avanzado".

amon
fuente
An intes casi inmutable en todos los idiomas.
Scott Whitlock el
66
@ScottWhitlock Veo por qué podrías pensar eso, pero en general los tipos primitivos son tipos de valores inmutables. Ningún lenguaje sensato le permite cambiar el valor del número siete. Sin embargo, muchos idiomas le permiten reasignar una variable que contiene un valor de un tipo primitivo a un valor diferente. En lenguajes tipo C, una variable es una ubicación de memoria con nombre y actúa como un puntero. Una variable no es lo mismo que el valor al que apunta. Un intvalor es inmutable, pero una intvariable no lo es.
amon
1
@amon: No hay lenguaje cuerdo; solo 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 pero esto suena como una programación basada en prototipos, que definitivamente es POO.
Michael
1
@ScottWhitlock la pregunta es si, si tienes int b = a, puedes hacer algo a b que cambie el valor de a. Ha habido algunas implementaciones de lenguaje donde esto es posible, pero generalmente se considera patológico y no deseado, a diferencia de hacer lo mismo para una matriz.
Random832
2

La mayoría de las implementaciones que conozco imponen tres restricciones en tales clases que permiten al compilador usar eficientemente los tipos primitivos como representación subyacente la gran mayoría de las veces. Estas restricciones son:

  • Inmutabilidad
  • Finalidad (no se puede derivar de)
  • Mecanografía estática

Las situaciones en las que un compilador necesita encajonar un primitivo en un objeto en la representación subyacente son relativamente raras, como cuando una Objectreferencia apunta a él.

Esto agrega un poco de manejo de casos especiales en el compilador, pero no se limita solo a un mítico compilador súper avanzado. Esa optimización está en compiladores de producción real en los principales idiomas. Scala incluso le permite definir sus propias clases de valor.

Karl Bielefeldt
fuente
1

En Smalltalk, todos ellos (int, float, etc.) son objetos de primera clase. El único caso especial es que SmallIntegers están codificados y tratados de manera diferente por la máquina virtual en aras de la eficiencia, y por lo tanto la clase SmallInteger no admitirá subclases (lo cual no es una limitación práctica). Tenga en cuenta que esto no requiere ninguna consideración especial por parte del programador ya que la distinción está circunscrita a rutinas automáticas como la generación de código o la recolección de basura.

Tanto el compilador Smalltalk (código fuente -> códigos de byte VM) como el nativizer VM (códigos de byte -> código máquina) optimizan el código generado (JIT) para reducir la penalización de las operaciones elementales con estos objetos básicos.

Leandro Caniglia
fuente
1

Estaba diseñando un lenguaje OO y tiempo de ejecución (esto falló por un conjunto completamente diferente de razones).

No hay nada inherentemente malo en hacer cosas como int clases verdaderas; de hecho, esto hace que el GC sea más fácil de diseñar ya que ahora solo hay 2 tipos de encabezados de montón (clase y matriz) en lugar de 3 (clase, matriz y primitivo) [el hecho de que podamos fusionar clase y matriz después de esto no es relevante ]

El caso realmente importante es que los tipos primitivos deberían tener métodos en su mayoría finales / sellados (+ realmente importa, ToString no tanto). Esto permite que el compilador resuelva estáticamente casi todas las llamadas a las funciones mismas y las incorpore. En la mayoría de los casos, esto no importa como comportamiento de copia (elegí hacer que la inclusión esté disponible a nivel de idioma [también lo hizo .NET]), pero en algunos casos si los métodos no están sellados, el compilador se verá obligado a generar la llamada a La función utilizada para implementar int + int.

Joshua
fuente