Principio de segregación de interfaz: ¿Qué hacer si las interfaces tienen una superposición significativa?

9

Del desarrollo de software ágil, principios, patrones y prácticas: Nueva edición internacional de Pearson :

A veces, los métodos invocados por diferentes grupos de clientes se superponen. Si la superposición es pequeña, las interfaces para los grupos deben permanecer separadas. Las funciones comunes deben declararse en todas las interfaces superpuestas. La clase de servidor heredará las funciones comunes de cada una de esas interfaces, pero las implementará solo una vez.

Tío Bob, habla sobre el caso cuando hay una superposición menor.

¿Qué debemos hacer si hay una superposición significativa?

Di que tenemos

Class UiInterface1;
Class UiInterface2;
Class UiInterface3;

Class UiIterface : public UiInterface1, public UiInterface2, public UiInterface3{};

¿Qué debemos hacer si hay una superposición significativa entre UiInterface1y UiInterface2?

q126y
fuente
Cuando me encuentro con interfaces muy superpuestas, creo una interfaz principal, que agrupa los métodos comunes y luego heredo de esta común para crear especializaciones. ¡PERO! Si nunca quiere que nadie use la interfaz común sin la especialización, entonces realmente necesita ir a la duplicación del código, porque si introduce la interfaz común de los padres, la gente podría usarla.
Andy
La pregunta es un poco vaga para mí, uno podría responder con muchas soluciones diferentes dependiendo de los casos. ¿Por qué creció la superposición?
Arthur Havlicek

Respuestas:

1

Fundición

Es casi seguro que será una tangente completa al enfoque del libro citado, pero una forma de ajustarse mejor al ISP es adoptar una mentalidad de fundición en un área central de su base de código utilizando un QueryInterfaceenfoque de estilo COM.

Muchas de las tentaciones para diseñar interfaces superpuestas en un contexto de interfaz puro a menudo provienen del deseo de hacer que las interfaces sean "autosuficientes" más que desempeñar una responsabilidad precisa, similar a un francotirador.

Por ejemplo, puede parecer extraño diseñar funciones de cliente como esta:

// Returns the absolute position of an entity as the sum
// of its own position and the position of its ancestors.
// `position` and `parenting` parameters should point to the 
// same object.
Vec2i abs_position(IPosition* position, IParenting* parenting)
{
     const Vec2i xy = position->xy();
     auto parent = parenting->parent();
     if (parent)
     {
         // If the entity has a parent, return the sum of the
         // parent position and the entity's local position.
         return xy + abs_position(dynamic_cast<IPosition*>(parent),
                                  dynamic_cast<IParenting*>(parent));
     }
     return xy;
}

... así como bastante feo / peligroso, dado que estamos perdiendo la responsabilidad de realizar una conversión propensa a errores al código del cliente usando estas interfaces y / o pasando el mismo objeto como argumento varias veces a múltiples parámetros de la misma función. Por lo tanto, a menudo deseamos diseñar una interfaz más diluida que consolide las inquietudes IParentingy IPositionen un lugar, como IGuiElemento algo así, que luego se vuelve susceptible de superponerse con las inquietudes de las interfaces ortogonales que también estarán tentadas a tener más funciones miembro para la misma razón de "autosuficiencia".

Mezcla de responsabilidades versus casting

Cuando se diseñan interfaces con una responsabilidad totalmente singular y ultradifilada, la tentación a menudo será aceptar algunas interfaces de downcasting o consolidar para cumplir con múltiples responsabilidades (y, por lo tanto, pisar tanto en ISP como en SRP).

Al usar un enfoque de estilo COM (solo la QueryInterfaceparte), jugamos con el enfoque de downcasting pero consolidamos la conversión a un lugar central en la base de código, y podemos hacer algo más como esto:

// Returns the absolute position of an entity as the sum
// of its own position and the position of its ancestors.
// `obj` should implement `IPosition` and optionally `IParenting`.
Vec2i abs_position(Object* obj)
{
     // `Object::query_interface` returns nullptr if the interface is
     // not provided by the entity. `Object` is an abstract base class
     // inherited by all entities using this interface query system.
     IPosition* position = obj->query_interface<IPosition>();
     assert(position && "obj does not implement IPosition!");
     const Vec2i xy = position->xy();

     IParenting* parenting = obj->query_interface<IParenting>();
     if (parenting && parenting->parent()->query_interface<IPosition>())
     {
         // If the entity implements IParenting and has a parent, 
         // return the sum of the parent position and the entity's 
         // local position.
         return xy + abs_position(parenting->parent());
     }
     return xy;
}

... por supuesto con suerte con envoltorios de tipo seguro y todo lo que puede construir centralmente para obtener algo más seguro que los punteros sin formato.

Con esto, la tentación de diseñar interfaces superpuestas a menudo se mitiga al mínimo absoluto. Le permite diseñar interfaces con responsabilidades muy singulares (a veces solo una función miembro dentro) que puede mezclar y combinar todo lo que quiera sin preocuparse por ISP, y obtener la flexibilidad de escribir pseudo-pato en tiempo de ejecución en C ++ (aunque, por supuesto, con la compensación de las penalizaciones de tiempo de ejecución para consultar objetos para ver si admiten una interfaz particular). La parte de tiempo de ejecución puede ser importante, por ejemplo, en una configuración con un kit de desarrollo de software donde las funciones no tendrán la información en tiempo de compilación de los complementos de antemano que implementan estas interfaces.

Plantillas

Si las plantillas son una posibilidad (tenemos la información necesaria en tiempo de compilación por adelantado que no se pierde para el momento en que agarramos un objeto, es decir), entonces simplemente podemos hacer esto:

// Returns the absolute position of an entity as the sum
// of its own position and the position of its ancestors.
// `obj` should have `position` and `parent` methods.
template <class Entity>
Vec2i abs_position(Entity& obj)
{
     const Vec2i xy = obj.xy();
     if (obj.parent())
     {
         // If the entity has a parent, return the sum of the parent 
         // position and the entity's local position.
         return xy + abs_position(obj.parent());
     }
     return xy;
}

... por supuesto, en tal caso, el parentmétodo tendría que devolver el mismo Entitytipo, en cuyo caso probablemente queremos evitar las interfaces por completo (ya que a menudo querrán perder información de tipo a favor de trabajar con punteros de base).

Sistema de entidad-componente

Si comienza a seguir el enfoque de estilo COM desde un punto de vista de flexibilidad o rendimiento, a menudo terminará con un sistema de componente de entidad similar al que aplican los motores de juego en la industria. En ese punto, estará completamente perpendicular a muchos enfoques orientados a objetos, pero ECS podría ser aplicable al diseño de GUI (un lugar que he contemplado usar ECS fuera de un enfoque orientado a escena, pero lo consideré demasiado tarde después decidirse por un enfoque de estilo COM para probar allí).

Tenga en cuenta que esta solución de estilo COM está completamente disponible en lo que respecta a los diseños del kit de herramientas GUI, y ECS sería aún más, por lo que no es algo que esté respaldado por muchos recursos. Sin embargo, definitivamente le permitirá mitigar las tentaciones de diseñar interfaces que tienen responsabilidades superpuestas al mínimo absoluto, lo que a menudo lo convierte en una preocupación.

Enfoque pragmático

La alternativa, por supuesto, es relajar un poco la guardia o diseñar interfaces en un nivel granular y luego comenzar a heredarlas para crear interfaces más gruesas que use, como las IPositionPlusParentingque se derivan de ambos IPositionyIParenting(Ojalá con un nombre mejor que ese). Con interfaces puras, no debería violar a ISP tanto como esos enfoques monolíticos de jerarquía profunda comúnmente aplicados (Qt, MFC, etc.), donde la documentación a menudo siente la necesidad de ocultar miembros irrelevantes dado el nivel excesivo de violación de ISP con esos tipos de diseños), por lo que un enfoque pragmático podría simplemente aceptar cierta superposición aquí y allá. Sin embargo, este tipo de enfoque de estilo COM evita la necesidad de crear interfaces consolidadas para cada combinación que usará. La preocupación de "autosuficiencia" se elimina por completo en tales casos, y eso a menudo eliminará la fuente última de tentación para diseñar interfaces que tengan responsabilidades superpuestas que quieran luchar con SRP e ISP.


fuente
11

Esta es una decisión judicial que debe hacer, caso por caso.

En primer lugar, recuerde que los principios SÓLIDOS son solo eso ... principios. No son reglas. No son una bala de plata. Son solo principios. Eso no quita importancia, siempre debes inclinarte por seguirlos. Pero en el momento en que introducen un nivel de dolor, debes deshacerte de ellos hasta que los necesites.

Con eso en mente, piense por qué está separando sus interfaces en primer lugar. La idea de una interfaz es decir "Si este código de consumo requiere que se implemente un conjunto de métodos en la clase que se consume, necesito establecer un contrato para la implementación: si me proporciona un objeto con esta interfaz, puedo trabajar con eso."

El propósito del ISP es decir "Si el contrato que requiero es solo un subconjunto de una interfaz existente, no debería aplicar la interfaz existente en ninguna clase futura que pueda pasar a mi método".

Considere el siguiente código:

public interface A
{
    void X();
    void Y();
}

public class Foo
{
     public void ConsumeX(A a)
     {
         a.X();
     }
}

Ahora tenemos una situación en la que, si queremos pasar un nuevo objeto a ConsumeX, tiene que implementar X () e Y () para ajustarse al contrato.

Entonces, ¿deberíamos cambiar el código, ahora mismo, para que se vea como el siguiente ejemplo?

public interface A
{
    void X();
    void Y();
}

public interface B
{
    void X();
}

public class Foo
{
     public void ConsumeX(B b)
     {
         b.X();
     }
}

ISP sugiere que deberíamos, por lo que deberíamos inclinarnos hacia esa decisión. Pero, sin contexto, es difícil estar seguro. ¿Es probable que extendamos A y B? ¿Es probable que se extiendan independientemente? ¿Es probable que B implemente métodos que A no requiere? (Si no, podemos hacer que A se derive de B.)

Este es el juicio que debes hacer. Y, si realmente no tiene suficiente información para hacer esa llamada, probablemente debería tomar la opción más simple, que bien podría ser el primer código.

¿Por qué? Porque es fácil cambiar de opinión más tarde. Cuando necesite esa nueva clase, simplemente cree una nueva interfaz e implemente ambas en su clase anterior.

pdr
fuente
1
"Antes que nada, recuerda que los principios SÓLIDOS son solo eso ... principios. No son reglas. No son una bala de plata. Son solo principios. Eso no quita importancia, siempre debes inclinarte hacia seguirlos. Pero en el momento en que introducen un nivel de dolor, debes deshacerte de ellos hasta que los necesites ". Esto debería estar en la primera página de cada libro de patrones / principios de diseño. Debería aparecer también cada 50 páginas como recordatorio.
Christian Rodriguez