[Golang] ToLower() 분석

hop6·2022년 6월 1일
0

string을 소문자로 바꿔주는 메서드인 ToLower()를 할 때, 애초에 소문자를 넣으면 어떻게 될까? 미리 체크를 해보고 메서드를 호출해야 할까?
결론은 소문자에 ToLower()를 해도 소문자가 리턴된다.

(이 글에서는 타깃 string이 ASCII 코드인 경우만 다루겠습니다.)

// strings.ToLower()
func ToLower(s string) string {
	isASCII, hasUpper := true, false
	for i := 0; i < len(s); i++ {
		c := s[i]
		if c >= utf8.RuneSelf {
			isASCII = false
			break
		}
		hasUpper = hasUpper || ('A' <= c && c <= 'Z')
	}

	if isASCII { // optimize for ASCII-only strings.
		if !hasUpper {
			return s
		}
		var b Builder
		b.Grow(len(s))
		for i := 0; i < len(s); i++ {
			c := s[i]
			if 'A' <= c && c <= 'Z' {
				c += 'a' - 'A'
			}
			b.WriteByte(c)
		}
		return b.String()
	}
	return Map(unicode.ToLower, s)
}

strings 에서는 소문자 변환을 수행하기 전에 모든 캐릭터를 순회하며 ASCII 범위값을 벗어나는지, 대문자가 하나라도 존재하는지 확인한다.
ToLower() 조건에 부합하는 경우, Builder라는 타입을 선언한다.

type Builder struct {
	addr *Builder // of receiver, to detect copies by value
	buf  []byte
}

// grow copies the buffer to a new, larger buffer so that there are at least n
// bytes of capacity beyond len(b.buf).
func (b *Builder) grow(n int) {
	buf := make([]byte, len(b.buf), 2*cap(b.buf)+n)
	copy(buf, b.buf)
	b.buf = buf
}

// Grow grows b's capacity, if necessary, to guarantee space for
// another n bytes. After Grow(n), at least n bytes can be written to b
// without another allocation. If n is negative, Grow panics.
func (b *Builder) Grow(n int) {
	b.copyCheck()
	if n < 0 {
		panic("strings.Builder.Grow: negative count")
	}
	if cap(b.buf)-len(b.buf) < n {
		b.grow(n)
	}
}

func (b *Builder) copyCheck() {
	if b.addr == nil {
		// This hack works around a failing of Go's escape analysis
		// that was causing b to escape and be heap allocated.
		// See issue 23382.
		// TODO: once issue 7921 is fixed, this should be reverted to
		// just "b.addr = b".
		b.addr = (*Builder)(noescape(unsafe.Pointer(b)))
	} else if b.addr != b {
		panic("strings: illegal use of non-zero Builder copied by value")
	}
}

func (b *Builder) WriteByte(c byte) error {
	b.copyCheck()
	b.buf = append(b.buf, c)
	return nil
}

func (b *Builder) String() string {
	return *(*string)(unsafe.Pointer(&b.buf))
}

예상해봤을 때는 캐릭터를 하나하나 소문자로 바꾸며 string에 + operator를 수행할 줄 알았는데 생각과는 많이 다르다.
Builder의 주석에서는

A Builder is used to efficiently build a string using Write methods. It minimizes memory copying. The zero value is ready to use.

메모리 복사를 최소화하고, 효율적으로 Write method를 쓰기 위해서 쓰인다고 한다.
코드만 봤을 때는 복잡하다고 느끼지만... string + operator의 경우 string을 이어붙일 때 마다 메모리를 할당하고 copy하는 방법이라 느린 반면, Builder의 경우 처음에 메모리를 할당하고 append하는 방법이라 더욱 빠르다고 한다.
(참고 : http://cloudrain21.com/go-how-to-concatenate-strings)
의문인 점 하나는, Builder의 크기를 체크 하는 부분이 없다는 것? 할당된 크기를 넘어서서 append가 되면 어떨까?
Slice 같은 경우는 할당된 capacity를 넘으면 기존 capacity 크기의 두배인 slice를 생성해 copy하는 것으로 알고 있다.

func main() {
	builder := strings.Builder{}
	builder.Grow(5)

	builder.WriteByte(80)
	builder.WriteByte(81)
	builder.WriteByte(82)
	builder.WriteByte(83)
	builder.WriteByte(84)
	fmt.Println(builder.Cap())
	fmt.Println(builder.Len())

	builder.WriteByte(85)
	fmt.Println(builder.Cap())
	fmt.Println(builder.Len())

	fmt.Println(builder.String())
}

// 결과값
/*
	5
    5
    16
    6
*/

WriteByte() 메서드에서는 따로 메모리 재할당하는 부분이 없는데 자동으로 capacity가 늘어난다.
어느 부분에서 늘어나는 걸까?

func (b *Builder) WriteByte(c byte) error {
	fmt.Println(b.Cap())
	b.copyCheck()
	fmt.Println(b.Cap())
	b.buf = append(b.buf, c)
	fmt.Println(b.Cap())
	return nil
}

Builder 코드를 위와 같이 수정하고 테스트 해본 결과, append 이후로 capacity가 변경되었다.
이 부분은 지금 보고 있는 코드 단계에서 이루어지는 것이 아니라 더 로우한 레벨에서 수행되는 것 같아 이후 글에서 이에 대해 다루어야겠다.

본 글로 다시 돌아와서 요약해보자면... string.ToLower()에서는 파라미터로 받은 string이 ASCII 코드이며, 대문자가 하나라도 있을 경우, 해당 string의 길이만큼의 capacity를 갖는 Builder 구조체를 선언한다. 그 후, 캐릭터를 순회하며 소문자면 그대로, 대문자면 소문자로 변환하여 Builder 구조체안 byte 배열에 append 해준 후 모든 작업이 끝나면 string으로 변환하여 리턴한다.


이번에는 rune 타입의 ToLower()를 살펴보자.
// ToLower maps the rune to lower case.
func ToLower(r rune) rune {
	if r <= MaxASCII {
		if 'A' <= r && r <= 'Z' {
			r += 'a' - 'A'
		}
		return r
	}
	return To(LowerCase, r)
}

그냥... 심플하다. 하지만 strings.ToLower()와 비교하는 것은 아닌 것 같아 byte를 본다.

byte의 ToLower()

// ToLower returns a copy of the byte slice s with all Unicode letters mapped to
// their lower case.
func ToLower(s []byte) []byte {
	isASCII, hasUpper := true, false
	for i := 0; i < len(s); i++ {
		c := s[i]
		if c >= utf8.RuneSelf {
			isASCII = false
			break
		}
		hasUpper = hasUpper || ('A' <= c && c <= 'Z')
	}

	if isASCII { // optimize for ASCII-only byte slices.
		if !hasUpper {
			return append([]byte(""), s...)
		}
		b := make([]byte, len(s))
		for i := 0; i < len(s); i++ {
			c := s[i]
			if 'A' <= c && c <= 'Z' {
				c += 'a' - 'A'
			}
			b[i] = c
		}
		return b
	}
	return Map(unicode.ToLower, s)
}

strings.ToLower()와 Builder 선언 부분만 빼면 로직이 동일하다.
여기서 궁금해지는 것은, strings와 bytes의 ToLower(), 무엇이 빠를까? 단순히 생각해보았을 때는 따로 Builder를 선언하는 strings.ToLower() 보다는 bytes.ToLower()가 빠를 것 같다는 생각이 든다.


테스트 코드
func main() {
	str := "Hello, My name is seungbae."

	start := time.Now()
	for i := 0; i < 100000; i++ {
		strings.ToLower(str)
	}
	escape := time.Since(start)
	fmt.Printf("strings.ToLower() : %v\n", escape)

	start = time.Now()
	for i := 0; i < 100000; i++ {
		byt := []byte(str)
		_ = string(bytes.ToLower(byt))
	}
	escape = time.Since(start)
	fmt.Printf("bytes.ToLower() : %v\n", escape)
}

bytes.ToLower()의 경우엔 string을 byte로 변환하고 ToLower()를 수행한 뒤 다시 string으로 변환하는 로직까지 시간에 포함시켰다.

결과

❯ go run main.go
strings.ToLower() : 17.744042ms
bytes.ToLower() : 8.1155ms
❯ go run main.go
strings.ToLower() : 10.723583ms
bytes.ToLower() : 5.57725ms
❯ go run main.go
strings.ToLower() : 9.454834ms
bytes.ToLower() : 5.422833ms
❯ go run main.go
strings.ToLower() : 10.825584ms
bytes.ToLower() : 5.865709ms
❯ go run main.go
strings.ToLower() : 10.27125ms
bytes.ToLower() : 5.494042ms

생각보다 차이가 컸다. 클 때는 약 두배정도 까지도 나는 것으로 보인다.
이정도 차이라면 byte로 변환해서 사용하는 불편함을 감수할 필요가 있지 않을까?

0개의 댓글