在本系列的第一部分中 ,我讨论了函数式编程的核心概念,并提供了一些示例说明它们如何发挥作用。 函数编程的核心概念列表(再次从第一部分开始)如下:
- 使用功能作为其他功能的输入和输出,
高阶函数 - 使用
map
,filter
和reduce
类型函数而不是循环 - 不变的状态
- 递归代替循环
- 从其他功能组合功能
- 将“纯”功能与具有副作用的功能区分开
我给出了使用前四个项目的一些示例,因此让我们更深入地研究这些项目,并添加第5个项目。
与Elm建立高效的国际象棋AI
但是首先,有关国际象棋编程的一些信息:
为类似象棋的游戏编写AI是一件很有趣的事情。 真正的国际象棋AI必须非常复杂。 与国际象棋相比,计算机下象棋的方式更为残酷。 他们尝试查看每个可能的移动顺序,并为结果位置分配一个值。 这种蛮力方法非常费力,以至于如果不进行重大优化,一台AI将需要几天的时间才能移动,并且还需要大量的内存。
优化国际象棋AI代码的挑战已经产生了一些很棒的算法,例如alpha-beta修剪和min-max搜索。 击败世界冠军加里·卡斯帕罗夫 ( Garry Kasparov)的AI Deep Blue内置了专门的“象棋”芯片,使其速度比纯软件解决方案快了许多倍。 另外,国际象棋公开赛百科全书包含了所有流行公开赛的最佳棋步,并且这些棋子被硬编码为国际象棋程序,因为数据不需要每部分游戏的前十到二十步都需要AI的处理。
相比之下,我的宠物项目“伤口”的AI(请参阅本系列的第一部分 )是非常原始的。 在评估董事会中的给定职位时,它将比较军队(包括受伤的部分)的实力,并且将其各自的机动性与每个团队可以进行多少法律行动的原始计数进行比较。
我们将看到在不耗尽某些资源(包括人的耐心)的情况下,我们可以展望未来的许多举措。
为简单起见,我们假设每轮连续进行的合法移动次数保持不变(事实并非如此;通常随着游戏第一部分中的每次移动而增加)。 在Wounds中,第一个球员是Red球员,在游戏开始时有大约30个合法动作。 AI将尝试这些动作中的每一个作为第1层,然后它将使Blue团队的每一个响应(第2层)为30乘以30,即900。您将看到结果。 接下来将考虑Red的27,000次举动(第3页)和Blue的810,000次举动(第4页)。 当您开始寻找比这更远的时间时,很容易耗尽时间和内存。
比较Objective-C和Elm
当我在Objective-C中为此编写代码时,在遇到函数编程之前,我看到递归将使我能够为此编写干净的代码。 当我实现递归代码时,我发现深度为5层时,代码将使我的iPad崩溃。 造成这种情况的原因有两个:一,在董事会上进行下一步行动之前,需要保存每个董事会职位的副本;二,由于没有针对递归对Objective-C进行优化,因此会产生额外的开销。 递归调用(没有优化)会占用堆栈空间,因此通常是最弱的链接。
让我们希望这在Elm中效果更好。 看看这个:
makeAIMove : Player -> Board -> Board
makeAIMove player board =
let
moveList = getAIMoves board player
scoredMobility = List.map (\move -> scoreMoveMobility board player move) moveList
scoredMoves =
List.map (\move -> scoreMoveMaterial board player move) scoredMobility
sortedMoves = List.sortBy .score scoredMoves |> List.reverse
maybeMove = List.head sortedMoves
in
case maybeMove of
Just maybeMove ->
let _ = Debug.log "score " maybeMove.score
in
makeMove board maybeMove
_ ->
board
在这里,我们看到对List.map
的两次调用,该调用将一个函数作为输入。 如果需要,传入的函数还会获取参数。 最后,列表通过。 该函数将在列表中的每个项目上调用。 List.map
将返回结果列表。 在这种情况下,我要传递动作列表。 首先是所有合法举动,然后是那些举动的得分,然后添加材料的得分,然后进行排序,以便我们可以抓住得分最高的列表的头。
请注意,每个后续调用的结果如何放入其自己的变量中,该变量将被传递给下一个函数。 这是我的下一个尝试,其中我利用链接来消除这些临时变量:
makeAIMove : Player -> Board -> Board
makeAIMove player board =
let
maybeMove =
getAIMoves board player
|> List.map (\move -> scoreMoveMobility board player move)
|> List.map (\move -> scoreMoveMaterial board player move)
|> List.sortBy .score
|> List.reverse
|> List.head
in
case maybeMove of
Just maybeMove ->
makeMove board maybeMove
_ ->
board
在Elm中, |>
运算符将其前面的函数的输出作为最后一个参数传递给其后面的函数。 如果比较上面的代码段,您将看到在第二个代码段中,每行末尾的参数丢失了。 在语言之间,该参数传递顺序未标准化。
例如,Elixir中的链接函数将输出参数作为第一个输入参数发送给后续函数,而不是最后一个参数。 这看起来类似于使用用于连接UNIX命令行实用程序的管道运算符。 但是可以这么说,还有一些更强大的事情正在发生。
部分应用的功能
当我说一个功能的输出传递给下一个功能时,我撒了点谎。 实际传递的称为部分应用函数 。 将这些信息传递出去的行为称为currying 。
Currying是为了向组合逻辑的先驱Haskell Brooks Curry致敬。 动词“ to curry”不仅指他和他的作品,而且还以他的名字命名了三种函数式编程语言:Haskell,Brooks和Curry。 在这三者中,只有Haskell有大量追随者。
像这样传递部分应用的函数是命名函数编程的另一个原因。 这个名字很贴切的另一个原因是功能程序员对所谓的副作用非常谨慎。
在面向对象的编程中,对象包含内部状态以及它们的方法既可以读取状态又可以写入状态是司空见惯的。 代码和数据交织在一起。 如果没有精心设计,可能会出现各种难以预料的副作用。
函数式程序员喜欢对这些副作用要非常小心。 给定相同的输入,“纯”功能始终返回相同的输出。 此外,它不会影响应用程序状态。 由于很少有没有状态的应用程序可以工作,因此需要以某种方式解决。 因此,仍然存在状态,但是必须非常谨慎和认真地处理它。
例如,在React / Redux中,您通过编码称为“ reducers”的Redux函数来处理状态。 然后创建“动作”。 当Redux收到一个动作时,它会小心地应用任何中间件,然后调用所有的reducer。 减速器可以对自己感兴趣的动作采取行动,或者可以说只是推卸责任。 React / Redux并不是纯粹的功能性工具链,但是作者说,它们已经受到Elm架构的严重影响。
不变状态的优雅
让我们再次讨论不可变状态,这一次更深入地介绍了在代码中使用它的优雅之处。 “纯”功能性编程工具强制执行不可变状态。 半功能性工具(如React(与Redux)一起使用)强烈建议并假设您将坚持不变的状态设计模式。 例如ReactJS,您可以调用
this.state = ‘new state’;
在constructor()
但是应该使用后续的状态更改
this.setState(‘new state’);
这似乎没有被强制执行,所以我可以想象自己通过忘记此规则而引入了错误。 有点像当我键入=
而不是==
。 我敢打赌,这从未发生在你身上;)。
诸如Elixir和Elm之类的功能语言返回数据的更新副本,而不是修改数据本身。 直观地讲,这在处理和内存方面看起来都是昂贵的,但是实际上这些工具已经过优化,因此实际上效率更高而不是更低。
这是我的(为简单起见而编辑的)Objective-C递归代码,用于搜索良好的AI动作:
- (Move *) pickBestMove: (Board *) board forPlayer: (Player *) player depth: (int) depth
{
Move *bestMove = nil;
NSMutableArray *moves = [self generateLegalMoves:board forPlayer:player];
if(moves.count > 0)
{
bestMove = moves[0];
BOOL alphaBeta = depth % 2 == 1;
for(Move *move in moves)
{
move.score = 0;
int interimValueScore = 0;
if(move.defending_man != nil)
{
interimValueScore += move.defending_man.value;
}
if(self.searchDepth > depth)
{
if(move.defending_man != nil)
{
if(depth < 4)
{
Board *tempBoard = [board copy];
[tempBoard makeMove:move];
Player *nextPlayer = [self getNextPlayer:player];
// here's the recursive call
Move *nextPlyBestMove =
[self pickBestMove:tempBoard forPlayer:nextPlayer
depth: depth + 1];
if(nextPlyBestMove != nil)
{
interimValueScore += nextPlyBestMove.score;
}
[tempBoard unMakeMove:move];
}
}
}
if(alphaBeta)
{
move.score += interimValueScore;
if(move.score > bestMove.score)
{
bestMove = move;
}
}
else
{
move.score -= interimValueScore;
if(move.score < bestMove.score)
{
bestMove = move;
}
}
}
}
return bestMove;
}
每次我搜索一个更深的层(层)时,都会制作一个新的木板副本,然后在该木板的副本上进行每次移动,并为得到的位置打分。 该代码很难调试,需要各种仪器代码才能在特定深度和板上位置停止。 即使在我的iMac上,它的速度也非常慢,这意味着它无法玩出非常出色的游戏。
现在让我们回到Elm,这要求状态是不可变的。 我在Elm制作的makeAIMove
第一个版本在为移动性评分时向前看了一个动作,而在给材质评分时没有任何动作。 这非常快,但是不太聪明。 它打得非常激进,没有考虑对手的反应。 这是一个更智能的版本,它使用递归来搜索由输入参数控制的深度:
makeAIMove : Player -> Int -> Board -> Board
makeAIMove player searchDepth board =
let
maybeMove = pickBestMove player 1 4 board
in
case maybeMove of
Just maybeMove ->
maybeMove
|> makeMove board
_ ->
board
pickBestMove
函数对其进行递归调用,直到达到由limit
参数指定的搜索深度为止:
pickBestMove : Player -> Int -> Int -> Board -> Maybe Move
pickBestMove player depth limit board =
let
legalMoves = getAIMoves board player
alphaBeta = (rem depth 2) /= 1
setMoveScore move player depth limit board =
if depth < limit then
let
nextBoard = makeMove board move
nextPlayer = (Player.getNextPlayer player)
-- here's the recursive call
nextPlyBestMove =
pickBestMove nextPlayer (depth + 1) limit nextBoard
in
case nextPlyBestMove of
Just nextPlyBestMove ->
if alphaBeta then
{move | score = move.score + (scoreBoard nextPlayer nextBoard)}
else
{move | score = move.score - (scoreBoard nextPlayer nextBoard)}
_ ->
move
else
{move | score = makeMove board move |> scoreBoard player}
in
List.map (\move -> setMoveScore move player depth limit board) legalMoves
|> List.sortBy .score
|> List.reverse
|> List.head
在顶部的let
部分中,我定义了一个本地函数setMoveScore
,该函数setMoveScore
legalMoves
列表中的每个移动而legalMoves
。 setMoveScore
递归调用外部函数pickBestMove
。 本地/内部函数递归调用外部函数是一种有趣的模式。 可以将setMoveScore
视为代码块而不是谨慎的功能。
模式{move | score = makeMove board move |> scoreBoard player}
{move | score = makeMove board move |> scoreBoard player}
显示了Elm如何响应状态变化。 此摘要返回更新了分数的新的move
副本。 这与List.map
函数调用非常匹配, List.map
通过将函数应用于旧列表中的每个项目来创建新列表。
同样,函数调用nextBoard = makeMove board move
返回位置更改的新板。 Player.getPlayer
工作方式相同。 这非常有用,因为我们希望能够在此功能的最后一行中使用原始的board
和player
。
我们不需要在每个层深度管理这些临时板,并确保我们撤消所做的移动。 这就是Objective-C示例的工作方式。 那在很大程度上取决于可变状态。
不变状态版本不易出错。 最终,它也变得更容易理解。 刚开始围绕FP概念进行研究时,我发现比面向对象的代码更难理解。 现在,我变得更加熟练了,我看到了这里的吸引力:一种令人上瘾的品质,对问题的正确解决方案具有优雅的感觉。 在那一刻,一切都点击了。
接下来
本系列的下一篇文章将深入研究React和Redux。
翻译自: https://www.javacodegeeks.com/2017/10/advantages-functional-programming.html