¿Por qué los lenguajes OOP estáticos fuertes y convencionales impiden la herencia de primitivos?

53

¿Por qué esto está bien y es lo más esperado?

abstract type Shape
{
   abstract number Area();
}

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

... mientras esto no está bien y nadie se queja:

concrete type Name : string
{
}

concrete type Index : int
{
}

concrete type Quantity : int
{
}

Mi motivación es maximizar el uso del sistema de tipos para la verificación de corrección en tiempo de compilación.

PS: sí, he leído esto y el ajuste es una forma de piratear.

    
pregunta Den 10.08.2016 - 11:37

10 respuestas

83

¿Supongo que estás pensando en lenguajes como Java y C #?

En esos idiomas, las primitivas (como int ) son básicamente un compromiso para el rendimiento. No admiten todas las características de los objetos, pero son más rápidos y con menos gastos generales.

Para que los objetos admitan la herencia, cada instancia debe "saber" en el tiempo de ejecución de qué clase es una instancia. De lo contrario, los métodos anulados no se pueden resolver en tiempo de ejecución. Para los objetos, esto significa que los datos de instancia se almacenan en la memoria junto con un puntero al objeto de clase. Si dicha información también se almacenara junto con valores primitivos, los requisitos de memoria se aumentarían. Un valor entero de 16 bits requeriría sus 16 bits para el valor y adicionalmente 32 o 64 bits de memoria para un puntero a su clase.

Además de la sobrecarga de memoria, también se espera poder anular operaciones comunes en primitivas como operadores aritméticos. Sin subtipos, los operadores como + pueden compilarse en una simple instrucción de código de máquina. Si pudiera ser anulado, tendría que resolver los métodos en tiempo de ejecución, una operación más costosa de mucho . (Es posible que sepa que C # admite la sobrecarga del operador, pero esto no es lo mismo. La sobrecarga del operador se resuelve en el momento de la compilación, por lo que no hay una penalización de tiempo de ejecución predeterminada).

Las cadenas no son primitivas pero siguen siendo "especiales" en la forma en que se representan en la memoria. Por ejemplo, están "internados", lo que significa que dos literales de cadenas que son iguales se pueden optimizar para la misma referencia. Esto no sería posible (o al menos mucho menos efectivo) si las instancias de cadena también deberían realizar un seguimiento de la clase.

Lo que describas sería ciertamente útil, pero su soporte requeriría una sobrecarga de rendimiento por cada uso de primitivas y cadenas, incluso cuando no se aprovechan de la herencia.

El lenguaje Smalltalk does (creo) permite la subclasificación de enteros. Pero cuando se diseñó Java, Smalltalk se consideró demasiado lento, y la sobrecarga de tener todo como un objeto se consideró una de las principales razones. Java sacrificó un poco de elegancia y pureza conceptual para obtener un mejor rendimiento.

    
respondido por el JacquesB 10.08.2016 - 13:24
20

Lo que proponen algunos idiomas no es la subclasificación, sino subtipo . Por ejemplo, Ada le permite crear tipos derivados o subtipos . Vale la pena leer la sección Ada Programming / Type System para comprender todos los detalles. Puede restringir el rango de valores, que es lo que desea la mayor parte del tiempo:

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

Puede usar ambos tipos como enteros si los convierte explícitamente. Tenga en cuenta también que no puede utilizar uno en lugar de otro, incluso cuando los rangos son estructuralmente equivalentes (los tipos están marcados por nombres).

 type Reference is Integer;
 type Count is Integer;

Los tipos anteriores son incompatibles, aunque representan el mismo rango de valores.

(Pero puedes usar Unchecked_Conversion; no le digas a la gente que te lo dije)

    
respondido por el coredump 10.08.2016 - 12:20
16

Creo que esto podría ser una pregunta X / Y. Puntos salientes, de la pregunta ...

  

Mi motivación es maximizar el uso del sistema de tipos para la verificación de corrección en tiempo de compilación.

... y de tu comentario elaborando:

  

No quiero poder sustituir uno por otro implícitamente.

Discúlpeme si me falta algo, pero ... Si estos son sus objetivos, ¿por qué demonios está hablando de herencia? La sustituibilidad implícita es ... como ... todo. ¿Sabes, el principio de sustitución de Liskov?

Lo que parece querer, en realidad, es el concepto de "fuerte typedef", por lo que algo es "por ejemplo. un int en términos de rango y representación pero no se puede sustituir en contextos que esperan un int y viceversa. Sugeriría buscar información sobre este término y como lo llamen los idiomas elegidos. Una vez más, es casi literalmente lo contrario de la herencia.

Y para aquellos a los que no les guste una respuesta X / Y, creo que el título aún podría responderse con referencia al LSP. Los tipos primitivos son primitivos porque hacen algo muy simple, y eso es todo lo que hacen . Permitir que se hereden y, por lo tanto, hacer infinitos sus posibles efectos llevaría a una gran sorpresa en el mejor de los casos y, en el peor de los casos, a una grave violación del LSP. Si puedo asumir con optimismo que Thales Pereira no me importará que cite este comentario fenomenal:

  

Existe el problema adicional de que si alguien pudiera heredar de Int, tendría un código inocente como "int x = y + 2" (donde Y es la clase derivada) que ahora escribe un registro en la base de datos, se abre Una URL y de alguna manera resucitar a Elvis. Se supone que los tipos primitivos son seguros y con un comportamiento más o menos garantizado y bien definido.

Si alguien ve un tipo primitivo, en un lenguaje sano, presumiría con razón que siempre hará solo una pequeña cosa, muy bien, sin sorpresas. Los tipos primitivos no tienen declaraciones de clase disponibles que indiquen si pueden o no pueden heredarse y tienen sus métodos anulados. Si lo fueran, sería muy sorprendente (y rompería totalmente la compatibilidad hacia atrás, pero soy consciente de que es una respuesta al revés para 'por qué X no fue diseñada con Y').

... aunque, como Mooing Duck señaló en respuesta, los idiomas que permiten la sobrecarga del operador permiten al usuario confundirse en una medida similar o igual si realmente lo desean, por lo que es dudoso que este último argumento sea válido. Y dejaré de resumir los comentarios de otras personas ahora, je.

    
respondido por el underscore_d 10.08.2016 - 22:19
4

Para permitir la herencia con un envío virtual (que a menudo se considera bastante deseable en el diseño de la aplicación), se necesita información del tipo de tiempo de ejecución. Para cada objeto, deben almacenarse algunos datos sobre el tipo de objeto. Una primitiva, por definición, carece de esta información.

Hay dos lenguajes OOP principales (administrados, ejecutados en una máquina virtual) que presentan primitivas: C # y Java. Muchos otros idiomas no tienen primitivos en primer lugar, o usan un razonamiento similar para permitirlos / usarlos.

Los primitivos son un compromiso para el rendimiento. Para cada objeto, necesita espacio para su encabezado de objeto (en Java, normalmente 2 * 8 bytes en máquinas virtuales de 64 bits), además de sus campos, más un eventual relleno (en un punto de acceso, cada objeto ocupa un número de bytes que es un múltiplo de 8). Por lo tanto, un int como objeto necesitaría mantener al menos 24 bytes de memoria, en lugar de solo 4 bytes (en Java).

Por lo tanto, se agregaron tipos primitivos para mejorar el rendimiento. Hacen un montón de cosas más fáciles. ¿Qué significa a + b si ambos son subtipos de int ? Se debe agregar algún tipo de dispathcing para elegir la adición correcta. Esto significa despacho virtual. Tener la capacidad de usar un código de operación muy simple para la adición es mucho, mucho más rápido y permite optimizaciones en tiempo de compilación.

String es otro caso. Tanto en Java como en C #, String es un objeto. Pero en C # está sellado, y en Java su final. Esto se debe a que tanto las bibliotecas estándar de Java como las de C # requieren que String s sea inmutable, y su subclasificación rompería esta inmutabilidad.

En el caso de Java, la máquina virtual puede (y hace) internar cadenas y "agruparlas", permitiendo un mejor rendimiento. Esto solo funciona cuando las cadenas son realmente inmutables.

Además, uno rara vez necesita subclasificar tipos primitivos. Mientras las primitivas no puedan clasificarse, hay muchas cosas interesantes que las matemáticas nos dicen sobre ellas. Por ejemplo, podemos estar seguros de que la adición es conmutativa y asociativa. Eso es algo que la definición matemática de enteros nos dice. Además, en muchos casos podemos fácilmente realizar invariantes sobre los bucles a través de la inducción. Si permitimos la subclasificación de int , perdemos esas herramientas que nos dan las matemáticas, porque ya no podemos garantizar que ciertas propiedades se mantengan. Por lo tanto, diría que la capacidad no para subclasificar tipos primitivos es realmente algo bueno. Menos cosas que alguien puede romper, más un compilador a menudo puede probar que se le permite hacer ciertas optimizaciones.

    
respondido por el Polygnome 10.08.2016 - 23:03
4

En los principales lenguajes OOP estáticos fuertes, la escritura secundaria se considera principalmente como una forma de extender un tipo y anular los métodos actuales del tipo.

Para hacerlo, los 'objetos' contienen un puntero a su tipo. Esto es una sobrecarga: el código en un método que usa una instancia Shape primero tiene que acceder a la información de tipo de esa instancia, antes de conocer el método Area() correcto para llamar.

Una primitiva tiende a permitir solo operaciones en ella que pueden traducirse en instrucciones en un solo lenguaje de máquina y no llevan ningún tipo de información con ellas. Al hacer que un número entero sea más lento para que alguien pueda subclasificar, no fue lo suficientemente atractivo como para evitar que cualquier idioma que lo hizo se convirtiera en algo común.

Entonces la respuesta a:

  

¿Por qué los lenguajes OOP estáticos fuertes y convencionales impiden la herencia?   primitivas?

Es:

  • Había poca demanda
  • Y habría hecho el lenguaje demasiado lento
  • Subtipo fue visto principalmente como una forma de extender un tipo, en lugar de una manera de mejorar la verificación de tipos estática (definida por el usuario).

Sin embargo, estamos empezando a obtener idiomas que permiten la verificación estática basada en propiedades de variables distintas a 'tipo', por ejemplo, F # tiene "dimensión" y "unidad", por lo que no puede, por ejemplo, agregar una longitud a un área.

También hay idiomas que permiten 'tipos definidos por el usuario' que no cambian (o intercambian) lo que hace un tipo, pero solo ayudan con la comprobación de tipos estática; ver la respuesta de coredump.

    
respondido por el Ian 10.08.2016 - 13:39
3

No estoy seguro de si estoy pasando por alto algo aquí, pero la respuesta es bastante simple:

  1. La definición de primitivos es: los valores primitivos no son objetos, los tipos primitivos no son tipos de objetos, los primitivos no forman parte del sistema de objetos.
  2. La herencia es una característica del sistema de objetos.
  3. Ergo, los primitivos no pueden participar en la herencia.

Tenga en cuenta que en realidad solo hay dos lenguajes OOP estáticos potentes que incluso tienen primitivos, AFAIK: Java y C ++. (En realidad, ni siquiera estoy seguro de esto último, no sé mucho acerca de C ++, y lo que encontré al buscar fue confuso).

En C ++, las primitivas son básicamente un legado heredado (destinado al juego de palabras) de C. Por lo tanto, no participan en el sistema de objetos (y, por lo tanto, en la herencia) porque C no tiene un sistema de objetos ni una herencia.

En Java, las primitivas son el resultado de un intento equivocado de mejorar el rendimiento. Los primitivos también son los únicos tipos de valor en el sistema; de hecho, es imposible escribir tipos de valor en Java, y es imposible que los objetos sean tipos de valor. Entonces, aparte del hecho de que los primitivos no participan en el sistema de objetos y, por lo tanto, la idea de "herencia" ni siquiera tiene sentido, incluso si usted pudiera heredar de ellos, no lo haría. ser capaz de mantener el "valor-ness". Esto es diferente de, por ejemplo, C♯ que tiene tiene tipos de valor ( struct s), que no obstante son objetos.

Otra cosa es que el hecho de no poder heredar tampoco es exclusivo de los primitivos. En C♯, struct s hereda implícitamente de System.Object y puede implementar interface s, pero no puede heredar ni heredar de class es o struct s. Además, sealed class es no se puede heredar de. En Java, no se puede heredar final class es.

tl; dr :

  

¿Por qué los principales lenguajes OOP estáticos fuertes impiden la herencia de primitivos?

  1. las primitivas no son parte del sistema de objetos (por definición, si lo fueran, no serían primitivas), la idea de herencia está ligada al sistema de objetos, la herencia primitiva ergo es una contradicción en términos
  2. las primitivas no son únicas, muchos otros tipos no se pueden heredar también ( final o sealed en Java o C♯, struct s en C♯, case class es en Scala)
respondido por el Jörg W Mittag 10.08.2016 - 22:07
2

Joshua Bloch en "Java efectiva" recomienda diseñar explícitamente para herencia o prohibirlo. Las clases primitivas no están diseñadas para herencia porque están diseñadas para ser inmutables y permitir la herencia podría cambiar eso en las subclases, por lo tanto, romper el Principio de Liskov y sería una fuente de muchos errores.

De todas formas, ¿por qué es este un intruso? solución? Realmente deberías preferir la composición sobre la herencia. Si el motivo es el rendimiento, tiene un punto y la respuesta a su pregunta es que no es posible poner todas las funciones en Java porque lleva tiempo analizar todos los aspectos diferentes de agregar una función. Por ejemplo, Java no tenía Genéricos antes del 1.5.

Si tienes mucha paciencia, entonces tienes suerte porque hay un plan para agregar clases de valor a Java, que te permitirá crear tus clases de valor, lo que te ayudará a aumentar el rendimiento y, al mismo tiempo, te dará más flexibilidad.

    
respondido por el CodesInTheDark 10.08.2016 - 17:42
2

En el nivel abstracto, puedes incluir lo que quieras en un idioma que estés diseñando.

A nivel de implementación, es inevitable que algunas de esas cosas sean más fáciles de implementar, algunas sean complicadas, algunas se puedan acelerar, otras deban ser más lentas, y así sucesivamente. Para dar cuenta de esto, los diseñadores a menudo tienen que tomar decisiones difíciles y compromisos.

En el nivel de implementación, una de las maneras más rápidas para acceder a una variable es averiguar su dirección y cargar el contenido de esa dirección. Hay instrucciones específicas en la mayoría de las CPU para cargar datos desde direcciones y esas instrucciones generalmente necesitan saber cuántos bytes necesitan cargar (uno, dos, cuatro, ocho, etc.) y dónde colocar los datos que cargan (registro único, registro par, registro extendido, otra memoria, etc). Al conocer el tamaño de una variable, el compilador puede saber exactamente qué instrucción emitir para los usos de esa variable. Al no saber el tamaño de una variable, el compilador tendría que recurrir a algo más complicado y probablemente más lento.

En el nivel abstracto, el punto de subtipo es poder usar instancias de un tipo donde se espera un tipo igual o más general. En otras palabras, se puede escribir un código que espere un objeto de un tipo particular o algo más derivado, sin saber de antemano qué sería exactamente esto. Y claramente, como más tipos derivados pueden agregar más miembros de datos, un tipo derivado no necesariamente tiene los mismos requisitos de memoria que sus tipos básicos.

En el nivel de implementación, no hay una manera simple para que una variable de un tamaño predeterminado contenga una instancia de tamaño desconocido y se pueda acceder a ella de una manera que normalmente llamaría eficiente. Pero hay una manera de mover las cosas un poco y usar una variable no para almacenar el objeto, sino para identificar el objeto y dejar que ese objeto se almacene en otro lugar. De esa manera es una referencia (por ejemplo, una dirección de memoria), un nivel adicional de direccionamiento indirecto que garantiza que una variable solo necesita mantener algún tipo de información de tamaño fijo, siempre que podamos encontrar el objeto a través de esa información. Para lograrlo, solo tenemos que cargar la dirección (tamaño fijo) y luego podemos trabajar como de costumbre con las compensaciones del objeto que sabemos que son válidas, incluso si ese objeto tiene más datos en las compensaciones que no sabemos. Podemos hacerlo porque ya no nos preocupamos por sus requisitos de almacenamiento cuando accedemos a él.

En el nivel abstracto, este método le permite almacenar una (referencia a a) string en una variable object sin perder la información que hace que sea un string . Está bien que todos los tipos funcionen de esta manera y también podría decir que es elegante en muchos aspectos.

Aún así, en el nivel de implementación, el nivel adicional de indirección implica más instrucciones y en la mayoría de las arquitecturas hace que cada acceso al objeto sea un poco más lento. Puede permitir que el compilador exprima más rendimiento de un programa si incluye en su idioma algunos tipos de uso común que no tienen ese nivel adicional de indirección (la referencia). Pero al eliminar ese nivel de direccionamiento indirecto, el compilador ya no puede permitirte subtitular de forma segura en memoria. Esto se debe a que si agrega más miembros de datos a su tipo y los asigna a un tipo más general, todos los miembros de datos adicionales que no encajen en el espacio asignado para la variable de destino se eliminarán.

    
respondido por el Theodoros Chatzigiannakis 11.08.2016 - 18:43
1

En general

Si una clase es abstracta (metáfora: una caja con orificio (s)), está bien (¡incluso se requiere que tenga algo utilizable!) "rellenar el (los) orificio (s)", es por eso que clasificamos las clases abstractas.

Si una clase es concreta (metáfora: una caja llena), no está bien alterar la existente porque si está llena, está llena. No tenemos espacio para agregar algo más dentro de la caja, es por eso que no debemos subclasificar clases concretas.

Con primitivas

Las primitivas son clases concretas por diseño. Representan algo que es bien conocido, completamente definido (nunca he visto un tipo primitivo con algo abstracto, de lo contrario ya no es un primitivo) y se usa ampliamente a través del sistema. ¡Permitir subclasificar un tipo primitivo y proporcionar su propia implementación a otros que confían en el comportamiento diseñado de los primitivos puede causar muchos efectos secundarios y daños enormes!

    
respondido por el Spotted 10.08.2016 - 13:53
1

Por lo general, la herencia no es la semántica que desea, porque no puede sustituir su tipo especial en cualquier lugar donde se espera una primitiva. Para tomar prestado de su ejemplo, un Quantity + Index no tiene sentido semánticamente, por lo que una relación de herencia es la relación incorrecta.

Sin embargo, varios idiomas tienen el concepto de tipo de valor que expresa el tipo de relación que está describiendo. Scala es un ejemplo. Un tipo de valor usa una primitiva como la representación subyacente, pero tiene una identidad de clase diferente y operaciones en el exterior. Eso tiene el efecto de extender un tipo primitivo, pero es más una composición en lugar de una relación de herencia.

    
respondido por el Karl Bielefeldt 12.08.2016 - 18:10

Lea otras preguntas en las etiquetas