Las interfaces permiten lenguajes tipados estáticamente para soportar el polimorfismo. Un purista orientado a objetos insistiría en que un lenguaje debe proporcionar herencia, encapsulación, modularidad y polimorfismo para ser un lenguaje orientado a objetos con todas las funciones. En lenguajes de tipo dinámico (o tipo pato), el polimorfismo es trivial (como Smalltalk); sin embargo, en lenguajes tipados estáticamente (como Java o C #), el polimorfismo está lejos de ser trivial (de hecho, en la superficie parece estar en desacuerdo con la noción de tipeo fuerte).
Déjame demostrarte:
En un lenguaje de tipo dinámico (o tipo pato) (como Smalltalk), todas las variables son referencias a objetos (nada menos y nada más). Entonces, en Smalltalk, puedo hacer esto:
|anAnimal|
anAnimal := Pig new.
anAnimal makeNoise.
anAnimal := Cow new.
anAnimal makeNoise.
Ese código:
- Declara una variable local llamada anAnimal (tenga en cuenta que NO especificamos el TIPO de la variable; todas las variables son referencias a un objeto, ni más ni menos).
- Crea una nueva instancia de la clase llamada "Pig"
- Asigna esa nueva instancia de Pig a la variable anAnimal.
- Envía el mensaje
makeNoise
al cerdo.
- Repite todo usando una vaca, pero asignándola a la misma variable exacta que el Cerdo.
El mismo código Java se vería así (suponiendo que Duck y Cow son subclases de Animal:
Animal anAnimal = new Pig();
duck.makeNoise();
anAnimal = new Cow();
cow.makeNoise();
Eso está muy bien, hasta que presentamos la clase Vegetal. Las verduras tienen el mismo comportamiento que los animales, pero no todas. Por ejemplo, tanto Animal como Vegetal podrían crecer, pero claramente las verduras no hacen ruido y los animales no pueden ser cosechados.
En Smalltalk, podemos escribir esto:
|aFarmObject|
aFarmObject := Cow new.
aFarmObject grow.
aFarmObject makeNoise.
aFarmObject := Corn new.
aFarmObject grow.
aFarmObject harvest.
Esto funciona perfectamente bien en Smalltalk porque está escrito en forma de pato (si camina como un pato y grazna como un pato, es un pato). En este caso, cuando se envía un mensaje a un objeto, se realiza una búsqueda en la lista de métodos del receptor, y si se encuentra un método coincidente, se llama. Si no, se produce algún tipo de excepción NoSuchMethodError, pero todo se realiza en tiempo de ejecución.
Pero en Java, un lenguaje de tipo estático, ¿qué tipo podemos asignar a nuestra variable? El maíz debe heredarse de los vegetales, para apoyar el crecimiento, pero no puede heredarse de los animales, porque no hace ruido. La vaca necesita heredar de Animal para soportar makeNoise, pero no puede heredar de Vegetal porque no debe implementar la cosecha. Parece que necesitamos herencia múltiple : la capacidad de heredar de más de una clase. Pero eso resulta ser una característica del lenguaje bastante difícil debido a todos los casos extremos que aparecen (¿qué sucede cuando más de una superclase paralela implementa el mismo método?, Etc.)
A lo largo vienen las interfaces ...
Si hacemos clases de animales y vegetales, con cada implementación de Growable, podemos declarar que nuestra vaca es animal y nuestro maíz es vegetal. También podemos declarar que tanto animal como vegetal son cultivables. Eso nos permite escribir esto para hacer crecer todo:
List<Growable> list = new ArrayList<Growable>();
list.add(new Cow());
list.add(new Corn());
list.add(new Pig());
for(Growable g : list) {
g.grow();
}
Y nos permite hacer esto, hacer ruidos de animales:
List<Animal> list = new ArrayList<Animal>();
list.add(new Cow());
list.add(new Pig());
for(Animal a : list) {
a.makeNoise();
}
La ventaja del lenguaje de tipo pato es que obtienes un polimorfismo realmente agradable: todo lo que una clase tiene que hacer para proporcionar comportamiento es proporcionar el método. Mientras todos jueguen bien y solo envíen mensajes que coincidan con los métodos definidos, todo está bien. La desventaja es que el tipo de error a continuación no se detecta hasta el tiempo de ejecución:
|aFarmObject|
aFarmObject := Corn new.
aFarmObject makeNoise. // No compiler error - not checked until runtime.
Los lenguajes de tipo estático proporcionan una "programación por contrato" mucho mejor, porque detectarán los dos tipos de error a continuación en tiempo de compilación:
// Compiler error: Corn cannot be cast to Animal.
Animal farmObject = new Corn();
farmObject makeNoise();
-
// Compiler error: Animal doesn't have the harvest message.
Animal farmObject = new Cow();
farmObject.harvest();
Entonces ... para resumir:
La implementación de la interfaz le permite especificar qué tipo de cosas pueden hacer los objetos (interacción) y la herencia de clase le permite especificar cómo se deben hacer las cosas (implementación).
Las interfaces nos brindan muchos de los beneficios del polimorfismo "verdadero", sin sacrificar la verificación del tipo de compilador.