[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
14function 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ày1
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 | function getUserEmails($users) |
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 | $usersWithEmails = filter($users, function ($user) { |
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 | function getUserEmails($users) |
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 | function getUserEmails($users) |
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:
- 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. - Chúng ta đã loại bỏ biến thu thập
$email
bằng cách sử dụngmap
để chuyển đổi mảng người dùng thành một mảng email. - 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
filter
vàmap
.
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
và $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
10function 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
8function 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
8function 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ì string và array 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
14function 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
8function 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ợ map
và filter
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24class 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
8function 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
4public 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ơn1
2
3
4
5
6
7
8
9function 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ả map
và filter
.
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
6public 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 | interface ArrayAccess |
Để 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
34class 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
4interface 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
15class 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
12class 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
16class 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!
[Collection] Transforming Data, Introducing Collections
http://yoursite.com/2019/12/27/Collection-Transforming-Data-Introducing-Collections/