前言

在编写 lctt-client 时,我遇到了很多处理文件名的需求,其中最典型的莫过于 生成符合规范的 Git 分支名称

在 Git 分支命名规则中,有几个条件是在限制开头和末尾不能包含指定的字符(串)。

这就要求我们必须把不符合规则的字符(串)替换掉,否则会因为分支名称不合法而无法创建该分支。

如果你用过 GitHub Desktop 的话,你可能会注意到:当你使用它创建分支,并输入一个不合规的名称时,它自动把名称里的“坏分子”删除或是替换成 -,然后给你提示。

如果我们自己要实现这一点的话,就必须使用到许多字符串操作的函数,其中就包括在字符串内指定的子串。

strings 包函数

Go 的内置包 strings 提供了一系列字符串操作的函数,其中有几个专门用来删除字符串中的指定字符,它们能帮上忙吗?

删除空格

首先,我们先来看看 strings.TrimSpace 函数,它可以将字符串首尾的空格“删除”,然后返回首尾不含空格的字符串。

之所以我要给“删除”加上引号,是因为空格并没有真的被删除,事实上,原来的字符串没有发生任何改变。

这个函数真正做的是:找到开头第一个非空格字符,然后找到末尾第一个非空格字符,记住它们的下标,然后返回介于它们之间的一个字符串切片。

事实上,strings 包内提供了所有字符串操作都是如此,因为 Go 中的 string 被设计为不可变类型。

无论如何,从结果来看,它是符合期望的。不过,我们可能用不上它,因为 Git 分支名称的任意位置都不许包含空格,不仅是首尾而已。

因此,在这种情况下,strings.ReplaceAll 会是更好的选择,它能够一次性删除包含所有的指定子串(只需要把它们替换为空串即可)。但它不是本文研究的重点,因此我们先把它搁置一旁。

删除前缀

如果你只是想要删除字符串开头的某个子串,你可能会选择这个函数,毕竟它乍一看非常符合你的需求。

但是,它并不是一个正确的选择。你可能会疑惑,尤其是当你曾经使用过它,并且也得到了符合预期的输出时。

考虑下面这个例子:

// extractSentence 从一个给定的 quote(句子)中删除开头的 speaker(说话者),
// 从而提取出并返回一个修改后的新字符串
func extractSentence(quote string, speaker string) string {
    leading := speaker + ": "
    return strings.TrimLeft(quote, leading)
}

func TestExtractSentence(t *testing.T) {
    quote := "LKXED: What do you think of it?"
    speaker := "LKXED"
    want := "What do you think of it?"
    got := extractSentence(quote, speaker)
    if got != want {
        log.Faltalf("Test failed. Want %s, but got %s.", want, got)
    }
    fmt.Println(got)
}

在这个例子中,你写了一个提取句子的函数,还有针对它的单元测试。

你发现自己果然可以得到预期输出 “What do you think of it?”,你兴高采烈地开始基于它实现新的功能。

直到有一天,你的程序传进来一个不凑巧的 quote,内容是 “LKXED: Looks good to me.”,猜猜你最终会得到什么?

出人意料地,你得到了 “ooks good to me.”,开头的 “L” 被某种神秘力量吃掉了。

这是怎么回事呢?

仔细看 strings.TrimLeft 的函数注释,你就会发现,它并不是在删除某个指定的前缀子串,而是在删除某个指定的字符集合:

// TrimLeft returns a slice of the string s with all leading
// Unicode code points contained in cutset removed.
//
// To remove a prefix, use TrimPrefix instead.

具体来说,它会从第一个字符开始,按序逐个检查当前字符是否属于给定的字符集合,直到找到一个不满足条件的字符为止,然后返回字符串的剩余部分。

它还好心地提示了我们,如果需要删除一个指定的前缀,我们应该使用 strings.TrimPrefix 函数。

将上述例子中的 strings.TrimLeft 修改为 strings.TrimPrefix,测试通过,打印出了符合预期的 “Looks good to me.”。

原来,strings.TrimPrefix 的不同之处在于,它是真的在匹配前缀子串,并且需要子串完全匹配才行,否则就会返回原字符串,徒劳无功。

删除后缀

经过了上面的教训,这是时候你应该能够正确地选择 strings.TrimSuffix 来完成这个功能。

值得指出的是,对于文件名而言,这里的“后缀”通常会是文件扩展名。

对于特定的某一类文件,它的扩展名通常是固定的,可如果你的应用涉及到多种类型的文件,你可能需要想办法获取它们的扩展名才行。

事实上,当涉及到“文件路径”时,path 包内的函数通常都是有帮助的。比如你可能会需要的 path.Ext

path.Ext 只做了一件事:获取文件的扩展名。你可以结合它与 strings.TrimSuffix 来完成提取文件名的任务,就像下面这样:

// filenameWithoutExt 从 filename 中提取不含扩展名的文件名
func filenameWithoutExt(filename string) string {
    ext := path.Ext(filename)
    filename = strings.TrimSuffix(filename, ext)
    return filename
}

func TestFilenameWithoutExt(t *testing.T) {
    filename := "The Go Programming Language.pdf"
    want := "The Go Programming Language"
    got := filenameWithoutExt(filename)
    if got != want {
        t.Fatalf(`want "%s", but got "%s".`, want, got)
    }
    fmt.Println(got)
}

自定义删除逻辑

如果你既不想要删除前后缀,也不想要删除指定的首尾字符集合,那么,你可以试试 strings.TrimLeftFuncstrings.TrimRightFunc

这两个函数都支持让你提供一个 func(r rune) bool 函数,让你自己开始删除和结束删除的标志。

需要注意的是,一旦你的自定义函数返回 false,这两个函数都会立即返回当前结果。

如果你想要双管齐下,首尾并进,strings.TrimFunc 更适合你。它其实就是在 strings.TrimLeftFunc 的基础上,再调用一次 strings.TrimRightFunc

自己实现

有时候,即便是这样,你的需求也不能得到满足。我想,那只能够说明你需要的不是 “Trim” 操作,而是更加具体的东西。

比如,你有一个 nums 字符串,里面按序排列着 ‘1’ ~ ‘9’ 九个字符。

现在,出于某种说不清道不明的无聊想法,你希望删除其中的偶数字符,保留奇数字符,该怎么实现呢?

我不认为 strings 包提供了这种函数,并且,它最好永远也不要。

你仍然固执地想要实现它,那么你可能会这样做:

// removeEvenIndices 从给定的字符串中删除下标为偶数的字符,返回所有奇数组成的新字符串
func removeEvenIndices(nums string) string {
    // 使用 strings.Builder 高效拼接字符串
    var result strings.Builder
    // 预分配内存空间,避免不必要的内存拷贝
    result.Grow(9)
    for i, r := range nums {
        if (i+1)%2 == 1 {
            result.WriteRune(r)
        }
    }
    return result.String()
}

func TestRemoveEvenIndices(t *testing.T) {
    nums := "123456789"
    want := "13579"
    got := removeEvenIndices(nums)
    if got != want {
        t.Fatalf(`want "%s", but got "%s".`, want, got)
    }
    fmt.Println(got)
}

后语

无论是什么编程语言,字符串操作都会是开发者关注的重点。毕竟程序的终极服务对象是人,而人眼不适合识别二进制数据。

或许有一天,信息不需要以视觉的方式呈现,我们可以直接通过意念交流,不再需要语言。

语言确实是优美的,但是意念沟通直接跳过了理解语言的步骤,岂不更美哉?


本文使用 [CC BY-SA 4.0 国际协议][f] 进行许可,欢迎 遵照协议规定 转载。
作者:六开箱
链接:https://lkxed.github.io/posts/go-string-trim/