Patrón de iterador: ¿por qué es importante no exponer la representación interna?


24

Estoy leyendo C # Design Pattern Essentials . Actualmente estoy leyendo sobre el patrón iterador.

Entiendo completamente cómo implementar, pero no entiendo la importancia o veo un caso de uso. En el libro se da un ejemplo donde alguien necesita obtener una lista de objetos. Podrían haber hecho esto exponiendo una propiedad pública, como IList<T>o una Array.

El libro escribe

El problema con esto es que la representación interna en ambas clases ha sido expuesta a proyectos externos.

¿Qué es la representación interna? El hecho de que es un arrayo IList<T>? Realmente no entiendo por qué esto es algo malo para el consumidor (el programador lo llama) para saber ...

El libro luego dice que este patrón funciona al exponer su GetEnumeratorfunción, por lo que podemos llamar GetEnumerator()y exponer la 'lista' de esta manera.

Supongo que estos patrones tienen un lugar (como todos) en ciertas situaciones, pero no veo dónde y cuándo.



44
Porque, la implementación puede querer cambiar de usar una matriz a usar una lista vinculada sin requerir cambios en la clase de consumidor. Esto sería imposible si el consumidor sabe la clase exacta devuelta.
MTilsted

11
No es malo que el consumidor lo sepa , es malo que el consumidor confíe en él . No me gusta el término ocultar información porque te lleva a creer que la información es "privada" como tu contraseña en lugar de privada como el interior de un teléfono. Los componentes internos están ocultos porque son irrelevantes para el usuario, no porque sean algún tipo de secreto. Todo lo que necesita saber es marcar aquí, hablar aquí, escuchar aquí.
Capitán Man

Supongamos que tenemos una buena "interfaz" Fque me da conocimiento (métodos) de a, by c. Todo está bien, puede haber muchas cosas diferentes pero solo Fpara mí. Piense en el método como "restricciones" o cláusulas de un contrato que Fobliga a las clases a hacer. Supongamos que agregamos un d, porque lo necesitamos. Esto agrega una restricción adicional, cada vez que hacemos esto, imponemos más y más en el Fs. Eventualmente (en el peor de los casos), solo una cosa puede ser, Fpor lo que bien podríamos no tenerla. Y Flimita tanto que solo hay una forma de hacerlo.
Alec Teal

@captainman, qué comentario tan maravilloso. Sí, veo por qué "abstraer" muchas cosas, pero el giro sobre saber y confiar es ... Francamente ... Brillante. Y una distinción importante que no consideré hasta leer tu publicación.
MyDaftQuestions

Respuestas:


56

El software es un juego de promesas y privilegios. Nunca es una buena idea prometer más de lo que puede cumplir, o más de lo que su colaborador necesita.

Esto se aplica particularmente a los tipos. El punto de escribir una colección iterable es que su usuario puede iterar sobre ella, ni más ni menos. Exponer el tipo concretoArray generalmente crea muchas promesas adicionales, por ejemplo, que puede ordenar la colección por una función de su elección, sin mencionar el hecho de que una normal Arrayprobablemente le permitirá al colaborador cambiar los datos que están almacenados dentro de ella.

Incluso si crees que esto es algo bueno ("Si el renderizador nota que falta la nueva opción de exportación, ¡solo puede parchearlo! ¡Genial!"), En general, esto disminuye la coherencia de la base del código, lo que hace que sea más difícil razonar sobre, y hacer que el código sea fácil de razonar es el objetivo principal de la ingeniería de software.

Ahora, si su colaborador necesita acceso a una serie de cosas para garantizar que no se pierda ninguna de ellas, implemente una Iterableinterfaz y exponga solo los métodos que esta interfaz declara. De esa manera, el próximo año, cuando aparezca una estructura de datos enormemente mejor y más eficiente en su biblioteca estándar, podrá cambiar el código subyacente y beneficiarse de él sin corregir su código de cliente en todas partes . Hay otros beneficios de no prometer más de lo necesario, pero este solo es tan grande que, en la práctica, no se necesitan otros.


12
Exposing the concrete type Array usually creates many additional promises, e.g. that you can sort the collection by a function of your own choosing...- Brillante Simplemente no lo pensé. ¡Sí, trae de vuelta una 'iteración' y solo, una iteración!
MyDaftQuestions

18

Ocultar la implementación es un principio central de OOP y una buena idea en todos los paradigmas, pero es especialmente importante para los iteradores (o como se los llame en ese lenguaje específico) en los idiomas que admiten la iteración diferida.

El problema con exponer el tipo concreto de iterables, o incluso interfaces como IList<T>, no está en los objetos que los exponen, sino en los métodos que los usan. Por ejemplo, supongamos que tiene una función para imprimir una lista de Foos:

void PrintFoos(IList<Foo> foos)
{
    foreach (foo in foos)
    {
        Console.WriteLine(foo);
    }
}

Solo puede usar esa función para imprimir listas de foo, pero solo si implementan IList<Foo>

IList<Foo> foos = //.....
PrintFoos(foos);

Pero, ¿qué pasa si desea imprimir cada elemento indexado de la lista? Deberá crear una nueva lista:

IList<Foo> everySecondFoo = new List<T>();
bool isIndexEven = true;
foreach (foo; foos)
{
    if (isIndexEven)
    {
        everySecondFoo.Add(foo);
    }
    isIndexEven = !isIndexEven;
}
PrintFoos(everySecondFoo);

Esto es bastante largo, pero con LINQ podemos hacerlo de una sola vez, lo que en realidad es más legible:

PrintFoos(foos.Where((foo, i) => i % 2 == 0).ToList());

Ahora, ¿te diste cuenta .ToList()al final? Esto convierte la consulta diferida en una lista, para que podamos pasarla PrintFoos. Esto requiere una asignación de una segunda lista y dos pases en los elementos (uno en la primera lista para crear la segunda lista y otro en la segunda lista para imprimirlo). Además, ¿qué pasa si tenemos esto?

void Print6Foos(IList<Foo> foos)
{
    int counter = 0;
    foreach (foo in foos)
    {
        Console.WriteLine(foo);
        ++ counter;
        if (6 < counter)
        {
            return;
        }
    }
}

// ........

Print6Foos(foos.Where((foo, i) => i % 2 == 0).ToList());

¿Qué pasa si foostiene miles de entradas? Tendremos que revisarlos todos y asignar una lista enorme solo para imprimir 6 de ellos.

Ingrese Enumerators: la versión de C # de The Iterator Pattern. En lugar de que nuestra función acepte una lista, hacemos que acepte Enumerable:

void Print6Foos(Enumerable<Foo> foos)
{
    // everything else stays the same
}

// ........

Print6Foos(foos.Where((foo, i) => i % 2 == 0));

Ahora Print6Foospuede iterar perezosamente sobre los primeros 6 elementos de la lista, y no necesitamos tocar el resto.

No exponer la representación interna es la clave aquí. Cuando Print6Foosaceptamos una lista, tuvimos que darle una lista, algo que admite el acceso aleatorio, y por lo tanto tuvimos que asignar una lista, porque la firma no garantiza que solo se repita. Al ocultar la implementación, podemos crear fácilmente un Enumerableobjeto más eficiente que solo admita lo que la función realmente necesita.


6

Exponer la representación interna casi nunca es una buena idea. No solo hace que el código sea más difícil de razonar, sino que también es más difícil de mantener. Imagine que ha elegido cualquier representación interna, digamos una IList<T>, y expuso esta interna. Cualquier persona que use su código puede acceder a la Lista y el código puede confiar en que la representación interna sea una Lista.

Por alguna razón, está decidiendo cambiar la representación interna IAmWhatever<T>en algún momento posterior. En lugar de simplemente cambiar las partes internas de su clase, tendrá que cambiar cada línea de código y método dependiendo de que la representación interna sea de tipo IList<T>. Esto es engorroso y propenso a errores en el mejor de los casos, cuando usted es el único que usa la clase, pero puede romper el código de otras personas usando su clase. Si acaba de exponer un conjunto de métodos públicos sin exponer ninguna parte interna, podría haber cambiado la representación interna sin ninguna línea de código fuera de su clase tomando nota, trabajando como si nada hubiera cambiado.

Es por eso que la encapsulación es uno de los aspectos más importantes de cualquier diseño de software no trivial.


6

Cuanto menos diga, menos tendrá que seguir diciendo.

Cuanto menos pidas, menos te tienen que decir.

Si su código solo se expone IEnumerable<T>, lo que solo admite GetEnumrator()puede ser reemplazado por cualquier otro código que pueda admitir IEnumerable<T>. Esto agrega flexibilidad.

Si su código solo lo usa IEnumerable<T>, puede ser compatible con cualquier código que implemente IEnumerable<T>. Nuevamente, hay flexibilidad adicional.

Todos los linq-to-objects, por ejemplo, solo dependen de IEnumerable<T>. Si bien realiza algunas implementaciones particulares de manera rápida, IEnumerable<T>lo hace de una manera de prueba y uso que siempre puede recurrir al uso GetEnumerator()y la IEnumerator<T>implementación que regresa. Esto le da mucha más flexibilidad que si se construyera sobre matrices o listas.


Buena respuesta clara. Muy adecuado para que sea breve y, por lo tanto, siga sus consejos en la primera oración. ¡Bravo!
Daniel Hollinrake

0

Una frase que me gusta usar refleja el comentario de @CaptainMan: "Está bien que un consumidor conozca los detalles internos. Es malo que un cliente necesite conocer los detalles internos".

Si un cliente tiene que escribir arrayo IList<T>en su código, cuando esos tipos eran detalles internos, el consumidor debe acertarlos. Si están equivocados, el consumidor puede no compilar, o incluso obtener resultados incorrectos. Esto produce los mismos comportamientos que las otras respuestas de "siempre oculta los detalles de la implementación porque no debe exponerlos", pero comienza a investigar las ventajas de no exponer. Si nunca expone un detalle de implementación, ¡su cliente nunca podrá ponerse en apuros al usarlo!

Me gusta pensarlo desde esta perspectiva porque abre el concepto de ocultar la implementación a matices de significado, en lugar de un draconiano "siempre oculta los detalles de su implementación". Hay muchas situaciones en las que exponer la implementación es totalmente válido. Considere el caso de los controladores de dispositivos en tiempo real, donde la capacidad de saltar capas pasadas de abstracción y meter bytes en los lugares correctos puede ser la diferencia entre hacer el tiempo o no. O considere un entorno de desarrollo en el que no tenga tiempo para hacer "buenas API" y necesite usar las cosas de inmediato. A menudo, exponer las API subyacentes en dichos entornos puede ser la diferencia entre hacer una fecha límite o no.

Sin embargo, a medida que deja atrás esos casos especiales y observa los casos más generales, comienza a ser cada vez más importante ocultar la implementación. Exponer detalles de implementación es como una cuerda. Utilizado correctamente, puede hacer todo tipo de cosas con él, como escalar montañas altas. Se usa incorrectamente y puede atarte en una soga. Cuanto menos sepa sobre cómo se usará su producto, más cuidadoso deberá ser.

El caso de estudio que veo una y otra vez es uno en el que un desarrollador crea una API que no tiene mucho cuidado al ocultar los detalles de la implementación. Un segundo desarrollador, usando esa biblioteca, descubre que a la API le faltan algunas características clave que le gustaría. En lugar de escribir una solicitud de función (que podría tener un tiempo de respuesta de meses), deciden abusar de su acceso a los detalles ocultos de implementación (lo que lleva segundos). Esto no es malo en sí mismo, pero a menudo el desarrollador de la API nunca planeó que eso fuera parte de la API. Más tarde, cuando mejoran la API y cambian ese detalle subyacente, descubren que están encadenados a la implementación anterior, porque alguien la usó como parte de la API. La peor versión de esto es cuando alguien usó su API para proporcionar una función centrada en el cliente, y ahora su empresa ' ¡El cheque de pago está vinculado a esta API defectuosa! ¡Simplemente exponiendo los detalles de implementación cuando no sabía lo suficiente sobre sus clientes demostró ser suficiente cuerda para atar un lazo alrededor de su API!


1
Re: "O considere un entorno de desarrollo en el que no tenga tiempo para hacer 'buenas API' y necesite usar las cosas de inmediato": si se encuentra en dicho entorno y, por alguna razón, no desea abandonar ( o no está seguro si lo hace), le recomiendo el libro Death March: The Complete Software Developer Guide to Surviving 'Mission Impossible' Projects . Tiene excelentes consejos sobre el tema. (Además, recomiendo cambiar de opinión acerca de no dejar de fumar)
Ruakh

@ruakh He encontrado que siempre es importante entender cómo trabajar en un entorno donde no tienes tiempo para hacer buenas API. Francamente, por mucho que amemos el enfoque de la torre de marfil, el código real es lo que realmente paga las cuentas. Cuanto antes podamos admitir eso, antes podremos comenzar a abordar el software como un equilibrio, equilibrando la calidad API con un código productivo, para que coincida con nuestro entorno exacto. He visto demasiados desarrolladores jóvenes atrapados en un lío de ideales, sin darse cuenta de que necesitan flexionarlos. (También he sido ese joven desarrollador ... todavía recuperándome de 5 años de diseño de "buena API")
Cort Ammon - Reinstala a Monica el

1
El código real paga las cuentas, sí. Un buen diseño de API es cómo se asegura de que continuará siendo capaz de generar código real. El código malo deja de pagarse cuando un proyecto se vuelve imposible de mantener y se hace imposible corregir errores sin crear otros nuevos. (Y de todos modos, se trata de un falso dilema ¿De qué código requiere no lo es. El tiempo tanto como columna vertebral .)
ruakh

1
@ruakh Lo que he encontrado es que es posible hacer un buen código sin "buenas API". Si se les da la oportunidad, las buenas API son buenas, pero tratarlas como una necesidad causa más conflictos de lo que vale. Considere: la API para los cuerpos humanos es horrible, pero aún creemos que son pequeñas hazañas de ingeniería =)
Cort Ammon - Reinstala a Monica el

El otro ejemplo que daría es el que sirvió de inspiración para el argumento sobre la sincronización. Uno de los grupos con los que trabajo tenía algunas cosas de controladores de dispositivos que necesitaban desarrollar, con requisitos difíciles en tiempo real. Tenían 2 API para trabajar. Uno era bueno, el otro no. La buena API no pudo cronometrar porque ocultó la implementación. La API menor expuso la implementación y, al hacerlo, le permite utilizar técnicas específicas del procesador para crear un producto que funcione. La buena API se puso cortésmente en el estante y continuaron cumpliendo con los requisitos de tiempo.
Cort Ammon - Restablece a Monica el

0

No necesariamente sabe cuál es la representación interna de sus datos, o puede llegar a ser en el futuro.

Suponga que tiene una fuente de datos que representa como una matriz, puede iterar sobre ella con un ciclo for regular.

Ahora di que quieres refactorizar. Su jefe ha pedido que la fuente de datos sea un objeto de base de datos. Además, le gustaría tener la opción de extraer datos de una API, o un mapa hash, o una lista vinculada, o un tambor magnético giratorio, o un micrófono, o un recuento en tiempo real de virutas de yak encontradas en Mongolia Exterior.

Bueno, si solo tiene uno para el bucle, puede modificar su código fácilmente para acceder al objeto de datos de la forma en que espera que se acceda.

Si tiene 1000 clases intentando acceder al objeto de datos, ahora tiene un gran problema de refactorización.

Un iterador es una forma estándar de acceder a una fuente de datos basada en listas. Es una interfaz común. Usarlo hará que su fuente de datos de código sea independiente.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.