Các phương pháp nối chuỗi hiệu quả trong Golang - 88vin

Ngày 03 tháng 4 năm 2021 - Máy tính

Trong quá trình lập trình hàng ngày, việc nối chuỗi là không thể thiếu. Phương pháp phổ biến nhất thường được sử dụng là cách nối nguyên thủy (+=). Mặc dù cách này hoạt động tốt khi số lần nối ít, nhưng khi cần thực hiện nhiều phép nối chuỗi, nên sử dụng các phương pháp khác hiệu quả hơn.

Bài viết này sẽ liệt kê một số cách nối chuỗi phổ biến trong Golang, sau đó tiến hành kiểm thử hiệu suất của chúng để giúp người đọc hiểu rõ hơn về từng phương pháp và biết cách áp dụng phù hợp.

1. Có những cách nào để nối chuỗi?

Giống như câu hỏi "Có bao nhiêu cách viết chữ 'hồi'?" mà Khổng Duy đã đặt ra, trong thế giới Golang, cũng có ai đó từng hỏi: "Có bao nhiêu cách để nối chuỗi?". Dưới đây là một số phương pháp chính:

a) Cách nối nguyên thủy (+=)

Phương pháp này sử dụng toán tử + để nối hai chuỗi lại với nhau. Ví dụ dưới đây minh họa cách sử dụng += để nối chuỗi và gán lại giá trị cho biến:

1var s string
2s += "chào"

Tại sao phương pháp này lại không hiệu quả? Trong Golang, chuỗi là bất biến (immutable), nghĩa là mỗi khi bạn nối chuỗi, hệ thống sẽ tạo ra một chuỗi mới bằng cách sao chép toàn bộ nội dung cũ và thêm phần mới vào cuối chuỗi. Điều này đòi hỏi phải phân bổ lại bộ nhớ và sao chép toàn bộ chuỗi gốc mỗi lần nối, dẫn đến độ phức tạp thời gian O(N^2).

b) bytes.Buffer

bytes.Buffer là một vùng nhớ đệm (buffer) lưu trữ dữ liệu kiểu byte với khả năng thay đổi kích thước linh hoạt. Nội bộ của nó sử dụng slice để lưu trữ các byte (buf []byte).

1buf := bytes.NewBufferString("chào")
2buf.WriteString(" thế giới") // fmt.Fprint(buf, " thế giới")

Khi sử dụng WriteString để nối chuỗi, bytes.Buffer sẽ tự động điều chỉnh kích thước của slice và sử dụng hàm copy tích hợp để sao chép chuỗi cần nối vào vùng nhớ đệm. Vì buffer có thể mở rộng, nó chỉ cần thêm phần mới vào cuối chứ không cần sao chép lại toàn bộ chuỗi cũ, từ đó cải thiện hiệu suất. Độ phức tạp thời gian của phương pháp này là O(N).

c) strings.Builder

strings.Builder cũng sử dụng slice byte để lưu trữ dữ liệu bên trong.

1var builder strings.Builder
2builder.WriteString("chào") // fmt.Fprint(&builder, "chào")

Khi gọi WriteString, strings.Builder sẽ sử dụng hàm append tích hợp để thêm chuỗi cần nối vào vùng nhớ đệm. Cách tiếp cận này rất hiệu quả vì nó giảm thiểu việc sao chép dữ liệu thừa thãi.

d) Hàm copy tích hợp

Hàm copy tích hợp hỗ trợ sao chép các phần tử từ một slice nguồn sang một slice đích. Do chuỗi trong Golang được biểu diễn bằng []byte, bạn cũng có thể sử dụng hàm này để nối chuỗi. Tuy nhiên, yêu cầu là phải biết trước độ dài của slice byte.

1bytes := make([]byte, 11)
2size := copy(bytes[0:], "chào")
3copy(bytes[size:], " thế giới")
4fmt.Println(string(bytes))

Hàm copy sẽ sao chép các phần tử từ slice nguồn sang slice đích, trả về số bắn cá đổi thẻ cào lượng phần tử đã sao chép. Mỗi lần nối, chỉ cần thêm phần mới vào cuối slice, nhờ đó tăng hiệu suất.

e) strings.Join

Nếu bạn muốn nối tất cả các phần tử của một slice chuỗi ([]string) thành một chuỗi duy nhất, có thể sử dụng hàm strings.Join.

1s := strings.Join([]string{"chào", "thế", "giới"}, " ")

Hàm này sử dụng strings.Builder ở bên trong để thực hiện việc nối chuỗi, do đó rất hiệu quả.

2. Kiểm thử hiệu suất

Dưới đây là mã nguồn của tệp kiểm thử string_test.go, nơi so sánh hiệu suất của các phương pháp nối chuỗi đã đề cập trên. Lưu ý rằng strings.Join không được đưa vào kiểm tra vì nó yêu cầu một slice chuỗi làm đầu vào, khác với các phương pháp còn lại.

Mã nguồn string_test.go:

 1package string_test
 2
 3import (
 4	"bytes"
 5	"strings"
 6	"testing"
 7)
 8
 9var (
10	concatSteps = 1000
11	subStr      = "s"
12	expectedStr = strings.Repeat(subStr, concatSteps)
13)
14
15func BenchmarkConcat(b *testing.B) {
16	for n := 0; n < b.N; n++ {
17		var s string
18		for i := 0; i < concatSteps; i++ {
19			s += subStr
20		}
21		if s != expectedStr {
22			b.Errorf("kết quả không mong đợi, nhận được: %s, mong muốn: %s", s, expectedStr)
23		}
24	}
25}
26
27func BenchmarkBuffer(b *testing.B) {
28	for n := 0; n < b.N; n++ {
29		var buffer bytes.Buffer
30		for i := 0; i < concatSteps; i++ {
31			buffer.WriteString(subStr)
32		}
33		if buffer.String() != expectedStr {
34			b.Errorf("kết quả không mong đợi, nhận được: %s, mong muốn: %s", buffer.String(), expectedStr)
35		}
36	}
37}
38
39func BenchmarkBuilder(b *testing.B) {
40	for n := 0; n < b.N; n++ {
41		var builder strings.Builder
42		for i := 0; i < concatSteps; i++ {
43			builder.WriteString(subStr)
44		}
45		if builder.String() != expectedStr {
46			b.Errorf("kết quả không mong đợi, nhận được: %s, mong muốn: %s", builder.String(), expectedStr)
47		}
48	}
49}
50
51func BenchmarkCopy(b *testing.B) {
52	for n := 0; n < b.N; n++ {
53		bytes := make([]byte, len(subStr)*concatSteps)
54		c := 0
55		for i := 0; i < concatSteps; i++ {
56			c += copy(bytes[c:], subStr)
57		}
58		if string(bytes) != expectedStr {
59			b.Errorf("kết quả không mong đợi, nhận được: %s, mong muốn: %s", string(bytes), expectedStr)
60		}
61	}
62}

Kết quả kiểm thử:

 1$ go test -benchmem -bench .
 2goos: darwin
 3goarch: amd64
 4pkg: github.com/leileiluoluo/test
 5cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
 6BenchmarkConcat-4      7750      148143 ns/op     530274 B/op    999 allocs/op
 7BenchmarkBuffer-4     161848       7151 ns/op      3248 B/op     6 allocs/op
 8BenchmarkBuilder-4    212043       5406 ns/op      2040 B/op     8 allocs/op
 9BenchmarkCopy-4      281827       4208 ns/op      1024 B/op     1 allocs/op
10PASS
11ok   github.com/leileiluoluo/test  5.773s

Như có thể thấy, hàm copy tích hợp và strings.Builder là hai phương pháp hiệu quả nhất, tiếp theo là bytes.Buffer, và cuối cùng là cách nối nạp tiền bằng thẻ game w88 nguyên thủy.

[1] Làm thế nào để nối chuỗi hiệu quả trong Go - Stack Overflow
[2] Tài liệu cho bytes.Buffer
[3] Tài liệu cho strings.Builder
[4] Tài liệu cho builtin.copy

#Golang