Концепция этого цикла начиналась с простого переноса тайловых миров на F#. Однако в процессе его описания я основательно растёкся по древу, за счёт чего у нас образовался большой подготовительный этап из пяти глав про языковые фичи и прочую «фундаменталочку». Думаю, что с подготовкой закончено, поэтому сегодня мы обратимся непосредственно к тайловым мирам.

Но начнём мы практически с конца — с адаптации поиска пути. Это несложная задачка, но в процессе её решения мы успеем закрепить пройденный материал и по инерции заскочить в новый.

Эта глава не будет «законченным произведением». Её пришлось обрубить из-за значительного превышения удобоваримых размеров. Очерчивать границы темы в таких текстах сложно, но с некоторой долей условности можно сказать, что по итогу в этой главе мы научимся правильно возвращать и получать из функции любую хтонь. Зачем она нам понадобилась, начнём обсуждать в этот раз, а что с ней делать дальше, выясним позднее.

Простой перенос поиска пути

В статье Прямоугольные тайловые миры дан пример поиска пути через A* (по-русски «астар»). Это простой алгоритм, которому меня учили ещё в школе, но его легко можно оптимизировать и усложнить под конкретные задачи. В статье даётся самый простой его вариант, который я переписал с GDScript на F#, после чего несколько раз успешно адаптировал под свои игровые механики.

Мы проделаем приблизительно тот же путь, но вместо механик сосредоточимся на удобстве использования. В алгоритме 3 блока: несколько мелких функций, структура данных PriorityStack и, собственно, сам алгоритм поиска. Примечательные моменты есть во всех блоках, но для дальнейшего развития сюжета важнее всех именно поиск. Поэтому тут следует предупредить, что мы в некотором смысле будем изобретать велосипед, ибо в Godot есть готовые AStar2D, AStarGrid2D и что-то ещё для 3D. Я ими так и не воспользовался, но они должны справляться с обычными сценариями. Что касается необычных сценариев (как у меня), то я счёл бесполезным адаптироваться указанные типы под них.

Вспомогательные функции

Больше всего на вспомогательных функциях сказалась замена самолепных операций на библиотечные функции и типы. Перенос на F# этого не требовал, но я воспользовался им как поводом. У меня исчезающе редко получается совершать «сложные ошибки». Если исключить чужие баги, то большую часть времени у меня отнимает какая-нибудь фигня, типа инвертированного bool, ошибочного > вместо >= или просчёта на 1. Поэтому я побаиваюсь писать код так:

func in_map(grid_pos:Vector2, map_size:Vector2) -> bool:
	return grid_pos.x < map_size.x and grid_pos.x >= 0 and grid_pos.y >= 0 and grid_pos.y < map_size.y

А не так:

let inMap mapSize pos =
    Rect2I(Vector2I.Zero, mapSize).HasPoint pos

Судя по большинству туториалов, в Godot-сообществе никто таких страхов не испытывает, поэтому там процветает этакий BCL-нигилизм. С этим надо что-то делать, ибо я уже больше года почти полтора года ковыряюсь в движке, но так и не встретил автора, код которого можно считать близким к библиотечному пределу. С другой стороны, я чувствую себя дискомфортно, когда предъявляю претензии к велосипедам внутри велосипеда.


Я поменял непрерывный Vector2 на дискретный Vector2I и изменил порядок аргументов. Для удобного каррирования необходимо, чтобы наиболее стабильный элемент передавался в функцию раньше всех. Поэтому размер поля идёт первым, изменяемое состояние поля (в нашем случае препятствия) — вторым, а конкретный запрос (клетка поля) — последним.

func can_stand(grid_pos:Vector2, obsts:PoolVector2Array, map_size:Vector2) -> bool:
	return not (grid_pos in obsts) and in_map(grid_pos, map_size)
let canStand mapSize isObstacle pos =
    inMap mapSize pos
    && not ^ isObstacle pos

Коллекция obsts в F# перестала быть объектом и превратилась в функцию-предикат. Функция говорит о данных гораздо меньше, чем «полноценный» тип, но такую зависимость гораздо проще поддерживать. Чисто для примера мы могли бы взять интерфейс IReadOnlySet, который реализуется и дотнетовским HashSet, и F#-ским Set, что потенциально позволило бы нам использовать как мутабельные, так и иммутабельные коллекции. Однако здесь мы сталкиваемся с ограничениями Set<'a when 'a : comparion>. Эта коллекция требует, чтобы тип 'a поддерживал сравнение (больше, меньше и т. д.), в то время как Vector2I в Godot этим свойством не обладает.

Надо либо использовать другую коллекцию (из System.Collections.Immutable или FSharpx.Collections), либо взять Set<int * int> и спроецировать его в Vector2I IReadOnlySet. Задача решаема, но вообще-то в недрах canStand нам нужен только один метод (Contains), который перешивается на Set тривиальным способом:

interface IReadOnlySet<Vector2I> with
    override this.Contains pos = 
        (obstacles : _ Set).Contains(pos.X, pos.Y)
    ...
    // Ещё 6 override в этом интерфейсе и 3 в предках

Получается, что остальные 9 методов мы будем реализовывать (или имитировать) только из-за того, что кому-то показалось эстетически правильным выразить obsts через «общепринятый» тип.


Все шаги на единицу вправо, влево и т. д. имеют одноимённые свойства в классах, но кажется, о них никто не знает:

func neighbors(grid_pos:Vector2,  obsts:PoolVector2Array, map_size:Vector2) -> PoolVector2Array:
	var res:PoolVector2Array = []
	var _neighbors = PoolVector2Array([grid_pos+Vector2(-1, 0), grid_pos+Vector2(1, 0), 
		grid_pos+Vector2(0, -1), grid_pos+Vector2(0, 1)])
	for neigh in _neighbors:
		if can_stand(neigh, obsts, map_size):
			res.append(neigh)
	return res
let neighbours mapSize isObstacle pos =
    [
        Vector2I.Left
        Vector2I.Right
        Vector2I.Up
        Vector2I.Down
    ]
    |> Seq.map ^ fun p -> p + pos
    |> Seq.filter ^ canStand mapSize isObstacle

Наконец, не надо забывать, про векторные операции:

func heuristic(a:Vector2, b:Vector2) -> int:
	return int(abs(a.x-b.x)+abs(a.y-b.y))
let heuristic (a : Vector2I) b =
    let xy = (a - b).Abs()
    xy.X + xy.Y

PriorityStack

Источник:

class PriorityStack:
	
	var items:Array
	
	func _init():
		items = Array()
		
	func empty() -> bool:
		return items.size() == 0
		
	func put(item, priority:int) -> void:
		if empty():
			items.append([item, priority])
		elif priority <= items[0][1]:
			items.insert(0, [item, priority])
		elif priority > items[-1][1]:
			items.append([item, priority])
		else:
			for i in range(len(items)):
				if priority <= items[i][1]:
					items.insert(i, [item, priority])
					break
					
	func take(): # "get" name already taken by Variant
		return items.pop_front()[0]

В оригинальной статье PriorityStack — это коллекция точек (в нашем случае Vector2I), упорядоченных по приоритету (int). Важно, что приоритеты точек могут совпадать и поэтому не могут быть ключами. Также важно, что алгоритм поиска пути не нуждается в произвольном доступе к элементам коллекции. Он всегда забирает элемент с самым низким приоритетом (который находится в голове списка).

В dotnet очень похожим поведением обладает PriorityQueue<Vector2I, int>, но результат работы очереди не идентичен стеку. Затык в порядке укладки элементов с одним и тем же уровнем приоритета. PriorityStack при запросе выдаст самого свежего собрата, а PriorityQueue кого-то «из». Кого точно не знаю, но там не рандом, не FIFO и не LIFO.

Чуть больше конкретики по `PriorityQueue`

Цитата из документации:

Note that the type does not guarantee first-in-first-out semantics for elements of equal priority.

property {
    let! items =
        Range.constant 1 10
        |> Gen.int32
        |> Gen.list ^ Range.constant 2 10
        |> Gen.map ^ List.mapi ^ fun index priority -> priority, index
    let trueQueue = TruePriorityQueue()
    let dotnetQueue = PriorityQueue()
    do  for priority, value in items do
            trueQueue.Put priority value
            dotnetQueue.Put priority value
    let actual, expected =
        let f factory =
            items
            |> Seq.choose ^ fun _ -> factory ()
            |> List.ofSeq
        f dotnetQueue.TryTake
        , f trueQueue.TryTake
    counterexample $"Dotnet PriorityQueue: {actual}"
    counterexample $"True PriorityQueue: {expected}"
    return actual = expected
}
*** Failed! Falsifiable (after 1 test and 4 shrinks):
[(7, 0); (1, 1); (7, 2)]
Dotnet PriorityQueue: [1; 2; 0]
True PriorityQueue: [1; 0; 2]

Стоимость пути у всех получается одинаковая, но сам путь при наличии альтернатив может оказаться другим. На открытом пространстве вместо классической буквы Г у PriorityQueue выходит пьяный Брезенхем, который к тому же делает значительно больше ненужных проверок, чем PriorityStack:

В общем, не перенести его было нельзя:

type PriorityStack<'priority, 'value when 'priority : comparison> () =
    let items = ResizeArray()
    
    member _.Put (priority : 'priority) (item : 'value) =
        items
        // Если `priority <= p`, то у LIFO
        // если `priority < p`, то у FIFO
        |> Seq.tryFindIndex ^ fun (p, _) -> priority <= p
        |> function
            | Some index -> items.Insert(index, (priority, item))
            | None -> items.Add (priority, item)

    member _.TryTake () =
        if items.Count = 0 then None else
        let _, res = items.[0]
        items.RemoveAt 0
        Some res

Тут активно используется option, что радикально улучшает читаемость и обессмысливает удалённый мною IsEmpty.

Здесь мы опять встречаемся с ограничением when 'priority : comparison, которое означает, что тип приоритета должен поддерживать сравнение. В противном случае у компилятора возникнут вопросы к выражению priority <= p. Если бы речь шла не о типе, а о функции, то это ограничение было бы выведено из указанного выражения автоматически. То есть компилятор прогнул бы обобщение в угоду логике функции. С типами такая магия не работает. Дженерики и их ограничения надо обозначать явно. В общих чертах ситуация напоминает проблему с генерализацией сеттеров. Терпимо, но иногда бесит.

PriorityStack является структурой данных, и эти данные иногда хочется выводить наружу, например, в логи упавших тестов или в маску карты. Данные хранятся линейно, что обычно отражается в имплементации интерфейса _ IEnumerable (он же _ seq). И будь здесь C#, мы, скорее всего, так бы и поступили. Но на F# вполне достаточно метода AsSeq (или ToSeq):

    member this.AsSeq () = Seq.readonly items

Кому надо, дёрнет. Кому не надо, пройдёт мимо.

Первое время выглядит необычно, но к этому быстро привыкаешь, так как в действительности мы имеем дело со старым паттерном «фасад». Если вдуматься, то AsSeq всего лишь выдаёт объект, который реализует целевой интерфейс. В нашем случае это каждый раз новая обёртка над items. Её, конечно же, можно закешировать, но главное не это, а то, что полученный seq не тождественен своему PriorityStack.

Данная схема применима не только к последовательностям. Она актуальна для всех типов, которые легко собираются на лету. Поэтому возможны ситуации, когда на одном «типе-матке» висит целая гроздь таких фасадов, и через несколько глав мы к этому придём.

Алгоритм

Источник:

func find_path(start:Vector2, goal:Vector2, obsts:PoolVector2Array, map_size:Vector2) -> PoolVector2Array:
	var frontier = PriorityStack.new()
	frontier.put(start, 0)
	var came_from = {}
	var cost_so_far = {}
	came_from[start] = start
	cost_so_far[start] = 0
	
	var current:Vector2
	var new_cost:int
	
	if not can_stand(goal, obsts, map_size):
		return PoolVector2Array()
		
	while not frontier.empty():
		current = frontier.take()
			
		if current == goal:
			break
			
		for next in neighbors(current, obsts, map_size):
			new_cost = cost_so_far[current] + 1
				
			if not (next in cost_so_far) or new_cost < cost_so_far[next]:
				cost_so_far[next] = new_cost
				frontier.put(next, new_cost + heuristic(goal, next))
				came_from[next] = current
				
	if frontier.empty() and current != goal:
		return PoolVector2Array()
		
	current = goal
	var path:PoolVector2Array = PoolVector2Array([current])
	
	while current != start:
		current = came_from[current]
		path.append(current)
	
	path.invert()

В принципе ничего сложного, но мне не нравится, что пустой список означает ненайденный путь. Также мне трудно считывать выходы. Их тут 3 штуки: не искали (в целевой точке препятствие или она за пределами поля), не нашли и нашли. Переход между вторым и третьим return зависит от единственного break. С первого взгляда этот факт не считывается, а хотелось бы.

В F# с направлением вычислений как-то попроще:

let tryFindPath mapSize isObstacle start goal =
    if not ^ canStand mapSize isObstacle goal then None else
    let frontier = PriorityStack()
    frontier.Put 0 start
    let cameFrom = Dictionary()
    cameFrom.[start] <- start
    let costSoFar = Dictionary()
    costSoFar.[start] <- 0

    let rec tryFind () =
        match frontier.TryTake () with
        | None -> None
        | Some current when current = goal ->
            let path = Stack()
            path.Push goal
            let rec buildPath current =
                if current <> start then
                    let current = cameFrom.[current]
                    path.Push current
                    buildPath current
            buildPath goal
			// Стек раскроется сверху вниз, так что `rev` не нужен.
            Some ^ List.ofSeq path
        | Some current ->
            let newCost = costSoFar.[current] + 1
            for next in neighbours mapSize isObstacle current do
                match costSoFar.TryGetValue next with
                | true, oldCost when newCost >= oldCost -> ()
                | _ ->
                    costSoFar.[next] <- newCost
                    frontier.Put (newCost + heuristic goal next) next
                    cameFrom.[next] <- current
            tryFind ()
    tryFind ()

Этот вариант рабочий и, за исключением option, он делает то же самое, что и GDScript. Однако к нему есть несколько косметических замечаний.

Инициализация коллекций на базе одного элемента в F# имеет устоявшееся имя — singleton. Например, List.singleton 42 = [42]. В случае списков код короче не становится, но не у всех типов есть короткий синтаксис. singleton тоже есть не у всех, но в отличие от синтаксиса его можно добавить самостоятельно:

module PriorityStack =
    let singleton priority item =
        let result = PriorityQueue()
        result.Put priority item
        result

module Dictionary =
    let singleton key value =
        let result = Dictionary()
        result.[key] <- value
        result

Одна из веток tryFind начинается с:

| Some current when current = goal ->

И может показаться, что её можно упростить до:

| Some goal ->

Однако при такой записи мы получим всего лишь шадовинг старого goal, а не фильтр. Нужного синтаксиса можно достичь, если добавить в глобальный Utils следующий шаблон:

let (|Eq|_|) expected actual =
    if expected = actual then Some () else None

Тогда фильтр будет выглядеть так:

| Some (Eq goal) ->

Чуть длиннее, чем Some goal, но без разночтений. Критически важное преимущество Eq — скоуп не засоряется лишним current. А здесь он точно лишний, так как вместо него можно и нужно использовать уже существующий goal.

Код после всех правок:

let tryFindPath mapSize isObstacle start goal =
    if not ^ inMap mapSize goal then Result.Error OutOfBounds
    elif isObstacle goal then Result.Error Obstacle else
    let frontier = PriorityStack.singleton 0 start
    let cameFrom = Dictionary.singleton start start
    let costSoFar = Dictionary.singleton start 0

    let rec tryFind () =
        match frontier.TryTake () with
        | None -> None
        | Some (Eq goal) ->
            let path = Stack()
            path.Push goal
            let rec buildPath current =
                if current <> start then
                    let current = cameFrom.[current]
                    path.Push current
                    buildPath current
            buildPath goal
            Some ^ List.ofSeq path
        | Some current ->
            let newCost = costSoFar.[current] + 1
            for next in neighbours mapSize isObstacle current do
                match costSoFar.TryGetValue next with
                | true, oldCost when newCost >= oldCost -> ()
                | _ ->
                    costSoFar.[next] <- newCost
                    frontier.Put (newCost + heuristic goal next) next
                    cameFrom.[next] <- current
            tryFind ()
    tryFind ()

Ошибки и не совсем ошибки

Причина недостижимости цели должна быть объяснена в UI. По идее, наличие препятствия в конечной точке можно отследить заранее. Но эту информацию ещё нужно параллельными путями дотащить до потребителя, в то время как в алгоритме поиска нужная проверка уже есть. Нет смысла усложнять логистику, проще сбросить внешний фильтр и перейти с option на Result<_, Error>:

// Внутри модуля PathFinder.
type Error =
    | OutOfBounds
    | Obstacle
    | Unreachable

Тип Error можно дополнять по мере необходимости, но о ветке Ok забывать тоже не стоит. Очень вероятно, что для неё потребуется собственный DU. Как минимум в моих пет-проектах это произошло ещё на этапе обозначения типов. Причина в том, что существуют ответы «со звёздочками», в которых путь получен не совсем так, как ожидалось.

Например, на больших картах принято лимитировать длину пути, чтобы случайно не пропесочить половину песочницы (это дорого). Если целевая точка выходит за границы поиска, то юнит отправляют к точке с максимально близким показателем эвристической функции (см. heuristic). Эвристика — наука неточная, поэтому распространены ситуации, когда при первой команде юнит забегает в ближайший тупик, при повторении команды выбегает, при следующей снова забегает и так по кругу. Выйти из цикла можно, только построив маршрут по кусочкам самостоятельно. В RPG такое выглядит терпимо, так как герой непрерывно в поле зрения игрока, но в RTS можно проморгать незапланированное самоубийство конных лучников о вражеский донжон.

С моей точки зрения, согласие на эвристику (или наоборот) должно даваться явно. Как минимум надо подсвечивать точку замены. Как максимум — требовать подтверждения (хотя бы через зажатый Ctrl). Те же претензии можно предъявить к гипотетическим путям через туман войны. Если моя катапульта верит, что за каждым «чёрным» гексом скрывается суша, то в попытке вдарить по Осаке она может дотопать от Пусана до Анадыри. Я жду не этого. Приблизительно из той же оперы поиск оптимального пути в средах с пассивным уроном. В обычных условиях я не хочу таскать армию по пустыням Гедросии, но что, если мне надо? И если надо, то насколько сильно?

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

Вывод промежуточных данных

В теле функции есть три коллекции: frontier, cameFrom и costSoFar. По большей части они — побочный продукт поиска пути. Их содержимое пересчитывается до тех пор, пока искомый путь не будет найден, после чего на основе небольшой доли «доделанных» данных путь будет построен и извлечён наружу.

Остальные «недоделанные» данные нельзя просто так использовать для принятия решения, но в них бывает полезно заглядывать, чтобы понять принцип работы алгоритма или же причину неожиданного поведения. Нежданчик не всегда связан с багом, гораздо чаще имеет место недопонимание ситуации. Выше, когда я объяснял разницу между различными версиями PriorityStack, мы делали именно это. Те схемы нарисованы на основе внутренних коллекций, которые перед этим таки надо было извлечь наружу. Проще всего это было сделать через анонимные рекорды:

Ok {|
    Path = List.ofSeq path
    CostSoFar = costSoFar.AsReadOnly()
    CameFrom = cameFrom.AsReadOnly()
    Frontier = List.ofSeq ^ frontier.AsSeq()
    // Сюда сразу стоит добавить поля `Start`, `Cost` и `Goal`, но я этого делать не буду, чтобы не увеличивать следующие примеры.
|}

Анонимный рекорд лучше обычного тем, что нам не нужно объяснять, какой тип имеет каждое из полей. Они строго определяются историей своего происхождения или контекстом. Проблема в том, что без этого контекста ссылаться на тип анонимного рекорда бывает трудно. Пока речь идёт о чём-то простом, типа переменной, мы можем выкрутиться:

let mutable path = None

let invalidatePath cellUnderMouse =
    ...
    // С этого момента компилятор точно знает тип `path`
    path <- Some ^ PathFinder.tryFindPath ...

match path with
| Some (Ok result) ->
    // Компилятор подскажет свойство `Path` и его тип ` : Vector2I list`.
    for step in result.Path do
        ...

Но если речь идёт не о конкретной ячейке данных, а обо всех экземплярах типа, то мы попадаем впросак. На анонимный рекорд нельзя навесить расширения. Придётся работать через функции и поминать его по «имени», которое соответствует его сигнатуре:

let debugDraw (pathFound : {| CameFrom: IReadOnlyDictionary<Vector2I, Vector2I>; CostSoFar: IReadOnlyDictionary<Vector2I, int>; Frontier: (int, Vector2I) list; Path: Vector2I list |}) ... = ...

Как видим, всё преимущество от контекстного вывода типов сливается в унитаз. В качестве издёвки, полноценное определение обычного рекорда на 2 знака короче, чем «имя» анонимного. Кроме того, любое определение обретает собственную жизнь и начинает функционировать как самостоятельный источник данных. Пришлые программисты ошибочно воспринимают его как высшую инстанцию. По ходу развития кода нам начнёт прилетать от компилятора за то, что результат функции не подходит под определения типа, хотя на самом деле это именно тип не подходит под результат функции.

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

Оболочки

К счастью, от этой болячки можно избавиться за небольшую плату. Выше, когда мы разбирались с PriorityStack, я говорил, что дженерики в типах необходимо указывать явно. Следует задаться вопросом, какой тип будет у аргумента value (и свойства Value), если мы забудем его явно обобщить?

type Wrapper (value) = 
    member this.Value = value

Компилятор попытается вывести из контекста наиболее конкретный тип. Если контекста не будет, то value : obj. Но если где-то в рамках файла написать Wrapper 42, то value : int. А если вместо 42 мы передадим наш анонимный рекорд, то Wrapper запомнит именно его. Таким образом, нам нужен отдельный враппер под каждый анонимный рекорд:

type PathFound1 (core) = 
    member this.Core = core

let tryFindPath1 ... =
    ...
    Ok ^ PathFound1 {|
        Path = List.ofSeq path
        CostSoFar = costSoFar.AsReadOnly()
        CameFrom = cameFrom.AsReadOnly()
        Frontier = List.ofSeq ^ frontier.AsSeq()
    |}

Дальше мы можем использовать PathFound1 как замену рекорда в сигнатурах. В телах методов придётся вызывать свойство Core, что может выглядеть неэстетично, но при необходимости его можно выдернуть и использовать автономно. Шадовинг в помощь.

Важно, что функция tryFindPath не обязана создавать оболочку в своём теле. Мы можем добавить её на более позднем этапе, например, в конкретной сцене:

module MyScene

type PathFound (core) =
    member this.Core = core

let tryFindPath ... =
    PathFinder.tryFindPath ...
    |> Result.map PathFound

В этот момент все возможные версии алгоритма уже определены, так что мы можем выбирать их, просто меняя функцию и некоторые параметры. PathFound автоматически словит изменения, и дальше они каскадом распространятся по всем зависимостям. Если анонимные рекорды в функциях различаются, то мы получим ошибку в точке потребления, то есть ровно там, где её и хочется видеть.

Бояться большого числа правок не нужно, так как повторяемые операции можно убрать в расширения PathFound. Туда же стоит запихивать и неповторяемые, если они потенциально реализуемы для всех версий. Например, для отрисовки стоимостей можно итерироваться прямо по Core.CostSoFar, а можно по проекции PathFound.Costs : (Vector2I * int) seq. Второй вариант предпочтительнее, так как в нём внутренняя структура рекорда не протекает в код рендера.

Локальное определение оболочки позволяет наращивать не только себя, но и ядро. Мы можем положить туда любую информацию важную для рендера. Например, если кораблей несколько и у них разный Material стрелочек, то мы можем закинуть ссылку на корабль прямо в результат вычислений:

let tryFindPath spaceShip ... =
    PathFinder.tryFindPath ...
    |> Result.map ^ fun p -> 
        PathFound {| p with SpaceShip = spaceShip |}

Разумеется, я веду речь о рендере, который сможет этой информацией воспользоваться. Не надо пичкать анонимку входными параметрами за просто так.

Оболочка, определённая близко к точке потребления, перестаёт быть просто побочным продуктом коммуникации и начинает функционировать как самостоятельная точка роста. Она больше напоминает полноценный, но чрезвычайно пластичный тип с очень странной точкой развёртывания. При этом она всё равно сохраняет некоторую аморфность, так как в первую очередь отвечает за передачу контекста (или скоупа) из одной области в другую. Уверен, что эта штука на UML-диаграммах будет выглядеть как какое-то недоразумение, так что я бы встрял, если бы мне пришлось защищать её перед кем-то, кто далёк от практического F#. Поэтому если вы не контролируете свой проект, будьте готовы писать всё по старинке. Вы серьёзно потеряете в надёжности и удобстве, но с точки зрения формальных критериев всё будет зашибись.

Промежуточное заключение

Во второй главе я упоминал, что проведу нас от обычной функции до зарождения ООП естественным путём. Если вернуться к той призме, то сегодня мы наблюдали процесс того, как функция создала свой первый тип, отличный от алгебраического. Этот тип нужен нам только в качестве якоря, ну или чекпоинта, если в игровых категориях. Мы используем его как удобную точку, за которую можно зацепиться и продолжить экспансию дальше.

Подозреваю, что из всех задач, которыми можно оправдать существование класса, эта самая маленькая. И, наверное, именно поэтому она никогда не попадает в фокус «общественного внимания». Пропагандистские усилия F#-сообщества сосредоточены на самодостаточных типах, смысл которых можно сравнительно легко описать стороннему наблюдателю. Причины подхода понятны, надо разговаривать на языке целевой аудитории. Результативность тоже очевидна, благодаря проектированию через типы мы имеем непрерывный поток неофитов с предсказуемым (хотя и не во всём правильном) набором установок.

Проблема в том, что в областях, которые находятся в стадии формирования, внятных категорий ещё нет. Есть лишь первичный бульон, в рамках которого мы пытаемся нащупать границы будущих типов. Разработка игры определённо относится к категории несформировавшихся доменов, для которых нужен дополнительный набор инструментов, не таких чистых, местами странных, но достаточно гибких и дешёвых. В практическом плане это означает, что здесь надо делать акцент не на типах, а на функциях (и ХМ). По крайней мере до тех пор, пока мы не упрёмся в предел эффективности.

В следующий раз мы продолжим работу с оболочками. Улучшим их форму, увеличим объёмы передаваемой информации и почти дойдём до типов в привычном понимании слова.


НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.

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