Hình dung lúc trước khi màn kéo lên. Trước khi khán giả đầu tiên thấy gì (main() chạy), cả một chuỗi công việc hậu đài diễn ra theo thứ tự cố định: đạo cụ và phông màn mượn từ các đoàn khác (package được import) phải dựng xong hẳn trước; rồi trên sân khấu chính, cảnh trí được đặt vào (biến package) trước khi quản lý sân khấu chạy bản kiểm tra cuối (init()). Go làm một loạt thứ trước main(), và bài này in ra đúng thứ tự đó.

Thứ tự thật

Tôi đặt một package kho được main import, mỗi nơi có biến khởi tạo bằng hàm và có init():

  biến kho.A
  init() của package kho
  biến main.X
  init() thứ nhất của main
  init() thứ hai của main
  main() chạy

Quy tắc rút ra:

Package được import chạy xong hoàn toàn trước. Cả biến lẫn init() của kho đều xong trước khi main bắt đầu khởi tạo bất cứ thứ gì — đoàn khách dựng xong sân khấu của họ rồi mới tới lượt mình.

Trong một package: biến trước, init() sau.

Một package có nhiều init() được — kể cả nhiều init() trong cùng một tệp. Chúng chạy theo thứ tự xuất hiện, và giữa các tệp thì theo thứ tự tên tệp mà trình biên dịch nhận được.

Chi tiết cuối là lý do đầu tiên để tránh init(): thứ tự giữa các tệp phụ thuộc vào tên tệp, và đó là thứ không ai nên dựa vào.

Biến package khởi tạo theo phụ thuộc

var X = khoiTao("main.X")
var Y = X + 1

Go không khởi tạo theo thứ tự khai báo — nó phân tích phụ thuộc và chạy theo thứ tự đúng. Y phụ thuộc X nên X chạy trước, dù bạn có viết Y lên trên. Bạn treo phông nền trước, rồi mới gắn cái đèn kẹp vào nó — bất kể trên kịch bản dòng nào viết trước.

Phụ thuộc vòng giữa các biến package là lỗi biên dịch, không phải lỗi lúc chạy. Đây là điểm Go làm tốt hơn Java, nơi trường tĩnh phụ thuộc vòng cho ra giá trị null im lặng.

Import chỉ để chạy init()

import _ "github.com/lib/pq"

Dấu _ nghĩa là "import package này nhưng không dùng tên của nó". Mục đích duy nhất: chạy init() của nó.

Đây là mẫu chuẩn để đăng ký driver:

import _ "github.com/lib/pq"        // driver PostgreSQL tự đăng ký
db, _ := sql.Open("postgres", dsn)  // rồi tìm được bằng tên

Cùng cơ chế với image/png, image/jpeg — import để thêm khả năng giải mã.

Nhược điểm rất thật: phụ thuộc trở nên vô hình. Đây là người phụ việc bạn gọi vào chỉ để bật đúng một công tắc rồi thôi, không bao giờ gọi tên; gạch tên họ khỏi danh sách vì thấy "không dùng" là một ngọn đèn tắt ngóm lúc diễn. Xoá dòng import vì thấy "không dùng" là chương trình chết lúc chạy với unknown driver. Luôn thêm comment giải thích.

Vì sao nên tránh init()

Bốn lý do, theo thứ tự tôi thấy quan trọng:

Không trả về lỗi được. init() không có giá trị trả về. Hỏng thì chỉ còn cách panic, và panic lúc khởi động cho ra thông báo khó lần.

Không test được. Nó đã chạy trước khi test của bạn bắt đầu. Không tắt được, không giả lập được.

Thứ tự khó đoán giữa các tệp và các package.

Che giấu tác dụng phụ. Người đọc main() không thấy gì đang xảy ra.

Cách thay thế gần như luôn tốt hơn: một hàm New() hoặc Setup() trả về (T, error), gọi tường minh từ main(). Bạn kiểm soát thứ tự, xử lý được lỗi, và test được.

Khi nào init() chính đáng

Đăng ký vào một sổ đăng ký toàn cục — driver, codec, plugin. Đây là lý do nó tồn tại.

Biên dịch sẵn thứ tốn kém và không thể hỏng — biểu thức chính quy hằng:

var reEmail = regexp.MustCompile(`^[^@]+@[^@]+$`)

Ở đây không cần init() — biến package là đủ. MustCompile panic nếu regex sai, và điều đó đúng: regex hằng sai là lỗi lập trình, phải chết ngay lúc khởi động chứ không phải lúc phục vụ request.

Quy ước Must... trong Go nghĩa là "panic thay vì trả lỗi", và nó chỉ dành cho dữ liệu hằng do lập trình viên viết.

Muốn thấy đồ thị phụ thuộc thật của dự án trong ba mươi giây, thêm vào một package bất kỳ:

func init() { fmt.Println("init:", "tên package") }

rồi chạy chương trình. Thứ tự in ra cho bạn thấy đồ thị phụ thuộc thật của dự án — thường sâu hơn bạn nghĩ, và đôi khi lộ ra những package bạn không biết mình đang kéo vào.

Mẫu số chung

Khởi tạo toàn cục trước khi main chạy là chuyện mọi ngôn ngữ đều có, và khi nó dính phụ thuộc lẫn nhau cộng tác dụng phụ thì nó là một cái bẫy kinh điển — các ngôn ngữ chỉ khác nhau ở chỗ hỏng tệ tới đâu.

  • C++ có hẳn một cái tên cho nó: static initialization order fiasco — thứ tự dựng các đối tượng tĩnh giữa các tệp biên dịch là không xác định, nguồn của vô số lỗi khó tả.
  • Java chạy khối static lúc nạp lớp; phụ thuộc vòng cho ra null im lặng — đúng cái Go biến thành lỗi biên dịch.
  • Python chạy mã top-level lúc import lần đầu; import vòng cho ra module khởi tạo dở và AttributeError.

Go ở đây làm tốt nhất trong nhóm: phân tích phụ thuộc trong package và biến vòng thành lỗi biên dịch — bắt ngay thay vì để nổ lúc chạy. Và cái mẫu "blank import để đăng ký" cũng có khắp nơi: ServiceLoader và khối static của Java, entry point của Python — tất cả là "nạp để tự ghi danh vào một sổ đăng ký".

Sợi chỉ chung đáng mang theo: nếu có mã chạy trước main mà bạn không hề gọi, bạn đã từ bỏ quyền kiểm soát thứ tự, khả năng xử lý lỗi, và khả năng test. Chấp nhận đánh đổi đó chỉ cho hai ca thật sự xứng — sổ đăng ký và hằng số — còn lại hãy nối dây tường minh (một New(), hay một khung tiêm phụ thuộc). Cả một ngành công nghiệp "dependency injection" sinh ra phần lớn chỉ để biến cái thứ tự khởi tạo ngầm đó thành thứ nhìn thấy được và test được.

Ngày mai: tổ chức dự án Go — và vì sao đừng bắt chước bố cục của Kubernetes.

Bài tập làm thử

Bài 1 (đọc hiểu). main import package kho. Cả main và kho đều có biến khởi tạo và hàm init(). Theo thứ tự thật đo được trong bài, hãy sắp xếp đúng thứ tự chạy của sáu bước sau: (a) init() của kho, (b) biến kho.A, (c) main() chạy, (d) biến main.X, (e) init() thứ nhất của main, (f) init() thứ hai của main.

Đáp án

Thứ tự đúng: (b) biến kho.A → (a) init() của kho → (d) biến main.X → (e) init() thứ nhất của main → (f) init() thứ hai của main → (c) main() chạy.

Hai quy tắc rút ra: package được import (ở đây là kho) chạy xong hoàn toàn (cả biến lẫn init()) trước khi package chính bắt đầu khởi tạo bất cứ thứ gì; và trong một package, biến luôn khởi tạo trước init().

Bài 2 (đọc hiểu — thứ tự theo phụ thuộc). Đoạn mã sau khai Y trước X, nhưng Y lại phụ thuộc vào X. Go có khởi tạo theo đúng thứ tự viết trong mã nguồn không? Giải thích.

var Y = X + 1
var X = khoiTao("main.X")
Đáp án

Không. Go không khởi tạo biến package theo thứ tự khai báo trong mã nguồn — nó phân tích đồ thị phụ thuộc và chạy theo thứ tự đúng về mặt logic. Vì Y phụ thuộc X, Go sẽ khởi tạo X trước dù X được viết sau Y trong mã nguồn — "bạn treo phông nền trước, rồi mới gắn cái đèn kẹp vào nó, bất kể trên kịch bản dòng nào viết trước".

Bài 3 (sửa lỗi/nhận diện bẫy). Một đồng nghiệp thấy dòng import sau trong dự án và xoá nó đi vì "không có gì dùng tên pq cả, chắc là thừa":

import _ "github.com/lib/pq"

Điều gì xảy ra sau khi xoá dòng này, và tại sao dòng import đó lại tồn tại dù không có tên nào được dùng?

Đáp án

Chương trình sẽ chết lúc chạy với lỗi unknown driver (hoặc tương tự) ngay khi gọi sql.Open("postgres", dsn). Dấu gạch dưới _ trong import nghĩa là "import package này nhưng không dùng tên của nó" — mục đích duy nhất là chạy init() của package đó, và ở đây init() của driver lib/pq tự đăng ký nó vào database/sql để có thể tìm bằng tên "postgres" sau này. Xoá import vì thấy "không dùng" là tắt một công tắc quan trọng một cách vô hình — bài viết khuyến nghị luôn thêm comment giải thích cho loại import này.

Bài 4 (vận dụng thực tế). Bài viết nêu bốn lý do nên tránh dùng init(). Cho đoạn mã sau, hãy chỉ ra ít nhất hai trong bốn lý do đó áp dụng trực tiếp vào tình huống này, và đề xuất cách viết lại tốt hơn.

var db *sql.DB

func init() {
	var err error
	db, err = sql.Open("postgres", os.Getenv("DSN"))
	if err != nil {
		panic(err)
	}
}
Đáp án

Hai lý do áp dụng rõ nhất: (1) không trả về lỗi được — init() không có giá trị trả về, nên nếu kết nối CSDL hỏng lúc khởi động, cách duy nhất là panic, cho ra thông báo lỗi khó lần tại thời điểm khởi động ứng dụng; (2) không test được — init() đã chạy trước khi test bắt đầu, không thể tắt hay giả lập kết nối CSDL cho việc test đơn vị. Cách viết lại tốt hơn: dùng một hàm New()/Setup() trả về (*sql.DB, error), gọi tường minh từ main():

func New(dsn string) (*sql.DB, error) {
	return sql.Open("postgres", dsn)
}

// trong main():
db, err := New(os.Getenv("DSN"))
if err != nil {
	log.Fatal(err)
}

Bài 5 (bẫy/đánh đổi — khi nào init() chính đáng). Đoạn mã sau dùng biến package thay vì init() để biên dịch sẵn một biểu thức chính quy hằng. Giải thích vì sao cách này đúng, và vì sao MustCompile (thay vì Compile trả lỗi) lại là lựa chọn hợp lý ở đây.

var reEmail = regexp.MustCompile(`^[^@]+@[^@]+$`)
Đáp án

Đây là một trong hai trường hợp init() (hoặc cơ chế tương đương) chính đáng theo bài: "biên dịch sẵn thứ tốn kém và không thể hỏng". Ở đây không cần init() — biến package là đủ, vì Go tự khởi tạo biến package trước main(). MustCompile panic ngay nếu regex sai cú pháp, và điều này đúng đắn: một regex hằng viết sai là lỗi lập trình (không phải lỗi vận hành), nên nó phải làm chương trình chết ngay lúc khởi động — dễ phát hiện trong quá trình phát triển/CI — chứ không phải chết bất ngờ lúc đang phục vụ request thật.