Map, Reduce, Filter trong Python
Trong hướng dẫn này chúng ta cùng đi nghiên cứu về ba hàm đặc thù Map, Reduce, Filter trong lập trình Python.
Map, filter và reduce là ba hàm đặc thù của lập trình hàm. Đặc điểm chung của chúng là cho phép áp dụng một hàm xử lý trên một danh sách dữ liệu: map áp dụng một hàm trên dữ liệu của một danh sách; filter áp dụng hàm cho danh sách và chỉ lấy những kết quả phù hợp điều kiện; reduce biến một danh sách về một giá trị.
Ba loại hàm đặc biệt này cho phép viết code xử lý dữ liệu ngắn gọn, xúc tích, hiệu quả hơn mà không cần đến các kỹ thuật lập trình thông thường với vòng lặp và rẽ nhánh.
Imperative và functional
Cùng xuất phát với một ví dụ đơn giản nhất. Giả sử bạn đang có một danh sách X chứa các giá trị như sau:
Ví dụ
X = [x/100 for x in range(100, 1000)] #dải giá trị [1, 10] với bước 0,01
Bạn muốn tạo ra một danh sách khác, Y, trong đó mỗi phần tử của y
danh sách Y được tính theo hàm y = ax^2+bx+c
, với x là giá trị của phần tử tương ứng trong X. Đây là bài toán mô phỏng hoạt động của hàm toán học.
Theo lối suy nghĩ thông thường, bạn sẽ:
- tạo một danh sách trống Y = []
- định nghĩa hàm parabolic
- sử dụng vòng lặp với danh sách X
- Ứng với mỗi phần tử của x sẽ tính ra y thông qua gọi hàm parabolic
- Thêm giá trị y vào cuối danh sách Y
“Thuật toán” trên được minh họa qua code Python như sau:
Ví dụ
X = [x/100 for x in range(100, 1000)]
Y = []
def parabolic(x, a=1.0, b=0.0, c=0.0):
return a * x * x + b * x + c
for x in X:
y = parabolic(x)
Y.append(y)
Lối lập trình này được gọi là lập trình mệnh lệnh (imperative programming).
Lối lập trình này không sai nhưng dài dòng và thủ công. Bạn phải tự mình quản từng bước trong quá trình thực hiện thuật toán đặt ra.
Trong các bài học trước bạn đã biết về các cấu trúc dữ liệu dạng container như list, touple, dict. Bạn cũng học cách xây dựng các container riêng qua bài học về iterable/iterator và generator trong Python.
Bạn có thể đã thắc mắc, tại sao Python lại phải tạo các cấu trúc rắc rối như iterator?
Trên thực tế, đó là các cấu trúc dữ liệu rất mạnh để sử dụng trong lập trình hàm (functional programming) – một xu hướng lập trình rất mạnh trong xử lý dữ liệu. Map, filter và reduce là ba loại hàm thông dụng trong xu hướng này.
Sử dụng hàm map() trong Python
Giờ hãy thay thế toàn bộ code ở trên như sau:
Ví dụ
X = [x / 100 for x in range(100, 1000)]
def parabolic(x, a=1.0, b=0.0, c=0.0):
return a * x * x + b * x + c
Y = list(map(parabolic, X))
for y in Y:
print(y, end=' ')
Đoạn code mới vẫn giữ lại phần khai báo X và hàm parabolic nhưng phần tính toán Y đã hoàn toàn thay đổi. Chúng ta sử dụng hàm map() thay cho vòng lặp for và chuyển đổi kết quả về dạng danh sách qua hàm list().
Để ý cách sử dụng hàm map(): map(parabolic, X)
. Trong đó, map() là một hàm built-in của Python. Bạn có thể trực tiếp sử dụng nó. Bạn truyền tên hàm parabolic chứ không truyền lời gọi hàm (lời gọi hàm sẽ là parabolic(x, a, b, c)).
Do hàm parabolic đòi hỏi 1 tham số, bạn chỉ cần truyền 1 iterable cho hàm map(). Nếu hàm yêu cầu nhiều hơn 1 tham số, bạn cần cung cấp số lượng iterable tương ứng. Ví dụ, nếu hàm cần 2 tham số, bạn phải cung cấp 2 iterable. Trong mỗi lượt thực hiện, map() sẽ lấy từ mỗi iterable một phần tử.
Về ý nghĩa, sử dụng hàm map() là tương tự như phiên bản imperative bạn đã thấy ở ví dụ 1: (1) lần lượt duyệt qua từng phần tử x của X; (2) ứng với mỗi phần tử x áp dụng hàm parabolic để thu được kết quả y; (3) thêm giá trị y vào object kết quả cuối cùng.
Sự khác biệt nằm ở chỗ: (1) quá trình được Python thực hiện tự động; (2) kết quả cuối cùng là một object kiểu map chứ không phải list. Do vậy bạn cần chuyển đổi từ kiểu map về kiểu list hoặc kiểu khác (nếu cần).
map() của Python tương tự như LINQ Select của C#.
Kiểu map cũng đồng thời là một generator nên bạn cũng có thể sử dụng nó trong vòng lặp for:
Ví dụ
for y in Y:
print(y, end=' ')
Giá trị của y cùng kiểu với kiểu trả về của hàm parabolic (float).
Khi sử dụng map() cần lưu ý:
- Cú pháp chung như sau:
map(<hàm>, *<iterable>)
, tức làsẽ lần lượt đem áp dụng cho từng phần tử của . Bạn cần truyền tên hàm chứ không phải lời gọi hàm. - Ở vị trí
bạn có thể sử dụng hàm toàn cục hoặc phương thức của class. Không có gì khác biệt giữa chúng. - Phụ thuộc vào lượng tham số của
, bạn cần truyền số lượng tương ứng. Nếu có 1 tham số, bạn cần truyền 1 . Nếu có 2 tham số, bạn cần truyền 2 . - Hàm map() sẽ lấy từng bộ giá trị từ các
. Ví dụ, với hàm 2 tham số (và 2 ), lượt 1 sẽ áp dụng với phần tử 1 trong thứ nhất và phần tử 1 trong thứ hai, lượt 2 sẽ áp dụng với phần tử 2 trong thứ nhất và phần tử 2 trong thứ hai, v.v.. - Kết quả trả về của hàm map() là một object kiểu map (
). Kiểu map cũng là một generator. - Phần tử của danh sách map sẽ có cùng kiểu với kiểu trả về của
. - Sử dụng map() không phải là phiên bản functional của for. Hàm map() chỉ quan tâm đến kết quả trả về của
. map() sẽ bỏ qua những hiệu ứng phụ (side-effect) như các lệnh in trong .
*Một đặc thù của lập trình hàm là mọi hàm phải trả về kết quả. Trong Python, nếu hàm không có return, Python sẽ coi kết quả trả về của hàm làNone
. None
trong Python là một từ khóa – giá trị. Các lệnh như xuất dữ liệu được gọi là hiệu ứng phụ (side-effect) trong lập trình hàm.
Hàm filter() trong Python
Hãy bắt đầu bằng một ví dụ:
Ví dụ
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def print(self):
print(f'{self.name} ({self.age} years old)')
@staticmethod
def elder(person):
if person.age >= 75:
return True
return False
presidents = [Person('Putin', 65),
Person('Trump', 80),
Person('Obama', 70),
Person('Bush', 75),
Person('Clinton', 55),
Person('Yeltsin', 65),
Person('Nixon', 90)]
def elder(person):
if person.age >= 75:
return True
return False
if __name__ == '__main__':
old_presidents = filter(Person.elder, presidents)
for p in old_presidents:
p.print()
Chạy chương trình cho kết quả:
Ví dụ
Trump (80 years old)
Bush (75 years old)
Nixon (90 years old)
Trong ví dụ trên chúng ta xây dựng một class đơn giản Person với hai attribute name và age. Trong class này chúng ta định nghĩa static method elder. Phương thức này trả về giá trị True nếu age >= 75.
Để thử nghiệm, chúng ta tạo danh sách presidents chứa các object thuộc kiểu Person.
Yêu cầu đặt ra là cần lọc danh sách presidents để lấy ra những người từ 75 tuổi trở lên.
Theo logic thông thường (imperative), bạn sẽ dùng vòng lặp for trên presidents. Ứng với mỗi phần tử bạn áp dụng hàm elder() hoặc phương thức Person.elder(). Nếu thu được kết quả True, bạn sẽ đưa object tương ứng vào danh sách kết quả.
Thay vì làm thủ công như trên, trong Python bạn có thể áp dụng giải pháp của lập trình hàm với hàm filter(): old_presidents = filter(Person.elder, presidents)
.
Cách sử dụng hàm filter() rất giống với hàm map() bạn đã làm quen ở phần trước. Tuy nhiên có vài điểm sau cần lưu ý:
- filter() cũng là một hàm built-in, do vậy bạn có thể sử dụng nó ngay lập tức trong bất kỳ module nào.
- Hàm filter() luôn yêu cầu hai tham số: filter(
, , trong đó) là hàm sẽ áp dụng trên danh sách . Có thể là hàm toàn cục hoặc phương thức của class. Trong ví dụ 3 ở trên, ở vị trí của bạn có thể dùng hàm elder() hoặc phương thức Person.elder(), ở vị trí là danh sách presidents kiểu list. - Kiểu phần tử của
phải trùng với kiểu đầu vào cũng . Điều này là đương nhiên vì sẽ lần lượt nhận từng giá trị của để kiểm tra. Trong ví dụ 3, phần tử của presidents có kiểu Person, và hàm elder()/phương thức Person.elder() đều xử lý tham số kiểu Person. - Hàm
phải trả về kết quả kiểu boolean. Nếu bạn để trả về kết quả khác, filter() sẽ trả lại nguyên vẹn danh sách . Trong ví dụ 3, elder()/Person.elder() trả về True nếu age >= 75 và False nếu ngược lại. - Do filter() chỉ nhận một
, hàm cũng chỉ được phép có 1 tham số bắt buộc. Trong ví dụ 3, hàm elder()/Person.elder() chỉ nhận 1 tham số có kiểu Person. - Dữ liệu trả về của hàm filer() có kiểu filter. Hàm filter() áp dụng
trên từng phần tử của và lấy tất cả các phần tử mà trả về giá trị True. Các phần tử này được đưa vào một object có kiểu filter. - Kiểu filter cũng là một generator, do đó bạn có thể sử dụng kết quả trong vòng lặp for hoặc chuyển nó thành các kiểu dữ liệu tập hợp phù hợp (như list).
Hàm filter() của Python hoạt động giống như phương thức Where của C# LINQ.
Hàm reduce() trong Python
Trước hết hãy xem ví dụ về hàm tính giai thừa của một số:
Ví dụ
from functools import reduce
# imperative factorial function
def imp_fact(n: int):
p = 1
for i in range(1, n + 1):
p *= i
return p
# functional factorial function
def fun_fact(n: int):
def mult(x, y): return x * y
return reduce(mult, range(1, n + 1))
def test1():
n = 3
f = imp_fact(n)
print(f'{n}! = {f}')
def test2():
n = 3
f = fun_fact(n)
print(f'{n}! = {f}')
if __name__ == '__main__':
test1()
test2()
Trong ví dụ này chúng ta xây dựng hai hàm tính giai thừa, một hàm theo kiểu imperative (imp_fact) và một hàm theo kiểu functional (fun_fact).
Để ý trong hàm imp_fact, ở vòng lặp for chúng ta thực hiện phép toán nhân dồn (cumulative) các số liên tiếp trong khoảng [1, n+1) cho p để thu được giá trị giai thừa theo định nghĩa của phép toán này.
Phép toán nhân dồn (và các phép dồn tương tự) được thực hiện bởi hàm reduce() trong fun_fact(): reduce(mult, range(1, n + 1))
với mult() là hàm nhân hai số được định nghĩa trước đó.
Khác biệt với map() và filter(), hàm reduce() không phải là hàm built-in. Để sử dụng hàm reduce(), bạn phải import nó từ package functools: from functools import reduce
. Bạn nên đặt lệnh import ở đầu file script.
Cách hoạt động của reduce với mult và range(1, n+1) trong ví dụ trên như sau:
- Giả sử n = 4, khi đó range(1, n+1) sẽ cho danh sách phần tử bao gồm các giá trị 1, 2, 3, 4.
- Áp dụng mult cho hai phần tử đầu tiên: mult(1, 2) thu được kết quả 2.
- Lấy kết quả trên (2) làm tham số thứ nhất và lấy phần tử tiếp theo của danh sách (3) làm tham số thứ hai cho mult: mult(2, 3) thu được kết quả 6.
- Tiếp tục lấy kết quả trên (6) làm tham số thứ nhất, lấy phần tử tiếp theo của danh sách (4) làm tham số thứ hai cho mult: mult(6, 4) cho kết quả 24.
- Danh sách không còn giá trị => kết thúc và trả lại giá trị 24.
Cách thức hoạt động của reduce hoàn toàn tương tự như cách chúng ta sử dụng phép nhân dồn trong vòng for bình thường:
Ví dụ
# nhân dồn với vòng lặp
for i in range(1, n+1):
p = p * i # hay p *= i
Khi sử dụng reduce() cần lưu ý:
- Cú pháp chung của reduce là:
reduce(<func>, <iterable>)
. phải là một hàm có hai tham số. - Tham số của
phải cùng kiểu với giá trị trong . - Kết quả trả về của
cũng phải cùng kiểu với giá trị trong (và cùng kiểu với tham số đầu vào của chính ) - Kết quả trả về của reduce là một giá trị cùng kiểu với kết quả trả về của
.
Sử dụng hàm lambda
Trong các ví dụ trên bạn có thể thấy, map(), filter() và reduce() đều nhận một hàm khác làm tham số thứ nhất. Do vậy, bạn đều phải định nghĩa hàm tương ứng.
Tuy nhiên, trong rất nhiều trường hợp các hàm này đều đơn giản, thường chỉ có 1 biểu thức duy nhất, ví dụ như return a*x*x + b*x + c
hay return x * y
. Một đặc điểm khác nữa là các hàm này không được sử dụng ở những chỗ khác trong code.
Việc định nghĩa hàng loạt hàm “mini” sử dụng một lần như vậy làm code khó theo dõi.
Python cho phép định nghĩa trực tiếp hàm ở nơi cần dùng hàm tham số với hàm lambda.
Hãy cùng làm lại các ví dụ ở các phần trên:
Ví dụ
def fun_fact(n: int):
# sử dụng hàm lambda x, y: x *y
return reduce(lambda x, y: x * y, range(1, n + 1))
# sử dụng hàm lambda p : True if p.age >= 75 else False
old_presidents = filter(lambda p: True if p.age >= 75 else False, presidents)
# sử dụng hàm lambda x, a=1.0, b=0.0, c=0.0: a * x * x + b * x + c
Y = map(lambda x, a=1.0, b=0.0, c=0.0: a * x * x + b * x + c, X)
Trong đoạn code trên chúng ta đã sử dụng một loạt hàm lambda:
Ví dụ
lambda x, y: x *y
lambda p : True if p.age >= 75 else False
lambda x, a=1.0, b=0.0, c=0.0: a * x * x + b * x + c
Hàm lambda là những hàm không có tên và thân chỉ chứa 1 biểu thức duy nhất.
Cú pháp chung của hàm lambda là: lambda
Trong đó, lambda là từ khóa bắt buộc. Danh sách tham số tương tự như của một hàm thông thường. Biểu thức kết quả không cần từ khóa return. Kết quả tính của biểu thức kết quả chính là kết quả thực hiện hàm lambda tương ứng.
Hàm lambda có thể được khai báo trực tiếp ở nơi cần dùng, do vậy tránh được nhu cầu xây dựng các hàm mini sử dụng một lần.
Kết luận
Trong bài học này bạn đã làm quen với một số hàm thông dụng trong lập trình hàm:
- map() áp dụng một hàm với danh sách. Có thể sử dụng map() thay cho vòng lặp for.
- filter() áp dụng một hàm với danh sách và chỉ lấy ra những phần tử phù hợp. Có thể sử dụng filter() thay cho vòng lặp for và cấu trúc rẽ nhánh if.
- reduce() áp dụng một hàm với danh sách để thu được một giá trị duy nhất. Có thể sử dụng reduce() cho phép toán cộng dồn, nhân dồn.
- Có thể sử dụng hàm lambda làm tham số thay cho hàm thông thường.
- Nhìn chung các phương pháp của lập trình hàm hơi khó hiểu hơn nhưng cách viết gọn nhẹ và xúc tích.