mundo - imprimir en haskell
¿Cómo se acercan los experimentados desarrolladores de Haskell a la pereza en el momento*del diseño*? (1)
Soy un programador intermedio de Haskell con mucha experiencia en lenguajes estrictos de FP y no FP. La mayor parte de mi código Haskell analiza conjuntos de datos moderadamente grandes (10 ^ 6..10 ^ 9 cosas), por lo que la pereza siempre acecha. Tengo una comprensión bastante buena de los thunks, WHNF, la coincidencia de patrones y el uso compartido, y pude arreglar fugas con patrones de explosión y seq, pero este enfoque de perfil y oración se siente sórdido e incorrecto.
Quiero saber cómo los programadores experimentados de Haskell se acercan a la pereza en el momento del diseño . No estoy preguntando por elementos fáciles como Data.ByteString.Lazy o foldl ''; más bien, quiero saber cómo piensas acerca de la maquinaria perezosa de menor nivel que causa problemas de memoria de tiempo de ejecución y depuración complicada.
¿Cómo piensas en los thunks, la coincidencia de patrones y el intercambio durante el tiempo de diseño?
¿Qué patrones de diseño y expresiones idiomáticas usa para evitar fugas?
¿Cómo aprendiste estos patrones y modismos, y tienes algunos buenos refs?
¿Cómo se evita la optimización prematura de no-problemas sin fugas?
(Enmendado 2014-05-15 para el presupuesto del tiempo):
¿Presupuesta un tiempo considerable de proyecto para encontrar y solucionar problemas de memoria?
O bien, ¿sus habilidades de diseño normalmente evitan los problemas de memoria y obtiene el consumo de memoria esperado muy temprano en el ciclo de desarrollo?
Creo que la mayoría de los problemas con "filtraciones de rigor" ocurren porque las personas no tienen un buen modelo conceptual. Los Haskellers sin un buen modelo conceptual tienden a tener y propagar la superstición de que cuanto más estricto es mejor. Tal vez esta intuición proviene de sus resultados de jugar con pequeños ejemplos y bucles apretados. Pero es incorrecto Es tan importante ser flojo en los momentos adecuados como ser estricto en los momentos adecuados.
Hay dos campos de tipos de datos, generalmente denominados "datos" y "codata". Es esencial respetar los patrones de cada uno.
- Las operaciones que producen "datos" (Int, ByteString, ...) deben forzarse cerca de donde ocurren. Si agrego un número a un acumulador, debo asegurarme de que será forzado antes de agregar otro. Una buena comprensión de la pereza es muy importante aquí, especialmente su naturaleza condicional (es decir, las proposiciones de rigor no toman la forma "
X
se evalúa" sino más bien " cuando se evalúaY
, también lo esX
"). - Las operaciones que producen y consumen "codata" (listas la mayor parte del tiempo, árboles, la mayoría de los otros tipos recursivos) deben hacerlo de forma incremental. Por lo general, la transformación de codata -> codata debería producir cierta información para cada bit de información que consumen (módulo saltando como
filter
). Otra pieza importante para la codata es que la usas linealmente siempre que sea posible, es decir, utilizas la cola de una lista exactamente una vez; usa cada rama de un árbol exactamente una vez. Esto asegura que el GC puede recoger piezas a medida que se consumen.
Las cosas requieren una atención especial cuando tienes una codata que contiene datos. Ej iterate (+1) 0 !! 1000
iterate (+1) 0 !! 1000
terminará produciendo un thunk de tamaño 1000 antes de evaluarlo. Debe volver a pensar en el rigor condicional: la forma de evitar este caso es garantizar que cuando se consuma un inconveniente de la lista, se produzca la adición de su elemento. iterate
viola esto, por lo que necesitamos una versión mejor.
iterate'' :: (a -> a) -> a -> [a]
iterate'' f x = x : (x `seq` iterate'' f (f x))
Cuando empiezas a componer cosas, por supuesto, es más difícil saber cuándo ocurren los casos malos. En general, es difícil crear estructuras / funciones de datos eficientes que funcionen igual de bien en datos y codatos, y es importante tener en cuenta cuál es cuál (incluso en un entorno polimórfico en el que no está garantizado, debes tenerlo en mente y probarlo). para respetarlo).
Compartir es complicado, y creo que lo abordo principalmente caso por caso. Debido a que es complicado, trato de mantenerlo localizado, eligiendo no exponer grandes estructuras de datos a los usuarios del módulo en general. Esto generalmente se puede hacer exponiendo los combinadores para generar el objeto en cuestión, y luego producirlo y consumirlo de una sola vez (la transformación de la concordancia en las mónadas es un ejemplo de esto).
Mi objetivo de diseño es lograr que cada función sea respetuosa con los patrones de datos / codatos de mis tipos. Usualmente puedo pegarle (aunque a veces se requiere una gran reflexión, se ha vuelto natural a lo largo de los años), y rara vez tengo problemas de fugas cuando lo hago. Pero no pretendo que sea fácil, requiere experiencia con las bibliotecas canónicas y los patrones del lenguaje. Estas decisiones no se toman de forma aislada, y todo tiene que ser correcto a la vez para que funcione bien. Un instrumento mal ajustado puede arruinar todo el concierto (razón por la cual "la optimización por perturbación aleatoria" casi nunca funciona para este tipo de problemas).
El artículo de Apfelmus''s Space Invariantes es útil para desarrollar aún más tu intuición espacial / thunk. También vea el comentario de Edward Kmett a continuación.