Tham Số Mặc Định Biến Đổi và Những Ký Ức Không Ngờ
Trong Python, một hàm không chỉ là một khối mã, nó là một đối tượng sống động với bộ nhớ. Đôi khi, bộ nhớ đó ồn ào hơn bạn nghĩ.
Chúng ta mong đợi rằng mỗi lần gọi ring(), nó sẽ tạo ra một danh sách mới và thêm "clang". Nhưng thực tế, mỗi lần gọi lại nhớ đến lần trước. Tại sao lại như vậy?
Phần 1: Khi Nào Tham Số Mặc Định Được Đánh Giá?
Trong Python, tham số mặc định chỉ được đánh giá một lần - vào thời điểm hàm được định nghĩa, không phải mỗi lần nó được gọi.
Vì vậy, trong:
python
def ring(bell=[]):
Python tạo ra một danh sách rỗng mới một lần và gán nó cho tham số mặc định bell. Tất cả các lần gọi sau tới ring() mà không có tham số bell sẽ sử dụng cùng một danh sách đó.
Do đó:
python
first = ring()
second = ring()
third = ring()
print(first is second is third) # True
Tất cả đều là cùng một đối tượng.
Hành vi này thường gây bất ngờ cho người mới bắt đầu. Nó khiến bạn cảm thấy như Python đang nhớ những thứ mà nó nên quên. Nhưng đó không phải là ma thuật. Đó là bộ nhớ: bền bỉ và chia sẻ.
Phần 2: Tham Số Mặc Định Biến Đổi và Bất Biến: Một Sự Đối Chiếu Nhẹ Nhàng
Vấn đề chỉ phát sinh với giá trị mặc định biến đổi, như danh sách hoặc từ điển. Thế còn nếu chúng ta sử dụng một giá trị bất biến, chẳng hạn như một tuple thì sao?
Xem xét:
python
def ring(bell=()):
bell += ("clang",)
return bell
Bạn mong đợi điều gì?
python
ring() # ('clang',)
ring() # ('clang',)
ring() # ('clang',)
Mỗi lần gọi đều cho chúng ta một kết quả mới. Không có sự tích lũy. Không có âm vang.
Tại sao?
Vì tuple là bất biến. Toán tử += không thể sửa đổi tuple hiện có. Thay vào đó, nó tạo ra một cái mới. Trong Python, hàm id() cho bạn địa chỉ bộ nhớ của một đối tượng. Bạn có thể sử dụng điều này để kiểm tra xem các đối tượng có giống nhau hay không, nhưng hãy cẩn thận với bộ nhớ đệm CPython (còn gọi là interning) mà chúng ta đã học tuần trước. Để tránh interning của CPython, chúng ta cần gán kết quả cho một đối tượng mới:
python
import time
def ring(bell=()):
bell += (str(time.time()),)
return bell
sound1 = ring(); print(id(sound1)) # 135080430662176
sound2 = ring(); print(id(sound2)) # 135080430179360
sound3 = ring(); print(id(sound3)) # 135080430181856
Mỗi lần, Python xây dựng một đối tượng mới và trả về nó. Tham số mặc định ban đầu vẫn không bị thay đổi.
Đó là lý do tại sao chỉ có tham số mặc định biến đổi mới gây ra vấn đề. Không phải chính tham số mặc định là nguy hiểm, mà là khả năng biến đổi.
Khi một đối tượng có thể thay đổi và bạn sử dụng lại đối tượng đó qua các lần gọi, bạn có nguy cơ gặp phải sự bền bỉ không mong muốn.
Phần 3: Cách Đúng: Sử Dụng None Như Một Sentinel
Giải pháp thông thường là:
python
def ring(bell=None):
if bell is None:
bell = []
bell.append("clang")
return bell
Bây giờ:
python
ring() # ['clang']
ring() # ['clang']
Mỗi lần gọi đều nhận được một danh sách mới.
Tại sao lại sử dụng None?
Bởi vì None là bất biến và duy nhất. Nó làm một sentinel tuyệt vời, một dấu hiệu cho chúng ta biết, "Không có tham số nào được cung cấp."
Nếu bạn sử dụng mẫu này, các hàm của bạn sẽ hoạt động một cách dự đoán được. Những âm vang sẽ biến mất khi chúng nên như vậy.
Phần 4: Khi Nào Bạn Có Thể Sử Dụng Tham Số Mặc Định Biến Đổi?
Có những lúc hiếm hoi khi trạng thái biến đổi chia sẻ là có chủ ý. Ví dụ, một hàm ghi nhớ kết quả của chính nó:
python
def factorial(n, cache={0: 1}):
if n not in cache:
cache[n] = n * factorial(n - 1)
return cache[n]
Ở đây, từ điển mặc định hoạt động như một bộ nhớ bền bỉ. Nhưng đây là một lựa chọn có chủ ý, không phải một tai nạn.
Nếu bạn làm điều này, hãy tài liệu hóa rõ ràng hành vi này. Hầu hết thời gian, những mẫu như vậy được xử lý tốt hơn bằng các decorator hoặc bộ nhớ bên ngoài.
Trạng thái chia sẻ không phải là sai, nhưng nó phải là một thiết kế có ý thức, không phải là một tác dụng phụ tình cờ.
Thực Hành Tốt Nhất: Những Ghi Chú Của Người Làm Chuông
- Tránh sử dụng các đối tượng biến đổi như danh sách hoặc từ điển làm tham số mặc định.
Chúng tồn tại qua các lần gọi và có thể dẫn đến những hành vi bất ngờ.
- Sử dụng
Nonenhư một mặc định khi bạn muốn có một đối tượng mới mỗi lần.
Bên trong hàm, hãy tạo một đối tượng mới chỉ khi cần thiết.
- Các tham số mặc định bất biến (như
None,0,'', hoặc()) luôn an toàn.
Các phép toán như += trên chúng sẽ trả về các đối tượng mới, giữ nguyên tham số mặc định.
- Nếu bạn phải sử dụng một tham số mặc định biến đổi, hãy tài liệu hóa hành vi rõ ràng.
Xem xét nó như một trạng thái chia sẻ và đảm bảo thiết kế của bạn yêu cầu điều đó.
- Khi gỡ lỗi, sử dụng
id()hoặc ghi nhật ký để xác nhận liệu cùng một đối tượng có được sử dụng lại hay không, nhưng hãy cẩn thận với interning có thể gây nhầm lẫn.
Nếu hàm của bạn "nhớ" những điều gì đó, hãy tự hỏi: "Liệu tôi có ý định rung chuông giống nhau lần nữa không?"
Kết Thúc Vòng Tròn
Người mới hỏi, "Tại sao chuông ngày càng lớn tiếng mỗi khi tôi gọi nó?"
Người thầy trả lời,
"Bởi vì bạn chưa bao giờ yêu cầu một cái chuông mới."
Trong Python, nếu một tham số mặc định là biến đổi, nó sẽ phát triển, vọng lại, và tồn tại qua các lần gọi.
Để viết mã Python sạch sẽ, bạn cần biết những chuông nào vọng lại, và khi nào cần rung một cái mới.
Cảm ơn bạn đã đọc Python Koans! Nếu bạn thích bài viết này, hãy xem xét việc chia sẻ với bạn bè hoặc đăng ký bên dưới:
Python Koans | Vivis Dev | Substack
Các bài học Python được bọc trong các koans. Những câu đố nhỏ, những sự thật sâu sắc. Không phải là chuỗi hướng dẫn thông thường của bạn. Nhấp để đọc Python Koans, bởi Vivis Dev, một ấn phẩm Substack với hàng trăm người đăng ký.