21 agosto 2025

Los interfaces de C# y el inspector de Unity

Interface Contract
En C#, una interfaz es un contrato que define un conjunto de métodos, propiedades, eventos o indexadores que una clase o estructura debe implementar, sin que dicho contrato proporcione la implementación concreta. Es como un plano que especifica "qué" debe hacerse, pero no el "cómo". Cuando una clase se adhiere a una interfaz, se compromete públicamente a ofrecer, al menos, los métodos de dicha interfaz. Por tanto, dicha clase debe implementar el código de cada uno de los métodos del interfaz.

Las interfaces en C# ofrecen varias ventajas que las hacen fundamentales en el diseño de software. Las principales son:

  • Abstracción: Definen un contrato sin detalles de implementación, permitiendo centrarse en "qué" hace un objeto, no en "cómo" lo hace.
  • Flexibilidad y extensibilidad: Facilitan la adición de nuevas clases que implementen la misma interfaz sin modificar el código existente.
  • Herencia múltiple: Permiten que una clase implemente varias interfaces, superando la restricción de herencia única de clases en C#. Una clase sólo puede heredar de una clase madre, pero puede implementar varios interfaces simultáneamente.
  • Desacoplamiento: Reducen la dependencia entre componentes, promoviendo un diseño modular y facilitando la inyección de dependencias.
  • Mantenimiento y testing: Simplifican la sustitución de implementaciones (por ejemplo, en pruebas unitarias con mocks) al trabajar con contratos en lugar de clases concretas.
  • Consistencia: Garantizan que las clases que implementan la interfaz cumplan con un comportamiento estándar, mejorando la interoperabilidad.
  • Soporte a patrones de diseño: Son esenciales en patrones como Strategy, Factory o Repository, que dependen de abstracciones.

Todo esto se entiende mejor con ejemplos. En mi caso, he desarrollado una clase abstracta, denominada SteeringBehavior, que implementa la funcionalidad básica del movimiento de un agente inteligente. De esa clase heredan otras muchas hijas que implementan movimientos especializados. 

La clase abstracta SteeringBehavior
La clase abstracta SteeringBehavior

Algunas (no todas) de esas clases hijas necesitan que se les fije un blanco para poder calcular su movimiento, ya sea para alcanzar ese blanco, para perseguirlo, para mirar hacia él, etc. Ese blanco se define con un campo Target. 

Podría haber incluido ese campo dentro de la definición de las clases hijas, pero eso me habría restado flexibilidad. El problema habría venido al encadenar clases SteeringBehavior. Una clases SteeringBehavior ofrecen su funcionalidad componiendo las salidas de otras clases SteeringBehavior más sencillas. La flexibilidad de ese enfoque viene de poder construir en el inspector el árbol de dependencias de los diferentes SteeringBehavior.

Un agente, construido con la composición de varios SteeringBehavior
Un agente, construido con la composición de varios SteeringBehavior

Referencias en el inspector a los SteeringBehavior hijos
Referencias en el inspector a los SteeringBehavior hijos

Hay algunos SteeringBehavior para los que sabemos exactamente el tipo de los SteeringBehavior hijos que va a necesitar. En la captura anterior se puede ver que ActiveWallAvoiderSteeringBehavior necesita concretamente un hijo del tipo PassiveWallSteeringBehavior. Pero también hay casos en los que al SteeringBehavior padre le da igual el tipo concreto del hijo, siempre y cuando herede de SteeringBehavior y ofrezca un campo Target (al que pasarle su respectivo campo Target). En el caso de la captura, yo he pasado un SeekSteeringBehavior, pero podría haber pasado un ArriveSteeringBehavior o un PursuitSteeringBehavior, para obtener tipos de movimiento lgeramente diferentes. Si hubiera implementado el campo Target por herencia, habría perdido la flexibilidad de pasar cualquier tipo de SteeringBehavior con un campo Target, al verme obligado a especificar el tipo concreto, hijo de SteeringBehavior.

En vez de eso, lo que he hecho ha sido crear un interfaz.

El interfaz ITargeter
El interfaz ITargeter

Como se puede ver en la captura, el interfaz no puede ser más sencillo. Se declara con la palabra clave interface y, en este caso, se limita a señalar que las clases que se adhieran a este interfaz se comprometen a ofrecer una propiedad Target.

A una clase que se adhiriese a dicho interfaz le bastaría con implementar esa propiedad.

Una clase que implementa el interfaz ITargeter

Como se puede ver en la captura, la clase SeekSteeringBehavior hereda de SteeringBehavior y al mismo tiempo implementa el interfaz ITargeter.

Es posible que estés notando cierto solape entre las clases abstractas y los interfaces. Aunque ambas son mecanismos de abstracción, tienen diferencias importantes en su propósito y uso. Un interfaz define un contrato puro con firmas de métodos, propiedades, eventos o indexadores, sin ninguna implementación. Es ideal para especificar comportamientos que diferentes clases no relacionadas pueden compartir. Por su parte, una clase abstracta es una clase que no se puede instanciar directamente y puede contener tanto miembros abstractos (sin implementación) como miembros concretos (con implementación). Sirve como base para clases relacionadas que comparten lógica común. En general, si quisieras dotar de un comportamiento común a clases relacionadas por una madre, usarías clases abstractas (posiblemente haciendo abstracta a la clase madre); mientras que si el comportamiento común se diese en clases heterogéneas, lo habitual sería usar interfaces.

Mientras que una clase abstracta puede incluir métodos concretos, campos, constructores y lógica compartida que las clases derivadas pueden usar directamente; un interfaz no puede incluir implementación de métodos ni campos (solo a partir de C# 8.0 permite métodos por defecto, pero con restricciones, en general todo debe ser implementado por la clase que la adopta). Por otro lado, la clase abstracta permite modificadores de acceso (public, private, protected, etc.) para sus miembros; mientras que en en un interfaz todos los miembros son implícitamente públicos y no se pueden especificar modificadores de acceso. Todo ello sin olvidar la diferencia en cuanto la herencia: las clases abstractas sólo permiten herencia simple, mientras que los interfaces permiten herencia múltiple.

Unity añade una diferencia adicional, y que resulta determinante para este artículo, dado que permite mostrar en el inspector clases abstractas, pero no interfaces. En una de las capturas anteriores se veía el inspector de la clase ActiveWallAvoiderSteeringBehavior. Te pido que te fijes en su campo Steering Behavior. El código que declara ese campo y lo muestra en el inspector es el siguiente:

Declaración del campo steeringBehavior
Declaración del campo steeringBehavior

Fíjate en que steeringBehavior es del tipo SteeringBehavior (una clase abstracta) y en que el inspector no tiene problema en mostrarlo. Por tanto, podemos usar el inspector para meter en ese campo cualquier script que herede de SteeringBehavior.

Si quisiéramos que ese campo aceptase cualquier script que implementase ITargeter, deberíamos poder hacer [SerializeField] private ITargeter steeringBehavior. El problema es que aunque C# nos permite hacer algo así, Unity no permite exponer campos de tipo interfaz en el inspector.

Entonces, ¿cómo podemos hacer para asegurar que al campo steeringBehavior se le pase no sólo una clase que herede de SteeringBehavior, sino que además implemente el interfaz ITargeter? La respuesta es que Unity, no ofrece ninguna salvaguarda de serie, pero al menos podemos desarrollar la nuestra, tal y como te voy a explicar. 

Será más sencillo si te enseño primero lo que queremos conseguir y luego te explico cómo implementarlo. La solución que he encontrado ha sido diseñar mi propio PropertyAttribute para decorar con él los campos que quiero confirmar que cumplen uno o varios interfaces.

Atributo personalizado para confirmar el cumplimiento de interfaces
Atributo personalizado para confirmar el cumplimiento de interfaces

En la captura anterior se puede ver que he llamado a mi atributo InterfaceCompliant. En el ejemplo, el atributo verifica que el SteeringBehavior que se le pase al campo steeringBehavior cumple con el interfaz ITargeter.

Si el script que se le pasase al campo no cumpliese con el interfaz, el atributo se encargaría de mostrar un mensaje de alerta en el inspector.

Mensaje de alerta al pasar un script que no cumpliese con el interfaz
Mensaje de alerta al pasar un script que no cumpliese con el interfaz


Una vez que ya tenemos claro lo que queremos conseguir, vamos a ver cómo implementarlo. Como en el caso del resto de atributos, tenemos que crear una clase que herede de PropertyAttribute. Esta última es abstracta y nos obligará a implementar el método InterfaceCompliantAttribute, cuyos parámetros son los que se le pasarán al atributo cuando decore un campo.

Implementación de nuestro atributo personalizado
Implementación de nuestro atributo personalizado

Fíjate que el nombre de esta clase debe ser el de nuestro atributo, seguido del sufijo "Attribute". La clase debe ubicarse en dentro de la carpeta Scripts.

Esta clase sirve de modelo de datos del atributo. Guardaremos en ella, toda la información que necesitemos para hacer nuestra comprobación y mostrar el mensaje de advertencia si fuese necesario. En este caso, el atributo admite un número variable de parámetros (por eso la palabra clave params). Dichos parámetros son los tipos de nuestros interfaces y se pasarán en un arry que guardaremos en el campo InterfaceTypes.

Para representar al atributo personalizado en el inspectos, así como al campo que decora, utilizaremos un PropertyDrawer. Estas son clases que hereda, ¡oh, sorpresa!, de PropertyDrawer y que deben situarse en una carpeta Editor, al margen de la de Scripts. Podemos llamar a nuestra clase como queramos, lo importante es que la decoremos con un atributo CustomPropertyDrawer para señalar qué atributo es el que "dibuja" este PropertyDrawer en el inspector.

PropertyDrawer para dibujar nuestro atributo y el campo que decora
PropertyDrawer para dibujar nuestro atributo y el campo que decora

En nuestro caso, he empleado el atributo CustomPropertyDrawer para señalar que debe usarse este PropertyDrawer siempre que un campo aparezca decorado por un atributo InterfaceCompliantAttribute.

PropertyDrawer es otra clase abstracta que obliga a implementar un método denominado CreatePropertyGUI. Este método es al que llama el inspector cuando quiere dibujar en el inspector un campo decorado por este atributo. 

Implementación del método CreatePropertyGUI de InterfaceCompliantAttributeDrawer
Implementación del método CreatePropertyGUI de InterfaceCompliantAttributeDrawer

Una clase que herede de PropertyDrawer cuenta con una referencia al atributo que dibuja a través del campo attribute. Por eso, en la línea 22 se hace casting de él al tipo InterfaceCompliantAttributo que sabemos que es en realidad, para poder recuperar la lista de interfaces a comprobar en la línea 23.

Fíjate en que mientras el atributo se puede rescatar usando el campo attribute de PropertyDrawer, el campo decorado se pasa como un parámetro del método, property, encapsulado en un tipo Serialized Property (línea 19).

 Acto seguido, en la línea 26 generamos el contenedor visual en el que meteremos los widgets para representar nuestro campo en el inspector, así como, en su caso, el mensaje de advertencia. Por ahora, ese contenedor visual está vacío, pero en las líneas siguientes le iremos metiendo contenido. Conforme le vayamos añadiendo elementos al contenedor, estos se irán representando de arriba a abajo en el inspector.

Si miramos la captura en la que mostraba el mensaje de advertencia en el inspector, veremos que dicho mensaje iba antes del campo, así que por eso, el primer elemento que añadiremos al contenedor será dicho mensaje de advertencia, si fuese necesario (línea 28). Esta parte tiene bastante miga, así que la he sacado a su propio método (AddErrorBoxIfNeeded), para facilitar la lectura de CreatePropertyGUI y para permitirme reaprovechar ese método para volver a llamarlo en la línea 45. En cuanto acabe de explicar la implementación de CreatePropertyGUI veremos la de AddErrorIfNeeded.

Una vez que hemos añadido el mensaje de advertencia (si fuese necesario) al contenedor, hay que mostrar el campo decorado. Queremos mostrarlo con su aspecto por defecto en el inspector, así que nos limitamos a crear un PropertyField (línea 31). PropertyField detectará el tipo del campo encapsulado en property y los mostrará con el widget por defecto de Unity para ese tipo.

Atento ahora. Queremos que cada vez que metamos un nuevo script en el campo decorado, se vuelva a evaluar si el nuevo script cumple con los interfaces. Para eso, en la línea 43 le encargamos al contenedor que vigile el valor de property y que cada vez que este cambie vuelva a evaluar si mostrar o no el mensaje de advertencia (línea 45), redibujando el campo (línea 46). 

Hecho todo esto, añadimos el campo decorado al contenedor (línea 50) y retornamos este contenedor para que el inspector lo dibuje.

Como lo prometido es deuda, ahora vamos a analizar la implementación de AddErrorBoxIfNeeded.

Chequeo del campo decorado para ver si cumple con los interfaces

Para acceder al campo encapsulado dentro de un SerializedProperty hay que utilizar su campo objectReferenceValue (línea 70). 

Este campo es de tipo Object, así que luego habrá que hacer casting al tipo concreto que queremos comprobar. Eso lo hacemos en el método GetNotComplyingInterfaces de la línea 74. Ahora veremos su implementación, pero a alto nivel lo que hace ese método es coger una lista de interfaces (línea 75), y un objeto (línea 76), para luego hacer casting de ese objeto contra cada uno de los interfaces. Si alguno de esos castings resultase fallido, el tipo del interfaz que hubiese fallado se añadiría a una lista que se devolvería como salida del método (línea 74).

Dicha lista se pasa al método GenerateBox el cual dibuja el mensaje de advertencia, en caso de que la lista  tuviese algún elemento. En caso de estar vacía, el método no dibujaría nada. El mensaje dibujado se devolvería en un ErrorBox (línea 79) que se añadiría al contenedor (línea 85), tras borrar su contenido anterior (línea 82)

Por su parte, el chequeo de interfaces es muy sencillo.

Chequeo de interfaces sobre el script del campo
Chequeo de interfaces sobre el script del campo


A partir de la línea 120 se puede ver que, por cada uno de los interfaces de la lista (los pasados como parámetros del atributo), se comprueba si el script (checkedObject) que estamos chequeando lo implementa. Para ello, usamos el método de C# IsInstanceOfType (línea 123). Siempre he pensado que el método IsInstanceOfType es muy engañoso, porque leído de izquierda a derecha parece que quiere comprobar si "interfaceType-es-una-instancia-del-tipo-checkedObject", cuando, en realidad, lo que hace el método es comprobar si checkedObject es una instancia de interfaceType.

En caso de que checkedObject no implemente el interfaz, IsInstanceOfType devolverá falso y el tipo del interfaz se añadirá a la lista de interfaces no cumplidos (línea 125) que se devolverán como salida del método.

Por último, y por completitud, vamos a ver cómo se genera el mensaje de error en el método GenerateErrorBox.

Generación del mensaje de advertencia
Generación del mensaje de advertencia

Si el script a chequear cumpliese todos los interfaces, la lista que se le pasa como parámetro a GenerateErrorBox estaría vacía y el método se limitaría a devolver null (línea 90).

Sin embargo, si hubiese habido algún incumplimiento, se encadenarían los nombres de los interfaces no cumplidos, concatenándolos con comas entre ellos, como se puede ver en la líneas 93 a 95.

Esa lista de nombres de interfaces, concatenados con comas, se añadiría al final del mensaje de advertencia (línea 98) y con este se crearía un mensaje de error, que se devolvería para ser añadido al contenedor (línea 102).

Y con esto ya lo tienes todo. Es cierto que no vas a poder impedir que se metan en el campo decorado scripts que no cumplan con los interfaces, pero al menos saldrá un mensaje avisando del problema. Será muy difícil que no repares en el mensaje y soluciones el desliz. Por otro lado, todo este ejemplo ha servido para que veas cómo implementar un atributo personalizado con el que hacer uso de la flexibilidad del inspector de Unity. Espero que te haya resultado interesante.