🎯 什麼情境該想到我
當你「一個類別把內部的集合欄位(list、set…)直接透過 getter 交給外界」的時候——外部就能繞過這個類別偷偷增刪內容,破壞它該有的規則。改成提供 add/remove 方法,並讓取值回傳副本或唯讀視圖,把修改權收回來。
⚙️ 怎麼用(步驟 / 公式)
意圖:不要對外直接暴露集合欄位;由類別提供新增/移除方法,取值時回傳集合的副本或唯讀視圖。
動作要點(小步安全進行):
- 若集合尚未封裝,先對它做封裝變數 Encapsulate Variable。
- 在類別上加入 add/remove 之類的修改方法,讓外界透過它們來改動集合內容。
- 找出所有直接對集合做增刪的呼叫端,改成呼叫這些方法,測試。
- 修改取值函式:改為回傳「副本」或「唯讀視圖」,讓呼叫端拿到的東西即使被動也不會影響內部真正的集合。
- 確保設值(如果有)也是接收內容後複製進內部,而非直接持有外部傳入的集合參照。
★★ 核心問題:回傳集合就等於交出修改權
type Person struct{ courses []Course }
func (p *Person) Courses() []Course { return p.courses } // ★ 看起來封裝了p.Courses()[0].Name = "hacked" // ★ 生效了
p.Courses() = append(...) // 這行不合法,但——
c := p.Courses(); c[1] = Course{} // ★ 這行合法,而且改到了 p 的內部Go 的 slice 是「指向底層陣列的視窗」(指標 + len + cap)。回傳它 = 回傳一把鑰匙。
map 更糟——map 本身就是參考型別,回傳後呼叫端可以直接 delete。
★ 四種解法,代價由輕到重
// ① 提供修改方法,getter 回傳副本(Fowler 的原始建議)
func (p *Person) AddCourse(c Course) { p.courses = append(p.courses, c) }
func (p *Person) Courses() []Course { return slices.Clone(p.courses) }
// 代價:★ 每次呼叫一次配置。迴圈裡呼叫 getter 就是 O(n²) 配置
// ② ★ 只給迭代器(Go 1.23+)—— 不洩漏、不複製,最適合「只是要走一遍」
func (p *Person) Courses() iter.Seq[Course] { return slices.Values(p.courses) }
for c := range p.Courses() { … }
// 要真的一份切片時:slices.Collect(p.Courses())
// ③ 只給需要的查詢,根本不回傳集合(★ 常常是最好的答案)
func (p *Person) CourseCount() int { return len(p.courses) }
func (p *Person) HasCourse(n string) bool { return slices.ContainsFunc(…) }
// → 這就是 [[依戀情結]] 的對治:把邏輯搬到資料所在的地方
// ④ 回傳唯讀視圖(Go 沒有原生 const,只能靠型別)
type CourseList struct{ s []Course } // ★ 包一層,不提供任何寫入方法
func (l CourseList) At(i int) Course { return l.s[i] }
func (l CourseList) Len() int { return len(l.s) }⚠️ 三個 Go 特有的洩漏
① slices.Clone 是淺複製
type Course struct{ Tags []string }
c2 := slices.Clone(p.courses)
c2[0].Tags[0] = "x" // ★ p.courses[0].Tags 也變了→ 元素本身含指標/slice/map 時,Clone 擋不住。要自己寫深複製,或讓元素不可變。
② setter 收到的切片也是共用的
func (p *Person) SetCourses(cs []Course) { p.courses = cs } // ★ 呼叫端留著 cs 還能改
func (p *Person) SetCourses(cs []Course) { p.courses = slices.Clone(cs) } // ✅進出兩邊都要防。 只防回傳是最常見的半套封裝。
③ cap 洩漏
func (p *Person) First2() []Course { return p.courses[:2] } // ★ cap 還是完整的
sub := p.First2()
sub = append(sub, x) // ★ 直接覆寫 p.courses[2]!→ 要切子集就用三索引限制 cap:p.courses[:2:2],append 才會另外配置。
⚠️ 併發:封裝集合不等於執行緒安全
func (p *Person) Courses() []Course {
p.mu.RLock(); defer p.mu.RUnlock()
return p.courses // ★ 鎖在 return 時就放了,切片被帶出去了
}必須在鎖內就複製(return slices.Clone(p.courses)),否則鎖是裝飾品。
用 go test -race 驗。
TypeScript
get courses(): readonly Course[] { return this.#courses; } // ★ 編譯期唯讀,零成本★ TS 在這題比 Go 漂亮:readonly T[] 不需要複製就能阻止呼叫端 push/索引賦值。
但它只是編譯期——as Course[] 或從 JS 呼叫就繞過了,不能當安全邊界。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
若語言/團隊約定已用不可變集合,或此集合本就無需維持不變式,封裝的必要性降低。
-
別做半套:只加 add/remove 卻仍讓 getter 回傳可變的原集合,等於沒封裝。
-
★ 只防 getter 不防 setter = 半套封裝(見上的洩漏②)。
-
★
slices.Clone是淺的。元素含 slice/map/指標時擋不住。 -
每次呼叫都複製會變成效能問題。迴圈裡呼叫
obj.Items()是 O(n²) 配置——這時該給迭代器(方案②)或直接提供查詢方法(方案③)。 -
★ 併發下要在鎖內複製,否則鎖沒有意義。
-
不要無腦包。函式內部的區域 slice、同套件內的 struct 欄位,包起來只是噪音。封裝的對象是「跨越 package 邊界、且有不變式」的集合。
-
回傳
nilslice 和空 slice 在 JSON 裡不一樣:nil→null,[]Course{}→[]。前端會炸。封裝時順手統一成後者。
🔗 相關工具
- 封裝變數 Encapsulate Variable(封裝集合是它的特例)
- 封裝記錄 Encapsulate Record(欄位層級的封裝思路一致)
- 重構
- 封裝記錄 —— 整個記錄的封裝
- 封裝變數 —— 更基本的版本
- 依戀情結 —— 「不回傳集合,只提供查詢」正是它的對治
- 值物件 —— 讓元素不可變,就不需要深複製