函数式编程的优点

本文探讨了函数式编程在国际象棋AI开发中的应用,对比了Objective-C和Elm的表现,强调了不可变状态和部分应用功能的优势。通过Elm的示例,展示了如何使用递归和函数组合优化AI搜索。文章还讨论了函数式编程如何减少错误,提高代码可读性和性能。
摘要由CSDN通过智能技术生成

本系列的第一部分中 ,我讨论了函数式编程的核心概念,并提供了一些示例说明它们如何发挥作用。 函数编程的核心概念列表(再次从第一部分开始)如下:

  1. 使用功能作为其他功能的输入和输出,
    高阶函数
  2. 使用mapfilterreduce类型函数而不是循环
  3. 不变的状态
  4. 递归代替循环
  5. 从其他功能组合功能
  6. 将“纯”功能与具有副作用的功能区分开

我给出了使用前四个项目的一些示例,因此让我们更深入地研究这些项目,并添加第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列表中的每个移动而legalMovessetMoveScore递归调用外部函数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工作方式相同。 这非常有用,因为我们希望能够在此功能的最后一行中使用原始的boardplayer

我们不需要在每个层深度管理这些临时板,并确保我们撤消所做的移动。 这就是Objective-C示例的工作方式。 那在很大程度上取决于可变状态。

不变状态版本不易出错。 最终,它也变得更容易理解。 刚开始围绕FP概念进行研究时,我发现比面向对象的代码更难理解。 现在,我变得更加熟练了,我看到了这里的吸引力:一种令人上瘾的品质,对问题的正确解决方案具有优雅的感觉。 在那一刻,一切都点击了。

接下来

本系列的下一篇文章将深入研究React和Redux。

翻译自: https://www.javacodegeeks.com/2017/10/advantages-functional-programming.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值