Chương 07 · File I/O & Regex

Xử lý tệp tin và biểu thức chính quy

Phần lớn công việc thực tế của một script Python là đọc dữ liệu từ đâu đó, biến đổi nó, rồi ghi ra chỗ khác. Chương này trang bị đủ cho việc đó: mở tệp đúng chế độ, không bao giờ quên đóng, hiểu encoding để không gặp UnicodeDecodeError lúc nửa đêm, dùng pathlib thay cho chuỗi đường dẫn nối bằng dấu /, và đọc/ghi hai định dạng phổ biến nhất là JSON và CSV. Nửa sau chương là biểu thức chính quy — công cụ tìm kiếm theo mẫu mạnh nhất, và cũng dễ bị lạm dụng nhất.

Độ dài: ~1500 dòng Bài tập: 5 Quiz: 8 Prerequisites: Chương 1-6
🎯 Mục tiêu chương
  • Dùng open() với đúng chế độ mở tệp: r, w, a, x, b, +.
  • Luôn dùng with — hiểu vì sao nó an toàn hơn try/finally thủ công.
  • Đọc tệp theo 4 cách và biết khi nào dùng cách nào (đặc biệt: tệp lớn hơn RAM).
  • Hiểu encoding: vì sao luôn ghi encoding="utf-8", và cách gỡ UnicodeDecodeError.
  • Thành thạo pathlib.Path — chuẩn hiện đại thay cho os.path.
  • Đọc/ghi JSON và CSV bằng stdlib, kể cả trường hợp có tiếng Việt.
  • Nắm cú pháp regex: ký tự đại diện, lượng từ, nhóm, nhóm có tên, lớp ký tự.
  • Dùng đúng search / match / fullmatch / findall / finditer / sub / split.
  • Biết khi nào KHÔNG nên dùng regex — và dùng gì thay thế.
Simple is better than complex.
Complex is better than complicated.

1. open() — cánh cửa vào tệp tin

Hàm built-in open() trả về một đối tượng tệp (file object) — thứ bạn đọc, ghi, và cuối cùng phải đóng. Ba tham số quan trọng nhất:

chu_ky_open.py
open(
    file,                    # đường dẫn (str hoặc Path)
    mode="r",               # chế độ mở — xem bảng bên dưới
    encoding=None,          # ⚠️ LUÔN đặt "utf-8" cho tệp văn bản
    newline=None,           # cách xử lý ký tự xuống dòng
    errors=None,             # hành vi khi giải mã lỗi
)

1.1. Chế độ mở tệp — bảng đầy đủ

Chế độTênTệp chưa tồn tạiTệp đã tồn tạiVị trí con trỏĐọcGhi
"r"read (mặc định)FileNotFoundErrorGiữ nguyênĐầu tệp✅❌
"w"writeTạo mớiXoá sạch nội dung!Đầu tệp❌✅
"a"appendTạo mớiGiữ nguyênCuối tệp❌✅
"x"exclusive createTạo mớiFileExistsErrorĐầu tệp❌✅
"r+"read + writeFileNotFoundErrorGiữ nguyênĐầu tệp✅✅
"w+"write + readTạo mớiXoá sạch!Đầu tệp✅✅
"a+"append + readTạo mớiGiữ nguyênCuối tệp✅✅

Thêm hậu tố b vào bất kỳ chế độ nào để làm việc ở dạng nhị phân: "rb", "wb", "ab", "rb+". Ở chế độ nhị phân, bạn đọc/ghi bytes thay vì str, và không được truyền encoding.

⚠️ "w" xoá sạch tệp ngay lúc mở

Không phải lúc ghi — mà ngay khoảnh khắc open(path, "w") chạy. Kể cả khi chương trình sập ngay dòng sau, tệp cũ đã mất. Đây là cách phá dữ liệu phổ biến nhất của người mới. Ba cách phòng:

  • Dùng "x" khi bạn biết tệp phải chưa tồn tại — Python sẽ chặn thay vì ghi đè.
  • Dùng "a" khi chỉ muốn thêm vào.
  • Ghi ra tệp tạm rồi Path.replace() — thao tác nguyên tử, không bao giờ để lại tệp ghi dở.

2. with — luôn luôn dùng

ba_cach.py
# ❌ TỆ — quên đóng, hoặc lỗi giữa chừng thì không bao giờ đóng
f = open("data.txt", encoding="utf-8")
noi_dung = f.read()
f.close()

# ⚠️ TẠM ĐƯỢC — đúng nhưng dài dòng
f = open("data.txt", encoding="utf-8")
try:
    noi_dung = f.read()
finally:
    f.close()

# ✅ ĐÚNG — with tự đóng, kể cả khi có exception
with open("data.txt", encoding="utf-8") as f:
    noi_dung = f.read()
# ra khỏi khối → f.close() đã được gọi
🧠 Vì sao quên close() lại nguy hiểm
  • Dữ liệu chưa được ghi xuống đĩa. Python đệm nội dung trong bộ nhớ; close() mới xả (flush) xuống. Chương trình sập trước đó → mất dữ liệu.
  • Cạn file descriptor. Hệ điều hành giới hạn số tệp mở đồng thời (thường 256-1024). Vòng lặp mở 10.000 tệp mà không đóng → OSError: Too many open files.
  • Khoá tệp trên Windows. Tệp đang mở không xoá/đổi tên được.

CPython có bộ đếm tham chiếu nên thường tự đóng tệp khi biến ra khỏi phạm vi — nhưng đó là chi tiết cài đặt của CPython, không phải cam kết của ngôn ngữ. PyPy hay các bản cài đặt khác không đảm bảo điều đó. Luôn dùng with.

nhieu_file.py — mở nhiều tệp trong một with
# Nối hai tệp thành một
with (
    open("a.txt", encoding="utf-8") as f1,
    open("b.txt", encoding="utf-8") as f2,
    open("gop.txt", "w", encoding="utf-8") as out,
):
    out.write(f1.read())
    out.write(f2.read())
# Cả 3 tệp đều được đóng, kể cả nếu open("b.txt") ném lỗi

Cú pháp ngoặc tròn bao nhiều context manager là tính năng của Python 3.10+ (PEP 617), cho phép xuống dòng gọn gàng. Bản cũ hơn viết trên một dòng: with open(a) as f1, open(b) as f2:. Cơ chế bên trong của with — __enter__/__exit__ — sẽ được mổ xẻ ở Chương 10.

3. Bốn cách đọc tệp

CáchTrả vềBộ nhớDùng khi
f.read()str toàn bộ tệpBằng cỡ tệpTệp nhỏ, cần xử lý cả khối
f.read(n)str tối đa n ký tựn ký tựĐọc theo khối cố định
f.readline()Một dòng (kèm \n)Một dòngCần kiểm soát thủ công từng dòng
f.readlines()list[str] mọi dòngBằng cỡ tệp + overhead listHiếm khi cần — thường có cách tốt hơn
for line in f ⭐Lặp lười từng dòngMột dòngMặc định — mọi kích thước tệp
doc_file.py
# ① Đọc toàn bộ — tệp nhỏ
with open("config.txt", encoding="utf-8") as f:
    noi_dung = f.read()

# ② Lặp từng dòng — CÁCH MẶC ĐỊNH, chạy được với tệp 100 GB
with open("nhat_ky.log", encoding="utf-8") as f:
    for so_dong, dong in enumerate(f, start=1):
        if "ERROR" in dong:
            print(f"{so_dong}: {dong.rstrip()}")   # rstrip bỏ \n cuối dòng

# ③ Đọc theo khối — tệp nhị phân lớn, tính checksum...
with open("video.mp4", "rb") as f:
    while khoi := f.read(8192):    # walrus := gán và kiểm tra cùng lúc
        xu_ly(khoi)

# ④ Danh sách dòng đã làm sạch (chỉ khi tệp nhỏ)
with open("ten.txt", encoding="utf-8") as f:
    ten = [d.strip() for d in f if d.strip()]   # bỏ dòng trống

Đối tượng tệp là một iterator

for dong in f không phải phép màu — đối tượng tệp cài đặt giao thức iterator, mỗi lần next() đọc thêm đúng một dòng từ đĩa. Nhờ vậy bạn xử lý được tệp lớn hơn RAM mà không cần nghĩ gì. Hệ quả cần nhớ: iterator dùng một lần — đọc hết rồi thì vòng for thứ hai không thấy gì nữa, phải f.seek(0) để tua về đầu. Chương 9 sẽ mổ xẻ giao thức này.

4. Ghi tệp

ghi_file.py
# ① write() — KHÔNG tự thêm \n
with open("ra.txt", "w", encoding="utf-8") as f:
    f.write("Dòng một\n")
    f.write("Dòng hai\n")
    f.write(f"Kết quả: {42}\n")

# ② writelines() — cũng KHÔNG tự thêm \n (tên gây hiểu lầm!)
dong = ["a\n", "b\n", "c\n"]
with open("ra.txt", "w", encoding="utf-8") as f:
    f.writelines(dong)

# ③ Cách Pythonic khi có sẵn list không có \n
ten = ["An", "Bình", "Chi"]
with open("ten.txt", "w", encoding="utf-8") as f:
    f.write("\n".join(ten))

# ④ print() ghi thẳng vào tệp — tiện vì tự thêm \n
with open("bao_cao.txt", "w", encoding="utf-8") as f:
    print("=== BÁO CÁO ===", file=f)
    for t in ten:
        print(f"- {t}", file=f)

# ⑤ Thêm vào cuối, không ghi đè
with open("nhat_ky.log", "a", encoding="utf-8") as f:
    f.write("[INFO] khởi động\n")
💡 Ghi an toàn — mẫu "ghi tạm rồi thay thế"
ghi_an_toan.py
from pathlib import Path

def ghi_an_toan(duong_dan: Path, noi_dung: str) -> None:
    """Ghi nguyên tử: hoặc tệp cũ nguyên vẹn, hoặc tệp mới hoàn chỉnh."""
    tam = duong_dan.with_suffix(duong_dan.suffix + ".tmp")
    tam.write_text(noi_dung, encoding="utf-8")
    tam.replace(duong_dan)      # thao tác nguyên tử ở tầng hệ điều hành

Nếu chương trình sập giữa lúc ghi, tệp gốc vẫn nguyên vẹn — chỉ còn lại một tệp .tmp dở dang vô hại. Mẫu này bắt buộc với tệp cấu hình và dữ liệu quan trọng.

5. Encoding — nguồn gốc của mọi lỗi tiếng Việt

Trên đĩa, tệp chỉ là dãy byte. Chuỗi Python là dãy ký tự Unicode. Encoding là quy tắc chuyển đổi giữa hai thứ đó. Chọn sai quy tắc → chữ biến thành ký tự lạ, hoặc chương trình sập.

encoding_demo.py
s = "Xin chào"

print(len(s))                        # 8 — tám KÝ TỰ
print(len(s.encode("utf-8")))        # 9 — chín BYTE (chỉ "à" có dấu, tốn 2 byte: \xc3\xa0)
print(s.encode("utf-8"))             # b'Xin ch\xc3\xa0o'

# Giải mã sai bảng mã → chữ rác (mojibake)
byte_utf8 = s.encode("utf-8")
print(byte_utf8.decode("latin-1"))    # 'Xin chà o' — sai nhưng KHÔNG báo lỗi!

# Bảng mã không biểu diễn được → lỗi
s.encode("ascii")
# UnicodeEncodeError: 'ascii' codec can't encode character '\xe0'
⚠️ Luôn ghi encoding="utf-8" — mọi lúc

Không truyền encoding thì Python dùng bảng mã mặc định của hệ thống (locale.getpreferredencoding()). Trên macOS/Linux thường là UTF-8 — có vẻ ổn. Nhưng trên Windows tiếng Việt, mặc định có thể là cp1258. Kết quả: code chạy hoàn hảo trên máy bạn, sập trên máy đồng nghiệp, hoặc tệ hơn — chạy được nhưng lưu ra chữ rác.

luon_utf8.py
# ❌ phụ thuộc hệ thống
open("data.txt")

# ✅ tường minh, giống nhau trên mọi máy
open("data.txt", encoding="utf-8")

Python 3.15 sẽ đổi mặc định thành UTF-8 (PEP 686), nhưng đến lúc đó vẫn nên viết tường minh — code của bạn phải chạy được trên cả bản cũ. Bật cảnh báo bằng python -X warn_default_encoding để tìm những chỗ còn thiếu.

errors=Hành vi khi gặp byte không giải mã đượcDùng khi
"strict" (mặc định)Ném UnicodeDecodeErrorMặc định — bạn muốn biết dữ liệu hỏng
"ignore"Bỏ qua byte lỗiChấp nhận mất dữ liệu để chương trình chạy tiếp
"replace"Thay bằng � (U+FFFD)Hiển thị cho người xem, thấy rõ chỗ hỏng
"surrogateescape"Giữ byte lỗi ở dạng đặc biệt, khôi phục được khi ghi lạiĐọc-sửa-ghi tệp có byte lạ mà không phá phần còn lại
go_loi_encoding.py
# Gặp UnicodeDecodeError? Ba bước chẩn đoán:

# 1. Xem vài byte đầu để đoán bảng mã
with open("bi_an.txt", "rb") as f:
    print(f.read(50))
# b'\xef\xbb\xbfXin ch\xc3\xa0o'  ← \xef\xbb\xbf là BOM của UTF-8

# 2. Có BOM → dùng utf-8-sig để Python tự bỏ BOM
with open("bi_an.txt", encoding="utf-8-sig") as f:
    print(f.read())

# 3. Không rõ bảng mã → thử lần lượt
for ma in ("utf-8", "utf-8-sig", "cp1258", "latin-1"):
    try:
        with open("bi_an.txt", encoding=ma) as f:
            print(ma, "→", f.read(80))
    except UnicodeDecodeError as e:
        print(ma, "→ hỏng ở byte", e.start)
📌 BOM là gì?

Byte Order Mark — ba byte EF BB BF mà một số công cụ Windows (Notepad, Excel) chèn vào đầu tệp UTF-8 để đánh dấu. Đọc bằng "utf-8" thì ký tự đầu tiên của bạn sẽ là '\ufeff' vô hình — gây lỗi kiểu KeyError: '\ufeffTên' khi đọc CSV. Dùng encoding="utf-8-sig" để Python tự bỏ nó. Khi ghi CSV cho Excel mở đúng tiếng Việt, ngược lại bạn cần ghi bằng "utf-8-sig".

6. pathlib — cách hiện đại làm việc với đường dẫn

Trước Python 3.4, đường dẫn là chuỗi và bạn dùng module os.path với hàng chục hàm rời rạc. pathlib gói tất cả vào một class Path — hướng đối tượng, gọn, và tự lo khác biệt giữa Windows (\) và Unix (/).

Việc cần làmos.path (cũ)pathlib (nên dùng)
Nối đường dẫnos.path.join(a, b, c)Path(a) / b / c
Tệp có tồn tạios.path.exists(p)p.exists()
Lấy tên tệpos.path.basename(p)p.name
Lấy thư mục chaos.path.dirname(p)p.parent
Lấy phần mở rộngos.path.splitext(p)[1]p.suffix
Tên không có đuôios.path.splitext(os.path.basename(p))[0]p.stem
Đường dẫn tuyệt đốios.path.abspath(p)p.resolve()
Tạo thư mục lồng nhauos.makedirs(p, exist_ok=True)p.mkdir(parents=True, exist_ok=True)
Liệt kê tệp .txtglob.glob(os.path.join(p, "*.txt"))p.glob("*.txt")
Đọc cả tệpopen(p).read()p.read_text(encoding="utf-8")
pathlib_co_ban.py
from pathlib import Path

p = Path("/home/anh/du_an/bao_cao.thang_5.csv")

print(p.name)        # 'bao_cao.thang_5.csv'  — tên đầy đủ
print(p.stem)        # 'bao_cao.thang_5'      — bỏ đuôi CUỐI CÙNG
print(p.suffix)      # '.csv'
print(p.suffixes)    # ['.thang_5', '.csv']   — mọi đuôi
print(p.parent)      # Path('/home/anh/du_an')
print(p.parents[1])  # Path('/home/anh')      — ông nội
print(p.parts)       # ('/', 'home', 'anh', 'du_an', 'bao_cao.thang_5.csv')

# Toán tử / để nối — đọc rất tự nhiên
goc = Path("/du_lieu")
dich = goc / "2026" / "thang_08" / "ban_hang.csv"
print(dich)          # /du_lieu/2026/thang_08/ban_hang.csv

# Đổi tên/đuôi mà không cắt chuỗi thủ công
print(p.with_suffix(".json"))       # .../bao_cao.thang_5.json
print(p.with_name("tong_hop.csv"))  # .../tong_hop.csv
pathlib_thao_tac.py
from pathlib import Path

thu_muc = Path("du_lieu")

# --- Kiểm tra ---
thu_muc.exists()          # tồn tại?
thu_muc.is_file()         # là tệp?
thu_muc.is_dir()          # là thư mục?

# --- Tạo / xoá ---
thu_muc.mkdir(parents=True, exist_ok=True)   # tạo cả cây, không lỗi nếu đã có
(thu_muc / "tam.txt").unlink(missing_ok=True)   # xoá tệp
thu_muc.rmdir()                                # xoá thư mục RỖNG

# --- Đọc / ghi một phát ---
(thu_muc / "ghi_chu.txt").write_text("Xin chào", encoding="utf-8")
noi_dung = (thu_muc / "ghi_chu.txt").read_text(encoding="utf-8")
byte_du_lieu = (thu_muc / "anh.png").read_bytes()

# --- Duyệt ---
for f in thu_muc.iterdir():          # mọi mục con, 1 cấp
    print(f)

for f in thu_muc.glob("*.csv"):        # theo mẫu, 1 cấp
    print(f)

for f in thu_muc.rglob("*.py"):        # ĐỆ QUY mọi cấp (r = recursive)
    print(f)

# --- Thông tin ---
p = thu_muc / "ghi_chu.txt"
print(p.stat().st_size)         # kích thước (byte)
print(p.stat().st_mtime)        # thời điểm sửa cuối (timestamp)

# --- Đường dẫn hữu ích ---
print(Path.cwd())                # thư mục làm việc hiện tại
print(Path.home())               # thư mục home của người dùng
print(Path(__file__).resolve().parent)   # thư mục chứa CHÍNH FILE NÀY
💡 Mẫu vàng: định vị tệp dữ liệu cạnh mã nguồn
dinh_vi.py
from pathlib import Path

# ❌ SAI — phụ thuộc thư mục người dùng đang đứng khi chạy
cau_hinh = Path("config.json")

# ✅ ĐÚNG — luôn trỏ đúng, chạy từ đâu cũng được
THU_MUC_NAY = Path(__file__).resolve().parent
cau_hinh = THU_MUC_NAY / "config.json"
du_lieu  = THU_MUC_NAY.parent / "data" / "input.csv"

Path("config.json") là đường dẫn tương đối — nó phụ thuộc Path.cwd(), tức nơi người dùng gõ lệnh. Chạy python src/app.py từ thư mục gốc sẽ tìm ./config.json chứ không phải ./src/config.json. Dùng __file__ là cách duy nhất chắc chắn.

7. JSON — định dạng trao đổi dữ liệu phổ biến nhất

json_co_ban.py
import json
from pathlib import Path

du_lieu = {
    "ten": "Nguyễn Việt Anh",
    "tuoi": 22,
    "ky_nang": ["Python", "TypeScript"],
    "dang_hoc": True,
    "diem_gpa": None,
}

# --- Ghi ra tệp: dump (có chữ s = string, không s = file) ---
with open("ho_so.json", "w", encoding="utf-8") as f:
    json.dump(du_lieu, f,
              ensure_ascii=False,   # ⚠️ BẮT BUỘC cho tiếng Việt
              indent=2)             # xuống dòng cho người đọc

# --- Đọc từ tệp ---
with open("ho_so.json", encoding="utf-8") as f:
    doc_lai = json.load(f)

# --- Chuỗi ↔ object (dumps / loads) ---
chuoi = json.dumps(du_lieu, ensure_ascii=False)
lai = json.loads(chuoi)

# --- Ngắn gọn nhất với pathlib ---
Path("ho_so.json").write_text(
    json.dumps(du_lieu, ensure_ascii=False, indent=2), encoding="utf-8"
)
doc_lai = json.loads(Path("ho_so.json").read_text(encoding="utf-8"))
🕳️ ensure_ascii=True là mặc định — và nó phá tiếng Việt
tieng_viet.py
print(json.dumps({"ten": "Việt Anh"}))
# {"ten": "Vi\u1ec7t Anh"}          ← đúng về kỹ thuật, không ai đọc nổi

print(json.dumps({"ten": "Việt Anh"}, ensure_ascii=False))
# {"ten": "Việt Anh"}               ← ✅

Mặc định này có từ thời JSON hay bị truyền qua kênh chỉ chấp nhận ASCII. Ngày nay mọi thứ đều UTF-8, nên hãy luôn đặt ensure_ascii=False — trừ khi bạn thực sự cần tương thích hệ thống cũ.

JSONPythonGhi chú chiều ngược lại
objectdictKey JSON luôn là chuỗi — {1: "a"} ghi ra thành {"1": "a"}
arraylisttuple cũng ghi thành array, nhưng đọc lại ra list
stringstr
number (int)int
number (real)floatDecimal không ghi được — phải tự chuyển
true / falseTrue / False
nullNone
—set, datetime, Path, class tự viếtNém TypeError — cần default=
json_nang_cao.py
import json
from datetime import datetime
from dataclasses import dataclass, asdict
from pathlib import Path

@dataclass
class SuKien:
    ten: str
    luc: datetime

def ma_hoa(o: object):
    """Được gọi cho MỌI object json không biết cách xử lý."""
    if isinstance(o, datetime):
        return o.isoformat()
    if isinstance(o, set):
        return sorted(o)
    if isinstance(o, Path):
        return str(o)
    raise TypeError(f"Không serialize được {type(o).__name__}")

sk = SuKien("Họp nhóm", datetime(2026, 8, 14, 9, 30))
print(json.dumps(asdict(sk), default=ma_hoa, ensure_ascii=False))
# {"ten": "Họp nhóm", "luc": "2026-08-14T09:30:00"}

# Bắt lỗi cú pháp JSON đúng cách
try:
    json.loads('{"a": 1,}')          # JSON không cho dấu phẩy thừa
except json.JSONDecodeError as e:
    print(f"Sai cú pháp tại dòng {e.lineno}, cột {e.colno}: {e.msg}")

8. CSV — bảng dữ liệu dạng văn bản

CSV trông đơn giản đến mức nhiều người tự tách bằng line.split(","). Đừng làm thế: một ô chứa dấu phẩy ("Hà Nội, Việt Nam"), chứa dấu nháy kép, hay chứa xuống dòng sẽ phá tan cách làm đó. Module csv xử lý đủ mọi trường hợp biên.

csv_co_ban.py
import csv

# --- ĐỌC bằng DictReader (khuyên dùng — truy cập theo tên cột) ---
with open("sinh_vien.csv", encoding="utf-8", newline="") as f:
    for hang in csv.DictReader(f):
        print(hang["ho_ten"], hang["diem"])   # hang là dict, GIÁ TRỊ LUÔN LÀ str

# --- ĐỌC bằng reader (theo chỉ số) ---
with open("sinh_vien.csv", encoding="utf-8", newline="") as f:
    doc = csv.reader(f)
    tieu_de = next(doc)                # bỏ qua dòng tiêu đề
    for hang in doc:
        print(hang[0], hang[2])          # hang là list[str]

# --- GHI bằng DictWriter ---
sinh_vien = [
    {"ho_ten": "Nguyễn Việt Anh", "lop": "CNTT-K18", "diem": 8.5},
    {"ho_ten": "Trần Thị Bình",   "lop": "CNTT-K18", "diem": 9.0},
]
with open("ket_qua.csv", "w", encoding="utf-8-sig", newline="") as f:
    ghi = csv.DictWriter(f, fieldnames=["ho_ten", "lop", "diem"])
    ghi.writeheader()
    ghi.writerows(sinh_vien)
🕳️ Ba cái bẫy của module csv
  1. newline="" là bắt buộc. Không có nó, trên Windows tệp ghi ra sẽ có dòng trống xen kẽ (vì cả module csv lẫn tầng văn bản đều dịch ký tự xuống dòng). Đây là yêu cầu nêu rõ trong tài liệu Python, không phải mẹo.
  2. Mọi giá trị đọc ra đều là str. hang["diem"] trả "8.5" chứ không phải 8.5. Phải tự chuyển: float(hang["diem"]). Cột rỗng cho "", không phải None.
  3. Excel và tiếng Việt. Ghi bằng encoding="utf-8" thuần thì Excel mở lên thấy chữ rác. Dùng "utf-8-sig" (có BOM) để Excel nhận đúng.
csv_thuc_te.py — đọc, lọc, tổng hợp
import csv
from collections import defaultdict
from pathlib import Path

def diem_trung_binh_theo_lop(duong_dan: Path) -> dict[str, float]:
    tong: dict[str, float] = defaultdict(float)
    dem: dict[str, int]   = defaultdict(int)

    with duong_dan.open(encoding="utf-8-sig", newline="") as f:
        for so_dong, hang in enumerate(csv.DictReader(f), start=2):
            try:
                diem = float(hang["diem"])
            except (ValueError, KeyError) as e:
                print(f"⚠️ Bỏ qua dòng {so_dong}: {e}")
                continue
            lop = hang["lop"].strip()
            tong[lop] += diem
            dem[lop]  += 1

    return {lop: round(tong[lop] / dem[lop], 2) for lop in tong}

print(diem_trung_binh_theo_lop(Path("sinh_vien.csv")))
# {'CNTT-K18': 8.75, 'CNTT-K19': 7.9}

Lưu ý duong_dan.open(...) — Path có method open riêng, nhận đúng các tham số như hàm built-in. Và enumerate(..., start=2) để số dòng khớp với thứ tự trong Excel (dòng 1 là tiêu đề).

9. Regex — module re

Biểu thức chính quy (regular expression) là một ngôn ngữ nhỏ để mô tả mẫu văn bản. Thay vì hỏi "chuỗi này có bằng abc không", bạn hỏi "chuỗi này có khớp với ba chữ cái theo sau là hai chữ số không".

re_dau_tien.py
import re

van_ban = "Liên hệ: an@example.com hoặc binh.tran@company.vn"

# r"" = raw string — BẮT BUỘC với regex (xem callout bên dưới)
mau = r"[\w.]+@[\w.]+\.\w+"

print(re.findall(mau, van_ban))
# ['an@example.com', 'binh.tran@company.vn']
⚠️ Luôn dùng raw string r"..."
raw_string.py
# Chuỗi thường: Python xử lý \ TRƯỚC, regex nhận được thứ khác
print("\d")     # SyntaxWarning: invalid escape sequence — may mắn còn cảnh báo
print("\n")     # thành ký tự xuống dòng thật, regex không thấy \n nữa
print("\\d")    # phải escape đôi mới ra \d — rối mắt

# Raw string: Python KHÔNG xử lý \, đưa nguyên xi cho regex
print(r"\d")    # \d — đúng thứ regex cần
print(r"\n")    # \n (hai ký tự), regex hiểu là "xuống dòng"

Quy tắc: mọi mẫu regex đều viết trong r"...", kể cả khi mẫu không có dấu \. Nhất quán quan trọng hơn tiết kiệm một ký tự.

9.1. Bảng cú pháp regex

Ký tự đại diện
MẫuKhớp vớiVí dụ
.Bất kỳ ký tự nào trừ xuống dònga.c → abc, a1c
\dChữ số [0-9]\d\d → 42
\DKhông phải chữ số
\wChữ, số, gạch dưới (kể cả tiếng Việt có dấu!)\w+ → Việt_Anh1
\WKhông phải \w
\sKhoảng trắng (space, tab, xuống dòng)
\SKhông phải khoảng trắng
[abc]Một trong các ký tự liệt kê[aeiou] → nguyên âm
[^abc]Bất kỳ ký tự nào ngoài danh sách[^0-9]
[a-z]Khoảng ký tự[A-Za-z0-9]
Lượng từ (quantifier) — "bao nhiêu lần"
MẫuSố lầnVí dụ
*0 trở lênab* → a, ab, abbb
+1 trở lênab+ → ab, abbb (không khớp a)
?0 hoặc 1 (tuỳ chọn)colou?r → color, colour
{n}Đúng n lần\d{4} → 2026
{n,}Ít nhất n lần\d{2,}
{n,m}Từ n đến m lần\d{2,4}
*? +? ??Lười — khớp ít nhất có thểxem callout bên dưới
Neo (anchor) và nhóm
MẫuÝ nghĩaVí dụ
^Đầu chuỗi (hoặc đầu dòng với cờ M)^Xin
$Cuối chuỗi (hoặc cuối dòng với cờ M)\.$
\bRanh giới từ\bcat\b khớp cat nhưng không khớp trong category
(...)Nhóm bắt (capturing group)(\d{4})-(\d{2})
(?:...)Nhóm không bắt — chỉ để gom(?:ab)+
(?P<ten>...)Nhóm có tên ⭐(?P<nam>\d{4})
|Hoặcmèo|chó
(?=...)Lookahead khẳng định — "theo sau phải là"\d+(?=đ)
(?!...)Lookahead phủ định — "theo sau không được là"\d+(?!%)
(?<=...)Lookbehind khẳng định — "phía trước phải là"(?<=\$)\d+
🧠 Tham lam vs lười — cái bẫy số một của regex
tham_lam.py
import re
html = "<b>đậm</b> và <i>nghiêng</i>"

# THAM LAM (mặc định) — .* nuốt càng nhiều càng tốt
print(re.findall(r"<.*>", html))
# ['<b>đậm</b> và <i>nghiêng</i>']   ← một kết quả, nuốt cả chuỗi!

# LƯỜI — thêm ? sau lượng từ
print(re.findall(r"<.*?>", html))
# ['<b>', '</b>', '<i>', '</i>']      ← ✅ bốn thẻ riêng biệt

# Cách thứ ba, thường tốt nhất: cấm ký tự kết thúc trong lớp
print(re.findall(r"<[^>]*>", html))
# ['<b>', '</b>', '<i>', '</i>']      ← ✅ và nhanh hơn .*?

Mặc định lượng từ *, +, {n,m} đều tham lam: chúng khớp dài nhất có thể rồi mới lùi dần. Thêm ? để đổi sang lười: khớp ngắn nhất rồi mới tiến dần.

9.2. Bảy hàm của module re

HàmTrả vềTìm ở đâuDùng khi
re.search(p, s)Match hoặc NoneBất kỳ đâu trong chuỗiMặc định — tìm lần khớp đầu tiên
re.match(p, s)Match hoặc NoneChỉ từ đầu chuỗiKiểm tra tiền tố
re.fullmatch(p, s)Match hoặc NonePhải khớp toàn bộKiểm tra hợp lệ (validate)
re.findall(p, s)list[str] (hoặc list tuple nếu có nhóm)Mọi lần khớpLấy nhanh danh sách
re.finditer(p, s)Iterator các MatchMọi lần khớpCần vị trí, nhóm; tệp lớn
re.sub(p, r, s)str mớiMọi lần khớpThay thế
re.split(p, s)list[str]Mọi lần khớpTách theo mẫu phức tạp
bay_ham.py
import re

s = "Đơn DH-2026-0042 ngày 14/08/2026, tổng 1500000đ"

# search — tìm ở bất kỳ đâu
m = re.search(r"DH-\d{4}-\d{4}", s)
print(m.group())        # 'DH-2026-0042'
print(m.start(), m.end())   # 4 16 — vị trí trong chuỗi

# match — chỉ từ đầu chuỗi → None vì chuỗi bắt đầu bằng "Đơn"
print(re.match(r"DH-\d+", s))    # None

# fullmatch — validate: phải khớp TOÀN BỘ
print(re.fullmatch(r"\d{10}", "0912345678"))   # khớp
print(re.fullmatch(r"\d{10}", "0912345678x"))  # None ✅

# findall — lấy nhanh mọi số
print(re.findall(r"\d+", s))
# ['2026', '0042', '14', '08', '2026', '1500000']

# finditer — khi cần cả vị trí
for m in re.finditer(r"\d+", s):
    print(f"{m.group()} tại {m.start()}")

# sub — thay thế (che số điện thoại chẳng hạn)
print(re.sub(r"\d{7,}", "[ẩn]", s))
# 'Đơn DH-2026-0042 ngày 14/08/2026, tổng [ẩn]đ'

# split — tách theo nhiều loại dấu phân cách
print(re.split(r"[,;|]\s*", "a, b;c | d"))
# ['a', 'b', 'c', 'd']

9.3. Nhóm và nhóm có tên

nhom.py
import re

# --- Nhóm đánh số ---
m = re.search(r"(\d{2})/(\d{2})/(\d{4})", "Hạn: 14/08/2026")
print(m.group(0))     # '14/08/2026'  — nhóm 0 = toàn bộ khớp
print(m.group(1))     # '14'
print(m.groups())      # ('14', '08', '2026')

# --- Nhóm CÓ TÊN — dễ đọc hơn nhiều, dùng cái này ---
mau = r"(?P<ngay>\d{2})/(?P<thang>\d{2})/(?P<nam>\d{4})"
m = re.search(mau, "Hạn: 14/08/2026")
print(m.group("nam"))         # '2026'
print(m.groupdict())           # {'ngay': '14', 'thang': '08', 'nam': '2026'}

# --- Tham chiếu nhóm trong chuỗi thay thế ---
# Đổi dd/mm/yyyy → yyyy-mm-dd
print(re.sub(mau, r"\g<nam>-\g<thang>-\g<ngay>", "Hạn: 14/08/2026"))
# 'Hạn: 2026-08-14'

# --- Nhóm không bắt (?:...) — gom mà không tạo nhóm ---
print(re.findall(r"(?:https?://)?(\w+\.com)", "https://a.com và b.com"))
# ['a.com', 'b.com']  — nếu dùng (...) thay (?:...) sẽ ra list tuple lộn xộn

# --- sub với hàm thay thế: xử lý phức tạp ---
def tang_gia(m: re.Match) -> str:
    return str(int(m.group()) * 2)

print(re.sub(r"\d+", tang_gia, "Giá 100 và 250"))
# 'Giá 200 và 500'
🕳️ findall đổi kiểu trả về khi có nhóm
findall_bay.py
s = "a1 b2 c3"

re.findall(r"\w\d", s)        # ['a1', 'b2', 'c3']         — không nhóm → list[str]
re.findall(r"(\w)\d", s)      # ['a', 'b', 'c']            — 1 nhóm → list nội dung nhóm
re.findall(r"(\w)(\d)", s)    # [('a','1'), ('b','2'), ...] — nhiều nhóm → list[tuple]

Ba mẫu gần giống nhau cho ba kiểu dữ liệu khác nhau — nguồn của rất nhiều TypeError khó hiểu. Khi có nhóm, hãy dùng finditer và tự lấy m.group() để kiểm soát rõ ràng.

9.4. Cờ và biên dịch trước

CờViết tắtTác dụng
re.IGNORECASEre.IKhông phân biệt hoa/thường
re.MULTILINEre.M^/$ khớp đầu/cuối mỗi dòng
re.DOTALLre.S. khớp cả ký tự xuống dòng
re.VERBOSEre.XCho phép khoảng trắng và chú thích trong mẫu ⭐
re.ASCIIre.A\w, \d, \s chỉ khớp ASCII
flag_compile.py
import re

# --- Biên dịch trước: dùng lại nhiều lần, đọc rõ hơn ---
MAU_EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+", re.IGNORECASE)

for dong in danh_sach:
    if MAU_EMAIL.search(dong):        # gọi method trên object đã compile
        ...

# --- VERBOSE: viết regex phức tạp mà vẫn đọc được ---
MAU_SDT_VN = re.compile(r"""
    ^                    # đầu chuỗi
    (?:\+84|0)           # mã quốc gia +84 hoặc số 0
    \s?
    (3|5|7|8|9)          # đầu số di động Việt Nam hợp lệ
    \d{8}                # 8 chữ số còn lại
    $                    # cuối chuỗi
""", re.VERBOSE)

print(bool(MAU_SDT_VN.match("0912345678")))    # True
print(bool(MAU_SDT_VN.match("+84912345678")))  # True
print(bool(MAU_SDT_VN.match("0112345678")))    # False — đầu số 1 không hợp lệ

# --- MULTILINE ---
log = """INFO khởi động
ERROR không kết nối được
INFO thử lại"""
print(re.findall(r"^ERROR.*", log, re.MULTILINE))
# ['ERROR không kết nối được']
📌 \w và tiếng Việt

Trong Python 3, mẫu regex trên chuỗi str mặc định hoạt động ở chế độ Unicode. Vì vậy \w khớp cả ệ, ữ, Đ — khác với nhiều ngôn ngữ khác. Nếu bạn muốn chỉ ASCII (ví dụ kiểm tra tên biến), thêm cờ re.ASCII. Ngược lại, để kiểm tra "chỉ chữ cái tiếng Việt", dùng [^\W\d_] (là \w trừ chữ số và gạch dưới).

10. Khi nào không nên dùng regex

Việc cần làmĐừng dùng regexDùng cái này
Kiểm tra chứa chuỗi conre.search(r"abc", s)"abc" in s
Kiểm tra tiền tố/hậu tốre.match(r"^http", s)s.startswith("http")
Tách theo một ký tựre.split(r",", s)s.split(",")
Thay thế chuỗi cố địnhre.sub(r"a", "b", s)s.replace("a", "b")
Phân tích HTMLBất kỳ regex nàoBeautifulSoup, lxml
Phân tích JSON/XML/CSVBất kỳ regex nàojson, xml, csv
Phân tích URLRegex tự chếurllib.parse.urlparse
Kiểm tra email chuẩn RFCRegex 300 ký tự trên mạngKiểm tra thô + gửi email xác thực
⚠️ ReDoS — regex có thể treo chương trình
redos.py
import re
# Mẫu có lượng từ lồng nhau — "catastrophic backtracking"
re.match(r"(a+)+$", "a" * 30 + "b")
# Treo vài phút đến vài giờ — engine thử 2^30 tổ hợp

Bộ máy regex của Python dùng thuật toán quay lui (backtracking). Mẫu có lượng từ lồng nhau ((a+)+, (a|a)*, (.*)*) trên chuỗi không khớp sẽ khiến số bước tăng theo hàm mũ. Nếu mẫu regex đến từ người dùng, đây là lỗ hổng từ chối dịch vụ thật. Phòng tránh: tránh lượng từ lồng nhau; ưu tiên lớp ký tự phủ định ([^"]* thay .*?); với dữ liệu không tin cậy, cân nhắc thư viện regex có tham số timeout.

11. Cheat sheet chương 7

Tình huốngCách viết
Đọc cả tệp văn bảnPath(p).read_text(encoding="utf-8")
Ghi cả tệp văn bảnPath(p).write_text(s, encoding="utf-8")
Đọc tệp lớnwith open(p, encoding="utf-8") as f: for dong in f:
Thêm vào cuối tệpopen(p, "a", encoding="utf-8")
Tạo tệp, lỗi nếu đã cóopen(p, "x", encoding="utf-8")
Tệp nhị phânopen(p, "rb") / Path(p).read_bytes()
Ghi an toànGhi .tmp rồi tmp.replace(dich)
Nối đường dẫnPath(a) / b / c
Thư mục chứa file hiện tạiPath(__file__).resolve().parent
Tìm tệp đệ quyPath(p).rglob("*.py")
Tạo cây thư mụcp.mkdir(parents=True, exist_ok=True)
JSON có tiếng Việtjson.dump(d, f, ensure_ascii=False, indent=2)
CSV đọc theo tên cộtcsv.DictReader(f) + newline=""
CSV cho Excel tiếng Việtencoding="utf-8-sig"
Regex — kiểm tra hợp lệre.fullmatch(r"...", s)
Regex — tìm tất cả kèm vị tríre.finditer(r"...", s)
Regex — mẫu phức tạpre.compile(r"""...""", re.VERBOSE)
Regex — khớp ngắn nhấtThêm ?: .*? — hoặc tốt hơn: [^x]*

Bài tập

Công cụ phân tích tệp nhật ký

Cho tệp app.log với định dạng mỗi dòng:

app.log
2026-08-14 09:15:32 INFO  [api] Khởi động thành công
2026-08-14 09:15:45 ERROR [db] Kết nối thất bại sau 3000ms
2026-08-14 09:16:02 WARN  [api] Phản hồi chậm: 1520ms

Viết phan_tich(duong_dan: Path) -> dict trả về:

  • Số dòng theo từng cấp độ ({"INFO": 12, "ERROR": 3, ...}) — dùng Counter.
  • Danh sách (thoi_gian, module, thong_diep) của mọi dòng ERROR.
  • Thời gian phản hồi trung bình, lấy từ mọi con số có hậu tố ms.

Yêu cầu: một regex duy nhất có nhóm có tên; đọc lười từng dòng để chạy được với tệp 5 GB.

Gợi ý

Khung mẫu: r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+(?P<cap>\w+)\s+\[(?P<mod>\w+)\]\s+(?P<msg>.*)". Nhớ re.compile ngoài vòng lặp. Dòng không khớp mẫu (log nhiều dòng, traceback) phải được xử lý — m sẽ là None, đừng để nổ AttributeError. Lấy số ms bằng regex thứ hai r"(\d+)ms" trên nhóm msg.

Chuyển đổi CSV ↔ JSON hai chiều

Viết hai hàm và một CLI nhỏ:

chuyen_doi.py
def csv_sang_json(nguon: Path, dich: Path, kieu: dict[str, type] | None = None) -> int: ...
def json_sang_csv(nguon: Path, dich: Path) -> int: ...
# Cả hai trả về số bản ghi đã xử lý

# Ví dụ: ép kiểu cột khi đọc CSV
csv_sang_json(Path("sv.csv"), Path("sv.json"),
              kieu={"diem": float, "tuoi": int})

Yêu cầu: tiếng Việt hiển thị đúng ở cả hai chiều; Excel mở được tệp CSV sinh ra; dòng CSV hỏng thì báo số dòng và bỏ qua thay vì dừng.

Gợi ý

Ba điểm quyết định: ensure_ascii=False khi ghi JSON; encoding="utf-8-sig" khi ghi CSV (cho Excel); newline="" cho mọi tệp CSV. Với json_sang_csv, lấy fieldnames từ union các khóa của mọi bản ghi (không phải chỉ bản ghi đầu) để không mất cột.

Đổi tên hàng loạt bằng pathlib

Viết doi_ten(thu_muc: Path, khuon: str, khô_chạy: bool = True) đổi tên mọi tệp ảnh (.jpg, .jpeg, .png) trong thư mục theo khuôn mẫu, ví dụ "anh_{i:03d}{ext}" → anh_001.jpg, anh_002.png…

  • Sắp xếp theo thời gian sửa đổi (stat().st_mtime) trước khi đánh số.
  • khô_chạy=True chỉ in ra sẽ đổi gì, không động vào đĩa — mặc định an toàn.
  • Xử lý xung đột: tên đích đã tồn tại thì không được ghi đè.
  • Có tuỳ chọn đệ quy vào thư mục con.
Gợi ý

Dùng p.rename(dich) hoặc p.replace(dich) — khác nhau: rename lỗi nếu đích tồn tại trên Windows, replace luôn ghi đè. Ở đây bạn muốn không ghi đè nên kiểm tra dich.exists() trước.
Bẫy tinh vi: đổi a.jpg → b.jpg rồi b.jpg → c.jpg có thể giẫm lên nhau. Cách chắc chắn: đổi qua tên tạm ở lượt một, rồi đổi sang tên cuối ở lượt hai.

Bộ kiểm tra hợp lệ bằng regex

Viết các hàm trả về bool, dùng re.compile ở cấp module và re.fullmatch:

  • la_sdt_vn(s) — 0912345678 hoặc +84912345678; đầu số hợp lệ 3/5/7/8/9.
  • la_email(s) — kiểm tra thô, hợp lý (đừng cố theo RFC 5322).
  • la_mat_khau_manh(s) — ≥8 ký tự, có chữ hoa, chữ thường, số, ký tự đặc biệt. Dùng lookahead.
  • la_ma_so_thue(s) — 10 chữ số, hoặc 10 chữ số + - + 3 chữ số.
  • la_ngay_iso(s) — YYYY-MM-DD, tháng 01-12, ngày 01-31.

Viết bảng test cho mỗi hàm: ít nhất 3 giá trị hợp lệ và 3 giá trị không hợp lệ.

Gợi ý

Mật khẩu mạnh là bài tập lookahead kinh điển: r"(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}" — mỗi (?=...) kiểm tra một điều kiện mà không tiêu thụ ký tự nào, nên bốn điều kiện cùng áp lên một chuỗi.
Câu hỏi suy ngẫm cho la_ngay_iso: regex kiểm tra được ngày 31/02 không? Không — đó là lúc bạn nên dùng datetime.strptime thay vì regex.

Che thông tin nhạy cảm trong tệp nhật ký

Viết che_du_lieu(nguon: Path, dich: Path) -> dict[str, int] đọc tệp log và ghi ra bản đã che, trả về số lần che theo từng loại:

  • Email → a***@example.com (giữ ký tự đầu và tên miền)
  • Số điện thoại → 091****678 (giữ 3 đầu, 3 cuối)
  • Số thẻ 16 chữ số → ****-****-****-1234
  • "password": "..." trong JSON → "password": "[ẨN]"

Dùng re.sub với hàm thay thế cho các trường hợp cần giữ một phần.

Gợi ý

Hàm thay thế nhận một re.Match và trả về chuỗi: re.sub(MAU_EMAIL, lambda m: m.group(1)[0] + "***@" + m.group(2), s). Xử lý theo thứ tự từ mẫu cụ thể nhất đến chung nhất — nếu che số điện thoại trước, mẫu số thẻ 16 chữ số có thể không còn khớp. Đếm số lần che bằng re.subn (trả về tuple (chuoi_moi, so_lan)).

Quiz

Q1

Chế độ "w" và "a" khác nhau thế nào? Chuyện gì xảy ra với tệp đã tồn tại?

Xem đáp án
✓ Đáp án

"w" xoá sạch nội dung tệp ngay khoảnh khắc open() chạy — không phải lúc bạn gọi write(). Kể cả chương trình sập ngay dòng sau, dữ liệu cũ đã mất. Con trỏ đặt ở đầu tệp.
"a" giữ nguyên nội dung cũ và đặt con trỏ ở cuối tệp; mọi lần write() đều nối thêm vào cuối, kể cả sau khi bạn gọi seek(0).
Cả hai đều tạo tệp mới nếu chưa tồn tại. Muốn được bảo vệ khỏi ghi đè, dùng "x" — nó ném FileExistsError nếu tệp đã có.

Q2

Vì sao phải dùng with open(...) thay vì open() rồi close()?

Xem đáp án
✓ Đáp án

Vì with đảm bảo close() chạy kể cả khi có exception giữa chừng. Với cách thủ công, một lỗi trước dòng close() sẽ khiến tệp không bao giờ được đóng. Ba hậu quả: (1) dữ liệu còn trong bộ đệm không được xả xuống đĩa → mất dữ liệu; (2) cạn file descriptor → OSError: Too many open files; (3) trên Windows tệp bị khoá, không xoá/đổi tên được.
CPython thường tự đóng nhờ bộ đếm tham chiếu, nhưng đó là chi tiết cài đặt của CPython, không phải cam kết của ngôn ngữ — PyPy không đảm bảo điều đó. with còn ngắn hơn try/finally và không thể quên.

Q3

Vì sao luôn phải truyền encoding="utf-8"?

Xem đáp án
✓ Đáp án

Không truyền thì Python dùng bảng mã mặc định của hệ thống (locale.getpreferredencoding()) — thứ khác nhau giữa các máy. macOS và Linux hiện đại thường là UTF-8, nhưng Windows tiếng Việt có thể là cp1258. Hệ quả: code chạy hoàn hảo trên máy bạn nhưng ném UnicodeDecodeError trên máy đồng nghiệp, hoặc tệ hơn — chạy được nhưng ghi ra chữ rác mà không ai phát hiện cho tới khi quá muộn.
Python 3.15 sẽ đổi mặc định thành UTF-8 (PEP 686), nhưng viết tường minh vẫn đúng vì code phải chạy được trên bản cũ. Tìm chỗ còn thiếu bằng python -X warn_default_encoding.

Q4

pathlib.Path hơn os.path ở điểm nào?

Xem đáp án
✓ Đáp án

Bốn điểm: (1) Hướng đối tượng — p.exists(), p.parent, p.suffix thay vì gọi hàm rời rạc os.path.exists(p). (2) Toán tử / — Path("a") / "b" / "c" đọc tự nhiên hơn os.path.join lồng nhau. (3) Đa nền tảng — tự dùng đúng dấu phân cách cho Windows và Unix. (4) Nối chuỗi thao tác — Path(__file__).resolve().parent / "data" / "x.csv" viết một hơi, còn với os.path phải lồng ba lớp hàm.
Ngoài ra Path có sẵn read_text, write_text, read_bytes, glob, rglob, mkdir(parents=True) — gộp việc của nhiều module (os, glob, shutil) vào một chỗ.

Q5

Vì sao mở tệp CSV phải truyền newline=""?

Xem đáp án
✓ Đáp án

Vì có hai tầng cùng xử lý ký tự xuống dòng: tầng văn bản của open() (dịch \n ↔ \r\n theo hệ điều hành) và module csv (tự quản lý ký tự kết thúc bản ghi). Khi cả hai cùng làm việc, trên Windows tệp ghi ra sẽ có \r\r\n → mở lên thấy dòng trống xen kẽ giữa mọi dòng dữ liệu.
newline="" tắt tầng dịch của open(), giao toàn quyền cho module csv. Đây là yêu cầu nêu rõ trong tài liệu Python, áp dụng cho cả đọc và ghi. Khi đọc, nó còn giúp xử lý đúng ô có chứa ký tự xuống dòng bên trong dấu nháy kép.

Q6

re.match, re.search, re.fullmatch khác nhau ra sao?

Xem đáp án
✓ Đáp án

re.search tìm lần khớp đầu tiên ở bất kỳ đâu trong chuỗi — đây là hàm bạn muốn trong hầu hết trường hợp.
re.match chỉ khớp từ đầu chuỗi, nhưng không bắt buộc khớp hết: re.match(r"\d+", "123abc") vẫn khớp "123". Tên hàm gây hiểu lầm nhất module.
re.fullmatch bắt buộc khớp toàn bộ chuỗi — đây mới là hàm dùng để kiểm tra hợp lệ.

so_sanh.py
re.search(r"\d+", "abc123")      # khớp '123'
re.match(r"\d+", "abc123")       # None
re.match(r"\d+", "123abc")       # khớp '123'  ⚠️ vẫn khớp!
re.fullmatch(r"\d+", "123abc")   # None        ✅ đúng cho validate
Q7

.* và .*? khác nhau thế nào? Cho ví dụ hậu quả.

Xem đáp án
✓ Đáp án

.* tham lam — khớp dài nhất có thể rồi mới lùi dần cho tới khi phần còn lại của mẫu khớp được. .*? lười — khớp ngắn nhất rồi mới nới dần.

hau_qua.py
html = "<b>đậm</b> và <i>nghiêng</i>"
re.findall(r"<.*>", html)    # ['<b>đậm</b> và <i>nghiêng</i>'] ❌ nuốt hết
re.findall(r"<.*?>", html)   # ['<b>', '</b>', '<i>', '</i>']    ✅
re.findall(r"<[^>]*>", html) # ['<b>', '</b>', '<i>', '</i>']    ✅ và nhanh hơn

Cách thứ ba — dùng lớp ký tự phủ định [^>]* — thường là tốt nhất: nó diễn đạt ý định rõ ràng hơn ("mọi ký tự trừ dấu đóng") và không cần quay lui, nên nhanh hơn và tránh được rủi ro ReDoS.

Q8

Khi nào không nên dùng regex?

Xem đáp án
✓ Đáp án

Ba nhóm tình huống:
(1) Khi method chuỗi đủ dùng — "abc" in s, s.startswith(...), s.replace(...), s.split(",") đều nhanh hơn và dễ đọc hơn regex tương đương.
(2) Khi dữ liệu có cấu trúc lồng nhau — HTML, XML, JSON, CSV. Regex là ngôn ngữ chính quy, về mặt lý thuyết không thể phân tích đúng cấu trúc lồng nhau tuỳ ý. Dùng BeautifulSoup, json, csv, urllib.parse.
(3) Khi mẫu đến từ người dùng — rủi ro ReDoS: mẫu có lượng từ lồng nhau như (a+)+$ khiến engine thử số tổ hợp tăng theo hàm mũ, treo cả tiến trình. Nếu bắt buộc, dùng thư viện regex có tham số timeout.
Quy tắc thực dụng: nếu regex của bạn dài quá một dòng, hãy viết lại bằng re.VERBOSE kèm chú thích — hoặc cân nhắc rằng có thể nó không nên là regex.

Tổng kết

Sau chương 7, bạn đã master:

  • Chế độ mở tệp: r/w/a/x + b/+; "w" xoá tệp ngay lúc mở.
  • with là bắt buộc — đảm bảo đóng tệp kể cả khi có exception.
  • Đọc tệp: for dong in f là mặc định, chạy được với tệp lớn hơn RAM.
  • Ghi tệp: write không tự thêm \n; mẫu ghi nguyên tử qua tệp tạm + replace.
  • Encoding: luôn encoding="utf-8"; hiểu BOM và utf-8-sig; các chế độ errors=.
  • pathlib: / để nối, read_text/write_text, glob/rglob, Path(__file__).resolve().parent.
  • JSON: ensure_ascii=False cho tiếng Việt; default= cho kiểu tự định nghĩa.
  • CSV: DictReader/DictWriter, newline="", utf-8-sig cho Excel, mọi giá trị đọc ra là str.
  • Regex: raw string, bảng cú pháp, tham lam vs lười, nhóm có tên, 7 hàm của re, cờ và compile.
  • Giới hạn của regex: khi nào dùng method chuỗi hoặc parser chuyên dụng; rủi ro ReDoS.

Kết nối

  • Chương 3 (Data Structures) — Counter, defaultdict dùng liên tục khi tổng hợp dữ liệu từ tệp.
  • Chương 5 (Exceptions) — FileNotFoundError, PermissionError, UnicodeDecodeError, JSONDecodeError; EAFP tránh TOCTOU khi thao tác tệp.
  • Chương 6 (Modules) — pathlib, json, csv, re đều là stdlib; Path(__file__) để định vị tài nguyên trong package.
  • Chương 9 (Iterators & Generators) — đối tượng tệp là iterator; viết generator đọc tệp lười để dựng pipeline xử lý dữ liệu.
  • Chương 10 (Context Managers) — cơ chế __enter__/__exit__ đứng sau with; tự viết context manager cho tài nguyên của bạn.
  • Chương 11 (Async Python) — I/O tệp là thao tác chặn; dùng asyncio.to_thread hoặc aiofiles trong code async.
  • Chương 14 (Dự án) — ghép tất cả: đọc CSV, gọi API, ghi JSON, sinh báo cáo.
  • JavaScript Chương 8 — đối chiếu fs/promises: Node bắt buộc bất đồng bộ, Python mặc định đồng bộ.
  • CLI pillar — grep, sed, awk dùng chung ngôn ngữ regex (phương ngữ POSIX/PCRE, khác Python vài chi tiết nhỏ).