用 Go 如何實現(xiàn)精準(zhǔn)統(tǒng)計文章字?jǐn)?shù)
大家好,我是站長 polarisxu。
今天要聊的內(nèi)容應(yīng)該可以當(dāng)做一道面試題,你可以先想想該怎么實現(xiàn)。
統(tǒng)計字?jǐn)?shù)是一個很常見的需求,很多人印象最深的應(yīng)該是微博早些時候限制 140 字,而且邊輸入會邊統(tǒng)計剩余字?jǐn)?shù)。現(xiàn)在很多社區(qū)文章也會有字?jǐn)?shù)統(tǒng)計的功能,而且可以依據(jù)字?jǐn)?shù)來預(yù)估閱讀時間。比如 Go語言中文網(wǎng)就有這樣的功能。
01 需求分析
下手之前先分析下這個需求。從我個人經(jīng)驗看,在實際面試中,針對一個面試題,你的分析過程,循序漸進(jìn)的解決方案,可以很好的展示你的思考過程。正所謂分析問題、解決問題。這會給你加分的。
我們采用類似詞法分析的思路分析這個需求。
一篇文章通常包含如下元素,我們也稱之為 token:
- 普通文字
- 標(biāo)點符號
- 圖片
- 鏈接(包含各種協(xié)議的鏈接)
- 代碼
其中普通文字通常會分為歐美和中日韓(CJK),因為 CJK 屬于表意文字,和歐美字母的文字差異很大。同時這里還涉及到編碼的問題。本文假設(shè)使用 UTF-8 編碼。
對于標(biāo)點符號,中文標(biāo)點和英文標(biāo)點也會很不一樣。
此外還有全角和半角的問題。
根據(jù)以上分析,對于該需求作如下假定:
- 空格(包括換行)不算字?jǐn)?shù);
- HTML 標(biāo)簽需要剔除;
- 編碼方式:假定為 UTF-8 編碼;
- 標(biāo)點符號算不算做字?jǐn)?shù)。如果算,像括號這樣的按 2 個字算;
- 鏈接怎么算?一個鏈接約定為 1 個字可能更合適,大概閱讀時只是把它當(dāng)鏈接,而不太會關(guān)心鏈接由什么字母組成;
- 圖片不算做字?jǐn)?shù),但如果計算閱讀時間,可能需要適當(dāng)考慮圖片的影響;
- 對于技術(shù)文章,代碼是最麻煩的。統(tǒng)計代碼字?jǐn)?shù)感覺是沒多大意義的。統(tǒng)計代碼行數(shù)可能更有意義;
本文的解決方案針對以上的假定進(jìn)行。
02 Go 語言實現(xiàn)
先看最簡單的。
純英文
根據(jù)以上分析,如果文章只包含普通文本且是英文,也就是說,每個字(單詞)根據(jù)空格分隔,統(tǒng)計是最簡單的。
- func TotalWords(s string) int {
- n := 0
- inWord := false
- for _, r := range s {
- wasInWord := inWord
- inWord = !unicode.IsSpace(r)
- if inWord && !wasInWord {
- n++
- }
- }
- return n
- }
還有一種更簡單的方式:
- len(strings.Fields(s))
不過看 strings.Fields 的實現(xiàn),性能會不如第一種方式。
回顧上面的需求分析,會發(fā)現(xiàn)這個實現(xiàn)是有 Bug 的。比如下面的例子:
- s1 := "Hello,playground"
- s2 := "Hello, playground"
用上面的實現(xiàn),s1 的字?jǐn)?shù)是 1,s2 的字?jǐn)?shù)是 2。它們都忽略了標(biāo)點符號。而且因為寫法的多樣性(不規(guī)范統(tǒng)一),導(dǎo)致計算字?jǐn)?shù)會有誤差。所以我們需要對寫法進(jìn)行規(guī)范。
規(guī)范排版
其實和寫代碼要有規(guī)范一樣,文章也是有規(guī)范的。比如出版社對于一本書的排版會有明確的規(guī)定。為了讓我們的文章看起來更舒服,也應(yīng)該遵循一定的規(guī)范。
這里推薦一個 GitHub 上的排版指南:《中文文案排版指北》,它的宗旨,統(tǒng)一中文文案、排版的相關(guān)用法,降低團(tuán)隊成員之間的溝通成本,增強(qiáng)網(wǎng)站氣質(zhì)。這個規(guī)范開頭關(guān)于空格的一段話很有意思:
有研究顯示,打字的時候不喜歡在中文和英文之間加空格的人,感情路都走得很辛苦,有七成的比例會在 34 歲的時候跟自己不愛的人結(jié)婚,而其余三成的人最后只能把遺產(chǎn)留給自己的貓。畢竟愛情跟書寫都需要適時地留白。
建議大家可以看看這個指北,一些知名的網(wǎng)站就是按照這個做的。
因為 GCTT 的排版在這個規(guī)范做,但人為約束不是最好的方法,所以我開發(fā)了一個 Go 工具:https://github.com/studygolang/autocorrect,用于自動給中英文之間加入合理的空格并糾正專用名詞大小寫。
所以為了讓字?jǐn)?shù)統(tǒng)計更準(zhǔn)確,我們假定文章是按一定的規(guī)范書寫的。比如上面的例子,規(guī)范的寫法是 s2 := "Hello, playground"。不過這里標(biāo)點不算作字?jǐn)?shù)。
剛?cè)ノ⒉┥显嚵艘幌?,發(fā)現(xiàn)微博的字?jǐn)?shù)計算方式有點詭異,竟然是 9 個字。
測試一下發(fā)現(xiàn),它直接把兩個英文字母算作一個字(兩個字節(jié)算一個字)。而漢字是正常的。大家可以想想微博是怎么實現(xiàn)的。
中英文混合
中文不像英文,單詞之間沒有空格分隔,因此開始的那兩種方式不適合。
如果是純中文,我們怎么計算字?jǐn)?shù)呢?
在 Go 語言中,字符串使用 UTF-8 編碼,一個字符用 rune 表示。因此在標(biāo)準(zhǔn)庫中查找相關(guān)計算方法。
- func RuneCountInString(s string) (n int)
這個方法能計算字符串包含的 rune(字符)數(shù),對于純中文,就是漢字?jǐn)?shù)。
- str := "你好世界"
- fmt.Println(utf8.RuneCountInString(str))
以上代碼輸出 4。
然而,因為很多時候文章會中英文混合,因此我們先采用上面的純英文的處理方式,即:strings.Fields(),將文章用空格分隔,然后處理每一部分。
- func TotalWords(s string) int {
- wordCount := 0
- plainWords := strings.Fields(s)
- for _, word := range plainWords {
- runeCount := utf8.RuneCountInString(word)
- if len(word) == runeCount {
- wordCount++
- } else {
- wordCount += runeCount
- }
- }
- return wordCount
- }
增加如下的測試用例:
- func TestTotalWords(t *testing.T) {
- tests := []struct {
- name string
- input string
- want int
- }{
- {"en1", "hello,playground", 2},
- {"en2", "hello, playground", 2},
- {"cn1", "你好世界", 4},
- {"encn1", "Hello你好世界", 5},
- {"encn2", "Hello 你好世界", 5},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := wordscount.TotalWords(tt.input); got != tt.want {
- t.Errorf("TotalWords() = %v, want %v", got, tt.want)
- }
- })
- }
- }
發(fā)現(xiàn) en1 和 encn1 測試不通過,因為沒有按照上面說的規(guī)范書寫。因此我們通過程序增加必要的空格。
- // AutoSpace 自動給中英文之間加上空格
- func AutoSpace(str string) string {
- out := ""
- for _, r := range str {
- out = addSpaceAtBoundary(out, r)
- }
- return out
- }
- func addSpaceAtBoundary(prefix string, nextChar rune) string {
- if len(prefix) == 0 {
- return string(nextChar)
- }
- r, size := utf8.DecodeLastRuneInString(prefix)
- if isLatin(size) != isLatin(utf8.RuneLen(nextChar)) &&
- isAllowSpace(nextChar) && isAllowSpace(r) {
- return prefix + " " + string(nextChar)
- }
- return prefix + string(nextChar)
- }
- func isLatin(size int) bool {
- return size == 1
- }
- func isAllowSpace(r rune) bool {
- return !unicode.IsSpace(r) && !unicode.IsPunct(r)
- }
這樣可以在 TotalWords 函數(shù)開頭增加 AutoSpace 進(jìn)行規(guī)范化。這時結(jié)果就正常了。
處理標(biāo)點和其他類型
以上例子標(biāo)點沒計算在內(nèi),而且如果英文和中文標(biāo)點混合在一起,情況又復(fù)雜了。
為了更好地實現(xiàn)開始的需求分析,重構(gòu)以上代碼,設(shè)計如下的結(jié)構(gòu):
- type Counter struct {
- Total int // 總字?jǐn)?shù) = Words + Puncts
- Words int // 只包含字符數(shù)
- Puncts int // 標(biāo)點數(shù)
- Links int // 鏈接數(shù)
- Pics int // 圖片數(shù)
- CodeLines int // 代碼行數(shù)
- }
同時將 TotalWords 重構(gòu)為 Counter 的 Stat 方法,同時記錄標(biāo)點數(shù):
- func (wc *Counter) Stat(str string) {
- wc.Links = len(rxStrict.FindAllString(str, -1))
- wc.Pics = len(imgReg.FindAllString(str, -1))
- // 剔除 HTML
- str = StripHTML(str)
- str = AutoSpace(str)
- // 普通的鏈接去除(非 HTML 標(biāo)簽鏈接)
- str = rxStrict.ReplaceAllString(str, " ")
- plainWords := strings.Fields(str)
- for _, plainWord := range plainWords {
- words := strings.FieldsFunc(plainWord, func(r rune) bool {
- if unicode.IsPunct(r) {
- wc.Puncts++
- return true
- }
- return false
- })
- for _, word := range words {
- runeCount := utf8.RuneCountInString(word)
- if len(word) == runeCount {
- wc.Words++
- } else {
- wc.Words += runeCount
- }
- }
- }
- wc.Total = wc.Words + wc.Puncts
- }
- var (
- rxStrict = xurls.Strict()
- imgReg = regexp.MustCompile(`<img [^>]*>`)
- stripHTMLReplacer = strings.NewReplacer("\n", " ", "</p>", "\n", "<br>", "\n", "<br />", "\n")
- )
- // StripHTML accepts a string, strips out all HTML tags and returns it.
- func StripHTML(s string) string {
- // Shortcut strings with no tags in them
- if !strings.ContainsAny(s, "<>") {
- return s
- }
- s = stripHTMLReplacer.Replace(s)
- // Walk through the string removing all tags
- b := GetBuffer()
- defer PutBuffer(b)
- var inTag, isSpace, wasSpace bool
- for _, r := range s {
- if !inTag {
- isSpace = false
- }
- switch {
- case r == '<':
- inTag = true
- case r == '>':
- inTag = false
- case unicode.IsSpace(r):
- isSpace = true
- fallthrough
- default:
- if !inTag && (!isSpace || (isSpace && !wasSpace)) {
- b.WriteRune(r)
- }
- }
- wasSpace = isSpace
- }
- return b.String()
- }
代碼過多的細(xì)節(jié)不討論。此外,關(guān)于文章內(nèi)的代碼行數(shù)統(tǒng)計未實現(xiàn)(目前沒有想到特別好的方法,如果你有,歡迎交流)。
03 總結(jié)
通過本文的分析發(fā)現(xiàn),精準(zhǔn)統(tǒng)計字?jǐn)?shù)沒那么容易,這里涉及到很多的細(xì)節(jié)。
當(dāng)然,實際應(yīng)用中,字?jǐn)?shù)不需要那么特別精準(zhǔn),而且對于非正常文字(比如鏈接、代碼)怎么處理,會有不同的約定。
本文涉及到的完整代碼放在 GitHub:https://github.com/polaris1119/wordscount。
本文轉(zhuǎn)載自微信公眾號「polarisxu」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系polarisxu公眾號。