5

《Refactoring To Collection》

本文是翻译Adam Wathan 的《Collection To Refactoring》的试读篇章,这篇文章内容不多,但是可以为我们Laraver使用者能更好使用collection提供了可能性,非常值得看看。虽然是试读部分,但是Wathan还是很有诚意的,试读文章还是能学到东西的,但是遗憾的是,我大概搜了下,目前好像还没有中文版,为此,我决定翻译这篇文章,让英文不太好的朋友,可以学习这篇文章。
获取试读文章:https://adamwathan.me/refactoring-to-collections/#sample

高阶函数

高阶函数就是参数为可以为function,并且返回值也可为function的函数。我们举一个用高阶函数实现数据库事务的例子.代码如下:


    public function transaction($func)
   { 
    $this->beginTransaction();
    
    try { 
        $result = $func(); 
        $this->commitTransaction();
     } catch (Exception $e) {
        $this->rollbackTransaction(); throw $e; 
     }
        return $result;
    }

看下它的使用:

    try { 
        $databaseConnection->transaction(function () use ($comment) { 
            $comment->save(); 
        }); 
    } catch (Exception $e) { 
        echo "Something went wrong!"; 
    }

Noticing Patterns(注意模式)

高阶函数是非常强大的,因为我们可以通过它把其他编程模式下所不能重用的部分逻辑给抽象出来。
比方说,我们现在有顾客名单,但我们需要得到他们的邮箱地址.我们现在不用高阶函数,用一个foreach来实现它,代码如下。

    $customerEmails = [];
    
    foreach ($customers as $customer) {   
        $customerEmails[] = $customer->email; 
    }
    
    return $customerEmails;

现在我们有一批商品库存,我们想知道每种商品的总价,我们可能会这样处理:

    $stockTotals = [];
    
    foreach ($inventoryItems as $item) { 
        $stockTotals[] = [ 'product' => $item->productName, 'total_value' =>$item->quantity * $item->price, ]; 
     }
    
    return $stockTotals;

乍看之下,两个例子可能不太一样,但是把它们再抽象一下,如果你仔细观察,你会意识到其实两个例子之间只有一点是不一样的.

在这两个例子中,我们做的只是对数组中的每个元素进行相应的操作再将其赋给一个新数组.两个例子真正的不同点在于我们对数组元素的处理不一样。
在第一个例子中,我们需要'email'属性。

   # $customerEmails = [];

    #foreach ($customers as $customer) { 
       $email = $customer->email;
       #$customerEmails[] = $email; 
    #}
    
    #return $customerEmails;

在第二个例子中,我们用$item中的几个字段创建了一个新的关联数组.

   # $stockTotals = [];
    
    #foreach ($inventoryItems as $item) { 
        $stockTotal = [ 
        'product' => $item->productName, 
        'total_value' => $item->quantity * $item->price, 
        ];
       # $stockTotals[] = $stockTotal; 
    # }

   # return $stockTotals;

我们把两个例子的逻辑处理简化一下,我们可以得到如下代码:

    $results = [];
    
    foreach ($items as $item) { 
      # $result = $item->email; 
       $results[] = $result; 
    }
    
    return $results;
    $results = [];
    
    foreach ($items as $item) { 
      # $result = [ 
      #  'product' => $item->productName, 
      #  'total_value' => $item->quantity * $item->price,
      #  ]; 
      $results[] = $result;
    }
    
    return $results;

我们现在接近抽象化了,但是中间那两个代码还在防碍着我们进行下一步操作.我们需要将這两部分取出来,然后用使得两个例子保持不变的东西来代替他们.

我们要做的就是把这两个代码放到匿名函数中,每个匿名函数会将每个数组元素作为其参数,然后进行相应的处理并且将其返回.

以下是用匿名函数处理email的实例:

    $func = function ($customer) {
        return $customer->email; 
    };

    #$results = [];
    
    #foreach ($items as $item) { 
        $result = $func($item);
        #$results[] = $result; 
    #}

    #return $results;

以下用匿名函数的商品库存实例:

    $func = function ($item) { 
        return [ 
            'product' => $item->productName,
            'total_value' => $item->quantity * $item->price, 
        ]; 
     };
     
    #$results = [];
    
    #foreach ($items as $item) { 
        $result = $func($item);
         #$results[] = $result; 
    #}
    
    #return $results;

现在我们看到两个例子中有很多相同的代码我们可以提取出来重用,如果我们将其运用到自己的函数中,我们可以实现一个更高阶的函数叫map();

    function map($items, $func)
    { 
         $results = [];
         
         foreach ($items as $item) { 
             $results[] = $func($item); 
         }
         
         return $results;
    }
    
    $customerEmails = map($customers, function ($customer) {
     return $customer->email; 
     });
     
    $stockTotals = map($inventoryItems, function ($item) { 
         return [ 
             'product' => $item->productName,
              'total_value' => $item->quantity * $item->price, 
         ];
     });

Functional Building Blocks(功能构件块)

map()函数是强大的处理数组的高阶函数中的一种,之后的例子中我们会讲到这部分,但是现在让我们来深入了解下基础知识。

Each

Each只是一个foreach循环嵌套一个高阶函数罢了,如下:

    function each($items, $func) 
    { 
          foreach ($items as $item) {
              $func($item); 
          } 
    }

你或许会问你自己:"为什么会很厌烦写这个逻辑?"它隐藏了循环的详细实现(并且我们讨厌写循环逻辑).

假如PHP没有foreach循环,那each()实现方式就会变成这样:


    function each($items, $func) 
    { 
        for ($i = 0; $i < count($items); $i++) { 
             $func($items[$i]); 
        }    
    }

如果是没有foreach,那么就需要把对每个数组元素的处理进行抽象化.代码就会变成这样:

    for ($i = 0; $i < count($productsToDelete); $i++) {         
         $productsToDelete[$i]->delete(); 
    }

把它重写一下,让它变得更富有表达力.

    each($productsToDelete, function ($product) {
          $product->delete(); 
    });

一旦你上手了链式功能操作,Each()在使用foreach循环时会有明显的提升,这部份我们会在之后讲到.

在使用Each()有几件事需要注意下:

  • 如果你想获得集合中的某个元素,你不应该使用Each()

   // Bad! Use `map` instead. 
   each($customers, function ($customer) use (&$emails) { 
         $emails[] = $customer->email; 
   });
   // Good! 
   $emails = map($customers, function ($customer) { 
         return $customer->email; 
   });
  • 不像其他的数组处理函数,each不会返回任何值.由此可得,Each适合于执行一些逻辑处理,比如说像'删除商品','装货单','发送邮件',等等.

   each($orders, function ($order) { 
         $order->markAsShipped(); 
   });

MAP

我们在前文多次提到过map(),但是它是一个很重要的函数,并且需要专门的章节来介绍它.
map()通常用于将一个数组中的所有元素转移到另一个数组中.将一个数组和匿名函数作为参数,传递给map,map会对数组中的每个元素用这个匿名进行处理并且将其放到同样大小的新数组中,然后返回这个新数组.

看下map()实现代码:

    function map($items, $func) 
    { 
        $result = [];
        
        foreach ($items as $item) { 
            $result[] = $func($item); 
        }
        
       return $result;
   }

记住,新数组中的每个元素和原始数组中的元素是一一对应的关系。还有要理解map()是如何实现的,想明白:旧数组和新数组的每个元素之间存在一个映射关系就可以了.

Map对以下这些场景是非常适用的:

  • 从一个对象数组中获取一个字段 ,比如获取顾客的邮件地址.

     $emails = map($customers, function ($customer) { 
            return $customer->email; 
     });

Populating an array of objects from raw data, like mapping an array of JSON results into an array of domain objects

     $products = map($productJson, function ($productData) {
            return new Product($productData);
      });
  • 改变数组元素的格式,比如价格字段,其单位为"分",那么对其值进行格式化处理.
    (如:1001 ==> 1,001这种格式).

    $displayPrices = map($prices, function ($price) { 
             return '$' . number_format($price / 100, 2);
     });

Map vs Each

大部分人会对 "应该使用map"还是"使用each"犯难.

想下我们在前文用each做过商品删除的那个例子,你照样可以用map()去实现,并且效果是一样的.

    map($productsToDelete, function ($product) { 
         $product->delete(); 
    });

尽管代码可以运行成功,但是在语义上还是不正确的.我们不能什么都用map(),因为这段代码会导致创建一个完全没用处的,元素全为null的数组,那么这就造成了"资源浪费",这是不可取的.

Map是将一个数组转移到另一个数组中.如果你不是转移任何元素,那么你就不应该使用map.

一般来讲,如果满足以下条件你应该使用each而不是map:

  1. 你的回掉函数不会返回任何值.

  2. 你不会对map()返回的数组进行任何处理.

  3. 你只是需要每个数组的元素执行一些操作.

What's Your GitHub Score?

这儿有一份某人在Reddit分享的面试问题.
GitHub提供一个开放的API用来返回一个用户最近所有的公共活动.响应会以json个返回一个对象数组,如下:

[
    {
      "id": "3898913063",
      "type": "PushEvent",
      "public": true,
      "actor": "adamwathan",
      "repo": "tightenco/jigsaw",
      "payload": { /* ... */ }
    },
    // ...
]

你可以用你的GitHub账号,试下这个接口:

https://api.github.com/users/{your-username}/events

面试问题是:获取这些事件并且决定一个用户的"GitHubd Score",基于以下规则:

  1. 每个"PushEvent",5分.

  2. 每个"CreateEvent",4分.

  3. 每个"IssueEvent",3分.

  4. 每个'CommitCommentEvent',2分.

  5. 其他所有的事件都是1分.

Loops and Conditionals (循环和条件)

首先让我们采用用命令式编程来解决这个问题.

    function githubScore($username) 
    { 
    // Grab the events from the API, in the real world you'd probably use 
    // Guzzle or similar here, but keeping it simple for the sake of brevity. 
    $url = "https://api.github.com/users/{$username}/events"; 
    $events = json_decode(file_get_contents($url), true);
    
    // Get all of the event types 
    $eventTypes = [];
    
    foreach ($events as $event) {
        $eventTypes[] = $event['type']; 
    }
    // Loop over the event types and add up the corresponding scores 
    $score = 0;

    foreach ($eventTypes as $eventType) {
        switch ($eventType) { 
            case 'PushEvent':
                $score += 5;
                break; 
            case 'CreateEvent':
                $score += 4;
                break; 
            case 'IssuesEvent':
                $score += 3;
                break; 
            case 'CommitCommentEvent':
                $score += 2;
                break;
            default: 
                $score += 1;
                break;
       }
  }
  return $score;
}

Ok,让我们来"clean"(清理)下这块代码.

Replace Collecting Loop with Pluck(用pluck替换collection的循环)

首先,让我们把GitHub events 放到一个collection中.

    function githubScore($username) 
    { 
        $url = "https://api.github.com/users/{$username}/events";
-     $events = json_decode(file_get_contents($url), true); 
+     $events = collect(json_decode(file_get_contents($url), true));
     
     // ...
    }

Now,让我们看下第一次循环:

    #function githubScore($username) 
    #{ 
        #$url = "https://api.github.com/users/{$username}/events"; 
        #$events = collect(json_decode(file_get_contents($url), true));
    
        $eventTypes = [];
        
        foreach ($events as $event) { 
            $eventTypes[] = $event['type'];
        }
    
        #$score = 0;
        #foreach ($eventTypes as $eventType) { 
             switch ($eventType) { 
                 case 'PushEvent': 
                     $score += 5;
                      break; 
                      // ... 
             }
         }
    return $score;
}

我们知道,任何时候我们要转移一个数组的每个元素到另外一个数组,可以用map是吧?在这种情况下,"转移"是非常简单的,我们甚至可以使用pluck,所以我们把它换掉.

    #function githubScore($username) 
    #{ 
       #$url="https://api.github.com/users/{$username}/events";
       #$events = collect(json_decode(file_get_contents($url), true));
        
        $eventTypes = $events->pluck('type');
        
        #$score = 0;
        
        #foreach ($eventTypes as $eventType) { 
            #switch ($eventType) { 
                #case 'PushEvent':
                    #$score += 5; 
                   # break; 
                   # // ... 
           # }
        # }
    #return $score;
    
 #}

嗯,少了四行代码,代码更有表达力了,nice!

Extract Score Conversion with Map

那么switch这块怎么处理呢?

   # function githubScore($username) 
   # { 
   #     $url = "https://api.github.com/users/{$username}/events"; 
   #     $events = collect(json_decode(file_get_contents($url), true));
        
   #     $eventTypes = $events->pluck('type');
        
   #     $score = 0;
    
    foreach ($eventTypes as $eventType) { 
        switch ($eventType) { 
            case 'PushEvent': 
                $score += 5; 
                break; 
            case 'CreateEvent':
                 $score += 4;
                 break;
            case 'IssuesEvent':
                  $score += 3;
                  break;
            case 'CommitCommentEvent':
                   $score += 2;
                   break;
            default:
                   $score += 1;
                    break;
            }
       }
    
    return $score;
    
 }

我们现在要计算所有成绩的总和,但是我们用的是事件类型的集合(collection).

或许我们用成绩的集合去计算总成绩会更简单吗?让我们用map把事件类型转变为成绩,之后饭后该集合的总和.

    function githubScore($username) 
    { 
      
      $url ="https://api.github.com/users/{$username}/events"; 
      $events = collect(json_decode(file_get_contents($url), true));
      
      $eventTypes = $events->pluck('type');

      $scores = $eventTypes->map(function ($eventType) { 
          switch ($eventType) { 
              case 'PushEvent':
                  return 5;
              case 'CreateEvent':
                  return 4;
              case 'IssuesEvent':
                  return 3;
              case 'CommitCommentEvent':
                  return 2;
              default:
                  return 1;
            } 
       });
       
    return $scores->sum();
 }

这样看起来好一点了,但是switch这块还是让人不太舒服.再来.

Replace Switch with Lookup Table("映射表"替换switch)

如果你在开发过程中碰到类似的switch,那么你完全可以用数组构造"映射"关系.

    #function githubScore($username)
     { 
        $url = "https://api.github.com/users/{$username}/events"; 
        #$events = collect(json_decode(file_get_contents($url), true));
        
        #$eventTypes = $events->pluck('type');
        
        #$scores = $eventTypes->map(function ($eventType) { 
           $eventScores = [ 
               'PushEvent' => 5,
               'CreateEvent' => 4,
               'IssuesEvent' => 3,
               'CommitCommentEvent' => 2,
           ];
           
         return $eventScores[$eventType];
    #});
 
   # return $scores->sum();   
 #}

比起以前用switch,现在用数组找映射关系,使得代码更简洁了.但是现在有一个问题,switch的default给漏了,因此,当要使用数组找关系时,我们要判断事件类型是否在数组中.

   # function githubScore($username)
     #{ 
         // ...
        #$scores = $eventTypes->map(function ($eventType) { 
             #$eventScores = [ 
              #   'PushEvent' => 5,
              #   'CreateEvent' => 4,
              #   'IssuesEvent' => 3,
              #   'CommitCommentEvent' => 2,
             #];
             
             if (! isset($eventScores[$eventType])) { 
                 return 1; 
                }
                
              # return $eventScores[$eventType];
   # });
    
  #  return $scores->sum();
# }

额,现在看起来,好像并不比switch好到哪儿去,不用担心,希望就在前方.

Associative Collections(关联数组集合)

Everything is better as a collection, remember?

到目前为止,我们用的集合都是索引数组,但是collection也给我们提供了处理关联数组强大的api.

你以前听过"Tell, Don't Ask"原则吗?其主旨就是你要避免询问一个对象关于其自身的问题,以便对你将要处理的对象做出另一个决定.相反,相反,你应该把这个责任推到这个对象上,所以你可以告诉它需要什么,而不是问它问题.

那说到底,这个原则跟咱们例子有什么关系呢?我很happy你能这么问,ok,让我们再看下那个if判断.

   # $eventScores = [ 
    #     'PushEvent' => 5,
    #     'CreateEvent' => 4,
    #     'IssuesEvent' => 3,
    #     'CommitCommentEvent' => 2,
    #];

    if (! isset($eventScores[$eventType])) { 
        return 1;
    }
    
   # return $eventScores[$eventType];

嗯,我们现在呢就是在问这个关联数组是否存在某个值,存在会怎么样..,不存在怎么样..都有相应的处理.

Collection通过get方法让"Tell, Don't Ask"这个原则变得容易实现,get()有两个参数,第一个参数代表你要找的key,第二个参数是当找不到key时,会返回一个默认值的设置.

如果我们把$eventScores变成一个Collection,我们可以把以前的代码重构成这样:

    $eventScores = collect([ 
           'PushEvent' => 5,
           'CreateEvent' => 4,
           'IssuesEvent' => 3,
           'CommitCommentEvent' => 2,
    ]);
    
    return $eventScores->get($eventType, 1);

ok,把这部分还原到总代码中:

    function githubScore($username)
    { 
        $url = "https://api.github.com/users/{$username}/events"; 
        $events = collect(json_decode(file_get_contents($url), true));
        
        $eventTypes = $events->pluck('type');
        
        $scores = $eventTypes->map(function ($eventType) {
            return collect([ 
                'PushEvent' => 5,
                'CreateEvent' => 4,
                'IssuesEvent' => 3,
                'CommitCommentEvent' => 2,
            ])->get($eventType, 1);
    });
    return $scores->sum();

ok,我们所有处理简炼成" a single pipeline".(单一管道)

    function githubScore($username)
    { 
         $url = "https://api.github.com/users/{$username}/events";
         $events = collect(json_decode(file_get_contents($url), true));
    
    return $events->pluck('type')->map(function ($eventType) {
    return collect([ 
                  'PushEvent' => 5, 
                  'CreateEvent' => 4,
                  'IssuesEvent' => 3,
                   'CommitCommentEvent' => 2,
             ])->get($eventType, 1); 
         })->sum();
    }

Extracting Helper Functions(提取帮助函数)

有的时候,map()函数体内容会占很多行,比如上例中通过事件找成绩这块逻辑.

虽然到现在为止,我们谈的也比较少,这只是因为我们使用Collection PipeLine(集合管道)但是并不意味这我们不用其他编程技巧,比如我们可以把一些小逻辑写道函数中封装起来.

比如,在本例中,我想把API调用和事件成绩查询放到独立的函数中,代码如下:

    function githubScore($username) 
    { 
        return fetchEvents($username)->pluck('type')->map(function ($eventType) { 
        return lookupEventScore($eventType); 
        })->sum(); 
    }
    
    function fetchEvents($username) { 
         $url = "https://api.github.com/users/{$username}/events"; 
         return collect(json_decode(file_get_contents($url), true)); 
    }
    
    function lookupEventScore($eventType) {
       
        return collect([ 
                 'PushEvent' => 5,
                 'CreateEvent' => 4,
                 'IssuesEvent' => 3,
                 'CommitCommentEvent' => 2,
        ])->get($eventType, 1); 
   }

Encapsulating in a Class (封装到一个类)

现代PHPweb应用要获取某人GitHub成绩的典型做法是什么呢?我们肯定不是用一个全局函数来回互相调,对吧? 我们一般会定义一个带有namespace的类,方法的"封装型"自己定,

    class GitHubScore 
    { 
        public static function forUser($username) { 
            return self::fetchEvents($username) 
            ->pluck('type') 
            ->map(function ($eventType) { 
            return self::lookupScore($eventType); })->sum();
         }
         
         
       private static function fetchEvents($username) { 
            $url = "https://api.github.com/users/{$this->username}/events"; 
            return collect(json_decode(file_get_contents($url), true)); 
       }
         
       private static function lookupScore($eventType) { 
           return collect([ 
                     'PushEvent' => 5,
                     'CreateEvent' => 4,
                     'IssuesEvent' => 3,
                     'CommitCommentEvent' => 2,
            ])->get($eventType, 1);
    
     }

有了这个类,GitHubScore::forUser('adamwathan') 即可获得成绩.
这种方法的一个问题是,由于我们不使用实际的对象,我们无法跟踪任何状态。 相反,你最终在一些地方传递相同的参数,因为你真的没有任何地方可以存储该数据

这个例子现在看起来没什么问题,但是你可以看到我们必须传$username给fetchEvents()否则它不知道要获取的是那个用户的huod信息.

    class GitHubScore { 
           public static function forUser($username) 
           { 
                return self::fetchEvents($username)
                    ->pluck('type') 
                    ->map(function ($eventType) { 
                    return self::lookupScore($event['type']); })
                     ->sum(); 
            }
            
            
           private static function fetchEvents($username) 
           { 
                $url = "https://api.github.com/users/{$this->username}/events"; 
                
                return collect(json_decode(file_get_contents($url), true)); }
                // ...
    }

This can get ugly pretty fast when you've extracted a handful of small methods that need access to the same data.

像本例这种情况,我一般会创建一个私有属性.
代替掉类中的静态方法,我在第一个静态方法中创建了一个实例,委派所有的任务给这个实例.

    class GitHubScore 
    { 
        private $username;

        private function __construct($username) 
        { 
            $this->username = $username; 
        }
        
        public static function forUser($username) 
        { 
            return (new self($username))->score(); 
        }
        
        private function score() 
        { 
            $this->events()
            ->pluck('type')
            ->map(function ($eventType) { 
            return $this->lookupScore($eventType);
             })->sum(); 
         }
         
        private function events() 
        { 
            $url = "https://api.github.com/users/{$this->username}/events"; 
           return collect(json_decode(file_get_contents($url), true)); 
         }
           
        private function lookupScore($eventType) 
        { 
           return collect([ 
                'PushEvent' => 5,
                'CreateEvent' => 4,
                'IssuesEvent' => 3,
                'CommitCommentEvent' => 2,
            ])->get($eventType, 1); 
         }
    }
      

现在你得到了方便的静态API,但是其内部使用的对象是有它的状态信息.可以使你的方法署名可以更简短,非常灵巧!

额,真不容易,从晚上9点干到凌晨3:30,虽然辛苦,但是又巩固了一遍,还是值得的.2017/04/16 03:34

由于时间有限,未能复查,翻译的不周到的地方,麻烦你留言指出,我再改正,谢谢!


lcoding
317 声望40 粉丝

The quality of code is everything...