2016年9月9日 星期五

Refactoring to Collections 讀後心得

DRY - Don't repeat yourself

寫程式時常需要用一些暫時性的變數來將暫存資料,例如下面的 $tmp,其實最終我們需要的是 $order_data,$tmp 只是一個過度的變數:
$order_data = [];
$tmp = $this->order_model->get_all_order_today();
foreach ($tmp as $key => $row)
{
    if ('1' == $row['PAY'])
    {
        $order_data[] = $row['ORDER_NO'];
    }
}
根據這篇文章 Refactoring Loops and Conditionals,作者 Adam Wathan 示範了在多種情境下,如何將程式中的 loop 都使用 Laravel 的 collection 取代。
寫程式很大的目的是要減少做重複的事。上述的範例其實充斥在手邊的 legacy code 中,例如這兩段程式雖然目的不同,但 pattern 都一樣,都是要 foreach 資料來源,然後整理出需要的內容並回傳:
function get_customer_emails($customers)
{
    $customer_emails = [];

    foreach ($customers as $customer)
    {
       $customer_emails[] = $customer['email'];
    }

    return $customer_emails;
}

function get_stock_item($inventory_items)
{
    $stock_totals = [];

    foreach ($inventory_items as $item)
    {
       $stock_totals[] = [
            'product'     => $item['product_name'],
            'total_price' => $item['quantity'] * $item['price'],
       ];
    }

    return $customer_emails;
}
可以將這樣的 pattern 抽出成 function 以 reuse:
function map($input, $func)
{
    $result = [];

    foreach ($input as $each)
    {
        $result[] = $func($each);
    }

    return $result;
}

map($customers, function($customer) {
    return $customer['email'];
});

map($inventory_items, function($item) {
    return [
            'product'     => $item['product_name'],
            'total_price' => $item['quantity'] * $item['price'],
       ];
});
又例如資料庫操作常需要用到 db transaction,那乾脆再把包 transaction 這件事抽出好讓許多地方能共用:
private function _do_transaction($func)
{
    $this->db->trans_begin();

    $result = $func();

    if (TRUE !== $result)
    {
        $this->db->trans_rollback();
    }
    else
    {
        $this->db->trans_commit();
    }
}

public function something(array $input)
{
    $this->_do_transaction(function() use ($input) {
        $sql = 'UPDATE TEST_TABLE SET STATUS = 1 WHERE ID = :ID';
        $bind[':ID'] = $input['ID'];

        $this->db->query($sql, $bind);
    });
}

Tell, Don't Ask

呼叫端的程式不需要知道 how,只要 tell what we want

在 Laravel 外使用 Laravel Collections

使用 composer 單獨安裝 collect (不需依賴 Laravel)
官方教學範例 Laravel Collections

使用 Collection 的優點有

  1. 省略許多暫時性的變數
  2. 用有語意的 method 取代迴圈行為
  3. 用有語意的 method 取代判斷式 ( if -> filter )
  4. 將原本function 又包 function 的程式改為有步驟,並且段落分明
  5. 不再一次處理一大堆事情,而是將問題拆為多個小問題解決
  6. Pipeline solution. 每個操作都是完全獨立的
As your code gets more complex, splitting things up like this starts to pay oL in dividends because debugging a sequence of simple, independent operations turns out to be much easier than debugging a single complex operation.

使用 Collection 的大原則

不要在 Collection 外面使用 foreach

Collection method 介紹

  • 建立方式有三種
    $collection = new Collection($items);
    $collection = Collection::make($items);
    $collection = collect($items);
    
  • each 不會回傳結果,它應該被使用在執行某些命令,例如 deleting_product() 或 sending_email() 等等
    each(function($item, $key) {
        //
    });
    
  • map 用來抽取/整理 array,將每個 element 做了某個轉換後,都存回到一個新的 array 中 (故數量會和原本一樣)
    map(function($item, $key) {
        //
    });
    
    當有以下情境時,應該使用 each 而非 map
    1. callback 不會回傳任何東西
    2. 不會拿 map 回傳的內容做任何操作
    3. 需求只是要把 array 內的每個元素都去執行每個行為 Need to loop over an array to perform some operation on each item and stuL the result into another array? You don't need to loop, you need to map.
  • filter 用來過濾 array,只留下符合條件 (判斷式為 true) 的內容
    和 map 的差異是,我們可能會拿 products map 出 price,但 products 只會 filter 出 products
    Need to loop over an array to strip out the items that don't match some criteria?
    You don't need to loop, you need to 4lter.
  • reduce 將 array 中的每個元素進行累加或串接,直到原 array 最終減少為一個數值、字串或陣列。故 reduce() 運算後的結果不是 array 或 collection 
    The reduce operation is used to take some array of items and reduce it down to a single value. It has no opinion about what that single value should be; it could be a number, a string, an object, whatever you want, it doesn't matter.
    另 reduce() 也可用在將資料整理為 key value 的形式
    $employees = [
        [
            'name'       => 'John',
            'department' => 'Sales',
            'email'      => 'john@example.com'
        ],
        [
            'name'       => 'Jane',
            'department' => 'Marketing',
            'email'      => 'jane@example.com'
        ],
        [
            'name'       => 'Dave',
            'department' => 'Marketing',
            'email'      => 'dave@example.com'
        ],
    ];
    
    $emailLookup = $collect->reduce(function($carry, $item) {
        $lookup[$item['email']] = $item['account'];
        return $lookup;
    }, []);
    
    // 輸出結果
    $emailLookup = [
        'john@example.com' => 'John',
        'jane@example.com' => 'Jane',
        'dave@example.com' => 'Dave',
    ];
    
    不過 reduce() 就語意上比較不直覺,寫一個長長的 reduce() 倒不如以多段 Collect method 達成目的來的好理解
  • flattne() flatMap()
    Get a flattened array of the items in the collection.
    若資料的結構為多層的 array 常會需要做多次迴圈才能整理出需要的內容,這時使用 map()+flattne(1) 等同於 flatMap()
    flattne(?) 的參數可以決定要取到第幾層的 array 資料
    flatMap(function($item) {
        return $item['data'];
    });
    
    map(function($item) {
        return $item['data'];
    })
    ->flatten(1);
    
  • collapse()
    Collapse the collection of items into a single array.
    和 flatten(1) 作用類似 (還沒搞清楚)
  • pluck()
    相當於 array_column(),有時可以少做一次 map() 直接垂直選取該 column
    $emails = $users->map(function ($user) {
        return $user['email'];
    });
    
    // 可以用 pluck()
    
    $emails = $users->pluck('email');
    
  • sum()
    也可傳入 field_name 當做參數,只對該欄位做 sum
  • last()
    相當於 end() 取得 array 內最後一個元素,last() 語意上比較清楚
  • reverse()
    將陣列內的元素順序反轉
  • values()
    將陣列的 key 由 0 開始重新依序編號
  • get($key, $default_value)
    可以取得 key 為 $key 的值,若找不到則會回 default
    $collect->get($key, 'default');
    
  • contains()
    基本上就是 in_array()
    collect($receiver_mails)->contains($email);     // true
    
但參數除了查找的目標以外也可傳 closure 進去。contains 會將所有 collect 的值都丟給 closure 做檢查,只要一個通過就 return true,因此可以拿來運用在多維陣列的查找
(此處 closure 的 key value 順序是反的,Laravel 5.3 後有調整 continas() 和 first())
When we pass a closure, we're saying "check if the collection contains any items where this expression is true." The closure gets called for every item in the collection, and takes the item key as its Mrst parameter and the item value as its second parameter.
// 範例 二維 $messages 做迴圈內對 recipients 做 in_array() 檢查
collect($messages)->contains(function($key, $item) {
    return collect($item['recipients'])->contains('mary@example.co');
})

// 範例 舊
foreach ($this->checkers as $checker) {
    if ($checker->canCheck($file)) {
        return true;
    }
}

// 範例 新 => 第一個 contains() 若傳入 closure 相當於直接對外層做了 foreach
// 只能用在 closure 內一定要回傳 boolean
$this->checkers->contains(function ($i, $checker) use ($file)) {
    return $checker->canCheck($file);
}
  • first()
    基本用法為回傳陣列中的第一個元素。但實際上的定義應該是 first where當傳入 closure 時,則會回傳第一個符合 closure 為 true 的條件,若無符合則會回傳 null,除非有給第二個參數 default value
    $names = collect(['Adam', 'Tracy', 'Ben', 'Beatrice', 'Kyle']);
    $names->first(function ($i, $name) {
        return $name[0] == 'B';
    });
    // => 'Ben'
    
    $names = collect(['Adam', 'Tracy', 'Kyle']);
    $names->first(function ($i, $name) {
        return $name[0] == 'B';
    }, 'Bryan');
    // => 'Bryan'
    
  • zip() 用適合來對兩個結構一樣的 array 做比對
    collect([1, 2, 3])->zip(['a', 'b', 'c']);
    // [
    //  [1, 'a'],
    //  [2, 'b'],
    //  [c, 'c'],
    // ];
    
  • only()
    回傳指定 key 的陣列,官方範例若要指定多個 key 參數應包成 [] 傳入,但程式內使用了
    $keys = is_array($keys) ? $keys : func_get_args();
    
    所以寫成多個參數也可以照常運作
    collect($request)->only('names', 'emails');
    
  • except() 與 only() 相反
  • sortByDesc()
    輸入 key 對 value 做排序
    collect([
        ['score' => 76, 'team' => 'A'],
        ['score' => 62, 'team' => 'B'],
        ['score' => 82, 'team' => 'C'],
        ['score' => 86, 'team' => 'D'],
        ['score' => 91, 'team' => 'E'],
        ['score' => 67, 'team' => 'F'],
        ['score' => 67, 'team' => 'G'],
        ['score' => 82, 'team' => 'H'],
    ])
    ->sortByDesc('score')
    ->dd();
    
    也可傳入 closure 針對回傳值做排序
    $collect->sortByDESC(function($item) {
        return (2 * (int)$item['team']) - 1;
    });
    
  • groupBy() 可使用關連陣列的 key 或 closure 當做參數傳入,並依據 key 或 closure 的回傳值做 group by
    $names = collect(['Adam', 'Bryan', 'Jane', 'Dan', 'Kayla']);
    $names->groupBy(function ($name) {
        return strlen($name);
    })
    ->dd();
    

Tricks

  • 當使用多個靜態方法時可能會需要將參數不斷傳遞來給其他方法使用,由於不是建立一個物件,所以變數沒有辦法暫存於方法之間,此時可參考在入口 method 中自行建立 pirvate instance
    // forUser() 將收到的參數傳入 private instance 給 construct 使用,就不用再傳給 score() 了
    class GitHubScore
    {
        private $username;
        private function __construct($username)
        {
            $this->username = $username;
        }
        public static function forUser($username)
        {
            return (new self($username))->score();
        }
    
        // ....
    }
    
    GitHubScore::forUser('username');
    
  • 不一定都要先有 array 再轉為 collection,也可以讓變數一開始就是 collection,之後的程式 $this->messages 可以像一般 array 操作,當然也可以用 collection method
    public function __construct()
    {
        $this->messages = new Collection;
    }
    
  • 當 collection method 的 default value 參數給的是 closure 時則會被執行,可用來 throw exception
    // 這樣比寫判斷式接著拋例外來的漂亮
    return $this->checkers->first(function ($i, $checker) use ( $file) {
        return $checker->canCheck($file);
    }, function () {
        throw new Exception("No matching style checker found!");
    });
    
  • Null Object,讓他來幫你做 else {} 該做的事
    如果流程中 method 會 new object 並回傳,但有例外狀況時要特別寫判斷又有點醜,可以考慮回傳一個具有"不做事" method 的 object。"不做事" method 依情況做不同的事,基本上就是回傳當下應該有的 default value。可能是 0 可能是 []。
    例如:讓 getObject() 即使查無資料也回傳一個具有 checkt() methdo 的 default 物件好讓流程能繼續順完
    $this->getObject($input)->check();
    
  • Macroable
    Collection 使用了一個 Laravel support 的套件,其中有一個 Macroable trait 可以讓程式在執行時對 Collection class 增加 method
    Collection::macro('dd', function($input) {
        var_dump($input);
        die();
    });
    
    $collect->dd();
    
  • pipe()
    擴充 collection 以便自由串接各個自定義的 function,而不需使用到暫時性的變數
    Collection::macro('pipe', function ($callback) {
        return $callback($this);
    });
    
    例如 Collection method 已經寫的很長,其中又有稍微複雜的邏輯,也許將部分包裝為另一個較有語意的 method 會較好理解:
    複雜版做了排序、比對、整理、分組、整理、排序,但第一次看此段程式架構很難一眼看出目的要做什麼
    function rank_scores($scores)
    {
        return collect($scores)
            ->sortByDesc('score')
            ->zip(range(1, $scores->count()))
            ->map(function ($scoreAndRank) {
                list($score, $rank) = $scoreAndRank;
                return array_merge($score, [
                    'rank' => $rank
                ]);
            })
            ->groupBy('score')
            ->map(function ($tiedScores) {
                $lowestRank = $tiedScores->pluck('rank')->min();
                return $tiedScores->map(function ($rankedScore) use ($lowestRank) {
                    return array_merge($rankedScore, [
                        'rank' => $lowestRank
                    ]);
                });
            })
        ->collapse()
        ->sortBy('rank');
    }
    
    將複雜的東西包裝為 function 取有意義的名字,則每段邏輯相對簡單
    // 主流程
    function rank_scores($scores)
    {
        $rankedScores = assign_initial_rankings(collect($scores));
        $adjustedScores = adjust_rankings_for_ties($rankedScores);
        return $adjustedScores->sortBy('rank');
    }
    
    function assign_initial_rankings($scores)
    {
        return $scores->sortByDesc('score')
            ->zip(range(1, $scores->count()))
            ->map(function ($scoreAndRank) {
                list($score, $rank) = $scoreAndRank;
                return array_merge($score, [
                    'rank' => $rank
                ]);
            });
    }
    
    function adjust_rankings_for_ties($scores)
    {
        return $scores->groupBy('score')
            ->map(function ($tiedScores) {
                return apply_min_rank($tiedScores);
            })
            ->collapse();
    }
    
    function apply_min_rank($tiedScores)
    {
        $lowestRank = $tiedScores->pluck('rank')->min();
    
        return $tiedScores->map(function ($rankedScore) use ($lowestRank) {
            return array_merge($rankedScore, [
                'rank' => $lowestRank
            ]);
        });
    }
    
    但主流程又出現暫時性的變數,此時便可導入 pipe()
    Collection::macro('pipe', function ($callback) {
        return $callback($this);
    });
    
    // 主流程
    function rank_scores($scores)
    {
        return collect($scores)->pipe(function($scores) {
                    return assign_initial_rankings(collect($scores));
                })
                ->pipe(function($rankedScores) {
                    return adjust_rankings_for_ties($rankedScores);
                })
                ->sortBy('rank');
    }
    
    根據後面提到的 PHP 特性,還可以更簡化
    function rank_scores($scores)
    {
        return collect($scores)->pipe('assign_initial_rankings')
                               ->pipe('adjust_rankings_for_ties')
                               ->sortBy('rank');
    }
    
  • PHP lets you treat a string as a callback if it matches the name of a function
    若將字串傳入到應為 callback 的參數中,PHP 會嘗試先去找看看有沒有符合這個字串的 function,有的話便會執行
    function callme()
    {
        echo 'called' . PHP_EOL;
    }
    
    callme();
    // 'called'
    
    function doing(Callable $func)
    {
        $func();
    }
    
    doing(function() {
        echo 'whatever you want' . PHP_EOL;
    });
    // 'whatever you want'
    
    doing('callme');
    // 'called'
    

Question

  • 何時適合使用 static method
  • new self 和 new static 的差別

from : http://guitarbien.logdown.com/posts/737899

沒有留言:

wibiya widget