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

22

Estoy leyendo C # Design Essential Essentials . Actualmente estoy leyendo sobre el patrón del 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 haberlo hecho al exponer una propiedad pública, como IList<T> o 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 es un array o IList<T> ? Realmente no entiendo por qué esto es algo malo para el consumidor (el programador que llama esto) saber ...

El libro dice que este patrón funciona al exponer su función GetEnumerator , por lo que podemos llamar a 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.

    
pregunta MyDaftQuestions 08.10.2015 - 13:08

6 respuestas

54

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 sus colaboradores necesitan.

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 concreto Array generalmente crea muchas promesas adicionales, por ejemplo. que puede ordenar la colección por una función de su propia elección, sin mencionar el hecho de que un Array normal probablemente permitirá al colaborador cambiar los datos que se almacenan en su interior.

Incluso si piensa que esto es algo bueno ("Si el renderizador se da cuenta de que falta la nueva opción de exportación, puede parchearlo directamente en! Neat!"), en general, esto reduce la coherencia del código base. Es más difícil razonar, y hacer que el código sea fácil es el principal objetivo de la ingeniería de software.

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

    
respondido por el Kilian Foth 08.10.2015 - 13:19
17

Ocultar la implementación es un principio básico de la POO y una buena idea en todos los paradigmas, pero es especialmente importante para los iteradores (o como se llamen en ese lenguaje específico) en idiomas que admiten la iteración perezosa.

El problema con la exposición del tipo concreto de iterables, o incluso las interfaces como IList<T> , no está en los objetos que los exponen, sino en los métodos que los utilizan. Por ejemplo, supongamos que tiene una función para imprimir una lista de Foo s:

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

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

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

Pero, ¿qué sucede si desea imprimir todos los elementos de la lista con un índice pareado ? 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 en una sola línea, que en realidad es más legible:

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

Ahora, ¿notaste el .ToList() al final? Esto convierte la consulta perezosa en una lista, por lo que podemos pasarla a PrintFoos . Esto requiere una asignación de una segunda lista y dos pases a los elementos (uno en la primera lista para crear la segunda lista y otro en la segunda lista para imprimirla). 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 foos tiene 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, la hacemos aceptar Enumerable :

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

// ........

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

Ahora Print6Foos puede iterar perezosamente en los primeros 6 elementos de la lista, y no necesitamos tocar el resto.

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

    
respondido por el Idan Arye 08.10.2015 - 15:34
6

Exponer la representación interna es virtualmente nunca 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 un IList<T> , y exponga esta interna. Cualquier persona que use su código puede acceder a la Lista y el código puede depender de que la representación interna sea una Lista.

Por cualquier motivo, está decidiendo cambiar la representación interna a 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 basándose en que la representación interna es del tipo IList<T> . Esto es incómodo 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 los elementos internos, podría haber cambiado la representación interna sin que ninguna línea de código fuera de su clase se haya dado cuenta, trabajando como si nada hubiera cambiado.

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

    
respondido por el Paul Kertscher 08.10.2015 - 13:42
6

Cuanto menos digas, menos tienes que seguir diciendo.

Cuanto menos preguntes, menos tendrás que decirte.

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

Si su código solo usa IEnumerable<T> , puede ser compatible con cualquier código que implemente IEnumerable<T> . Una vez más, hay flexibilidad extra.

Todos los linq-to-objetos, por ejemplo, dependen solo de IEnumerable<T> . Si bien realiza rutas rápidas en algunas implementaciones particulares de IEnumerable<T> , lo hace de una manera de prueba y uso que siempre puede recurrir al simple uso de GetEnumerator() y la implementación IEnumerator<T> que devuelve. Esto le da mucha más flexibilidad que si estuviera construido sobre arreglos o listas.

    
respondido por el Jon Hanna 08.10.2015 - 17:35
0

Un texto que me gusta usar los espejos del comentario de @CaptainMan: "Es correcto que un consumidor conozca los detalles internos. Es malo para un cliente que necesite conocer los detalles internos . "

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

Me gusta pensar en esto desde esta perspectiva porque abre el concepto de ocultar la implementación a matices de significado, en lugar de un draconiano "siempre oculte 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 omitir las capas de abstracción y los bytes de inserción en los lugares correctos puede ser la diferencia entre hacer la sincronización o no. O considere un entorno de desarrollo en el que no tiene tiempo para hacer "buenas API" y necesita usar las cosas de inmediato. A menudo, la exposición de las API subyacentes en dichos entornos puede ser la diferencia entre establecer 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 los detalles de la implementación es como una cuerda. Usado correctamente, puedes 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 utilizará su producto, más cuidado tendrá que ser.

El caso de estudio que veo una y otra vez es uno en el que un desarrollador elabora una API que no es muy cuidadosa al ocultar los detalles de la implementación. Un segundo desarrollador, que usa esa biblioteca, encuentra que a la API le faltan algunas características clave que les gustaría. En lugar de escribir una solicitud de función (que podría tener un tiempo de respuesta de meses), deciden en cambio abusar de su acceso a los detalles ocultos de la implementación (lo que lleva segundos). Esto no es malo en sí mismo, pero a menudo el desarrollador de API nunca planeó que 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 característica centrada en el cliente, ¡y ahora el cheque de pago de su compañía está vinculado a esta API defectuosa! ¡Simplemente al exponer los detalles de la implementación cuando no sabía lo suficiente sobre sus clientes, se demostró que era suficiente para atar una soga alrededor de su API!

    
respondido por el Cort Ammon 09.10.2015 - 02:21
0

No necesariamente sabes cuál es la representación interna de tus 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 bucle regular para.

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, un hashmap, una lista enlazada, un tambor magnético giratorio, un micrófono o un recuento en tiempo real de virutas de yak encontradas en Mongolia Exterior.

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

Si tienes 1000 clases que intentan acceder al objeto de datos, bueno, ahora tienes 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. Su uso hará que su fuente de datos de código sea agnóstica.

    
respondido por el superluminary 09.10.2015 - 14:55

Lea otras preguntas en las etiquetas