Это не переполняет стек - даже в интерпретаторе, где нет оптимизаций и правил перезаписи - потому что он не использует стек.
Посмотрите на определение (++), например:
(++) :: [a] -> [a] -> [a]
(++) [] ys = ys
(++) (x:xs) ys = x : xs ++ ys
Ключевым моментом является x : (xs ++ ys)
- то есть рекурсия защищена конструктором (:) "cons". Поскольку Haskell ленив, он выделяет thunk для операции cons, и рекурсивный вызов переходит на этот (выделенный в куче) thunk. Таким образом, ваше выделение стека теперь является распределением кучи, которое может немного расшириться. Таким образом, все, что нужно сделать, - это пройтись по большому списку, распределить новые объекты cons в куче, чтобы заменить те, которые он обходит. Легко!
"реверс" немного отличается:
reverse l = rev l []
where
rev [] a = a
rev (x:xs) a = rev xs (x:a)
Это хвостовая рекурсивная функция в стиле аккумулятора, поэтому она снова будет размещаться в куче.
Итак, вы видите, что функции полагаются на использование cons-ячеек в куче, а не в стеке, поэтому переполнение стека отсутствует.
Чтобы действительно это понять, посмотрите статистику времени выполнения из GC vm:
$ time ./B +RTS -s
99
833 MB total memory in use (13 MB lost due to fragmentation)
Generation 0: 3054 collections, 0 parallel, 0.99s, 1.00s elapsed
%GC time 82.2% (85.8% elapsed)
Вот ваш большой список - он расположен в куче, и мы тратим 80% времени на очистку минусов, созданных (++).
Урок: вы часто можете обменять стек на кучу.