Refactoring to collection(譯)

lcoding發表於2019-02-16

《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

由於時間有限,未能複查,翻譯的不周到的地方,麻煩你留言指出,我再改正,謝謝!

相關文章