Цель данной статьи - познакомить читателя с основными идеями ФП на примере программы для решения судоку. Для простоты наложим на входные данные два дополнительных условия: решение всегда есть и его можно найти без перебора. Входные данные задаются как список списков.

puzzle = [[5,3,0,0,7,0,0,0,0],
          [6,0,0,1,9,5,0,0,0],
          [0,9,8,0,0,0,0,6,0],
          [8,0,0,0,6,0,0,0,3],
          [4,0,0,8,0,3,0,0,1],
          [7,0,0,0,2,0,0,0,6],
          [0,6,0,0,0,0,2,8,0],
          [0,0,0,4,1,9,0,0,5],
          [0,0,0,0,8,0,0,7,9]]

Прежде чем решать задачу, расскажем, что такое список.

Список

В Haskell список можно определить так:

data List = Empty
          | Pair Int List

Здесь List - имя типа, а Empty и Pair - имена конструкторов. Такие типы называются "алгебраический тип данных" (АТД). Данное определение означает, что список - это пустой список или пара, состоящая из целого числа и списка. Конструктор - это функция, создающая значение заданного типа. Имена типов и конструкторов пишутся с большой буквы (требование синтаксиса Haskell).

Нецелесообразно отдельно определять список для каждого типа элементов. Поэтому определим обобщённый тип - список элементов произвольного типа.

data List a = Empty
            | Pair a (List a)

Здесь a - это параметр типа. В C# аналогом будет Generic.

В C# в общем случае аналогичный тип можно определить так:

public enum ListTag
{
	Empty,
	Pair
}

public abstract class MyList<T>
{
	public ListTag Tag { get; }
	protected MyList(ListTag tag) => Tag = tag;
}

public class Empty<T> : MyList<T>
{
	public Empty() : base(ListTag.Empty) {}
}

public class Pair<T> : MyList<T>
{
	public T Head { get; }
	public MyList<T> Tail { get; }
	public Pair(T x, MyList<T> xs) : base(ListTag.Pair)
	{
		Head = x;
		Tail = xs;
	}
}

В качестве примера использования этих классов напишем функцию, возвращающую длину списка:

void Main()
{
	int Length<T>(MyList<T> list) 
	{
		switch(list.Tag)
		{
			case ListTag.Empty:
				return 0;
			case ListTag.Pair:
				var pair = (Pair<T>)list;
				return 1 + Length(pair.Tail);
			default:
				throw new ArgumentOutOfRangeException(nameof(list.Tag));
		}
	}
	
	var list1 = new Pair<int>(1, new Pair<int>(2, new Empty<int>()));
	Console.WriteLine(Length(list1));
}

В C# можно обойтись и без свойства Tag, используя оператор "is". А так как класс Empty не имеет свойств, то в данном конкретном случае можно обойтись одним классом Pair, договорившись, что значение null будет означать пустой список.

Это был классический ООП вариант. Ближе по смыслу к ФП было бы такое определение АТД:

public abstract class MyList<T>
{
	public abstract TResult Match<TResult>(Func<TResult> empty, Func<T, MyList<T>, TResult> pair);
}

public class Empty<T> : MyList<T>
{
	public override TResult Match<TResult>(Func<TResult> empty, Func<T, MyList<T>, TResult> pair)
	{
		return empty();
	}
}

public class Pair<T> : MyList<T>
{
	private readonly T Head;
	private readonly MyList<T> Tail;
	
	public Pair(T head, MyList<T> tail)
	{
		Head = head;
		Tail = tail;
	}
	
	public override TResult Match<TResult>(Func<TResult> empty, Func<T, MyList<T>, TResult> pair)
	{
		return pair(Head, Tail);
	}
}

Использование:

void Main()
{
	int Length<T>(MyList<T> list)
	{
		return list.Match(() => 0, (x, xs) => 1 + Length(xs));
	}
	
	var list1 = new Pair<int>(1, new Pair<int>(2, new Empty<int>()));
	Console.WriteLine(Length(list1));
}

Фактически, всё, что мы можем сделать со значением АТД - это выяснить, как оно было создано (какой из конструкторов использовался и с какими значениями аргументов). Такая деконструкция значения называется сопоставлением с образцом (pattern matching).

Функция length на Haskell будет выглядеть так:

myLength :: List a -> Int
myLength list = 
  case list of
  Empty     -> 0
  Pair x xs -> 1 + myLength xs

В первой строке - необязательное объявление типа, в котором стрелка "->" означает отображение (функцию). В большинстве случаев объявление типа можно опустить, и тип будет выведен компилятором. Применение функции в Haskell имеет максимальный приоритет 10, а сложение - приоритет 6. Поэтому в последней строке не обязательно использовать скобки.

Сопоставление с образцом аргумента функции удобнее записывать по-другому:

myLength Empty       = 0
myLength (Pair _ xs) = 1 + myLength xs

Это одна функция, состоящая из нескольких частей ("тел") для разных вариантов входных аргументов. Так как во втором случае мы не используем первый аргумент конструктора, то его можно (и нужно в соответствии с правилами красивого кода) заменить на подстановочный знак (wildcard) "_".

Создадим экземпляр списка целых чисел:

list1 :: List Int 
list1 = Pair 1 (Pair 2 (Pair 3 Empty))

Здесь первая строка содержит необязательное описание типа переменной list1. В отличии от C#, в Haskell переменная - это не имя ячейки памяти, в которой хранится значение, а синоним (обозначение) для выражения справа. Вспомните, как на уроках математики мы писали "пусть z = x + y" или "обозначим z = x + y".

Создание списка выглядит слишком громоздко. Чтобы упростить, добавим оператор:

infixr 5 +:
x +: xs = Pair x xs

Первая строка означает "правоассоативный инфиксный оператор с приоритетом 5". Вторая строка содержит код оператора. Её можно переписать так:

(+:) x xs = Pair x xs

Или даже так:

(+:) = Pair

Чтобы использовать произвольный инфиксный оператор * как функцию двух аргументов, достаточно взять его в скобки: (*).
Чтобы использовать произвольную функцию двух аргументов f как инфиксный оператор, достаточно взять её в обратные кавычки (backtick): `f`.

Теперь мы можем записать создание списка более наглядно:

list1 = 1 +: 2 +: 3 +: Empty

Для вывода в консоль используется функция print:

print (myLength list1)

Чтобы избавиться от скобок, введём оператор применения функции:

infixr 0 <|
f <| x = f x

Мы задали оператору применения функции минимальный приоритет, и сделали его правоассоативным.

С использованием этого оператора можем записать без скобок:

print <| myLength list1

В данном случае разница невелика, но часто такой оператор помогает сделать код более удобным для чтения.

Вот полный код примера:

data List a = Empty
            | Pair a (List a)

infixr 0 <|
f <| x = f x

infixr 5 +:
(+:) x xs = Pair x xs

myLength :: List a -> Int
myLength Empty       = 0
myLength (Pair _ xs) = 1 + myLength xs

list1 :: List Int          
list1 = 1 +: 2 +: 3 +: Empty

main :: IO ()
main = do
  print <| myLength list1 -- 3

В комментариях (--) я указываю результат вызова print. Этот и другие примеры кода можно запустить на https://play.haskell.org/

Если мы попробуем напечатать list1 в консоль с помощью функции print, то получим ошибку "No instance for (Show (List Int)) arising from a use of ‘print’". Смысл ошибки в том, что функции print нужно преобразовать значение типа List в строку, но наш тип List не относится к классу типов (Show), которые можно преобразовать в строку.

Приблизительным аналогом классов типов в C# являются интерфейсы. Правда, в C# нет необходимости в интерфейсе IShowable, так как в базовом классе object есть метод ToString().

Класс типов Show a определяется так:

class  Show a  where
    showsPrec  :: Int -> a -> ShowS
    show       :: a -> String 
    showList   :: [a] -> ShowS

Здесь a - это параметр типа, а ShowS - синоним для типа String -> String (функция, отображающая строку на строку). Специализированная функция showsPrec может использоваться для написания более эффективного кода, но мы не будем отвлекаться на её обсуждение.

Все три функции уже имеют определение по-умолчанию. При этом showsPrec определяется через show, и наоборот. Поэтому для полного определения нашего типа, как принадлежащего классу типов Show, достаточно определить showsPrec или show. Или мы можем попросить компилятор Haskell самостоятельно вывести для нас определение экземпляра класса (instance declaration), добавив к определению типа "deriving Show".

data List a = Empty
            | Pair a (List a)
  deriving Show

list1 :: List Int          
list1 = Pair 1 (Pair 2 (Pair 3 Empty))

main :: IO ()
main = do
  print list1 -- Pair 1 (Pair 2 (Pair 3 Empty))

Получается не очень красиво, поэтому определим объявление экземпляра явно. Для того чтобы преобразовывать в строку значение типа List a, необходимо уметь преобразовывать в строку элемент списка. Поэтому в определении экземпляра есть предварительное условие "(Show a) =>", означающее, что тип a является экземпляром класса Show.

instance (Show a) => Show (List a) where
  show Empty = "[]"
  show (Pair x xs) = "[" ++ show x ++ showl xs
    where
    showl Empty = "]"
    showl (Pair y ys) = "," ++ show y ++ showl ys

Здесь "++" - оператор конкатенации строк. Из-за сложения строк в цикле данная реализация неэффективна, но мы уже договорились не обсуждать здесь функцию showsPrec.

Итоговый код выглядит так:

data List a = Empty
            | Pair a (List a)

infixr 0 <|
f <| x = f x

infixr 5 +:
(+:) = Pair

instance (Show a) => Show (List a) where
  show Empty = "[]"
  show (Pair x xs) = "[" ++ show x ++ showl xs
    where
    showl Empty = "]"
    showl (Pair y ys) = "," ++ show y ++ showl ys

main :: IO ()
main = do
  print <| 1 +: 2 +: 3 +: Empty  -- [1,2,3]

А вот так выглядит то же самое с использованием типов и операторов из базовой библиотеки:

main :: IO ()
main = do
  print $ 1 : 2 : 3 : []  -- [1,2,3]

Для создания списков также можно использовать генераторы списков (list comprehension):

main :: IO ()
main = do
  print [1,2,3]  -- [1,2,3]
  print [1..5]  -- [1,2,3,4,5]
  print [5,4..1]  -- [5,4,3,2,1]
  print [(i,j) | i <- [1..3], j <- [1..3], even (i+j)]  -- [(1,1),(1,3),(2,2),(3,1),(3,3)]

Список - это рекурсивный тип, поэтому для работы с ним обычно используется рекурсия. Но в ФП принято вместо явного использования рекурсии использовать абстракции более высокого порядка, чем цикл или рекурсия. Основной такой абстракцией является свёртка (fold).

-- left fold
myFoldl :: (a -> b -> a) -> a -> [b] -> a
myFoldl f z []     = z
myFoldl f z (x:xs) = myFoldl f (f z x) xs

-- right fold
myFoldr :: (a -> b -> b) -> b -> [a] -> b
myFoldr f z []     = z
myFoldr f z (x:xs) = f x (myFoldr f z xs)

main :: IO ()
main = do
  print $ myFoldl (+) 0 [1..5]  -- 15
  print $ myFoldr (*) 1 [1..5]  -- 120

Левая свёртка использует хвостовую рекурсию, что позволяет избежать переполнения стека. В "ленивом" Haskell для этого нужно использовать строгую версию: foldl'.
Правая свёртка может применяться к бесконечным спискам. Вычисления завершатся, если в какой-то момент функции f не нужно будет вычислять значение второго аргумента.

Другие часто используемые абстракции - отображение (mapping) и фильтрация (filtering) - можно считать частным случаем свёртки.

myMap :: (a -> b) -> [a] -> [b]
myMap f = foldr (\x xs -> f x : xs) []

myFilter :: (a -> Bool) -> [a] -> [a]
myFilter f = foldr (\x xs -> if f x then x:xs else xs) []
  
main :: IO ()
main = do
  print $ myMap (*3) [1..5]  -- [3,6,9,12,15]
  print $ myFilter even [1..10]  -- [2,4,6,8,10]

Решение судоку

Забегая вперёд, скажу, что нам понадобятся 1 функция из библиотеки Data.List и 4 функции из библиотеки Data.Set. Назначение этих функций понятно из названия.

import Data.List (transpose)
import Data.Set (fromList, toList, difference, union) 

Для решения судоку будем использовать тот же алгоритм, который мы бы использовали, решая без помощи компьютера.

До тех пор пока не решено (судоку), вычисляем цифры:

sudoku = until solved resolveFigures 

Судоку решено, когда в матрице все элементы не равны нулю:

solved = all (/=0) . concat

В Haskell оператор "не равно" выглядит так "/=". Функция concat склеивает все списки в заданном списке списков (превращает список списков в плоский список).

Вычислять цифры будем по индексам строки и столбца:

resolveFigures m = [ [getFigure i j | j <- [0..8]] | i <- [0..8] ]

Для вычисления цифры по паре индексов просто заменяем нули на новые значения:

getFigure i j = case m !! i !! j of
              0 -> newFigure i j
              x -> x

Здесь "!!" - оператор получения значения по индексу. Таким образом, "m !! i !! j" - это j-тый элемент в i-том списке (строке) матрицы m.

Если у нас есть ровно один кандидат на позицию (список кандидатов состоит из одного элемента), то используем это значение, иначе, оставляем ноль:

newFigure i j = case candidates i j of 
              [x] -> x
              _   -> 0 

Кандидаты - это те цифры, которые не встречаются в текущей строке, столбце или квадрате:

candidates i j = toList $ difference figures $ (rows !! i) `union` (columns !! j) `union` (squares !! squareIndex)
    where squareIndex = i `div` 3 * 3 + j `div` 3

Здесь мы используем множества figures, rows, columns и squares.

Множество всех цифр от 1 до 9:

figures = fromList [1..9]

Список множеств всех цифр в каждой строке:

rows    = map fromList $ m

Список множеств всех цифр в каждом столбце:

columns = map fromList . transpose $ m

Немного сложнее получить множество всех цифр в каждом квадрате.

Нам потребуется вспомогательная функция, разбивающая список на трёхэлементные списки:

chunksOf3 = take 3 . map (take 3) . iterate (drop 3)

Функция iterate создаёт бесконечный список, применяя заданную функцию (первый аргумент) сначала к исходному значению (второй аргумент), затем к результату первого применения и так далее. В данном случае мы применяем функцию drop 3, которая отбрасывает первые 3 элемента списка. Например:

main = print $ take 5 $ iterate (drop 3) [1..9]  -- [[1,2,3,4,5,6,7,8,9],[4,5,6,7,8,9],[7,8,9],[],[]]

От каждого внутреннего списка мы берём только первые 3 элемента с помощью "map (take 3)". Всего нам нужно 3 элемента внешнего списка.

main = print $ take 3 $ map (take 3) $ iterate (drop 3) [1..9]  -- [[1,2,3],[4,5,6],[7,8,9]]

Для выделения квадратов 3 на 3, мы сначала делим все строки на группы по 3. Получаем список из трёх элементов вида
[[11,12,13,14,15,16,17,18,19],
[21,22,23,24,25,26,27,28,29],
[31,32,33,34,35,36,37,38,39]]

Каждый из таких элементов мы сначала делим на группы по 3 (map chunksOf3):
[[[11,12,13],[14,15,16],[17,18,19]],
[[21,22,23],[24,25,26],[27,28,29]],
[[31,32,33],[34,35,36],[37,38,39]]]

Затем транспонируем (transpose):
[[[11,12,13],[21,22,23],[31,32,33]],
[[14,15,16],[24,25,26],[34,35,36]],
[[17,18,19],[27,28,29],[37,38,39]]]

И склеиваем внутренние списки (map concat):
[[11,12,13,21,22,23,31,32,33],
[14,15,16,24,25,26,34,35,36],
[17,18,19,27,28,29,37,38,39]]

В результате каждый элемент списка содержит список из трёх "квадратов", которые мы склеиваем и превращаем в список множеств:

squares = map fromList . concat . map (map concat . transpose . map chunksOf3) . chunksOf3 $ m

Проследить все преобразования в squares по шагам можно с помощью следующего кода:

import Data.List (transpose)

chunksOf3 = take 3 . map (take 3) . iterate (drop 3)

puzzle = [ [i*10 + j | j <- [1..9]] | i <- [1..9]  ]

main = do
  pp1 $ puzzle
  pp2 $ chunksOf3 $ puzzle
  pp2 $ map (map chunksOf3) . chunksOf3 $ puzzle
  pp2 $ map (transpose . map chunksOf3) . chunksOf3 $ puzzle
  pp2 $ map (map concat . transpose . map chunksOf3) . chunksOf3 $ puzzle
  pp1 $ concat . map (map concat . transpose . map chunksOf3) . chunksOf3 $ puzzle

-- функции для печати с отступами (вместо print)
pp1 :: (Show a) => [[a]] -> IO ()
pp1 = pp show

pp2 :: (Show a) => [[[a]]] -> IO ()
pp2 = pp (showp show " ")

showp :: (a -> String) -> String -> [a] -> String
showp showf ident (x:xs) = "[" ++ showf x ++ showl xs
    where
    showl [] = "]"
    showl (y:ys) = ",\n " ++ ident ++ showf y ++ showl ys

pp :: (a -> String) -> [a] -> IO ()
pp showf m = do
  putStrLn $ showp showf "" m
  putStrLn ""

Осталось только собрать всё вместе, чтобы получить итоговый код нашего решения:

import Data.List (transpose)
import Data.Set (fromList, toList, difference, union) 

sudoku :: [[Int]] -> [[Int]]
sudoku = until solved resolveFigures
    where
    solved = all (/=0) . concat
    
    resolveFigures m = [ [getFigure i j | j <- [0..8]] | i <- [0..8] ]
        where
        figures = fromList [1..9]
        rows    = map fromList $ m
        columns = map fromList . transpose $ m
        squares = map fromList . concat . map (map concat . transpose . map chunksOf3) . chunksOf3 $ m
            where chunksOf3 = take 3 . map (take 3) . iterate (drop 3)
            
        candidates i j = toList $ difference figures $ (rows !! i) `union` (columns !! j) `union` (squares !! squareIndex)
            where squareIndex = i `div` 3 * 3 + j `div` 3
        newFigure i j = case candidates i j of 
                      [x] -> x
                      _   -> 0 
        getFigure i j = case m !! i !! j of
                      0 -> newFigure i j
                      x -> x

puzzle = [[5,3,0,0,7,0,0,0,0],
          [6,0,0,1,9,5,0,0,0],
          [0,9,8,0,0,0,0,6,0],
          [8,0,0,0,6,0,0,0,3],
          [4,0,0,8,0,3,0,0,1],
          [7,0,0,0,2,0,0,0,6],
          [0,6,0,0,0,0,2,8,0],
          [0,0,0,4,1,9,0,0,5],
          [0,0,0,0,8,0,0,7,9]]
  
main :: IO ()
main = do
  print $ sudoku puzzle

В итоге мы получили простой и понятный код (возможно, за исключением squares), который почти дословно повторяет словесное описание алгоритма. При этом мы нигде не использовали рекурсию явно. Конечно, для более сложных программ могут потребоваться более сложные абстракции (монады, линзы и так далее), изучение которых займёт немало времени. Главное, не использовать абстракции ради использования абстракций, а то может получиться что-то типа этого кода:

-- nubBy устраняет из списка дубли, используя заданную функцию сравнения
import Data.List (nubBy)

-- бесконечный список простых чисел
primes = nubBy (((>1).).gcd) [2..] 

main :: IO ()
main = do
  print $ take 30 primes 

Если у кого-то появилось желание больше узнать про Haskell, то можно начать с учебника на wikibooks https://en.wikibooks.org/wiki/Haskell (английский, но есть возможность переключить язык на русский). Учебник содержит упражнения, решения которых можно найти там же https://en.wikibooks.org/wiki/Category:Book:Haskell/Solutions .

Комментарии (19)


  1. forthuse
    10.01.2024 15:01
    +1

    Решение задачи Sudoku на разных языках на rosettacode.org

    P.S. Есть и на Haskell,

    А насколько Функциональное программирование хорошо и почему, к примеру,, имеет смысл демонстрировать на Haskell и на такой задаче, а не, к примеру, на классическом языке Common Lisp или Clojure?


    1. CorwinH Автор
      10.01.2024 15:01
      +3

      P.S. Есть и на Haskell,

      Спасибо! Посмотрел. Есть довольно занятные варианты решения. Например, такой:

      f x s@(h:y)=let(r,c)=divMod(length x)9;m#n=m`div`3==n`div`3;e=[0..8]in
        [a|z<-['1'..'9'],h==z||h=='.'&&notElem z(map((x++s)!!)[i*9+j|i<-e,
        j<-e,i==r||j==c||i#r&&j#c]),a<-f(x++[z])y]
      f x[]=[x]
      
      main=print$f[] "53..7....6..195....98....6.8...6...34..8.3..17...2...6.6....28....419..5....8..79"

      На выходных буду разбираться, как это работает :).

      насколько Функциональное программирование хорошо

      О преимуществах ФП написано много (впрочем, как и о преимуществах ООП). Лично мне нравится: короткие функции, чистые функции, абстракции высокого уровня, декларативный стиль.

      почему, к примеру,, имеет смысл демонстрировать на Haskell

      Lisp, Clojure и F# - замечательные языки, но ИМХО Haskell выражает идеи ФП более "дистиллированно".


      1. nickolaym
        10.01.2024 15:01

        Сдаётся мне, если хочется нахлебаться от души тацитным программированием (как в приведённом обфусканном коде на хаскелле) - то лучше брать язык, где тацитное программирование поставлено во главу угла. Это APL/J/K. Там, кстати, тензоры есть из коробки, и не надо трахаться со списками списков или с пересчётом индексов в одномерном массиве списке.

        Налепить судоку на джее... это, пожалуй, интересный этюд будет.


        1. CorwinH Автор
          10.01.2024 15:01

          как в приведённом обфусканном коде на хаскелле

          Насколько я понял, данный код был написан в рамках соревнования "чей код короче". Типа, код на Haskell оказался на несколько символов короче, чем код на Perl.

          Налепить судоку на джее... это, пожалуй, интересный этюд будет.

          https://code.jsoftware.com/wiki/Essays/Sudoku


      1. CorwinH Автор
        10.01.2024 15:01

        На выходных буду разбираться, как это работает :).

        Функция f возвращает список всех решений. Я ничего не менял, только вынес куски кода в отдельные функции с осмысленными названиями.

        f x [] = [x]
        f prefix (h:suffix) = [a | z <- candidates, a <- f (prefix ++ [z]) suffix]
          where
          -- индексы строки и столбца текущего символа
          (r,c) = divMod (length prefix) 9
          -- позиции всех символов в той же строке, столбце или квадрате
          positions  = [i*9+j | i <- [0..8], j <- [0..8], i==r || j==c || i#r && j#c]
            where
            m # n = m`div`3 == n`div`3
          -- все символы в той же строке, столбце или квадрате
          figures    = map ((prefix ++ h:suffix) !!) positions
          -- допустимые символы (не встречаются в строке, столбце или квадрате)
          isValid z  = notElem z figures
          -- кандидаты для замены текущего сивола h
          -- (сам сивол h, если он цифра, или допустимая цифра, если он точка)
          candidates = [x | x <- ['1'..'9'], h == x || h == '.' && isValid x]
          
        main = print $ f [] "53..7....6..195....98....6.8...6...34..8.3..17...2...6.6....28....419..5....8..79"

        Извращение то ещё. Алгоритм, рассчитанный на работу с изменяемым массивом, приспособили для работы со списком. (Операция склеивания списков ++ выполняется за линейное время).


  1. feelamee
    10.01.2024 15:01

    Не понял почему в последнем примере абстракция ради абстракции. Вроде бы красиво решили задачу нахождения простых чисел.
    Объясните


    1. CorwinH Автор
      10.01.2024 15:01

      Мы берём очередное натуральное число и начинаем вычислять НОД этого числа с каждым из уже найденных простых чисел. Это очень неэффективно.


  1. feelamee
    10.01.2024 15:01

    хорошо, изменим первую строку и импортируем вымышленную функцию cached_gcd. Получится в разы быстрее.

    Все ещё непонятно. Ну ладно, не буду занудой))


    1. CorwinH Автор
      10.01.2024 15:01

      Да, мы можем заменить функцию сравнения (закрыв глаза на то, что нарушается фундаментальное свойство сравнения - симметричность):

      primes = nubBy eq' [2..] 
        where
        eq' a b = b `mod` a == 0

      Но, в рамках данного подхода (с nubBy) мы не можем прервать процесс сравнения нового числа с уже вычисленными простыми по условию b < a*a.


  1. domix32
    10.01.2024 15:01
    +1

    Не очень понял причем тут C#.


    1. CorwinH Автор
      10.01.2024 15:01
      +1

      Чтобы читателям, не знакомым с функциональными языками, было проще представить, что такое алгебраический тип данных.

      p.s. В C# и монады есть, если что...


  1. nickolaym
    10.01.2024 15:01
    +2

    Странный план статьи, конечно. Сперва накидали каких-то кусков на сишарпе. Потом not-invented-here список на хаскелле. Потом вдруг бац, и судоку, где от двух предыдущих глав понадобилось чуть меньше, чем ничего.

    Если бы вместо списка был массив, то ничего бы по существу не поменялось.
    Всё равно тут делается перебор по индексам.
    Ну и вместо магии "тут бежим по строке, там бежим по столбцу и транспонируем, а здесь давайте нарежем на кубики с бесконечным списком" была бы отчётливая работа по координатам.

    Слишком много примитивных строительных кубиков, получается. ФП растворилось за подзадачами "ковыряться в списках". Чем это принципиально отличается от шарпа, чтобы можно было почувствовать вкус ФП? Только лаконичностью синтаксиса? Ну да, хаскелл в этом плане один из рекордсменов, тот же самый код на лиспе был бы в десять раз более пухлым, и от скобок потекли бы слёзы из глаз. Но это же вопрос синтаксиса, не более того.

    А ещё в кадр краешком влезли:

    • цикл по условию (опа, итеративность = императивность)

    • ввод-вывод (IO - монада из ада!)

    А ещё в кадр не влезла никакая обработка ошибок:

    • зацикливание

    • противоречивость входных данных

    • отсутствие решения

    А ещё алгоритм предельно тупой, не умеющий решать в глубину.


    1. CorwinH Автор
      10.01.2024 15:01
      +1

      Сперва накидали каких-то кусков на сишарпе. Потом not-invented-here список на хаскелле.

      Код на C# - для программистов на C++/C#/Java ("классическое" ООП), незнакомых с ФП.

      Самописный список - чтобы показать внутреннее устройство списка и, заодно, познакомить читателей с АТД, классами типов и операторами.

      Если Вы считаете, что это было неудачное решение, то не буду спорить. Вам, как читателю, виднее. Вопрос (для статистики): у Вас есть опыт работы с АТД и/или неизменяемыми связанными списками?

      ФП растворилось за подзадачами "ковыряться в списках". Чем это принципиально отличается от шарпа, чтобы можно было почувствовать вкус ФП?

      Отсутствием циклов. Тем, что код близок к описанию алгоритма ("решено = все не равны нулю" и т.д.). Хотя, конечно, и на C# можно писать в функциональном стиле.

      цикл по условию (опа, итеративность = императивность)

      Это Вы про until? Любая не бесконечная рекурсия должна иметь условие выхода из рекурсии. А "функциональный дух" в этом случае заключается в том, что мы не используем рекурсию явно.

      ввод-вывод (IO - монада из ада!)

      Куда же без него! Программа, которая никак не взаимодействует с внешним миром, бесполезна.

      А ещё в кадр не влезла никакая обработка ошибок:

      А ещё алгоритм предельно тупой, не умеющий решать в глубину.

      Это сделано намеренно, и я об этом предупредил в самом начале статьи:

      "Для простоты наложим на входные данные два дополнительных условия: решение всегда есть и его можно найти без перебора."


      1. domix32
        10.01.2024 15:01
        +1

        Навернуть шаблонных типов для показа "алгебраичности" типов в "классическом ООП" это ну крайне сомнительная идея, учитывая что в итоге оно не на типах решалось и имеет сомнительный же перфоманс. Не говоря уже про старнные представления о "классическом ООП".

        Ну и раз с C# начали, то что б не на F# тогда продолжить? Или как уже сами говорили - писать на C# в функциональном стиле.


        1. CorwinH Автор
          10.01.2024 15:01

          Предположу, что шаблонными типами Вы называете обобщённые типы C#. В данном примере обобщённость классов не принципиальна. Суть в другом.

          data Shape = Rectangle { width :: Double, height :: Double }
                     | Circle { radius :: Double }
                     deriving Show
          
          square :: Shape -> Double
          square (Rectangle w h) = w * h
          square (Circle r) = pi * r * r

          Если бы меня попросили как можно ближе переписать этот код на C# в то время, когда я только начинал изучать функциональное программирование, то я бы написал так:

          public abstract class Shape
          {
          	public abstract double Square();
          }
          
          public class Rectangle : Shape
          {
          	public double Width { get; set; }
          	public double Height { get; set; }
          	
          	public Rectangle(double width, double height)
          	{
          		Width = width;
          		Height = height;
          	}
          	
          	public override double Square()
          	{
          		return Width * Height;
          	}
          }
          
          public class Circle : Shape
          {
          	public double Radius { get; set; }
          
          	public Circle(double radius)
          	{
          		Radius = radius;
          	}
          
          	public override double Square()
          	{
          		return Math.PI * Radius * Radius;
          	}
          }

          Но правильней было бы написать так:

          public static class Shapes
          {
          	public abstract class Shape
          	{
          		public abstract TResult Match<TResult>(Func<double, double, TResult> rectangle, Func<double, TResult> circle);
          	}
          
          	public class Rectangle : Shape
          	{
          		public double Width { get; }
          		public double Height { get; }
          
          		public Rectangle(double width, double height)
          		{
          			Width = width;
          			Height = height;
          		}
          
          		public override TResult Match<TResult>(Func<double, double, TResult> rectangle, Func<double, TResult> circle)
          		{
          			return rectangle(Width, Height);
          		}
          	}
          
          	public class Circle : Shape
          	{
          		public double Radius { get; }
          
          		public Circle(double radius)
          		{
          			Radius = radius;
          		}
          
          		public override TResult Match<TResult>(Func<double, double, TResult> rectangle, Func<double, TResult> circle)
          		{
          			return circle(Radius);
          		}
          	}
          	
          	public static double Sqare(Shape shape)
          	{
          		return shape.Match((w, h) => w * h, r => Math.PI * r * r);
          	}
          }

          Между этими двумя реализациями есть принципиальная разница. В первом ("ООП") варианте нам легко добавить Треугольник, но чтобы добавить Периметр, придётся переписывать все классы. В втором ("ФП") варианте нам легко добавить Периметр, но чтобы добавить Треугольник, придётся переписывать все функции. Я не знаю, как объяснить эту разницу, не приводя примеры C# кода.


      1. nickolaym
        10.01.2024 15:01
        -1

        Ну как же отсутствие циклов, если цикл сделали. Который под капотом устроен на концевой рекурсии. Которая ещё более под капотом (что там компилятор накомпилирует) устроена опять как цикл.

        "Функциональный дух" в виде "ФП - это процедурное программирование для бедных"?

        Любой процедурщик прямо трактует цикл while как контракт! "Вышли из цикла = условие стало ложным". А если он это не делает, то он видимо ещё не наелся макарон в коде.

        Но функциональные макароны ничем не лучше процедурных. И здесь они тоже есть: мы вывернули доступ к подмассивам мясом наружу, играем в конечные головы бесконечных списков и всё такое. Хотя умом-то понимаем, что никакие это не списки, а тупо двумерный массив, который нам проще создавать на встроенном в синтаксис списке, нежели тащить специальную библиотеку.


        1. CorwinH Автор
          10.01.2024 15:01

          Ну как же отсутствие циклов, если цикл сделали. Который под капотом устроен на концевой рекурсии. Которая ещё более под капотом (что там компилятор накомпилирует) устроена опять как цикл.

          Здесь ключевая фраза - "под капотом". Понятно, что у любой программы под капотом условные и безусловные переходы, но это не имеет значения. Важно то, рассуждаю ли я в терминах условных и безусловных переходов во время написания программы.

          Цикл - это абстракция (многократное повторение одной и той же последовательности команд), которая может использоваться при написании кода.

              mov x0, #0      // помещаем в регистр X0  число 0
              b compare       // переходим к проверке условия
          while:              
              add x0, x0, #1  // X0 = X0 + 1
          compare:        
              cmp x0, #5      // сравнивание значение регистра X0 с числом 5
              b.lt while 

          Если кто-то видит здесь цикл, то этот цикл только у него в голове. Для того, чтобы написать подобный код, необязательно знать про абстракцию "цикл". А можно написать подобный код именно как реализацию цикла.

          until p f = go
            where
              go x | p x          = x
                   | otherwise    = go (f x)

          То же самое можно сказать и про хвостовую рекурсию. Здесь нет цикла. Хотя, возможно, этот код написал "императивщик", который не привык программировать с помощью абстракции "рекурсия", но знает, что цикл можно реализовать с помощью хвостовой рекурсии.

          Оптимизация хвостовой рекурсии не использует цикл. Упрощённо, она заключается в том, что вместо размещения в стеке нового блока данных, необходимых для вызова функции, модифицируется уже имеющийся блок данных. То, что хвостовую рекурсии можно заменить циклом, не делает её циклом.

          "Функциональный дух" в виде "ФП - это процедурное программирование для бедных"?

          Нет. Это (в том числе) использование абстракций более высокого порядка вместо абстракции "цикл". То есть, когда во время написания кода программист рассуждает не "тут нам нужен цикл по элементам списка, который мы реализуем с помощью рекурсии", а "тут нам нужна свёртка списка по функции f". А если копнуть глубже, то "функциональный дух" - это рассуждения в духе "что нам нужно получить", а не "какие действия нужно сделать, чтобы это получить".


      1. nickolaym
        10.01.2024 15:01

        +1 поставил ошибочно. промахнулся мимо "ответить".


      1. nickolaym
        10.01.2024 15:01

        "Для простоты наложим на входные данные два дополнительных условия: решение всегда есть и его можно найти без перебора."

        Некоторые однозначные решения можно и без перебора находить, но со взглядом на всю доску, а не на одну конкретную клетку с соседями.

        Например, если у нас расставлены восемь единичек, то очевидно, где должна оказаться последняя девятая.

        Вообще, модель данных "знаем конкретную цифру / не знаем ничего" - неоптимальна. Нужно "здесь заведомо могут быть такие-то цифры" - и когда это множество схлопнется до единственной цифры, тогда мы вообще молодцы. То есть, как бы, решаем не в двумерном десятеричном массиве, а в трёхмерном двоичном.