Polimorfismo |
---|
Polimorfismo ad hoc |
Polimorfismo paramétrico |
Subtipificación |
|
En informática , el envío dinámico es el proceso de seleccionar qué implementación de una operación polimórfica ( método o función) se llamará en tiempo de ejecución . Se emplea comúnmente en lenguajes y sistemas de programación orientada a objetos (POO) y se considera una característica principal de ellos. [1]
Los sistemas orientados a objetos modelan un problema como un conjunto de objetos que interactúan y ejecutan operaciones a las que se hace referencia por su nombre. El polimorfismo es el fenómeno en el que objetos que son intercambiables exponen cada uno una operación con el mismo nombre pero que posiblemente difieren en su comportamiento. Por ejemplo, un objeto File y un objeto Database tienen un método StoreRecord que se puede utilizar para escribir un registro de personal en el almacenamiento. Sus implementaciones difieren. Un programa contiene una referencia a un objeto que puede ser un objeto File o un objeto Database . Es posible que una configuración de tiempo de ejecución haya determinado cuál de ellos es y, en esta etapa, el programa puede no saber o no importarle cuál. Cuando el programa llama a StoreRecord en el objeto, algo debe elegir qué comportamiento se ejecuta. Si pensamos en la programación orientada a objetos como el envío de mensajes a objetos, entonces, en este ejemplo, el programa envía un mensaje StoreRecord a un objeto de tipo desconocido, dejando que el sistema de soporte de tiempo de ejecución envíe el mensaje al objeto correcto. El objeto ejecuta el comportamiento que implementa. [2]
El envío dinámico contrasta con el envío estático , en el que la implementación de una operación polimórfica se selecciona en tiempo de compilación . El propósito del envío dinámico es posponer la selección de una implementación adecuada hasta que se conozca el tipo de tiempo de ejecución de un parámetro (o múltiples parámetros).
El envío dinámico es diferente del enlace tardío (también conocido como enlace dinámico). El enlace de nombre asocia un nombre con una operación. Una operación polimórfica tiene varias implementaciones, todas asociadas con el mismo nombre. Los enlaces se pueden realizar en tiempo de compilación o (con enlace tardío) en tiempo de ejecución. Con el envío dinámico, se elige una implementación particular de una operación en tiempo de ejecución. Si bien el envío dinámico no implica enlace tardío, el enlace tardío sí implica envío dinámico, ya que la implementación de una operación enlazada tardío no se conoce hasta el tiempo de ejecución. [ cita requerida ]
La elección de qué versión de un método llamar puede basarse en un solo objeto o en una combinación de objetos. El primero se denomina envío único y está directamente respaldado por lenguajes orientados a objetos comunes como Smalltalk , C++ , Java , C# , Objective-C , Swift , JavaScript y Python . En estos y otros lenguajes similares, se puede llamar a un método para la división con una sintaxis que se parezca a
dividendo . dividir ( divisor ) # dividendo / divisor
donde los parámetros son opcionales. Esto se considera como enviar un mensaje llamado divide con el parámetro divisor a dividend . Se elegirá una implementación basándose únicamente en el tipo de dividend (quizás racional , punto flotante , matriz ), sin tener en cuenta el tipo o valor de divisor .
Por el contrario, algunos lenguajes envían métodos o funciones en función de la combinación de operandos; en el caso de la división, los tipos de dividendo y divisor juntos determinan qué operación de división se realizará. Esto se conoce como envío múltiple . Ejemplos de lenguajes que admiten el envío múltiple son Common Lisp , Dylan y Julia .
Un lenguaje puede implementarse con distintos mecanismos de distribución dinámica. Las opciones del mecanismo de distribución dinámica que ofrece un lenguaje modifican en gran medida los paradigmas de programación disponibles o que son más naturales de usar dentro de un lenguaje determinado.
Normalmente, en un lenguaje tipado, el mecanismo de envío se realizará en función del tipo de argumentos (más comúnmente, en función del tipo de receptor de un mensaje). Los lenguajes con sistemas de tipado débiles o inexistentes suelen incluir una tabla de envío como parte de los datos de objeto para cada objeto. Esto permite el comportamiento de instancias , ya que cada instancia puede asignar un mensaje determinado a un método independiente.
Algunos idiomas ofrecen un enfoque híbrido.
El envío dinámico siempre implicará una sobrecarga, por lo que algunos lenguajes ofrecen envío estático para métodos particulares.
C++ utiliza la vinculación temprana y ofrece tanto envío dinámico como estático. La forma predeterminada de envío es estática. Para obtener un envío dinámico, el programador debe declarar un método como virtual .
Los compiladores de C++ suelen implementar el envío dinámico con una estructura de datos denominada tabla de funciones virtuales (vtable) que define la asignación de nombre a implementación para una clase determinada como un conjunto de punteros de funciones miembro. Esto es puramente un detalle de implementación, ya que la especificación de C++ no menciona las vtables. Las instancias de ese tipo almacenarán un puntero a esta tabla como parte de sus datos de instancia, lo que complica los escenarios cuando se utiliza la herencia múltiple . Dado que C++ no admite la vinculación tardía, la tabla virtual en un objeto de C++ no se puede modificar en tiempo de ejecución, lo que limita el conjunto potencial de destinos de envío a un conjunto finito elegido en tiempo de compilación.
La sobrecarga de tipos no produce un envío dinámico en C++, ya que el lenguaje considera los tipos de los parámetros del mensaje como parte del nombre formal del mensaje. Esto significa que el nombre del mensaje que ve el programador no es el nombre formal utilizado para la vinculación.
En Go , Rust y Nim , se utiliza una variación más versátil del enlace temprano. Los punteros Vtable se transportan con referencias de objetos como 'punteros fat' ('interfaces' en Go, u 'objetos de rasgo' en Rust [3] [4] ).
Esto desacopla las interfaces admitidas de las estructuras de datos subyacentes. Cada biblioteca compilada no necesita conocer la gama completa de interfaces admitidas para usar correctamente un tipo, solo el diseño de tabla virtual específico que requieren. El código puede pasar diferentes interfaces al mismo fragmento de datos a diferentes funciones. Esta versatilidad se produce a expensas de datos adicionales con cada referencia de objeto, lo que es problemático si muchas de esas referencias se almacenan de forma persistente.
El término puntero gordo simplemente se refiere a un puntero con información adicional asociada. La información adicional puede ser un puntero de tabla virtual para el envío dinámico descrito anteriormente, pero es más común que sea el tamaño del objeto asociado para describir, por ejemplo, una porción . [ cita requerida ]
Smalltalk utiliza un despachador de mensajes basado en tipos. Cada instancia tiene un único tipo cuya definición contiene los métodos. Cuando una instancia recibe un mensaje, el despachador busca el método correspondiente en el mapa de mensajes a métodos para el tipo y luego invoca el método.
Debido a que un tipo puede tener una cadena de tipos base, esta búsqueda puede resultar costosa. Una implementación simple del mecanismo de Smalltalk parecería tener una sobrecarga significativamente mayor que la de C++ y esta sobrecarga se generaría por cada mensaje que reciba un objeto.
Las implementaciones reales de Smalltalk suelen utilizar una técnica conocida como almacenamiento en caché en línea [5] que hace que el envío de métodos sea muy rápido. El almacenamiento en caché en línea básicamente almacena la dirección del método de destino anterior y la clase de objeto del sitio de llamada (o varios pares para el almacenamiento en caché multidireccional). El método almacenado en caché se inicializa con el método de destino más común (o solo el controlador de errores de caché), según el selector de métodos. Cuando se llega al sitio de llamada del método durante la ejecución, simplemente llama a la dirección en la caché. (En un generador de código dinámico, esta llamada es una llamada directa ya que la dirección directa se parchea de nuevo mediante la lógica de errores de caché). El código de prólogo en el método llamado luego compara la clase almacenada en caché con la clase de objeto real y, si no coinciden, la ejecución se ramifica a un controlador de errores de caché para encontrar el método correcto en la clase. Una implementación rápida puede tener varias entradas de caché y, a menudo, solo se necesitan un par de instrucciones para obtener la ejecución en el método correcto en un error de caché inicial. El caso común será una coincidencia de clase almacenada en caché y la ejecución simplemente continuará en el método.
El almacenamiento en caché fuera de línea también se puede utilizar en la lógica de invocación de métodos, utilizando la clase de objeto y el selector de métodos. En un diseño, la clase y el selector de métodos se codifican y se utilizan como índice en una tabla de caché de envío de métodos.
Como Smalltalk es un lenguaje reflexivo, muchas implementaciones permiten mutar objetos individuales en objetos con tablas de búsqueda de métodos generadas dinámicamente. Esto permite alterar el comportamiento de los objetos en función de cada objeto. A partir de esto, ha surgido toda una categoría de lenguajes conocidos como lenguajes basados en prototipos , de los cuales los más famosos son Self y JavaScript . El diseño cuidadoso del almacenamiento en caché de envío de métodos permite que incluso los lenguajes basados en prototipos tengan un envío de métodos de alto rendimiento.
Muchos otros lenguajes tipados dinámicamente, incluidos Python , Ruby , Objective-C y Groovy, utilizan enfoques similares.
clase Gato : def hablar ( self ): imprimir ( "Miau" )clase Perro : def hablar ( self ): imprimir ( "Guau" )def speak ( pet ): # Despacha dinámicamente el método speak # pet puede ser una instancia de Cat o Dog pet . speak ()gato = Gato () habla ( gato ) perro = Perro () habla ( perro )
#include <flujo de datos> // hacer de Pet una clase base virtual abstracta class Pet { public : virtual void speak () = 0 ; }; clase Perro : público Mascota { público : void hablar () anular { std :: cout << "¡Guau! \n " ; } }; clase Gato : público Mascota { público : void hablar () anular { std :: cout << "¡Miau! \n " ; } }; // speak() podrá aceptar cualquier cosa derivada de Pet void speak ( Pet & pet ) { pet . speak (); } int main () { Perro fido ; Gato simba ; hablar ( fido ); hablar ( simba ); devolver 0 ; }
Los objetos de rasgo realizan un envío dinámico […] Cuando usamos objetos de rasgo, Rust debe usar un envío dinámico. El compilador no conoce todos los tipos que se pueden usar con el código que usa objetos de rasgo, por lo que no sabe qué método implementado en qué tipo llamar. En cambio, en tiempo de ejecución, Rust usa los punteros dentro del objeto de rasgo para saber qué método llamar. Esta búsqueda genera un costo de tiempo de ejecución que no ocurre con el envío estático. El envío dinámico también evita que el compilador elija incorporar en línea el código de un método, lo que a su vez evita algunas optimizaciones.(xxix+1+527+3 páginas)
[…] La razón por la que Geos necesita 16 interrupciones es porque el esquema se utiliza para convertir las llamadas de función entre segmentos ("lejanos") en interrupciones, sin cambiar el tamaño del código. La razón por la que esto se hace es para que "algo" (el núcleo) pueda engancharse a sí mismo en cada llamada entre segmentos realizada por una aplicación Geos y asegurarse de que los segmentos de código adecuados se carguen desde la memoria virtual y se bloqueen. En términos de DOS , esto sería comparable a un cargador superpuesto , pero uno que se puede agregar sin requerir soporte explícito del compilador o la aplicación. Lo que ocurre es algo como esto: […] 1. El compilador de modo real genera una instrucción como esta: CALL <segment>:<offset> -> 9A <offlow><offhigh><seglow><seghigh> con <seglow><seghigh> normalmente definida como una dirección que debe corregirse en el momento de la carga dependiendo de la dirección donde se haya colocado el código. […] 2. El enlazador Geos convierte esto en otra cosa: INT 8xh -> CD 8x […] DB <seghigh>,<offlow>,<offhigh> […] Nótese que esto es nuevamente cinco bytes, por lo que se puede corregir "en su lugar". Ahora el problema es que una interrupción requiere dos bytes, mientras que una instrucción CALL FAR solo necesita uno. Como resultado, el vector de 32 bits (<seg><ofs>) debe comprimirse en 24 bits. […] Esto se logra de dos maneras: primero, la dirección <seg> se codifica como un "identificador" para el segmento, cuyo nibble más bajo siempre es cero. Esto ahorra cuatro bits. Además […] los cuatro bits restantes van al nibble bajo del vector de interrupción, creando así cualquier cosa desde INT 80h a 8Fh. […] El manejador de interrupciones para todos esos vectores es el mismo. "Desempaquetará" la dirección de la notación de tres bytes y medio, buscará la dirección absoluta del segmento y reenviará la llamada, después de haber realizado su tarea de carga de memoria virtual... El retorno de la llamada también pasará por el código de desbloqueo correspondiente. […] El nibble bajo del vector de interrupción (80h–8Fh) contiene los bits 4 a 7 del manejador de segmento. Los bits 0 a 3 de un manejador de segmento son (por definición de un manejador Geos) siempre 0. […] todas las API Geos se ejecutan a través del esquema de "superposición" […]: cuando una aplicación Geos se carga en la memoria, el cargador reemplazará automáticamente las llamadas a funciones en las bibliotecas del sistema por las llamadas basadas en INT correspondientes. De todos modos, estas no son constantes, sino que dependen del manejador asignado al segmento de código de la biblioteca.[…] Originalmente, Geos estaba previsto que se convirtiera al modo protegido desde el principio […], con el modo realsiendo solo una "opción heredada" […] casi cada línea de código ensamblador está lista para ello […]
[…] en caso de punteros tan destrozados […] hace muchos años, Axel y yo estábamos pensando en una forma de usar *un* punto de entrada en un controlador para múltiples vectores de interrupción (ya que esto nos ahorraría mucho espacio para los múltiples puntos de entrada y el código de encuadre de inicio/salida más o menos idéntico en todos ellos), y luego cambiar a los diferentes controladores de interrupciones internamente. Por ejemplo: 1234h:0000h […] 1233h:0010h […] 1232h:0020h […] 1231h:0030h […] 1230h:0040h […] todos apuntan exactamente al mismo punto de entrada. Si conecta INT 21h a 1234h:0000h e INT 2Fh a 1233h:0010h, y así sucesivamente, todos pasarían por el mismo "vacío", pero aún podría distinguirlos y ramificarse en los diferentes controladores internamente. Piense en un punto de entrada "comprimido" en un stub A20 para la carga de HMA . Esto funciona siempre que ningún programa comience a hacer magia de segmento: desplazamiento. […] Compare esto con el enfoque opuesto de tener múltiples puntos de entrada (quizás incluso admitiendo el Protocolo de uso compartido de interrupciones de IBM ), que consume mucha más memoria si conecta muchas interrupciones. […] Llegamos a la conclusión de que esto probablemente no sería seguro en la práctica porque nunca se sabe si otros controladores normalizan o desnormalizan los punteros, por cualquier razón. […](NB. Algo similar a los " punteros gordos " específicamente para el segmento de modo real de Intel : direccionamiento de desplazamiento en procesadores x86 , que contiene tanto un puntero deliberadamente desnormalizado a un punto de entrada de código compartido como algo de información para seguir distinguiendo los diferentes llamadores en el código compartido. Si bien, en un sistema abierto , las instancias de terceros que normalizan punteros (en otros controladores o aplicaciones) no se pueden descartar por completo en interfaces públicas , el esquema se puede usar de manera segura en interfaces internas para evitar secuencias de código de entrada redundantes).