Trong các bài đăng đầu tiên và thứ hai của loạt bài Tư duy về lập trình hàm, tôi xem xét một số chủ đề về lập trình hàm và chúng liên quan đến Java™ và các ngôn ngữ liên quan của nó như thế nào. Bài đăng này tiếp tục hướng nghiên cứu này, cho thấy một phiên bản Scala của trình phân loại số từ các bài đăng trước và thảo luận về một số chủ đề nhuốm màu-học thuật như currying, áp dụng một phần (partial) và phép đệ quy.
Trình phân loại số trong Scala
Tôi đã dành lại một phiên bản trình phân loại số bằng Scala đến phút cuối vì nó là một phiên bản có ít bí ẩn cú pháp nhất, ít nhất là với các nhà phát triển Java. (Nhắc lại các yêu cầu của trình phân loại số: Cho số nguyên dương bất kỳ lớn hơn 1 nào, bạn phải phân loại nó là perfect (hoàn hảo), abundant (dư thừa) hay deficient (thiếu hụt). Một số hoàn hảo là một số mà các thừa số, không bao gồm chính số đó, cộng lại thì bằng chính số đó. Tổng các thừa số của số dư thừa sẽ lớn hơn số đó và tổng các thừa số của số thiếu hụt sẽ nhỏ hơn số đó). Liệt kê 1 cho thấy phiên bản Scala:
Liệt kê 1. Trình phân loại số trong Scala
package com.nealford.conf.ft.numberclassifier object NumberClassifier {
def isFactor(number: Int, potentialFactor: Int) = number % potentialFactor == 0
def factors(number: Int) = (1 to number) filter (number % _ == 0)
def sum(factors: Seq[Int]) = factors.foldLeft(0)(_ + _)
def isPerfect(number: Int) = sum(factors(number)) - number == number
def isAbundant(number: Int) = sum(factors(number)) - number > number
def isDeficient(number: Int) = sum(factors(number)) - number < number } |
Thậm chí nếu bạn chưa bao giờ nhìn thấy Scala cho đến nay, thì mã này vẫn hoàn toàn dễ
đọc. Như trước đây, hai phương thức đáng quan tâm là factors() và
sum(). Phương thức factors() lấy
danh sách các số từ 1 đến số đích và áp dụng phương thức filter()
dựng sẵn của Scala, sử dụng khối mã ở phía bên phải làm tiêu chuẩn lọc (cũng được gọi là một
vị từ). Khối mã này lợi dụng tham số ngầm, của Scala, cho phép ký hiệu
giữ chỗ không tên (ký tự _) khi không cần một biến có tên. Nhờ sự
linh hoạt cú pháp của Scala, bạn có thể gọi phương thức filter()
giống như cách bạn gọi một toán tử. Nếu bạn thích, có thể viết, (1 to
number).filter((number % _ == 0)) cũng được.
Phương thức sum() sử dụng phép toán fold left bây giờ
đã thành quen thuộc (trong Scala, phép toán này được triển khai thực hiện dưới dạng phương
thức foldLeft()). Tôi không cần đặt tên cho các biến trong trường
hợp này, vì vậy tôi sử dụng ký tự _ như là một ký hiệu giữ chỗ,
lợi dụng cú pháp sạch, đơn giản để định nghĩa một khối mã. Phương thức foldLeft() thực hiện nhiệm vụ giống như phương thức có tên tương tự trong thư viện
Functional Java (xem Tài nguyên), đã xuất hiện trong bài đăng đầu
tiên:
- Lấy một giá trị ban đầu và kết hợp nó qua một phép toán với phần tử đầu tiên của danh sách.
- Lấy kết quả và áp dụng phép toán tương tự cho phần tử tiếp theo.
- Tiếp tục làm việc này cho đến khi danh sách được khai thác hết.
Đây là một phiên bản tổng quát về cách áp dụng một phép toán ví dụ như cộng một danh sách các số: bắt đầu với số không, cộng vào phần tử đầu tiên, lấy kết quả đó và cộng nó với phần tử thứ hai và tiếp tục cho đến khi danh sách được dùng hết.
Mặc dù tôi đã không thể hiện các bài kiểm tra đơn vị đối với các phiên bản trước, tất cả
các ví dụ đều có các bài kiểm tra. Một thư viện kiểm tra đơn vị hiệu quả tên là ScalaTest
hiện có sẵn cho Scala (xem Tài nguyên). Liệt kê 2 cho thấy bài kiểm
tra đầu tiên mà tôi đã viết để kiểm tra lại phương thức isPerfect() của Liệt kê 1:
Liệt kê 2. Một bài kiểm tra đơn vị cho trình phân loại số của Scala
@Test def negative_perfection() {
for (i <- 1 until 10000)
if (Set(6, 28, 496, 8128).contains(i))
assertTrue(NumberClassifier.isPerfect(i))
else assertFalse(NumberClassifier.isPerfect(i))
} |
Nhưng cũng giống như bạn, tôi đang cố gắng để học cách suy nghĩ theo lập trình hàm nhiều
hơn và mã trong Liệt kê 2 đã làm phiền tôi theo hai cách. Đầu tiên,
nó dùng vòng lặp để làm một cái gì đó, trưng ra rõ ràng suy nghĩ mệnh lệnh. Thứ hai, tôi
không quan tâm đến cái bẫy nhị phân của các câu lệnh if. Vấn đề
tôi đang cố gắng giải quyết là gì? Tôi cần phải đảm bảo rằng trình phân loại số của tôi
không xác định nhầm một số không tròn số là tròn số. Liệt kê 3 cho thấy giải pháp cho vấn đề
này, sau khi đã phát biểu hơi khác đi:
Liệt kê 3. Bài kiểm tra khác để phân loại số tròn số
@Test def alternate_perfection() {
assertEquals(List(6, 28, 496, 8128), (1 until 10000)
filter (NumberClassifier.isPerfect(_))) } |
Liệt kê 3 xác nhận rằng các số tròn số duy nhất trong khoảng từ 1 đến 10.000, chỉ là những số có trong danh sách số tròn số đã biết. Suy nghĩ theo lập trình hàm mở rộng không chỉ với mã của bạn, mà còn với cách bạn suy nghĩ về việc kiểm tra nó nữa.
Cách tiếp cận lập trình hàm mà tôi đã cho thấy để lọc các danh sách là phổ biến trong các
ngôn ngữ và các thư viện lập trình hàm. Việc sử dụng khả năng chuyển giao mã lệnh như là
tham số (như với phương thức filter() trong Liệt kê 3) minh họa cho cách suy nghĩ về sử dụng lại mã theo một cách khác. Nếu bạn
đến từ một thế giới hướng đối tượng dựa vào-các mẫu-thiết kế truyền thống, hãy so sánh cách
tiếp cận này với mẫu thiết kế Phương thức khuôn mẫu (Template Method) từ cuốn sách Các
mẫu thiết kế của Bộ tứ (xem Tài nguyên). Các mẫu Phương thức
khuôn mẫu xác định bộ khung của một thuật toán trong một lớp cơ sở, sử dụng các phương thức
trừu tượng và cách viết đè để trì hoãn các chi tiết riêng cho các lớp con. Khi sử dụng hàm
hợp (composition), cách tiếp cận lập trình hàm cho phép bạn chuyển giao theo cách lập trình
hàm cho các phương thức để chúng áp dụng chức năng đó theo cách thích hợp.
Một cách khác để đạt được việc sử dụng lại mã là qua currying. Được đặt tên phỏng theo tên nhà toán học Haskell Curry (ngôn ngữ lập trình Haskell cũng được đặt tên theo tên nhà toán học đó), currying biến đổi một hàm nhiều đối số sao cho nó có thể được gọi như là một chuỗi các hàm chỉ có một đối số. Liên quan chặt chẽ với nó là áp dụng một phần, một kỹ thuật để gán một giá trị cố định cho một hoặc nhiều đối số của một hàm, vì thế tạo ra một hàm khác có arity (số các tham số của hàm đó) nhỏ hơn. Để hiểu rõ sự khác biệt này, hãy bắt đầu bằng cách xem xét mã Groovy trong Liệt kê 4, minh họa currying:
Liệt kê 4. Currying trong Groovy
def product = { x, y -> return x * y }
def quadrate = product.curry(4)
def octate = product.curry(8)
println "4x4: ${quadrate.call(4)}"
println "5x8: ${octate(5)}" |
Trong Liệt kê 4, tôi định nghĩa product
là một khối mã chấp nhận hai tham số. Khi sử dụng phương thức curry() dựng sẵn của Groovy, tôi sử dụng product như là
khối xây dựng cho hai khối mã mới: quadrate (gấp bốn) và octate (gấp tám). công khai một phương thức call() hoặc sử dụng vỏ cú pháp mức-ngôn ngữ đã được cung cấp bằng cách viết các
cặp dấu ngoặc đơn có chứa bất kỳ các tham số nào sau tên khối-mã (ví dụ như là octate(5)).
Áp dụng một phần là một kỹ thuật rộng hơn, tương tự như currying nhưng không chỉ hạn chế
hàm kết quả cho một đối số duy nhất. Groovy sử dụng phương thức curry() để xử lý cả hai currying và áp dụng một phần, như trong Liệt kê 5:
Liệt kê 5. Áp dụng một phần so với currying, cả hai đều sử dụng phương thức
curry() của Groovy
def volume = { h, w, l -> return h * w * l }
def area = volume.curry(1)
def lengthPA = volume.curry(1, 1) //partial application
def lengthC = volume.curry(1).curry(1) // currying
println "The volume of the 2x3x4 rectangular solid is ${volume(2, 3, 4)}"
println "The area of the 3x4 rectangle is ${area(3, 4)}"
println "The length of the 6 line is ${lengthPA(6)}"
println "The length of the 6 line via curried function is ${lengthC(6)}" |
Khối mã volume trong Liệt kê 5 tính thể
tích khối của khối chữ nhật ba chiều bằng cách sử dụng công thức đã biết. Sau đó tôi tạo ra
một khối mã area (tính diện tích của hình chữ nhật) bằng cách đặt
kích thước đầu tiên của volume (chiều caoh) là 1. Để sử dụng volume
như là một khối xây dựng cho một khối mã trả về độ dài của một đoạn thẳng, tôi có thể thực
hiện hoặc áp dụng một phần hoặc currying. lengthPA dùng áp dụng
một phần bằng cách đặt từng tham số trong hai tham số đầu tiên là 1. lengthC dùng currying hai lần để sinh ra cùng kết
quả. Khó thấy được sự khác biệt và kết quả cuối cùng là giống nhau, nhưng nếu bạn sử dụng
các thuật ngữ currying và áp dụng một phần lẫn lộn thay thế cho nhau trong
tầm nghe của một người lập trình hàm, bạn có thể bị chỉnh sửa lại đấy.
Lập trình hàm cung cấp cho bạn các khối xây dựng mới, khác nhau để đạt được cùng các mục tiêu như các ngôn ngữ mệnh lệnh thực hiện bằng các cơ chế khác. Các mối quan hệ giữa những khối xây dựng này phải được suy nghĩ cẩn thận. Ở trên, tôi đã cho thấy hàm hợp là một cơ chế sử dụng lại mã. Bạn đừng ngạc nhiên khi bạn có thể kết hợp currying và hàm hợp. Hãy xem xét mã Groovy trong Liệt kê 6:
Liệt kê 6. Hàm hợp áp dụng một phần
def composite = { f, g, x -> return f(g(x)) }
def thirtyTwoer = composite.curry(quadrate, octate)
println "composition of curried functions yields ${thirtyTwoer(2)}" |
Trong Liệt kê 6, tôi tạo ra khối mã composite là hàm hợp của hai hàm. Sử dụng khối mã đó, tôi tạo ra khối mã thirtyTwoer, dùng áp dụng một phần để kết hợp hai phương thức với
nhau.
Bằng cách dùng áp dụng một phần và currying, bạn đạt được các mục tiêu tương tự với các cơ
chế như của mẫu thiết kế Phương thức khuôn mẫu. Ví dụ, bạn có thể tạo ra một khối mã incrementer bằng cách xây dựng nó trên đỉnh một khối mã adder, như trong Liệt kê 7:
Liệt kê 7. Các khối xây dựng khác nhau
def adder = { x, y -> return x + y }
def incrementer = adder.curry(1)
println "increment 7: ${incrementer(7)}" |
Tất nhiên, Scala hỗ trợ currying, như được minh họa bằng đoạn mã (snippet) lấy từ tài liệu Scala được hiển thị trong Liệt kê 8:
Liệt kê 8. Currying trong Scala
object CurryTest extends Application {
def filter(xs: List[Int], p: Int => Boolean):
List[Int] = if (xs.isEmpty) xs
else if (p(xs.head)) xs.head :: filter(xs.tail, p)
else filter(xs.tail, p)
def dividesBy(n: Int)(x: Int) = ((x % n) == 0)
val nums = List(1, 2, 3, 4, 5, 6, 7, 8)
println(filter(nums, dividesBy(2)))
println(filter(nums, dividesBy(3)))
} |
Mã trong Liệt kê 8 cho thấy cách thực hiện một phương thức dividesBy() được phương thức filter() sử
dụng. Tôi chuyển một hàm vô danh tới phương thức filter() sử
dụng. Tôi chuyển một hàm vô danh tới phương thức dividesBy() bằng
giá trị được sử dụng để tạo khối mã. Khi tôi chuyển giao khối mã được tạo ra bằng cách
truyền số đích của tôi như một tham số, Scala tạo ra một hàm mới bằng currying.
Một chủ đề khác liên kết chặt chẽ với lập trình hàm là phép đệ quy, (theo Wikipedia) là "quá trình lặp lại các mục theo một cách tự đồng dạng". Trong thực tế, đó là một cách của khoa học máy tính để lặp lại qua hết mọi thứ bằng cách gọi một phương thức từ bên trong chính nó (luôn cẩn thận đảm bảo rằng bạn có một điều kiện thoát). Nhiều khi, phép đệ quy dẫn đến mã lệnh dễ hiểu bởi vì cốt lõi của vấn đề của bạn là cần làm điều tương tự lặp đi lặp lại xuống đến một danh sách giảm dần.
Hãy xem xét việc lọc một danh sách. Sử dụng một cách tiếp cận vòng lặp, tôi chấp nhận một tiêu chuẩn lọc và lặp lại qua các nội dung, lọc ra các phần tử mà tôi không muốn. Liệt kê 9 cho thấy một cách thực hiện lọc đơn giản với Groovy:
Liệt kê 9. Lọc trong Groovy
def filter(list, criteria) {
def new_list = [] list.each {
i -> if (criteria(i)) new_list << i
}
return new_list
}
modBy2 = { n -> n % 2 == 0 }
l = filter(1..20, modBy2)
println l |
Phương thức filter() trong Liệt kê 9
chấp nhận một list và một criteria
(một khối mã chỉ rõ cách lọc danh sách ra sao) và lặp qua hết danh sách, thêm từng mục vào
một danh sách mới nếu nó khớp với vị từ đó.
Bây giờ hãy xem lại Liệt kê 8, là một cách thực hiện bằng phép đệ
quy chức năng lọc trong Scala. Nó làm theo một mẫu phổ biến trong các ngôn ngữ lập trình hàm
để xử lý một danh sách. Một khung nhìn của một danh sách gồm hai phần: mục ở đầu danh sách
(mục đầu) và tất cả các mục khác còn lại. Nhiều ngôn ngữ lập trình hàm có các phương thức cụ
thể để lặp qua hết một danh sách bằng cách sử dụng cách biểu diễn này. Trước tiên phương
thức filter() kiểm tra xem danh sách có rỗng không — điều
kiện thoát quan trọng cốt yếu cho phương thức này. Nếu danh sách rỗng, chỉ cần trả về. Nếu
trái lại, sử dụng điều kiện vị từ (p) được chuyển giao như là một
tham số. Nếu điều kiện này đúng (có nghĩa là tôi muốn có mục này trong danh sách của mình),
tôi trả về một danh sách mới được xây dựng bằng cách lấy mục đầu hiện tại và phần còn lại
được lọc của danh sách. Nếu điều kiện vị từ sai, tôi trả về một danh sách mới chỉ chứa đúng
phần còn lại được lọc (loại bỏ phần tử đầu tiên). Các toán tử xây dựng danh sách trong Scala
làm cho các điều kiện trả về cho cả hai trường hợp hoàn toàn dễ đọc và dễ hiểu.
Tôi đoán là bây giờ bạn chưa sử dụng phép đệ quy tí nào — nó thậm chí không phải là một phần của hộp công cụ của bạn. Tuy nhiên, một phần lý do nằm ở thực tế là hầu hết các ngôn ngữ lập trình mệnh lệnh hỗ trợ nó mờ nhạt, làm cho việc sử dụng khó khăn hơn là đáng ra nên có. Bằng cách thêm cú pháp sạch và sự hỗ trợ, các ngôn ngữ lập trình hàm làm cho phép đệ quy trở thành một ứng cử viên để sử dụng lại mã đơn giản.
Bài đăng này hoàn tất nghiên cứu của tôi về các đặc tính trong thế giới tư duy lập trình hàm. Thật trùng hợp, phần lớn bài viết này đã nói về việc lọc, cho thấy nhiều cách để sử dụng nó và thực hiện nó. Nhưng điều này không quá bất ngờ. Nhiều mẫu hình lập trình hàm được xây dựng xung quanh các danh sách vì phần lớn việc lập trình rốt cuộc là xử lý các danh sách về mọi thứ. Rất có ý nghĩa khi tạo ra các ngôn ngữ và các khung công tác nạp thêm các tiện ích cho các danh sách.
Trong bài đăng tiếp theo, tôi sẽ nói sâu về một trong các khối xây dựng của lập trình hàm: tính bất biến.
Học tập
- Nhà lập trình năng suất (Neal Ford, O'Reilly Media, 2008): Cuốn sách mới
nhất của Neal Ford mở rộng một số các chủ đề trong loạt bài này.
- Scala: Scala là
một ngôn ngữ lập trình hàm hiện đại trên JVM.
- Functional Java:
Java lập trình hàm là một khung công tác bổ sung nhiều cấu kiện của ngôn ngữ lập trình hàm
cho Java.
-
Hướng dẫn của nhà phát triển Java bận rộn về Scala: Nghiên cứu sâu hơn vào
Scala trong loạt bài này trên developerWorks của Ted Neward.
- "Groovy thực
hành: Lập trình hàm với các bao đóng bằng currying" (Ken Barclay et al.,
developerWorks, 08.2005): Đọc thêm về lập trình hàm với Groovy.
- Các mẫu thiết kế: Các phần tử của Phần mềm hướng đối tượng sử dụng lại
được (Erich Gamma et al., Addison-Wesley, 1994): Tác phẩm kinh điển của Bộ tứ về
các mẫu thiết kế.
- Duyệt qua hiệu sách công
nghệ để tìm các cuốn sách về các chủ đề kỹ thuật này và khác.
-
Vùng công nghệ Java của developerWorks:
Tìm hàng trăm bài viết về mọi khía cạnh của việc lập trình Java.
Lấy sản phẩm và công nghệ
- ScalaTest: ScalaTest là thư viện kiểm tra đơn vị
(unit-testing) cho mã Java và Scala.
- Tải về các phiên bản đánh giá sản phẩm của IBM hoặc khám phá các bản dùng thử trực tuyến trong SOA Sandbox
của IBM và nhận lấy các công cụ phát triển ứng dụng thực hành của bạn và các sản phẩm
phần mềm trung gian từ DB2®, Lotus®, Rational®, Tivoli® và
WebSphere®.
Thảo luận
- Xem các blog developerWorks và dành tâm trí cho cộng đồng developerWorks.
Neal Ford là một kiến trúc sư phần mềm và Meme Wrangler tại Thought Works, một văn phòng tư vấn CNTT toàn cầu. Ông cũng thiết kế và phát triển các ứng dụng, tài liệu hướng dẫn, các bài báo trên tạp chí, học liệu và các bài thuyết trình video/DVD; và ông là tác giả hoặc người biên tập các cuốn sách bao trùm nhiều loại công nghệ, bao gồm cả cuốn sách gần đây nhất là The Productive Programmer. Ông tập trung vào việc thiết kế và xây dựng ứng dụng doanh nghiệp có quy mô lớn. Ông cũng là một diễn giả được quốc tế hoan nghênh tại hội nghị của các nhà phát triển trên toàn thế giới