12/08/2018, 13:29

Functional Programming in PHP

** Lời nói đầu: ** Gần đây, khái niệm lập trình hàm (functional programming) trở nên hết sức phổ biến, cùng với nó là sự đi lên của ngôn ngữ lập trình hàm Scala và Haskell. Xuất thân là một lập trình viên PHP, tôi được khuyên nên học một ngôn ngữ lập trình hàm kiểu như Scala, qua đó mở rộng tầm ...

** Lời nói đầu: **

Gần đây, khái niệm lập trình hàm (functional programming) trở nên hết sức phổ biến, cùng với nó là sự đi lên của ngôn ngữ lập trình hàm Scala và Haskell. Xuất thân là một lập trình viên PHP, tôi được khuyên nên học một ngôn ngữ lập trình hàm kiểu như Scala, qua đó mở rộng tầm hiểu biết và có thể quay trở lại viết code PHP sáng sủa hơn. Sau một thời gian tìm hiểu Scala, tôi đã lơ mơ hiểu được đôi chút về Scala cũng như Functional programming. Và tôi cũng thấy rằng, nếu muốn, PHP cũng có thể thực hiện được functional programming, đặc biệt là khi closure đang được sử dụng rất phổ biến. Bài viết này tôi tìm hiểu và dịch từ nguồn code.tutplus, rất mong có thể giúp các lập trình viên PHP có cái nhìn cơ bản về functional programming. Trong tương lai, tôi sẽ viết bài phân tích ngôn ngữ Scala để bạn đọc thấy nó hỗ trợ Functional Programming tuyệt vời thế nào.

Trong giới lập trình, khái niệm functional programming đang trở nên vô cùng hấp dẫn và được nhiều người tìm hiểu. Ngôn ngữ lập trình hàm (functional languages) cũng được sử dụng nhiều hơn và tốt hơn trong các ứng dụng. Scala, Haskell,... đang cực kỳ phát triển. Những ngôn ngữ lâu đời như Java cũng bắt đầu chấp nhận các đặc trưng của functional programming (ví dụ closure trong Java 7 là lazy eval cho lists trong Java 8). Tuy nhiên, điều mà ít người biết, là PHP thực sự rất linh hoạt khi chuyển sang functional programming. Hầu hết các đặc trưng chính của functional programming có thể mổ tả được ở PHP. Do vậy, nếu bạn là một người mới biết đến khái niệm này, hãy cố gắng mở rộng suy nghĩ. Và nếu bạn là người đã quen với functional programming, chắc chắn bạn sẽ thấy nhiều điều thú vị trong bài viết này.

Mô hình lập trình (Programming Paradigms)

Nếu không có các mô hình lập trình, chúng ta có thể làm mọi việc chúng ta muốn theo bất cứ cách nào. Mặc dù điều đó có vẻ rất linh hoạt, nhưng nó có thể đưa chúng ta đến những kiến trúc vô lý và những dòng code không rõ ràng. Do vậy, mô hình lập trình được sinh ra để giúp chúng ta, lập trình viên, giúp nghĩ ra cách giải quyết cụ thể cho một vấn đề cụ thể, và bằng cách đó, sẽ giới hạn khả năng của chúng ta khi mô tả lời giải cho vấn đề đó.

Mỗi mô hình lập trình lại lấy đi 1 chút tự do của chúng ta:

  • Modular Programming lấy đi các chương trình có độ dài không giới hạn.
  • Structured and Procedural Programming lấy đi cú pháp go-to và giới hạn lập trình viên với các chuỗi, lựa chọn và vòng lặp.
  • Object Oriented Programming lấy đi con trỏ và chỉ còn functions.
  • Functional Programming lấy đi việc gán giá trị và trạng thái có thể thay đổi.

Nguyên lý của functional programming

Trong functional programming, chúng ta không có dữ liệu được biểu diễn bởi biến số.

Trong functional programming, mọi thứ đều là hàm (function). Ý tôi là tất cả. Ví dụ một tập hợp, như trong toán học, có thể biển diễn bởi nhiều hàm. Một mảng hoặc danh sách cũng có thể biểu diễn bởi một hàm hoặc một nhóm các hàm.

Trong lập trình hướng đối tượng, mọi thứ đều là đối tượng (object). Và một object là một tập các dữ liệu, hàm thực hiện hành động với dữ liệu đó. Object có trạng thái có thể thay đổi.

Trong functional programming, bạn không có dữ liệu biểu diễn bởi biến số. Không có khối nào chứa dữ liệu. Dữ liệu không được gán cho biến. Một vài dữ liệu có thể được định nghĩa hoặc được gán. Tuy nhiên, trong hầu hết các trường hợp, hàm số được gán cho "biến"(variables). Tôi đặt "biến" trong dấu ngoặc kép, bởi trong functional programming, chúng không thể thay đổi. Mặc dù hầu hết ngôn ngữ lập trình hàm không bắt buộc việc không thay đổi, cũng như không phải cứ lập trình hướng đối tượng là bắt buộc phải dùng object, nhưng nếu bạn thay đổi giá trị sau khi chỉ định giá trị ban đầu, bạn không còn giữ được vẻ trong sáng của functional programming.

Bởi bạn không có giá trị được đặt ở biến, trong functional programming, không có cái gọi là trạng thái.

Bởi vì không có trạng thái, không có việc gán giá trị, nên hàm trong functional programming không gây ra tác dụng phụ. Vì ba lý do trên, hàm ở đây luôn có thể dự đoán được. Điều đó có nghĩa là, nếu bạn gọi một hàm với cùng một tham số truyền vào, gọi đi gọi lại,... bạn luôn nhận được cùng một kết quả. Đây thực sự là một lợi thế tuyệt vời hơn hẳn lập trình hướng đối tượng, và rất hiệu quả trong việc giảm độ phức tạp của lập trình đa luồng hay các ứng dụng lớn chạy đa luồng.

Tuy nhiên, nếu chúng ta muốn mô tả mọi thử bởi hàm, chúng ta cần truyền chúng dưới dạng tham số hay trả về chúng từ một hàm khác. Do vậy, functional programming yêu cầu việc ỗ trợ hàm ở mức cao (high-order functions). Về cơ bản, điều này có nghĩa là hàm có thể được gán như "biến", được truyền vào như tham số của hàm khác, và được trả về như kết quả của một hàm.

Cuối cùng, vì chúng ta không có giá trị trong biến, các vòng lặp while và for sẽ trở nên hiếm thấy trong lập trình hàm, chúng được thay thế bởi đệ quy.

Cho tôi xem code!

Nói lý thuyết như vậy là đủ rồi, giờ chúng ta sẽ code!

Tạo một project PHP bằng IDE hoặc Editor yêu thích của bạn. Tạo một thư mục Tests. Tạo 2 files: FunSets.php và FunSetsTest.php trong thư mục đó. Chúng ta sẽ tạo ra một ứng dụng, với chức năng kiểm thử, để mô tả khái đặc điểm tập hợp (sets).

Trong toán học, tập hợp là một tập các đối tượng tách biệt, được xem như một đối tượng theo cách riêng của nó. (wikipedia)

Về cơ bản, điều đó có nghĩa sets là một tập những thứ được đặt tại một nơi. Những tập hợp này có thể và được xác định thuộc tính thông qua các phép toán: hợp, giao, sai phân,... Và thông qua các thuộc tính mang tính chất hành động, kiểu như: bao hàm.

Giới hạn của lập trình

Nào, giờ hãy bắt đầu code! Tuy nhiên, đợi một chút. Bằng cách nào? Để bảo vệ quan điểm của functional programming, chúng ta sẽ phải đảm bảo những giới hạn sau:

  • Không gán giá trị - Chúng ta không được phép gán giá trị cho biến. Tuy nhiên chúng ta được gán hàm cho biến.

  • Không có trạng thái có thể thay đổi - Chúng ta không được phép, khi gán giá trị, thay đổi giá trị mà đã được gán. Chúng ta cũng không được thay đổi giá trị của biến mà có giá trị là tham số của hàm hiện tại. Do vậy, không được thay đổi tham số.

  • Không có vòng lặp while và for - Chúng ta không được phép sử dụng câu lệnh while và for trong PHP. Tuy nhiên, chúng ta được phép định nghĩa phương thức để duyệt qua các phần tử của tập hợp, và gọi đó là foreach/for/while.

Không có giới hạn với tests. Bởi vì PHPUnit cơ bản, chúng ta sẽ sử dụng lập trình hướng đối tượng thuần tuý. Để điều tiết tests một cách đơn giản, chúng ta sẽ viết tất cả code trong một class.

Hàm định nghĩa tập hợp

Nếu bạn là một lập trình viên có kinh nghiệm, nhưng không quen với functional programming, đây là thời điểm bạn nên dừng suy nghĩ về việc làm mọi thứ như bạn đã từng làm và sẵn sàng thoát khỏi vòng an toàn của bạn. Hãy quên tất cả cách tiếp cận trước đây với vấn đề và tưởng tượng tất cả là hàm.

Hàm định nghĩa của tập hợp là phương thức bao gồm - contains.

function contains($set, $elem) {
    return $set($elem);
}

OK. Có vẻ không hiển nhiên lắm. Giờ hãy xem cách chúng ta dùng nó.

$set = function ($element) {return true;};
contains($set, 100);

Ok, nó giải thích rõ hơn một chút. Hàm contains có 2 tham số:

  • $set - Biểu diễn một tập hợp định nghĩa như một hàm số.
  • $elem - Biển diễn một phần tử định nghĩa như một giá trị.

Trong trường hợp này, những gì mà contains làm là sử dụng hàm $set với tham số là $elem. Chúng ta sẽ thực hiện tests.

class FunSetsTest extends PHPUnit_Framework_TestCase {

    private $funSets;

    protected function setUp() {
        $this->funSets = new FunSets();
    }

    function testContainsIsImplemented() {
        // Chúng ta xác định thuộc tính một tập hợp thông qua hàm contains. Đây là thuộc tính cơ bản của tập hợp.

        $set = function ($element) { return true; };
        $this->assertTrue($this->funSets->contains($set, 100));
    }
}

Và chúng ta viết class FunSets.php.

class FunSets {
    public funciton contains($set, $elem) {
        return $set($elem);
    }
}

Bạn có thể chạy test này và nó luôn pass. Tập hợp chúng ta định nghĩa ở đây luôn trả về true, đó là một tập hợp đúng "true set".

Tập duy nhất - Singleton Set

Nếu phần trước có đôi chút khó hiểu về logic, ví dụ này sẽ rõ ràng hơn. Chúng ta muốn định nghĩa một tập hợp với một phần tử duy nhất, một tập hợp duy nhất. Nên nhớ rằng đó là một hàm, và chúng ta muốn dùng nó như trong test dưới đây.

function testSingletonSetContainsSingleElement() {
    $singleton = $this->funSets->singetonSet(1);
    $this->assertTrue($this->funSets->contains($singleton, 1));
}

Chúng ta cần một hàm gọi là singletonSet với một tham số mô tả phần tử của tập hợp. Trong test này, đó là một số (1). Sau đó chúng ta hy vọng hàm contains trả về true nếu tham số truyền vào bằng một. Chương trình sau làm cho bài test của chúng ta pass.

public function singletonSet($elem) {
    return function($otherElem) use ($elem) {
        return $elem == $otherElem;
    }
}

Vâng, rất tuyệt vời phải không nào. Hàm singletonSet nhận tham số truyền vào là một phần tử $elem. Sau đó nó trả lại một hàm nhận tham số là $otherElem và hàm này so sánh $elem với $otherElem.

Hãy xem cách chúng hoạt động thế nào. Đầu tiên:

$singleton = $this->funSets->singletonSet(1);

được chuyển thành cái mà singletonSet(1) trả về:

$singleton = function ($otherElem) {
    return 1 == $otherElem;
}

Sau đó, hàm contains($singleton, 1) được gọi. Hàm này dùng để kiểm tra xem phần tử có trong $singleton hay không. Code như sau:

$singleton(1);

Hàm này thực ra kiểm tra xem $otherElem có giá trị 1 hay không.

return 1 == 1;

Đương nhiên nó đúng và test của chúng ta sẽ pass.

Bạn có đang cười? Bạn có thấy tư duy của mình đang thay đổi? Tôi thực sự thấy điều đó khi tôi viết chương trình này với Scala và viết lại với PHP. Tôi thấy nó rất khác biệt. Chúng ta cố gắng tạo ra một tập hợp, với một phần tử, với khả năng kiểm tra xem nó có chứa giá trị ta truyền vào không. Chúng ta làm tất cả điều đó mà không cần gán bất cứ giá trị nào. Chúng ta không có biến chứa giá trị hoặc trạng thái của giá trị. Không trạng thái, không gán giá trị, không thay đổi, không vòng lặp. Chúng ta đang đi đúng hướng.

Hợp của tập hợp (Union of Sets)

Giờ chúng ta đã tạo một tập với một giá trị duy nhất. Chúng ta cần tạo ra tập với nhiều giá trị. Cách hiển nhiên nhất là định nghĩa phép hợp của tập hợp. Một hợp của hai tập duy nhất sẽ tạo ra một tập với cả 2 giá trị. Tôi muốn bạn nghĩ một chút trước khi xem code, trước tiên là phần test.

function testUnionContainsAllElements() {
    $s1 = $this->funSets->singletonSet(1);
    $s2 = $this->funSets->singletonSet(2);
    $union = $this->funSets->union($s1, $s2);

    $this->assertTrue($this->funSets->contains($union, 1));
    $this->assertTrue($this->funSets->contains($union, 2));

    $this->assertFalse($this->funSets->contains($union, 3));
}

Khi chúng ta gọi hàm union, nó nhận 2 tham số, cả 2 đều là tập hợp. Nên nhớ rằng set là một hàm, do vậy union sẽ nhận hai tham số đều là hàm. Sau đó, chúng ta dùng hàm contains để kiểm tra xem union có chứa phần tử hay không. Do vậy, union cần trả về hàm mà contains có thể sử dụng.

public function union($s1, $s2) {
    return function($otherElem) use ($s1, $s2) {
        return $this->contains($s1, $otherElem) || $this->contains($s2, $otherElem);
    }
}

Hàm này hoạt động tốt. Và nó vẫn hoạt động tốt kể cả khi bạn gọi phép hợp với một kết quả của phép hợp khác và một tập duy nhất. Nó sẽ gọi hàm contains bên trong chính nó với từng tham số. Nếu nó là một hợp của các tập hợp, nó có thể đệ quy. Rất đơn giản.

Giao (Intersect) và Sai phân (Difference)

Chúng ta có thể làm tương tự để có được hai hàm quan trọng của tập hợp là giao (các phần tử chung của hai tập hợp) và hàm sai phân (phần tử có ở tập thứ nhất nhưng không có ở tập thứ hai).

public function intersect($s1, $s2) {
    return function ($otherElem) use ($s1, $s2) {
        return $this->contains($s1, $otherElem) && $this->contains($s2, $otherElem);
    }
}

public function diff($s1, $s2) {
    return function ($otherElem) use ($s1, $s2) {
        return $this->contains($s1, $otherElem) && !$this->contains($s2, $otherElem);
    }
}

Lọc tập hợp

Nó khá phức tạp, và chúng ta không thể dễ dàng giải quyết với 1 dòng code. Hàm filter dùng 2 tham số: tập hợp và hàm lọc. Nó sử dụng hàm filter cho tập hợp và trả về một tập hợp chỉ chứa các phần tử thoả màn điều kiện lọc. Để hiểu rõ hơn, chúng ta sẽ test nó.

function testFilterContainsOnlyElementThatMatchConditionFunction() {
    $u12 = $this->createUnionWithElements(1, 2);
    $u123 = $this->funSets->union($u12, $this->funSets->singletonSet(3));

    $condition = function($elem) { return $elem > 1; };

    $filteredSet = $this->funSets->filter($u123, $condition);

    $this->assertFalse($this->funSets->contains($filteredSet, 1), "Should not contain 1");
    $this->assertTrue($this->funSets->contains($filteredSet, 2), "Should not contain 2");
    $this->assertTrue($this->funSets->contains($filteredSet, 3), "Should not contain 3");
}

private function createUnionWithElements($elem1, $elem2) {
    $s1 = $this->funSets->singletonSet($elem1);
    $s2 = $this->funSets->singletonSet($elem2);
    return $this->funSets->union($s1, $s2);
}

Chúng ta tạo ra một tập hợp có 3 phần tử 1, 2, 3. Và đặt nó ở biến $u123. Do vậy nó trở nên rất rõ ràng. Chúng ta định nghĩa một hàm chúng ta muốn test và đặt nó là $condition. Cuối cùng, chúng ta gọi hàm filter cho tập $u123 với hàm $condition và đặt kết quả ở $filteredSet. Sau đó chúng ta test hàm contains với các phần tử. Hàm lọc khá đơn giản, nó trả về true nếu phần tử lớn hơn 1. Do vậy mục tiêu cuối cùng của chúng ta là tập hợp có 2 phần tử 2 và 3.

public function filter($set, $condition) {
    return function ($otherElem) use ($set, $condition) {
        if ($condition($otherElem))
            return $this->contians($set, $otherElem);
        return false;
    };
}

Lặp giữa các phần tử

Bước tiếp theo là tạo ra hàng loạt hàm lặp. Hàm đầu tiên forall() sẽ nhận tập hợp $set và điều kiện $condition và trả về true nếu $condition được dùng cho các thành phần của tập hợp. Test sẽ như sau.

function testForAllCorrectlyTellsIfAllElementsSatisfyCondition() {
    $u123 = $this->createUnionWith123();

    $higherThanZero = function($elem) { return $elem > 0; };
    $higherThanOne = function($elem) { return $elem > 1; };
    $higherThanTwo = function($elem) { return $elem > 2; };

    $this->assertTrue($this->funSets->forall($u123, $higherThanZero));
    $this->assertFalse($this->funSets->forall($u123, $higherThanOne));
    $this->assertFalse($this->funSets->forall($u123, $higherThanTwo));
}

Chúng ta dùng $u123 được tạo ra từ hàm test tại đây. Sau đó chúng ta định nghĩa 3 điều kiện khác nhau: lớn hơn 0, lớn hơn 1 và lớn hơn 2. Vì tập hợp của chúng ta bao gồm 1, 2, 3 nên chỉ điều kiện với 0 là trả về true, còn lại trả về false. Do vậy, chúng ta cần vượt qua test với sự giúp đỡ của hàm đệ quy để duyệt tất cả các phần tử.

private $bound = 1000;

private function forallIterator($currentValue, $set, $condition) {
    if ($currentValue > $this->bound)
        return true;
    elseif ($this->contains($set, $currentValue))
        return $this->condition($currentValue) && $this->forallIterator($currentValue + 1, $set, $condition);
    else
        return $this->forallIterator($currentValue + 1, $set, $condition);
}

public function forall($set, $condition) {
    return $this->forallIterator(-$this->bound, $set, $condition);
}

Chúng ta bắt đầu bằng việc định nghĩa giới hạn cho tập hợp của chúng ta. Giá trị cần nằm trong khoảng -1000 đến +1000. Đây là một giới hạn chấp nhận được để ví dụ đơn giản. Hàm forall gọi hàm private forallIterator với các tham số cần thiết để thực hiện để quy để kiểm tra xem phần tử có thoả mãn điều kiện không. Ở hàm này, đầu tiên chúng ta check xem mình có bị nằm ngoài giới hạn không. Nếu có, trả về true. Tiếp theo, chúng ta kiểm tra xem tập hợp có phần tử này không, nếu có thì sử dụng điều kiện AND để kiểm tra phần tử hiện tại có thoả mãn điều không và gọi đệ quy đến các phần tử khác. Nếu không, chúng ta chỉ cần gọi đệ quy với phần còn lại và trả về kết quả.

Hàm này sẽ hoạt động tốt, và chúng ta có thể cài đặt theo cách tương tự cho exits(). Hàm này trả vè true nếu bất cứ phần tử nào thoả mãn điều kiện.

private function existsIterator($currentValue, $set, $condition) {
    if ($currentValue > $this->bound)
        return false;
    elseif ($this->contains($set, $currentValue))
        return $condition($currentValue) || $this->existsIterator($currentValue + 1, $set, $condition);
    else
        return $this->existsIterator($currentValue + 1, $set, $condition);
}

public function exists($set, $condition) {
    return $this->existsIterator(-$this->bound, $set, $condition);
}

Khác biệt duy nhất là hàm này trả về false khi nằm ngoài khoảng giá trị và chúng ta dùng OR thay vì AND trong câu if thứ hai.

Hàm map sẽ khác một chút, ngắn hơn và đơn giản hơn.

public function map($set, $action) {
    return function ($currentElem) use ($set, $action) {
        return $this->exists($set, function ($elem) use ($currentElem, $action) {
            return $currentElem == $action($elem);
        }
    }
}

Mapping có nghĩa là chúng ta sử dụng một hành động cho tất cả các phần tử của tập hợp. Với map, chúng ta không cần iterator và có thể sử dụng hàm exists() có sẵn để trả về các phần tử "tồn tại" và thoả mãn điều kiện $action. Ban đầu có thể không hiển nhiên lắm, nhưng hãy xem điều gì đang diễn ra.

  • Chúng ta tạo tập hợp với phần tư { 1, 2, } và thực hiện hành động $element * 2 (nhân đôi).
  • Đương nhiên chúng ta sẽ có một hàm sử dụng tập hợp và hành động từ một mức cao hơn.
  • Hàm này sẽ gọi exists với tập { 1, 2 } và điều kiện $curentElement tương ứng với $elem * 2.
  • exits() sẽ duyệt tất cả các phần từ từ -1000 đến + 1000 (phạm vi của chúng ta), vì tìm ra phần tử mà giá trị gấp đôi của nó được nhận được từ contains (giá trị của $currentElement) và trả về true.
  • Nói một cách khác, hàm so sánh cuối cùng sẽ trả về true cho hàm gọi đến giá trị 2, khi giá trị hiện tại được nhân 2 và trả về 4. Ở phần tử đầu tiên, 1, giá trị true sẽ trả về với 2. Ở phần từ giá trị 2, kết quả là 4.

Ví dụ thực tế

Vâng, có vẻ functional programming rất thú vị, nhưng ý tưởng của nó thì không dễ chấp nhận trong PHP. Do vậy, tôi không khuyến khích bạn viết tất cả bằng cách đó. Tuy nhiên, bạn đã biết cách PHP làm với hàm, do vậy bạn có thể dùng một phần kiến thức vào các bài toàn hàng ngày. Dưới đây là một mô-đun thực hiện xác thực người dùng. Class AuthPlugin nhận đầu vào là user và passwrod và có thể thực hiện xác thực cũng như chỉ định quyền người dùng.

class AuthPlugin {

    private $permissions = array();

    public function authenticate($username, $password) {
        $this->verifyUser($username, $password);

        $adminModules = new AdminModules();
        $this->permissions[] = $adminModules->allowRead($username)
                                          
0