[Collection] Transforming Data, Introducing Collections

….

Trở về ví dụ đầu tiên, lấy danh sách email của users:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getUserEmails($users)
{
$emails = [];

for ($i = 0; $i < count($users); $i++) {
$user = $users[$i];

if ($user->email !== null) {
$emails[] = $user->email;
}
}

return $emails;
}

Hay nhìn như kiểu này
1
SELECT email FROM users WHERE email IS NOT NULL

Đối với những người mới bắt đầu, lựa chọn foreach dễ dàng hơn

1
2
3
4
5
6
7
8
9
10
11
12
function getUserEmails($users)
{
$emails = [];

foreach ($users as $user) {
if ($user->email !== null) {
$emails[] = $user->email;
}
}

return $emails;
}

Liệu có thể dùng map? Nếu không kiểm tra null trong email, việc dùng map là phù hợp.

Đặc điểm của map là áp dụng chuyển đổi mỗi phần tử của mảng, bởi vậy nó cũng trả về 1 mảng với size giống với mảng gốc. Hàm getUserEmails chỉ có nghĩa là trả về tập con (subset) của items, những items có trường $email khác null.

Khi tạo tập con, chúng ta sẽ sử dụng filter, nhưng filter cũng không đúng ở đây.

Filter có nghĩa là trả cho bạn tất cả các phần tử thỏa mãn 1 vài điều kiện, trong trường hợp này là email không null. Vấn đề ở đây là filter là chúng trả về cho chúng ta users có email chứ không phải là địa chỉ email của họ.

Thinking in Steps

Vấn đề chúng đề đang phải đối mặt là chúng ta đang cố gắng làm quá nhiều thứ cùng 1 lúc. Khi bạn đang ở tình trạng này, thứ đầu tiên bạn làm là cố gắng cho “I can’t because …” vào “Nếu tôi có thể …” (I could if …).

Ví dụ:

Tôi không thể map vì nó sẽ được áp dụng cho mỗi users, không chỉ với users có email.


Trở thành:

Tôi có thể sử dụng map nếu tôi chỉ làm việc với những users có email.


Well, lấy những users với email nghe thôi đã thấy ta có thể sử dụng với filter
1
2
3
$usersWithEmails = filter($users, function ($user) {
return $user->email !== null;
});

Và giờ khi đã chúng ta chỉ làm việc với users có email rồi, chúng ta có thể xóa điều kiện ra khỏi lòng lặp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getUserEmails($users)
{
$usersWithEmails = filter($users, function ($user) {
return $user->email !== null;
});

$emails = [];

foreach ($usersWithEmails as $user) {
$emails[] = $user->email;
}

return $emails;
}

Rồi nha, một khi điều kiện đã không còn, chúng ta sẽ thay thế nó với map
1
2
3
4
5
6
7
8
9
10
11
12
function getUserEmails($users)
{
$usersWithEmails = filter($users, function ($user) {
return $user->email !== null;
});

$emails = map($usersWithEmails, function ($user) {
return $user->email;
});

return $emails;
}

Chúng ta đã giải quyết xong vấn đề bằng cách tách biệt vào 1 toán tử riêng biệt, mỗi cái phụ trách 1 phần rõ ràng

Khi code của bạn trở nên phức tạp hơn, việc tách biệt chia nhỏ mọi thứ như thế này bắt đầu hiệu quả bởi vì khi debug sẽ debug được các hoạt động độc lập, đơn giản hơn nhiều khi gỡ lỗi với các toán tử phức tạp

The Problem with Primitives (Vấn đề với kiểu dữ liệu nguyên thủy)

Mặc dù nó có thể trông chưa quen lắm nhưng những gì chúng ta có bây giờ đơn giản hơn theo một số cách:

  1. Chúng ta đã loại bỏ câu lệnh if bằng cách sử dụng filter để loại bỏ những người dùng không có địa chỉ email.
  2. Chúng ta đã loại bỏ biến thu thập $email bằng cách sử dụng map để chuyển đổi mảng người dùng thành một mảng email.
  3. Chúng ta đã loại bỏ vòng lặp, chọn coi nó như một chi tiết triển khai của filtermap.

Cái cuối cùng chúng ta cần cải thiện nữa loại bỏ đi các biến trung gian $usersWithEmails$email

Loại bỏ $email thì rất đơn giản, chúng ta chỉ cần trả về trực tiếp:

1
2
3
4
5
6
7
8
9
10
function getUserEmails($users)
{
$usersWithEmails = filter($users, function ($user) {
return $user->email !== null;
});

return map($usersWithEmails, function ($user) {
return $user->email;
});
}

Nhưng đoạn code bắt đầu khó hiểu 1 chút nếu bạn sử dụng biến $usersWithEmail cùng dòng như vậy:
1
2
3
4
5
6
7
8
function getUserEmails($users)
{
return map(filter($users, function ($user) {
return $user->email !== null;
}), function ($user) {
return $user->email;
});
}

What? Nó không dễ để đọc. Nó nhìn thậm chí còn tồi tệ hơn nếu bạn sử dụng các hàm build-in array của PHP vì chúng có thứ tự tham số không trực quan:
1
2
3
4
5
6
7
8
function getUserEmails($users)
{
return array_map(function ($user) {
return $user->email;
}, array_filter($users, function ($user) {
return $user->email !== null;
}));
}

Lý do code khó hiểu là vì nó phải được đọc “inside-out” (đọc từ trong ra ngoài)

Vấn đề giống nhau gặp phải phát sinh khi làm việc với string trong PHP. Ví dụ bạn có đoạn code chuyển đổi từ snake_case sang camelCase:

1
2
3
4
5
$camelString = lcfirst(
str_replace(' ', '',
ucwords(str_replace('_', ' ', $snakeString))
)
);

Mất một lúc để load não. Vì stringarray là kiểu dữ liệu nguyên thủy, chúng ta phải thực hiện toán tử với nó từ bên ngoài bằng cách truyền chúng dưới dạng tham số vào các hàm khác (kiểu phải thực hiện ở đâu đó rồi mới truyền vào được chứ không thể thực hiện trong hàm chính luôn ý). Đó là những gì dẫn đến “inside-out”, nơi bạn cần đếm các bước liên kết để xem chuyện gì xảy ra đầu tiên với biến của mình.

Thay thế chỗ inside-out code kia bằng ví dụ sau đây?

1
2
3
4
$camelString = $snakeString->replace('_', ' ')
->ucwords()
->replace(' ', '')
->lcfirst();

Có phải dễ hiểu hơn rất nhiều đúng ko?

Điểm khác biệt ở đây là chúng ta xem $snakeString như là 1 object thay vì giá trị kiểu nguyên thủy. Bằng cách gọi các phương thức trực tiếp thay vì truyền nó thông qua các tham số, bạn chỉ cần đọc code từ trái qua phải với các toán tử xuất hiện đã được sắp xếp

Arrays as Objects

Hãy tưởng tượng trong giây lát, cách chúng ta biến array thành Object trong hàm getUserEmails function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getUserEmails($users)
{
- $usersWithEmails = filter($users, function ($user) {
+ $usersWithEmails = $users->filter(function ($user) {
return $user->email !== null;
});

- $emails = map($usersWithEmails, function ($user) {
+ $emails = $usersWithEmails->map(function ($user) {
return $user->email;
});

return $emails;
}

Sự khác biệt lúc này là $usersWithEmails outside trong lời gọi map của chúng ta thay về inside. Bây giờ chúng ta chúng ta có thể inline nó, filter trước khi map, code của chúng ta được đọc từ trái qua phải mà không cần inside.
1
2
3
4
5
6
7
8
function getUserEmails($users)
{
return $users->filter(function ($user) {
return $user->email !== null;
})->map(function ($user) {
return $user->email;
});
}

Bạn có thấy thú vị không? Kiểu lập trình này được gọi chung là collection pipeline và chúng ta hoàn toàn có thể làm điều này trong PHP.

Hãy để code đọc theo Collection Pipeline thay vì inside-out

Giới thiệu về Collection

A collection is an object that bundles up an array and lets us perform array operations by calling methods on the collection instead of passing the array into functions.

Một collection là 1 object “bó” lại 1 mảng và cho phép chúng ta thực hiện các toán tử được gọi là các methods với collection thay vì truyền mảng vào mảng.

Đây là Collection class đơn giản chỉ hỗ trợ mapfilter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Collection
{
protected $items;

public function __construct($items)
{
$this->items = $items;
}

public function map($callback)
{
return new static(array_map($callback, $this->items));
}

public function filter($callback)
{
return new static(array_filter($this->items, $callback));
}

public function toArray()
{
return $this->items;
}
}

Để lấy dánh sách email của $user theo ví dụ trên chúng ta có thể dùng Collection:
1
2
3
4
5
6
7
8
function getUserEmails($users)
{
return (new Collection($users))->filter(function ($user) {
return $user->email !== null;
})->map(function ($user) {
return $user->email;
})->toArray();
}

Phương thức liên kết sau hàm tạo có thể nhìn có 1 chút lộn xộn vì vậy tôi sẽ thường xuyên tạo hàm tạo có tên để rõ ràng mọi thứ:
1
2
3
4
public static function make($items)
{
return new static($items);
}

Sử dụng hàm tạo có tên giúp chúng ta tiết kiệm được các dấu ngoặc đơn và nhìn gọn hơn
1
2
3
4
5
6
7
8
9
function getUserEmails($users)
{
- return (new Collection($users))->filter(function ($user) {
+ return Collection::make($users)->filter(function ($user) {
return $user->email !== null;
})->map(function ($user) {
return $user->email;
})->toArray();
}

Một chú ý về khả năng biến đổi

Bạn phải chú ý rằng trong các ví dụ tính đến thời điểm hiện tại, chúng ta áp dụng các toán tử với mảng và chúng ta luôn trả về mảng mới, chúng ta thực sự không thay đổi gì trong mảng gốc.

Điều này rõ ràng với các thực thi Collection ở dưới khi mà ta trả về new static với cả mapfilter.

So sánh điều đó với các thực hiện này, thay vì trả về new collection, chúng ta sẽ trả về thuộc tinh $item thay thế:

1
2
3
4
5
6
public function map($callback)
{
- return new static(array_map($callback, $this->items));
+ $this->items = array_map($callback, $this->items);
+ return $this;
}

Điều này có vẻ không phải là một sự khác biệt lớn, nhưng nó có thể gây ra các lỗi làm tan chảy não, ma quái hành động ở khoảng cách sẽ loại bỏ tất cả “niềm vui” ra khỏi lập trình.
=> Điều này có thể hack não và gây bug đó :D

Code sẽ như sau:

1
2
3
4
5
6
7
8
9
10
11
12
13
$employees = new Collection([
['name' => 'Mary', 'email' => 'mary@example.com', 'salaried' => true],
['name' => 'John', 'email' => 'john@example.com', 'salaried' => false],
['name' => 'Kelly', 'email' => 'kelly@example.com', 'salaried' => true],
]);

$employeeEmails = $employees->map(function ($employee) {
return $employee['email'];
});

$salariedEmployees = $employees->filter(function ($employee) {
return $employee['salaried'];
});

Bạn có thể phát hiện ra bug?

Bạn có thấy map giúp chúng tôi nhận được $employeeEmails không? Nếu chúng ta chỉ thay thế thuộc tính $items thay vì trả về một collection mới, thì biến $employees đó thực sự trở thành một collection các email ngay khi map hoàn tất. Vì vậy, khi chúng tôi cố gắng filter danh sách nhân viên, thực tế là chúng tôi đang filter danh sách email. Rất tiếc!

Vì vậy, đừng làm điều này.

Quacking Like… an Array?

Phần này nói về các interface để tương tác với Collection như 1 mảng nhé.

Collection đơn giản cho đến giờ khá gọn, nhưng hơi khó chịu 1 chút khi phải liên tục chuyển đổi dữ liệu qua lại giữa collection và array.

Thật sự tuyệt vời nếu chúng ta có thể xây dựng collection, cái mà chúng ta có thể sử dụng thay cho mảng mà không cần hệ thống nhận thấy, và rất mắn, PHP được được điều đó (hầu hếu, đa số) thông qua 1 số interfaces sau.

ArrayAccess

Một trong những chức năng đặc biệt của mảng là bạn có thể lấy phần tử bằng cách xác định offset thông qua dấu ngoặc vuông []

1
2
3
4
$items = [1, 2, 3];

echo $items[2];
// => 3

Nếu bạn thử làm nó với Collection, nó sẽ lỗi.
1
2
3
4
$items = Collection::make([1, 2, 3]);

echo $items[2];
// => Fatal error: Cannot use object of type Collection as array!

Chúng ta có thể thêm hỗ trợ cho ký hiệu dấu ngoặc vuông vào collection của mình bằng cách triển khai interface ArrayAccess, bao gồm 4 phương thức:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
interface ArrayAccess
{
// Allow the collection to respond to `isset($items['key'])` checks
abstract public function offsetExists($offset);

// Allow retrieving an item from the collection using `$items['key']`
abstract public function offsetGet($offset);

// Allow adding an item to the end of the collection using `$items[] =
// $foo` as well as at a specific key using `$items['key'] = $foo`
abstract public function offsetSet($offset, $value);

// Allow removing an item from the collection using `unset($items['key'])
abstract public function offsetUnset($offset);
}

Để thêm các phương thức này vào Collection chúng ta chỉ cần ủy quyền các lời gọi đến thuộc tính $items như sau:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Collection implements ArrayAccess
{
protected $items;

public function __construct($items)
{
$this->items = $items;
}

public function offsetExists($offset)
{
return array_key_exists($offset, $this->items);
}

public function offsetGet($offset)
{
return $this->items[$offset];
}

public function offsetSet($offset, $value)
{
if ($offset === null) {
$this->items[] = $value;
} else {
$this->items[$offset] = $value;
}
}

public function offsetUnset($offset)
{
unset($this->items[$offset]);
}
// ...
}

Bây giờ chúng ta có thể làm việc với các offset trong collection của mình một cách chính xác như thể collection của chúng ta là một mảng ^_^:
1
2
3
4
5
6
7
8
9
10
11
12
13
$items = Collection::make([1, 2, 3]);

echo $items[2];
// => 3

$items[] = 4;
// => [1, 2, 3, 4]

isset($items[3]);
// => true

unset($items[0]);
// => [2, 3, 4]

Countable

Interface Countable cho phép một đối tượng được chuyển tới hàm count tích hợp của PHP. Không hoàn toàn thú vị như những gì chúng tôi có thể làm với ArrayAccess, nhưng tôi hơi lạc đề.

Đây là interface:

1
2
3
4
interface Countable
{
abstract public function count();
}

Đây là đoạn thực thi:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Collection implements ArrayAccess, Countable
{
protected $items;

public function __construct($items)
{
$this->items = $items;
}

public function count()
{
return count($this->items);
}
// ...
}

Bây giờ chúng ta có thể nhận được kích thước của collection giống như một mảng thông thường, điều này giúp chúng ta đạt được hiệu ứng đa hình đẹp mắt mà chúng ta đang tìm kiếm:
1
2
3
4
$items = Collection::make([1, 2, 3]);

count($items);
// => 3

Là một lợi ích phụ, giờ đây chúng ta có một phương thức count() mà chúng ta có thể xâu chuỗi với các toán tử khác.

Ví dụ, chúng ta có thể kết hợp filter để tính số lượng nhân viên được trả lương:

1
2
3
4
5
6
7
8
9
$employees = new Collection([
['name' => 'Mary', 'email' => 'mary@example.com', 'salaried' => true],
['name' => 'John', 'email' => 'john@example.com', 'salaried' => false],
['name' => 'Kelly', 'email' => 'kelly@example.com', 'salaried' => true],
]);

$numberOfSalariedEmployees = $employees->filter(function ($employee) {
return $employee['salaried'];
})->count();

Với phiên bản sử dụng function bạn có thể lưu collection như một mảng thông thường nhưng nhìn có vẻ hơn ngốc nghếch:

1
2
3
4
// Gross!
$numberOfSalariedEmployees = count($employees->filter(function ($employee) {
return $employee['salaried'];
}));

IteratorAggregate

Có một điều khác mà chúng ta có thể làm với một mảng thông thường mà chúng ta vẫn không thể làm với collection của mình, đó là duyệt phần tử bằng vòng lặp foreach.

Bạn có thể không biết điều này, nhưng bạn thực sự có thể sử dụng foreach để lặp lại các thuộc tính chung của một đối tượng mà không cần lập trình thêm:

1
2
3
4
5
6
7
8
9
10
11
12
class Foo {
public $bar = 'baz';
public $qux = 'norf';
}

$foo = new Foo;

foreach ($foo as $property => $value) {
echo $property . ' -> ' . $value;
}
// => bar -> baz
// => qux -> norf

Nếu bạn chưa biết điều này thì có thể là do việc duyệt các thuộc tính công khai của một đối tượng không thực sự hữu ích cho lắm.

Điều hữu ích là nếu chúng ta có thể yêu cầu foreach duyệt thuộc tính $items của mình và interface IteratorAggregate cho phép chúng ta làm điều đó.

Để triển khai IteratorAggregate, chúng ta cần thêm phương thức getIterator vào collection để trả về Traversable.

Cách dễ nhất để làm điều đó là trả về thuộc tính $items được bọc trong ArrayIterator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Collection implements ArrayAccess, Countable, IteratorAggregate
{
protected $items;

public function __construct($items)
{
$this->items = $items;
}

public function getIterator()
{
return new ArrayIterator($this->items);
}

// ...
}

Bây giờ chúng ta có thể chuyển collection của mình vào foreach và lặp qua $items giống như chúng ta đang trực tiếp lặp qua một mảng thông thường:

1
2
3
4
5
6
7
8
$collection = Collection::make([1, 2, 3]);

foreach ($collection as $item) {
echo $item;
}
// => 1
// => 2
// => 3

Điều này khá thú vị, nhưng nếu may mắn, khi bạn đọc xong cuốn sách này, bạn sẽ không bao giờ muốn làm điều đó nữa.

Điều này dẫn chúng ta đến…

The Golden Rule of Collection Programming: Nguyên tắc vàng của lập trình collection

Never use a foreach loop outside of a collection!

Đừng bao giờ sử dụng foreach bên ngoài collection nữa!

Mỗi khi bạn sử dụng vòng lặp foreach, bạn đang làm cái qué gì đó và tôi hứa với bạn rằng “cái qué gì đó” đã có tên:

  • Cần vòng lặp trên 1 mảng để thực hiện 1 vài hành động với mỗi item và nhét kết quả vào mảng khác? Bạn không cần lặp, bạn cần map.
  • Bạn cần vòng lặp qua 1 mảng để loại bỏ 1 vài phần tử không trùng với điều kiện. Bạn không cần vòng lặp, bạn cần filter.
  • Pipeline programming (lập trình đường ống) là toán tử ở mức độ trừu tượng cao hơn. Thay vì làm điều gì đó với các items trong collection, bạn hãy làm tự làm nó với chính collection.
  • Map it, filter it, reduce it, sum it, zip it, reverse it, transpose it, flatten it, group it, count it, chunk it, sort it, slice it, search it; if you can do it with a foreach loop, you can do it with a collection method.
  • Mỗi khi bạn nâng cấp mảng từ các kiểu nguyên thủy lên thành object, cái mà có các hành vi, thì không có lý do gì để sử dụng vòng lặp bên ngoài chính collection đó và tôi sẽ chứng minh điều đó với bạn.
  • Kể từ thời điểm này, bạn sẽ không nhìn thấy 1 vòng foreach đơn nào trừ khi nó bên trong 1 collection method nữa.

Let the games begin!

Author

Ming

Posted on

2019-12-27

Updated on

2024-08-08

Licensed under

Comments