Цель данной статьи - познакомить читателя с основными идеями ФП на примере программы для решения судоку. Для простоты наложим на входные данные два дополнительных условия: решение всегда есть и его можно найти без перебора. Входные данные задаются как список списков.
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)
feelamee
10.01.2024 15:01Не понял почему в последнем примере абстракция ради абстракции. Вроде бы красиво решили задачу нахождения простых чисел.
ОбъяснитеCorwinH Автор
10.01.2024 15:01Мы берём очередное натуральное число и начинаем вычислять НОД этого числа с каждым из уже найденных простых чисел. Это очень неэффективно.
feelamee
10.01.2024 15:01хорошо, изменим первую строку и импортируем вымышленную функцию cached_gcd. Получится в разы быстрее.
Все ещё непонятно. Ну ладно, не буду занудой))
CorwinH Автор
10.01.2024 15:01Да, мы можем заменить функцию сравнения (закрыв глаза на то, что нарушается фундаментальное свойство сравнения - симметричность):
primes = nubBy eq' [2..] where eq' a b = b `mod` a == 0
Но, в рамках данного подхода (с nubBy) мы не можем прервать процесс сравнения нового числа с уже вычисленными простыми по условию b < a*a.
nickolaym
10.01.2024 15:01+2Странный план статьи, конечно. Сперва накидали каких-то кусков на сишарпе. Потом not-invented-here список на хаскелле. Потом вдруг бац, и судоку, где от двух предыдущих глав понадобилось чуть меньше, чем ничего.
Если бы вместо списка был массив, то ничего бы по существу не поменялось.
Всё равно тут делается перебор по индексам.
Ну и вместо магии "тут бежим по строке, там бежим по столбцу и транспонируем, а здесь давайте нарежем на кубики с бесконечным списком" была бы отчётливая работа по координатам.Слишком много примитивных строительных кубиков, получается. ФП растворилось за подзадачами "ковыряться в списках". Чем это принципиально отличается от шарпа, чтобы можно было почувствовать вкус ФП? Только лаконичностью синтаксиса? Ну да, хаскелл в этом плане один из рекордсменов, тот же самый код на лиспе был бы в десять раз более пухлым, и от скобок потекли бы слёзы из глаз. Но это же вопрос синтаксиса, не более того.
А ещё в кадр краешком влезли:
цикл по условию (опа, итеративность = императивность)
ввод-вывод (IO - монада из ада!)
А ещё в кадр не влезла никакая обработка ошибок:
зацикливание
противоречивость входных данных
отсутствие решения
А ещё алгоритм предельно тупой, не умеющий решать в глубину.
CorwinH Автор
10.01.2024 15:01+1Сперва накидали каких-то кусков на сишарпе. Потом not-invented-here список на хаскелле.
Код на C# - для программистов на C++/C#/Java ("классическое" ООП), незнакомых с ФП.
Самописный список - чтобы показать внутреннее устройство списка и, заодно, познакомить читателей с АТД, классами типов и операторами.
Если Вы считаете, что это было неудачное решение, то не буду спорить. Вам, как читателю, виднее. Вопрос (для статистики): у Вас есть опыт работы с АТД и/или неизменяемыми связанными списками?
ФП растворилось за подзадачами "ковыряться в списках". Чем это принципиально отличается от шарпа, чтобы можно было почувствовать вкус ФП?
Отсутствием циклов. Тем, что код близок к описанию алгоритма ("решено = все не равны нулю" и т.д.). Хотя, конечно, и на C# можно писать в функциональном стиле.
цикл по условию (опа, итеративность = императивность)
Это Вы про until? Любая не бесконечная рекурсия должна иметь условие выхода из рекурсии. А "функциональный дух" в этом случае заключается в том, что мы не используем рекурсию явно.
ввод-вывод (IO - монада из ада!)
Куда же без него! Программа, которая никак не взаимодействует с внешним миром, бесполезна.
А ещё в кадр не влезла никакая обработка ошибок:
А ещё алгоритм предельно тупой, не умеющий решать в глубину.
Это сделано намеренно, и я об этом предупредил в самом начале статьи:
"Для простоты наложим на входные данные два дополнительных условия: решение всегда есть и его можно найти без перебора."
domix32
10.01.2024 15:01+1Навернуть шаблонных типов для показа "алгебраичности" типов в "классическом ООП" это ну крайне сомнительная идея, учитывая что в итоге оно не на типах решалось и имеет сомнительный же перфоманс. Не говоря уже про старнные представления о "классическом ООП".
Ну и раз с C# начали, то что б не на F# тогда продолжить? Или как уже сами говорили - писать на C# в функциональном стиле.
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# кода.
nickolaym
10.01.2024 15:01-1Ну как же отсутствие циклов, если цикл сделали. Который под капотом устроен на концевой рекурсии. Которая ещё более под капотом (что там компилятор накомпилирует) устроена опять как цикл.
"Функциональный дух" в виде "ФП - это процедурное программирование для бедных"?
Любой процедурщик прямо трактует цикл while как контракт! "Вышли из цикла = условие стало ложным". А если он это не делает, то он видимо ещё не наелся макарон в коде.
Но функциональные макароны ничем не лучше процедурных. И здесь они тоже есть: мы вывернули доступ к подмассивам мясом наружу, играем в конечные головы бесконечных списков и всё такое. Хотя умом-то понимаем, что никакие это не списки, а тупо двумерный массив, который нам проще создавать на встроенном в синтаксис списке, нежели тащить специальную библиотеку.
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". А если копнуть глубже, то "функциональный дух" - это рассуждения в духе "что нам нужно получить", а не "какие действия нужно сделать, чтобы это получить".
nickolaym
10.01.2024 15:01"Для простоты наложим на входные данные два дополнительных условия: решение всегда есть и его можно найти без перебора."
Некоторые однозначные решения можно и без перебора находить, но со взглядом на всю доску, а не на одну конкретную клетку с соседями.
Например, если у нас расставлены восемь единичек, то очевидно, где должна оказаться последняя девятая.
Вообще, модель данных "знаем конкретную цифру / не знаем ничего" - неоптимальна. Нужно "здесь заведомо могут быть такие-то цифры" - и когда это множество схлопнется до единственной цифры, тогда мы вообще молодцы. То есть, как бы, решаем не в двумерном десятеричном массиве, а в трёхмерном двоичном.
forthuse
Решение задачи Sudoku на разных языках на rosettacode.org
P.S. Есть и на Haskell,
А насколько Функциональное программирование хорошо и почему, к примеру,, имеет смысл демонстрировать на Haskell и на такой задаче, а не, к примеру, на классическом языке Common Lisp или Clojure?
CorwinH Автор
Спасибо! Посмотрел. Есть довольно занятные варианты решения. Например, такой:
На выходных буду разбираться, как это работает :).
О преимуществах ФП написано много (впрочем, как и о преимуществах ООП). Лично мне нравится: короткие функции, чистые функции, абстракции высокого уровня, декларативный стиль.
Lisp, Clojure и F# - замечательные языки, но ИМХО Haskell выражает идеи ФП более "дистиллированно".
nickolaym
Сдаётся мне, если хочется нахлебаться от души тацитным программированием (как в приведённом обфусканном коде на хаскелле) - то лучше брать язык, где тацитное программирование поставлено во главу угла. Это APL/J/K. Там, кстати, тензоры есть из коробки, и не надо трахаться со списками списков или с пересчётом индексов в одномерном
массивесписке.Налепить судоку на джее... это, пожалуй, интересный этюд будет.
CorwinH Автор
Насколько я понял, данный код был написан в рамках соревнования "чей код короче". Типа, код на Haskell оказался на несколько символов короче, чем код на Perl.
https://code.jsoftware.com/wiki/Essays/Sudoku
CorwinH Автор
Функция f возвращает список всех решений. Я ничего не менял, только вынес куски кода в отдельные функции с осмысленными названиями.
Извращение то ещё. Алгоритм, рассчитанный на работу с изменяемым массивом, приспособили для работы со списком. (Операция склеивания списков ++ выполняется за линейное время).