본문으로 건너뛰기

표준 제네릭 패키지

이 챕터에서 다루는 것

제네릭이 언어에 들어오면서 표준 라이브러리에 세 패키지가 생겼다. slices, maps, cmp다. 직접 제네릭을 쓸 일보다 이 세 패키지를 쓸 일이 훨씬 많다.

4-5에서 sort.Interface를 "세 개도 많았다"는 사례로 썼다. 여기서 그 이야기를 끝맺는다.

문제 — 슬라이스에 아무것도 없었다

Go 1.20까지 []string에 특정 값이 있는지 확인하려면 이렇게 썼다.

found := false
for _, s := range langs {
if s == "go" {
found = true
break
}
}

이 여섯 줄이 프로젝트마다 contains, has, includes라는 이름으로 다시 쓰였다. 정렬, 중복 제거, 뒤집기, 삽입, 삭제도 마찬가지였다. 타입만 다른 코드의 교과서적인 예이므로, 제네릭이 들어오자마자 표준 라이브러리에 들어왔다.

slices

examples/05-generics-and-advanced/03-slices-tour/main.go
package main

import (
"fmt"
"slices"
)

func main() {
// ---- 조회: 원본을 바꾸지 않는다 ----
langs := []string{"go", "rust", "zig", "go"}
fmt.Println("Contains(go):", slices.Contains(langs, "go"))
fmt.Println("Index(zig):", slices.Index(langs, "zig"))
fmt.Println("IndexFunc(4글자):", slices.IndexFunc(langs, func(s string) bool { return len(s) == 4 }))
fmt.Println("Min/Max:", slices.Min(langs), slices.Max(langs))
fmt.Println("Equal:", slices.Equal(langs, []string{"go", "rust", "zig", "go"}))

// ---- 제자리 변경: 반환값이 없다 ----
nums := []int{5, 2, 9, 1}
slices.Sort(nums)
fmt.Println("Sort 후:", nums)
slices.Reverse(nums)
fmt.Println("Reverse 후:", nums)

// ---- 길이가 바뀌는 연산: 반환값을 반드시 다시 받는다 ----
// append와 같은 규칙이다. 받지 않으면 결과를 잃는다.
xs := []int{1, 2, 3}
xs = slices.Insert(xs, 1, 10, 20)
fmt.Println("Insert:", xs)
xs = slices.Delete(xs, 0, 2)
fmt.Println("Delete:", xs)
xs = slices.DeleteFunc(xs, func(n int) bool { return n%2 == 0 })
fmt.Println("DeleteFunc(짝수 제거):", xs)

// Delete는 원본 백킹 배열을 앞으로 당기고, 남은 꼬리를 제로값으로 지운다.
ys := []int{1, 2, 3, 4}
kept := slices.Delete(ys, 1, 3)
fmt.Println("Delete 결과:", kept, "/ 원본 슬라이스:", ys)

// ---- Compact는 "연속된" 중복만 지운다. 먼저 정렬해야 한다 ----
dup := []int{1, 1, 2, 1, 3, 3}
fmt.Println("정렬 없이 Compact:", slices.Compact(slices.Clone(dup)))
sorted := slices.Clone(dup)
slices.Sort(sorted)
fmt.Println("정렬 후 Compact:", slices.Compact(sorted))

// ---- Clone은 얕은 복사다 ----
type Item struct{ Tags []string }
orig := []Item{{Tags: []string{"a"}}}
cloned := slices.Clone(orig)
cloned[0].Tags[0] = "바뀜" // 백킹 배열을 공유하는 필드
fmt.Println("Clone 후 원본:", orig[0].Tags)

// ---- 정렬된 슬라이스에서 이진 탐색 ----
sortedNums := []int{1, 3, 5, 7}
if i, found := slices.BinarySearch(sortedNums, 5); found {
fmt.Println("BinarySearch(5) 위치:", i)
}
i, found := slices.BinarySearch(sortedNums, 4)
fmt.Println("BinarySearch(4): 삽입 위치", i, "존재:", found)

// ---- 제약이 ~[]E라서 정의된 슬라이스 타입도 그대로 들어간다 ----
type Scores []int
s := Scores{30, 10, 20}
slices.Sort(s)
fmt.Printf("정의된 타입: %v (타입 %T)\n", s, s)

// Clone은 원래 타입을 유지한다. 제약이 ~[]E이고 반환도 S이기 때문이다.
c := slices.Clone(s)
fmt.Printf("Clone 결과 타입: %T\n", c)
}
go run ./03-slices-tour
Contains(go): true
Index(zig): 2
IndexFunc(4글자): 1
Min/Max: go zig
Equal: true
Sort 후: [1 2 5 9]
Reverse 후: [9 5 2 1]
Insert: [1 10 20 2 3]
Delete: [20 2 3]
DeleteFunc(짝수 제거): [3]
Delete 결과: [1 4] / 원본 슬라이스: [1 4 0 0]
정렬 없이 Compact: [1 2 1 3]
정렬 후 Compact: [1 2 3]
Clone 후 원본: [바뀜]
BinarySearch(5) 위치: 2
BinarySearch(4): 삽입 위치 2 존재: false
정의된 타입: [10 20 30] (타입 main.Scores)
Clone 결과 타입: main.Scores

세 부류로 나눠 외운다

부류규칙
조회Contains, Index, Min, Max, Equal, BinarySearch원본을 안 건드린다
제자리 변경Sort, SortFunc, Reverse반환값이 없다
길이 변경Insert, Delete, DeleteFunc, Compact, Clip, Grow반환값을 다시 받아야 한다

세 번째 부류가 사고의 원천이다. append와 정확히 같은 규칙인데 (3-2), slices.Delete(xs, 0, 2)는 문장처럼 읽혀서 반환값을 잊기 쉽다.

slices.Delete(xs, 0, 2) // 결과를 버렸다. xs의 길이는 그대로다
xs = slices.Delete(xs, 0, 2) // 이렇게

go vet이 잡아 준다. result of slices.Delete call not used라는 메시지가 나온다. 1-6에서 go vet을 습관화하라고 한 이유가 이런 것이다.

Delete가 꼬리를 지운다

출력의 이 줄을 보자.

Delete 결과: [1 4] / 원본 슬라이스: [1 4 0 0]

slices.Delete는 남은 원소를 앞으로 당긴 뒤 뒤에 남는 자리를 제로값으로 덮는다. ys는 여전히 길이 4짜리 뷰이므로 [1 4 0 0]이 보인다.

이것은 성능 낭비가 아니라 메모리 누수 방지다. []*User에서 원소를 지웠는데 백킹 배열에 포인터가 남아 있으면 GC가 그 User를 회수하지 못한다. 제로값으로 덮으면 참조가 끊긴다. 3-2에서 본 백킹 배열 공유 문제의 다른 얼굴이다.

Clone은 얕은 복사다

Clone 후 원본: [바뀜]

slices.Clone원소를 하나씩 복사한다. 원소가 Item{Tags []string}이면 Tags 슬라이스 헤더가 복사될 뿐 백킹 배열은 공유된다. 깊은 복사가 필요하면 직접 돌면서 각 필드를 복사해야 한다. maps.Clone도 똑같다.

Compact는 연속된 중복만 지운다

정렬 없이 Compact: [1 2 1 3]
정렬 후 Compact: [1 2 3]

이름 때문에 "중복 제거"로 착각하기 쉽다. Compact인접한 같은 값만 하나로 합친다. 유닉스의 uniq와 같은 동작이며, sort | uniq처럼 정렬을 먼저 해야 전체 중복이 사라진다. 순서를 보존하면서 중복만 지우려면 5-2Dedup처럼 맵을 쓴다.

제약이 ~[]E인 이유

func Sort[S ~[]E, E cmp.Ordered](x S)

타입 파라미터가 둘이고 S~가 붙어 있다. 덕분에 type Scores []int처럼 정의된 슬라이스 타입을 그대로 넣을 수 있고, Clone[]int가 아니라 Scores를 돌려준다. 5-2에서 ~가 왜 거의 항상 필요한지 말한 그대로다. 자기 코드에서 슬라이스를 받는 제네릭 함수를 쓸 때 따라 할 만한 형태다.

sort.Interface에서 slices.SortFunc까지

Go의 정렬 API는 세 세대를 거쳤다. 셋 다 지금도 컴파일되므로 실제 코드베이스에서 전부 만난다.

examples/05-generics-and-advanced/03-sort-migration/main.go
package main

import (
"cmp"
"fmt"
"math"
"slices"
"sort"
)

type Person struct {
Name string
Age int
City string
}

func people() []Person {
return []Person{
{"김개발", 34, "서울"},
{"이배포", 28, "부산"},
{"박운영", 34, "서울"},
{"최설계", 28, "대전"},
}
}

// ---- 1세대: sort.Interface. 타입 하나와 메서드 세 개가 필요했다 ----

type byAge []Person

func (p byAge) Len() int { return len(p) }
func (p byAge) Less(i, j int) bool { return p[i].Age < p[j].Age }
func (p byAge) Swap(i, j int) { p[i], p[j] = p[j], p[i] }

// 다른 기준으로 정렬하려면 타입을 하나 더 만들어야 했다.
type byName []Person

func (p byName) Len() int { return len(p) }
func (p byName) Less(i, j int) bool { return p[i].Name < p[j].Name }
func (p byName) Swap(i, j int) { p[i], p[j] = p[j], p[i] }

func main() {
// 1세대
a := people()
sort.Sort(byAge(a))
fmt.Println("sort.Interface:", names(a))

b := people()
sort.Sort(byName(b))
fmt.Println("sort.Interface(이름):", names(b))

// 2세대: sort.Slice. 타입 선언은 사라졌지만 인덱스를 직접 다루고 x는 any다.
c := people()
sort.Slice(c, func(i, j int) bool { return c[i].Age < c[j].Age })
fmt.Println("sort.Slice:", names(c))

// 3세대: slices.SortFunc. 원소를 직접 받고, bool이 아니라 int를 돌려준다.
d := people()
slices.SortFunc(d, func(x, y Person) int { return cmp.Compare(x.Age, y.Age) })
fmt.Println("slices.SortFunc:", names(d))

// 다중 키는 cmp.Or로 잇는다. 0이 아닌 첫 값을 고른다.
e := people()
slices.SortFunc(e, func(x, y Person) int {
return cmp.Or(
cmp.Compare(x.City, y.City),
cmp.Compare(x.Age, y.Age),
)
})
fmt.Println("도시→나이:", detail(e))

// 내림차순은 인자를 뒤집는다.
f := people()
slices.SortFunc(f, func(x, y Person) int { return cmp.Compare(y.Age, x.Age) })
fmt.Println("나이 내림차순:", names(f))

// ---- 안정 정렬 ----
// SortFunc는 불안정하다. 같은 키끼리의 원래 순서를 보장하지 않는다.
// 두 번 나눠 정렬하려면 두 번째 정렬이 반드시 안정해야 한다.
g := people()
slices.SortFunc(g, func(x, y Person) int { return cmp.Compare(x.Name, y.Name) })
slices.SortStableFunc(g, func(x, y Person) int { return cmp.Compare(x.Age, y.Age) })
fmt.Println("이름 정렬 후 나이로 안정 정렬:", names(g))

// ---- 비교 함수가 지켜야 할 계약 ----
// x<y면 음수, x==y면 0, x>y면 양수. 뺄셈으로 만들면 오버플로가 난다.
big := []int64{math.MaxInt64, math.MinInt64, 0}
bad := slices.Clone(big)
slices.SortFunc(bad, func(x, y int64) int { return int(x - y) }) // 오버플로
good := slices.Clone(big)
slices.SortFunc(good, cmp.Compare)
fmt.Println("뺄셈 비교:", bad)
fmt.Println("cmp.Compare:", good)

// IsSortedFunc로 결과를 검증할 수 있다.
fmt.Println("뺄셈 결과가 정렬됐는가:", slices.IsSorted(bad))
}

func detail(ps []Person) []string {
out := make([]string, 0, len(ps))
for _, p := range ps {
out = append(out, fmt.Sprintf("%s/%s(%d)", p.City, p.Name, p.Age))
}
return out
}

func names(ps []Person) []string {
out := make([]string, 0, len(ps))
for _, p := range ps {
out = append(out, fmt.Sprintf("%s(%d)", p.Name, p.Age))
}
return out
}
go run ./03-sort-migration
sort.Interface: [이배포(28) 최설계(28) 김개발(34) 박운영(34)]
sort.Interface(이름): [김개발(34) 박운영(34) 이배포(28) 최설계(28)]
sort.Slice: [이배포(28) 최설계(28) 김개발(34) 박운영(34)]
slices.SortFunc: [이배포(28) 최설계(28) 김개발(34) 박운영(34)]
도시→나이: [대전/최설계(28) 부산/이배포(28) 서울/김개발(34) 서울/박운영(34)]
나이 내림차순: [김개발(34) 박운영(34) 이배포(28) 최설계(28)]
이름 정렬 후 나이로 안정 정렬: [이배포(28) 최설계(28) 김개발(34) 박운영(34)]
뺄셈 비교: [0 9223372036854775807 -9223372036854775808]
cmp.Compare: [-9223372036854775808 0 9223372036854775807]
뺄셈 결과가 정렬됐는가: false

세 세대 비교

코드량타입 안전성문제
sort.Interface (1.0)정렬 기준마다 타입 + 메서드 3개있음너무 번거롭다
sort.Slice (1.8)클로저 하나없음x any + reflect인덱스를 직접 다룬다
slices.SortFunc (1.21)클로저 하나있음없음. 이것을 쓴다

sort.Interface가 남긴 교훈4-5의 주장 그대로다. 메서드 세 개짜리 인터페이스는 이미 너무 컸다. 정렬 기준을 하나 추가할 때마다 타입 선언과 메서드 세 개가 늘어난다. 위 예제에서 byAgebyNameLenSwap을 글자 그대로 복제하고 있는 것을 보라.

sort.Slice는 타입 안전성을 대가로 지불했다. 시그니처가 sort.Slice(x any, less func(i, j int) bool)이고 내부에서 reflect로 스왑한다. x에 슬라이스가 아닌 값을 넣으면 런타임 패닉이고, 클로저는 원소가 아니라 인덱스를 받으므로 c[i]처럼 바깥 변수를 캡처해야 한다.

slices.SortFunc이 둘 다 해결했다. 원소를 직접 받고, 컴파일 시점에 타입이 맞고, reflect가 없다.

:::tip 새 코드에서 sort 패키지를 쓸 일은 거의 없다 sort.Interface는 아직 유효하고 표준 라이브러리 곳곳에서 쓰이지만, 새로 정렬을 작성한다면 slices.Sort/SortFunc/SortStableFunc이다. go fixslicessort 픽서가 있어 기본 타입에 대한 sort.Sliceslices.Sort로 자동 변환된다 (go tool fix help로 전체 목록을 볼 수 있다).

예외는 슬라이스가 아닌 것을 정렬할 때다. sort.InterfaceLen/Less/Swap만 있으면 되므로 두 슬라이스를 나란히 정렬하는 것 같은 특수한 경우에 여전히 쓸모가 있다. :::

비교 함수는 bool이 아니라 int

sort.InterfaceLessbool을 돌려줬지만 slices.SortFuncint를 요구한다.

반환값의미
음수xy보다 앞
0순서상 같음
양수xy보다 뒤

세 갈래인 이유는 "같음"을 표현해야 다중 키 정렬을 조합할 수 있기 때문이다. cmp.Or가 0이 아닌 첫 값을 고르므로, 비교 함수를 위에서 아래로 이어 붙이면 1순위·2순위 정렬이 된다.

뺄셈으로 만들지 않는다. 위 출력이 이유다.

뺄셈 비교: [0 9223372036854775807 -9223372036854775808]
뺄셈 결과가 정렬됐는가: false

math.MaxInt64 - math.MinInt64는 오버플로로 음수가 되어 대소가 뒤집힌다. cmp.Compare는 뺄셈이 아니라 비교로 구현되어 있고 NaN까지 처리한다 (5-2). 항상 cmp.Compare를 쓴다.

안정성

slices.SortSortFunc불안정하다. 같은 키를 가진 원소들의 원래 순서를 보장하지 않는다. 보장이 필요하면 SortStableFunc을 쓴다.

실무에서 안정 정렬이 필요한 대표적인 경우는 여러 단계로 나눠 정렬할 때다. "이름순으로 정렬한 다음 나이순으로 정렬"하려면 두 번째 정렬이 안정해야 첫 번째 결과가 보존된다. 다만 대부분은 cmp.Or로 한 번에 비교하는 편이 더 낫다 — 의도가 한 곳에 모이고 정렬을 한 번만 한다.

mapscmp

examples/05-generics-and-advanced/03-maps-cmp/main.go
package main

import (
"cmp"
"fmt"
"maps"
"slices"
)

type Stock struct {
Qty int
Tags []string
}

func main() {
inventory := map[string]int{
"사과": 12,
"바나나": 3,
"체리": 40,
"두리안": 0,
}

// maps.Keys는 []K가 아니라 iter.Seq[K]를 돌려준다. 5-4의 주제다.
// 슬라이스로 만들려면 slices.Collect 또는 slices.Sorted를 통과시킨다.
fmt.Println("Keys(정렬):", slices.Sorted(maps.Keys(inventory)))
fmt.Println("Values(정렬):", slices.Sorted(maps.Values(inventory)))

// 값 기준으로 키를 정렬하려면 키를 모은 뒤 SortFunc를 쓴다.
keys := slices.Collect(maps.Keys(inventory))
slices.SortFunc(keys, func(a, b string) int {
return cmp.Or(
cmp.Compare(inventory[b], inventory[a]), // 수량 내림차순
cmp.Compare(a, b), // 동률이면 이름순
)
})
fmt.Println("수량 많은 순:", keys)

// DeleteFunc는 제자리에서 지운다. 반환값이 없다.
filtered := maps.Clone(inventory)
maps.DeleteFunc(filtered, func(_ string, qty int) bool { return qty == 0 })
fmt.Println("품절 제거:", slices.Sorted(maps.Keys(filtered)))

// Copy는 dst에 src를 덮어쓴다.
extra := map[string]int{"사과": 99, "감": 5}
maps.Copy(filtered, extra)
fmt.Println("Copy 후 사과:", filtered["사과"], "/ 감:", filtered["감"])

// Equal은 값까지 비교한다. V가 comparable이어야 한다.
fmt.Println("Equal:", maps.Equal(inventory, maps.Clone(inventory)))

// V가 비교 불가능하면 EqualFunc를 쓴다.
a := map[string]Stock{"사과": {Qty: 1, Tags: []string{"과일"}}}
b := map[string]Stock{"사과": {Qty: 1, Tags: []string{"과일"}}}
fmt.Println("EqualFunc:", maps.EqualFunc(a, b, func(x, y Stock) bool {
return x.Qty == y.Qty && slices.Equal(x.Tags, y.Tags)
}))

// ---- Clone은 얕은 복사다 ----
cloned := maps.Clone(a)
cloned["사과"].Tags[0] = "바뀜" // 같은 백킹 배열을 가리킨다
fmt.Println("Clone 후 원본 Tags:", a["사과"].Tags)

// ---- cmp 패키지 ----
fmt.Println("Compare:", cmp.Compare(1, 2), cmp.Compare(2, 2), cmp.Compare(3, 2))
fmt.Println("Less:", cmp.Less("a", "b"))

// Or는 제로값이 아닌 첫 인자를 고른다. 기본값 채우기에도 쓴다.
fmt.Println("Or(기본값):", cmp.Or("", "", "기본"))
fmt.Println("Or(비교 연결):", cmp.Or(cmp.Compare(1, 1), cmp.Compare("b", "a")))
}
go run ./03-maps-cmp
Keys(정렬): [두리안 바나나 사과 체리]
Values(정렬): [0 3 12 40]
수량 많은 순: [체리 사과 바나나 두리안]
품절 제거: [바나나 사과 체리]
Copy 후 사과: 99 / 감: 5
Equal: true
EqualFunc: true
Clone 후 원본 Tags: [바뀜]
Compare: -1 0 1
Less: true
Or(기본값): 기본
Or(비교 연결): 1

maps.Keys는 슬라이스를 돌려주지 않는다

func Keys[Map ~map[K]V, K comparable, V any](m Map) iter.Seq[K]

[]K가 아니라 iter.Seq[K] 다. 이것이 5-4에서 다룰 이터레이터다. 지금은 두 가지만 알면 된다.

keys := slices.Collect(maps.Keys(m)) // 순서 없는 슬라이스
keys := slices.Sorted(maps.Keys(m)) // 정렬된 슬라이스

maps.Keys가 슬라이스 대신 이터레이터를 돌려주는 이유는 호출자가 무엇을 할지 모르기 때문이다. 정렬할 수도, 세기만 할 수도, 첫 하나만 볼 수도 있다. 슬라이스를 만들어 주면 필요 없는 할당이 항상 발생한다.

맵 순회 순서는 무작위다(3-3). 예제에서 slices.Sorted를 통과시킨 것은 출력을 재현 가능하게 만들기 위해서다. Collect만 쓰면 실행할 때마다 순서가 달라진다.

cmp.Or의 두 얼굴

func Or[T comparable](vals ...T) T

제로값이 아닌 첫 인자를 돌려준다. 이것이 두 가지 용도로 쓰인다.

  1. 비교 함수 연결. 비교 결과의 제로값이 "같음"(0)이므로, cmp.Or가 자연스럽게 "1순위가 같으면 2순위로" 가 된다.
  2. 기본값 채우기. cmp.Or(cfg.Host, os.Getenv("HOST"), "localhost")처럼 다른 언어의 ?? 연산자 자리에 쓴다.

두 번째 용도에는 주의할 점이 있다. 모든 인자가 평가된다. 함수 호출이 들어가면 필요 없어도 실행된다.

maps.Clone도 얕은 복사다

Clone 후 원본 Tags: [바뀜]

slices.Clone과 같다. 값이 슬라이스나 맵이나 포인터를 포함하면 그것들은 공유된다. map[string][]intmaps.Clone하고 안심하면 안 된다.

흔히 하는 실수

1. 길이가 바뀌는 함수의 반환값을 버린다

Insert, Delete, DeleteFunc, Compact, Clip, Grow. go vet이 잡는다.

2. Compact를 중복 제거로 쓴다

정렬하지 않으면 인접한 것만 지운다.

3. Clone을 깊은 복사로 믿는다

slices.Clone, maps.Clone 둘 다 원소 하나 깊이까지만 복사한다.

4. 비교 함수를 뺄셈으로 만든다

오버플로가 난다. cmp.Compare를 쓴다.

5. BinarySearch를 정렬 안 된 슬라이스에 쓴다

컴파일도 되고 패닉도 안 나지만 결과가 틀린다. 조용히 틀리는 종류의 버그라 가장 나쁘다. 확신이 없으면 slices.IsSorted로 먼저 확인한다.

6. maps.Keys의 결과를 슬라이스처럼 쓴다

keys := maps.Keys(m)
fmt.Println(len(keys)) // 컴파일 에러: invalid argument

iter.Seq[K]는 함수다. slices.Collectslices.Sorted를 통과시켜야 슬라이스가 된다.

7. golang.org/x/exp/slices를 계속 쓴다

제네릭 초기에는 실험 저장소에 있었다. 지금은 표준 라이브러리에 있으므로 import "slices"로 바꾼다. go fix가 처리해 준다.

정리

  • slices 함수는 조회 / 제자리 변경 / 길이 변경 세 부류다. 세 번째는 반환값을 반드시 다시 받는다. go vet이 잡아 준다.
  • slices.Delete는 남는 꼬리를 제로값으로 지운다. 포인터 누수 방지 목적이다.
  • Clone얕은 복사다. slicesmaps도 마찬가지다.
  • Compact인접한 중복만 지운다. 정렬이 먼저다.
  • 제약이 ~[]E, ~map[K]V라서 정의된 타입도 그대로 들어가고 그대로 나온다.
  • 정렬은 sort.Interfacesort.Sliceslices.SortFunc 순으로 진화했고, 새 코드는 마지막 것을 쓴다. sort.Interface는 "메서드 세 개도 많았다"는 증거로 남았다.
  • 비교 함수는 bool이 아니라 int 를 돌려준다. cmp.Compare로 만들고 뺄셈으로 만들지 않는다. 다중 키는 cmp.Or로 잇는다.
  • maps.Keys/Valuesiter.Seq를 돌려준다. slices.Collectslices.Sorted로 받는다.

연습문제

  1. slices.Delete가 꼬리를 제로값으로 지우는 것을 []*int로 직접 확인해 보자. 삭제 전후에 원본 슬라이스를 전체 길이로 출력하면 된다. 만약 이 처리가 없다면 어떤 상황에서 메모리가 회수되지 않는가? 힌트: []*os.File을 큐로 쓰고 있다고 생각해 보자.

  2. []Person을 "도시별로 묶어 map[string][]Person으로 만들고, 각 도시 안에서는 나이 내림차순, 도시 이름은 사전순"으로 출력하는 코드를 써 보자. maps.Keys, slices.Sorted, slices.SortFunc, cmp.Or를 전부 쓰게 된다. 나이가 같으면 어떤 순서가 나오는가? 그것을 결정적으로 만들려면 무엇을 추가해야 하는가?

  3. sort.Interface가 아직 유리한 경우를 하나 만들어 보자. 힌트: []string[]int 두 슬라이스를 같은 순열로 재배열해야 한다면 Swap이 두 슬라이스를 동시에 건드리면 된다. slices.SortFunc으로는 왜 이것이 어려운가?