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
, double
o 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?
fuente
Respuestas:
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
No.
El otro problema es que los tipos fundamentales tienden a ser utilizados por las operaciones fundamentales. El compilador necesita saber que
int + int
no 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 elint
objeto 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
D
donde puedas aB
. 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 deint
ser 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.
fuente
int + int
puede 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 deint
heredar deobject
no es solo la posibilidad de heredar otro tipo deint
, sino también la posibilidad deint
comportarse como unobject
sin 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 aobject
través del boxeo (implícito, generado por el compilador).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
int
tendrá 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 deint
s 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
int
s sonHashable
, y desea declarar una función que toma unHashable
parámetro que podría recibir objetos regulares pero tambiénint
s.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
double
afloat
podrí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".fuente
int
es casi inmutable en todos los idiomas.int
valor es inmutable, pero unaint
variable no lo es.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.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:
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
Object
referencia 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.
fuente
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.
fuente
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.
fuente