functools, itertools, operator, heapq và bisect: những viên ngọc trong thư viện chuẩn
Rất nhiều đoạn code Python dài 10-20 dòng thực ra có thể viết bằng một dòng gọi hàm trong thư viện chuẩn - và thường chạy nhanh hơn vì phần lặp được viết bằng C. Bài này là một chuyến tham quan có chọn lọc những công cụ ứng dụng nhiều nhất trong functools, itertools, operator, heapq và bisect, kèm theo những bẫy mà tài liệu chính thức chỉ nhắc thoáng qua.
Trong bài này, bạn sẽ học:
- Cache kết quả hàm với
cache/lru_cache- và vì sao dùng nó trên method có thể gây rò rỉ bộ nhớ partial,singledispatch,total_ordering,reduce- Xử lý dữ liệu dạng luồng với
itertools:chain,islice,groupby,batched,pairwise,accumulate, tổ hợp operator.itemgetter/attrgetter- nhanh và gọn hơn lambdaheapqcho top-k và hàng đợi ưu tiên,bisectcho tìm kiếm trên dãy đã sắp xếp
functools
Phần tiêu đề “functools”cache và lru_cache: ghi nhớ kết quả
Phần tiêu đề “cache và lru_cache: ghi nhớ kết quả”Hàm Fibonacci đệ quy kinh điển gọi lại chính nó với cùng tham số rất nhiều lần:
import functoolsimport timeit
def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2)
@functools.cache # Python 3.9+, tương đương lru_cache(maxsize=None)def fib_cached(n): return n if n < 2 else fib_cached(n - 1) + fib_cached(n - 2)
print(timeit.timeit(lambda: fib(30), number=1)) # ~0.12sprint(timeit.timeit(lambda: fib_cached(30), number=1)) # ~0.000007sprint(fib_cached.cache_info())# CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)Từ O(2ⁿ) xuống O(n) chỉ với một dòng decorator. Bên trong, cache là một dict với key được tạo từ các tham số - vì vậy:
1. Tham số phải hashable.
import functools
@functools.lru_cachedef total(items): return sum(items)
total((1, 2, 3)) # OKtotal([1, 2, 3]) # TypeError: unhashable type: 'list'2. f(1, 2) và f(a=1, b=2) là hai key khác nhau - cùng kết quả nhưng được cache hai lần.
3. cache không giới hạn kích thước. Với hàm nhận đầu vào từ người dùng (URL, ID…), cache sẽ lớn mãi - một dạng memory leak. Dùng lru_cache(maxsize=1024): khi đầy, bỏ phần tử lâu nhất chưa được dùng (Least Recently Used).
4. Kết quả được trả về chính là object đã cache. Nếu hàm trả về list và người gọi sửa list đó, lần gọi sau nhận list đã bị sửa. Hãy trả về dữ liệu bất biến (tuple, frozenset) từ hàm được cache.
Bẫy: lru_cache trên method
Phần tiêu đề “Bẫy: lru_cache trên method”import functools, gc, weakref
class UserService: @functools.lru_cache(maxsize=None) def get_user(self, user_id): return {"id": user_id}
svc = UserService()svc.get_user(1)
ref = weakref.ref(svc)del svcgc.collect()print(ref() is not None) # True - object KHÔNG được giải phóng!self là một phần của key cache, và cache nằm trên hàm của class (sống mãi). Vì vậy mọi instance từng gọi method bị giữ sống vĩnh viễn. Cách xử lý:
- Nếu kết quả không phụ thuộc
self→ chuyển thành hàm cấp module hoặc@staticmethodcó cache. - Cache cho từng instance → tạo cache trong
__init__:self.get_user = functools.lru_cache(maxsize=128)(self._get_user). - Thuộc tính tính một lần →
functools.cached_property(xem bài Descriptor), cache nằm trong__dict__của instance nên được giải phóng cùng object.
partial: cố định sẵn một số tham số
Phần tiêu đề “partial: cố định sẵn một số tham số”from functools import partial
def power(base, exp): return base ** exp
square = partial(power, exp=2)cube = partial(power, exp=3)print(square(5), cube(2)) # 25 8
# Rất tiện khi API cần một hàm không tham số / ít tham số hơnint_from_binary = partial(int, base=2)print(int_from_binary("1011")) # 11Khác với lambda, partial pickle được (nếu hàm gốc pickle được) - dùng được với multiprocessing - và có thể xem lại bên trong: square.func, square.keywords.
singledispatch: “overload” theo kiểu của tham số đầu tiên
Phần tiêu đề “singledispatch: “overload” theo kiểu của tham số đầu tiên”Thay vì một chuỗi if isinstance(...) elif isinstance(...):
from functools import singledispatchfrom datetime import datefrom decimal import Decimal
@singledispatchdef to_json(value): raise TypeError(f"không hỗ trợ kiểu {type(value).__name__}")
@to_json.registerdef _(value: str): return f'"{value}"'
@to_json.registerdef _(value: int | float): # union type được hỗ trợ từ 3.11 return str(value)
@to_json.registerdef _(value: date): return f'"{value.isoformat()}"'
@to_json.registerdef _(value: list): return "[" + ", ".join(to_json(v) for v in value) + "]"
print(to_json(["An", 16, date(2026, 9, 23)])) # ["An", 16, "2026-09-23"]Ưu điểm lớn: module khác có thể đăng ký thêm kiểu mà không sửa code gốc - to_json.register(Decimal, lambda v: str(v)). Cho method trong class, dùng singledispatchmethod.
total_ordering: viết 2 phương thức, có đủ 6 phép so sánh
Phần tiêu đề “total_ordering: viết 2 phương thức, có đủ 6 phép so sánh”from functools import total_ordering
@total_orderingclass Version: def __init__(self, s): self.parts = tuple(map(int, s.split(".")))
def __eq__(self, other): return self.parts == other.parts
def __lt__(self, other): return self.parts < other.parts
print(Version("3.10.1") > Version("3.9.7")) # True (so sánh chuỗi sẽ cho False!)print(max(["3.9", "3.10", "3.2"], key=Version)) # 3.10total_ordering tự sinh <=, >, >= từ __eq__ và __lt__. (Nếu class là dataclass, @dataclass(order=True) cũng làm được điều này.)
reduce: gộp một dãy thành một giá trị
Phần tiêu đề “reduce: gộp một dãy thành một giá trị”from functools import reduceimport operator
print(reduce(operator.mul, [1, 2, 3, 4, 5])) # 120merged = reduce(lambda a, b: a | b, [{"a": 1}, {"b": 2}, {"a": 3}])print(merged) # {'a': 3, 'b': 2}Với các phép gộp phổ biến, hãy ưu tiên hàm có sẵn vì dễ đọc hơn: sum, math.prod, max, min, any, all, "".join.
itertools: xử lý dữ liệu như dòng chảy
Phần tiêu đề “itertools: xử lý dữ liệu như dòng chảy”Mọi hàm trong itertools trả về iterator lười - chỉ tính phần tử khi được yêu cầu. Bạn có thể xử lý file hàng GB hoặc dãy vô hạn mà không cần nạp tất cả vào RAM (xem thêm Generators và Iterators).
Nối và cắt
Phần tiêu đề “Nối và cắt”from itertools import chain, islice
lists = [[1, 2], [3], [4, 5, 6]]print(list(chain.from_iterable(lists))) # [1, 2, 3, 4, 5, 6] - làm phẳng 1 cấp
# islice: cắt iterator như slice, không cần len()with open(__file__) as f: first_3_lines = list(islice(f, 3)) # đọc đúng 3 dòng, không đọc hết filebatched (3.12+): chia thành từng lô
Phần tiêu đề “batched (3.12+): chia thành từng lô”Rất hay dùng khi gửi dữ liệu lên API hoặc insert database theo lô:
from itertools import batched
ids = range(1, 8)for batch in batched(ids, 3): print(batch)# (1, 2, 3)# (4, 5, 6)# (7,)Từ 3.13, batched(..., strict=True) ném ValueError nếu lô cuối không đủ.
pairwise (3.10+): các cặp liền kề
Phần tiêu đề “pairwise (3.10+): các cặp liền kề”from itertools import pairwise
prices = [100, 104, 101, 110]changes = [b - a for a, b in pairwise(prices)]print(changes) # [4, -3, 9]accumulate: tổng dồn và hơn thế
Phần tiêu đề “accumulate: tổng dồn và hơn thế”from itertools import accumulate
print(list(accumulate([1, 2, 3, 4]))) # [1, 3, 6, 10] tổng tiền tốprint(list(accumulate([3, 1, 4, 1, 5], max))) # [3, 3, 4, 4, 5] giá trị lớn nhất tới hiện tạiTổng tiền tố (prefix sum) là kỹ thuật cơ bản để trả lời “tổng đoạn từ i đến j” trong O(1) - rất hay gặp trong bài thi lập trình.
groupby: gom nhóm các phần tử liền kề
Phần tiêu đề “groupby: gom nhóm các phần tử liền kề”from itertools import groupby
print([(k, list(g)) for k, g in groupby("aabba")])# [('a', ['a', 'a']), ('b', ['b', 'b']), ('a', ['a'])] <- 'a' xuất hiện HAI nhóm!groupby chỉ gom các phần tử đứng cạnh nhau - khác với GROUP BY của SQL. Muốn gom theo khoá trên toàn bộ dữ liệu, phải sắp xếp theo cùng khoá trước:
from itertools import groupbyfrom operator import itemgetter
orders = [ {"customer": "An", "total": 100}, {"customer": "Bình", "total": 50}, {"customer": "An", "total": 70},]
key = itemgetter("customer")for customer, group in groupby(sorted(orders, key=key), key=key): print(customer, sum(o["total"] for o in group))# An 170# Bình 50Thêm một bẫy: mỗi group là iterator dùng chung dữ liệu với groupby; khi chuyển sang nhóm tiếp theo, nhóm trước không còn đọc được. Cần giữ lại thì chuyển thành list ngay (list(group)). Nếu dữ liệu không có thứ tự sẵn, defaultdict(list) thường đơn giản hơn.
Tổ hợp
Phần tiêu đề “Tổ hợp”from itertools import product, permutations, combinations
print(list(product("AB", [1, 2]))) # [('A', 1), ('A', 2), ('B', 1), ('B', 2)]print(list(permutations("ABC", 2))) # 6 hoán vị chập 2print(list(combinations([1, 2, 3, 4], 2)))# 6 tổ hợp chập 2
# product thay cho vòng lặp lồng nhaufor x, y, z in product(range(2), repeat=3): pass # 8 bộ (x, y, z) nhị phânCác hàm khác đáng nhớ
Phần tiêu đề “Các hàm khác đáng nhớ”| Hàm | Công dụng |
|---|---|
count(start, step) |
đếm vô hạn - kết hợp zip để đánh số |
cycle(it) |
lặp vòng vô hạn (xoay vòng server, màu sắc) |
repeat(x, n) |
lặp một giá trị |
zip_longest(a, b, fillvalue=...) |
zip không cắt theo dãy ngắn nhất |
takewhile(pred, it) / dropwhile |
lấy/bỏ phần tử khi điều kiện còn đúng |
compress(data, selectors) |
lọc theo một dãy boolean |
starmap(f, pairs) |
f(*pair) cho từng cặp |
tee(it, n) |
tách một iterator thành n iterator độc lập |
operator: thay lambda bằng hàm C
Phần tiêu đề “operator: thay lambda bằng hàm C”import operatorimport timeit
data = [{"n": i % 100, "s": str(i)} for i in range(100_000)]
print(timeit.timeit(lambda: sorted(data, key=lambda d: d["n"]), number=10)) # ~0.061sprint(timeit.timeit(lambda: sorted(data, key=operator.itemgetter("n")), number=10)) # ~0.035sitemgetter nhanh gần gấp đôi lambda tương đương vì không phải tạo frame Python cho mỗi lần gọi. Và nó còn tiện hơn:
from operator import itemgetter, attrgetter, methodcaller
rows = [("An", 20, 8.5), ("Bình", 19, 9.0), ("Chi", 20, 7.5)]print(sorted(rows, key=itemgetter(1, 2))) # sắp xếp theo tuổi, rồi theo điểm
get_name_age = itemgetter(0, 1)print(get_name_age(rows[0])) # ('An', 20)
# attrgetter hỗ trợ đường dẫn có dấu chấm: attrgetter("address.city")# methodcaller("strip") tương đương lambda s: s.strip()print(list(map(methodcaller("upper"), ["a", "b"]))) # ['A', 'B']Các toán tử cũng có dạng hàm: operator.add, operator.mul, operator.or_, operator.not_…
heapq: top-k và hàng đợi ưu tiên
Phần tiêu đề “heapq: top-k và hàng đợi ưu tiên”heapq biến một list thường thành min-heap: phần tử nhỏ nhất luôn ở heap[0], thêm/lấy ra trong O(log n).
Top-k mà không cần sắp xếp toàn bộ
Phần tiêu đề “Top-k mà không cần sắp xếp toàn bộ”import heapqimport random
scores = [random.randint(0, 1_000_000) for _ in range(1_000_000)]
top5 = heapq.nlargest(5, scores) # O(n log k) thay vì O(n log n)print(top5)
students = [{"name": "An", "gpa": 3.2}, {"name": "Bình", "gpa": 3.8}, {"name": "Chi", "gpa": 3.5}]print(heapq.nsmallest(2, students, key=lambda s: s["gpa"]))Quy tắc: k nhỏ → nlargest/nsmallest; k = 1 → max/min; k gần bằng n → sorted(...)[:k].
Hàng đợi ưu tiên
Phần tiêu đề “Hàng đợi ưu tiên”import heapqimport itertools
class PriorityQueue: def __init__(self): self._heap = [] self._counter = itertools.count() # phá hoà để giữ thứ tự FIFO, tránh so sánh item
def push(self, item, priority): heapq.heappush(self._heap, (priority, next(self._counter), item))
def pop(self): priority, _, item = heapq.heappop(self._heap) return item
def __len__(self): return len(self._heap)
pq = PriorityQueue()pq.push("gửi email", priority=3)pq.push("xử lý thanh toán", priority=1)pq.push("ghi log", priority=5)while pq: print(pq.pop())# xử lý thanh toán# gửi email# ghi logBộ đếm _counter quan trọng: nếu hai task cùng priority, tuple sẽ so sánh tiếp tới item - với dict hay object không so sánh được, bạn sẽ gặp TypeError. Đây là mẫu dùng trong thuật toán Dijkstra, lập lịch công việc, merge nhiều file đã sắp xếp (heapq.merge).
Cần max-heap? Đẩy vào giá trị âm (-priority). (Python 3.14 bổ sung các hàm max-heap như heapq.heappush_max.)
bisect: tìm kiếm nhị phân trên dãy đã sắp xếp
Phần tiêu đề “bisect: tìm kiếm nhị phân trên dãy đã sắp xếp”import bisect
# Xếp loại theo điểm: tìm vị trí điểm trong các mốcbreakpoints = [5.0, 6.5, 8.0, 9.0]grades = ["Yếu", "Trung bình", "Khá", "Giỏi", "Xuất sắc"]
def classify(score): return grades[bisect.bisect_right(breakpoints, score)]
print([classify(s) for s in [4.0, 6.5, 7.9, 8.0, 9.5]])# ['Yếu', 'Khá', 'Khá', 'Giỏi', 'Xuất sắc']import bisect
# Chèn mà vẫn giữ thứ tự (tìm vị trí O(log n), chèn O(n))events = [10, 20, 40]bisect.insort(events, 30)print(events) # [10, 20, 30, 40]
# Đếm số phần tử trong khoảng [lo, hi] trên dãy đã sắp xếp: O(log n)def count_between(sorted_list, lo, hi): return bisect.bisect_right(sorted_list, hi) - bisect.bisect_left(sorted_list, lo)
print(count_between([1, 3, 3, 5, 7, 9], 3, 7)) # 4Từ Python 3.10, các hàm bisect nhận tham số key=, tiện khi tìm trong list các object đã sắp xếp theo một trường.
Bài tập
Phần tiêu đề “Bài tập”- Dùng
lru_cachegiải bài toán “số cách leo cầu thang n bậc, mỗi lần 1, 2 hoặc 3 bậc” với n = 500. Xemcache_info(). - Cho file log dạng
thời_gian,level,message(đã sắp xếp theo thời gian). Dùnggroupbyđếm số dòng mỗi level trong từng phút. - Dùng
heapq.mergegộp 3 file số đã sắp xếp (mỗi file vài triệu dòng) thành một file đã sắp xếp mà không nạp hết vào RAM. - Dùng
batchedvàThreadPoolExecutorgửi 10.000 bản ghi lên một API giả lập, mỗi request 100 bản ghi.
Kết luận
Phần tiêu đề “Kết luận”cache/lru_cachetăng tốc thần kỳ nhưng cần tham số hashable, có giới hạn kích thước, và tránh dùng trực tiếp trên method.partial,singledispatch,total_orderinggiúp code ngắn và dễ mở rộng.itertoolsxử lý dữ liệu lười:batched,pairwise,accumulate,chain- nhớgroupbychỉ gom phần tử liền kề.itemgetter/attrgetternhanh hơn và rõ nghĩa hơn lambda làm key.heapqcho top-k và hàng đợi ưu tiên;bisectcho tìm kiếm và chèn trên dãy đã sắp xếp.
Bài tiếp theo: Đo và tối ưu hiệu năng.