🎯 什麼情境該想到我

當你「一個類別把內部的集合欄位(list、set…)直接透過 getter 交給外界」的時候——外部就能繞過這個類別偷偷增刪內容,破壞它該有的規則。改成提供 add/remove 方法,並讓取值回傳副本或唯讀視圖,把修改權收回來。

⚙️ 怎麼用(步驟 / 公式)

意圖:不要對外直接暴露集合欄位;由類別提供新增/移除方法,取值時回傳集合的副本或唯讀視圖。

動作要點(小步安全進行):

  1. 若集合尚未封裝,先對它做封裝變數 Encapsulate Variable。
  2. 在類別上加入 add/remove 之類的修改方法,讓外界透過它們來改動集合內容。
  3. 找出所有直接對集合做增刪的呼叫端,改成呼叫這些方法,測試。
  4. 修改取值函式:改為回傳「副本」或「唯讀視圖」,讓呼叫端拿到的東西即使被動也不會影響內部真正的集合。
  5. 確保設值(如果有)也是接收內容後複製進內部,而非直接持有外部傳入的集合參照。

★★ 核心問題:回傳集合就等於交出修改權

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 邊界、且有不變式」的集合。

  • 回傳 nil slice 和空 slice 在 JSON 裡不一樣:nil → null,[]Course{} → []。前端會炸。封裝時順手統一成後者。

🔗 相關工具