strings, bytes: add CutLast
要約
概要
strings および bytes パッケージに CutLast 関数を追加する提案です。既存の strings.Cut が文字列の先頭から最初のセパレータを探すのに対し、CutLast は末尾から最後のセパレータを探して文字列を分割します。
ステータス変更
likely_accept → accepted
2026年4月8日のProposal Review Meetingで likely_accept となり、その後コアチームメンバーの @griesemer が実装CL(go.dev/cl/764601)を誤って自動マージ可能状態にしてしまい、コメント期間中に実装が取り込まれるという事態が発生しました。2026年4月16日の次回ミーティングでコンセンサスに変化がないことが確認され、正式に accepted となりました。
技術的背景
現状の問題点
strings.Cut は2021年にGo 1.18で追加され、文字列を「最初のセパレータの位置」で分割する非常に便利な関数として広く活用されています。しかし、末尾から最後のセパレータを探して分割する対称的な関数が標準ライブラリに存在しません。
そのため、開発者はそれぞれのコードベースで独自に同等の関数を実装しており、実際に複数の開発者が全く同じ実装をコピーペーストしていることが確認されています(@rogpeppe はコードベース内に少なくとも5つの独自定義を発見)。問題はそれだけでなく、「セパレータが見つからない場合の戻り値」の仕様が実装者によって異なり、バグの温床になっていました。
// 問題1: 開発者ごとに独自実装が必要
func cutLast(s, sep string) (before, after string, found bool) {
if i := strings.LastIndex(s, sep); i >= 0 {
return s[:i], s[i+len(sep):], true
}
return "", s, false // 実装者によってここが異なる!
}
@rogpeppe は自分が return "", s, false(後者に全文字列を返す)で実装し、それを誤った前提で使い続けていたことを報告しており、標準化の重要性を示す具体的な証拠となりました。
提案された解決策
strings.CutLast および bytes.CutLast を追加します。仕様は以下の通りです:
// CutLast slices s around the last instance of sep,
// returning the text before and after sep.
// The found result reports whether sep appears in s.
// If sep does not appear in s, CutLast returns s, "", false.
func CutLast(s, sep string) (before, after string, found bool) {
if i := strings.LastIndex(s, sep); i >= 0 {
return s[:i], s[i+len(sep):], true
}
return s, "", false
}
セパレータが見つからない場合は s, "", false を返します。これは既存の strings.Cut と対称的な設計であり(Cut も見つからない場合は s, "", false を返す)、一貫性のある使い方を促します。
これによって何ができるようになるか
末尾から検索して分割するパターンを標準ライブラリで簡潔に書けるようになります。
コード例
// Before: 独自実装が必要(または LastIndex + スライス操作)
func cutLast(s, sep string) (before, after string, found bool) {
if i := strings.LastIndex(s, sep); i >= 0 {
return s[:i], s[i+len(sep):], true
}
return s, "", false
}
host, port, ok := cutLast("example.com:8080:443", ":")
// host = "example.com:8080", port = "443", ok = true
// After: 標準ライブラリを直接利用
host, port, ok := strings.CutLast("example.com:8080:443", ":")
// host = "example.com:8080", port = "443", ok = true
// ユースケース1: package-url仕様のパース(最後の"/"でパスとファイル名を分離)
dir, file, _ := strings.CutLast("/usr/local/bin/go", "/")
// dir = "/usr/local/bin", file = "go"
// ユースケース2: ファイル名から拡張子を分離
name, ext, hasExt := strings.CutLast("archive.tar.gz", ".")
// name = "archive.tar", ext = "gz", hasExt = true
// ユースケース3: URLのパスから最後のセグメントを取得
base, slug, _ := strings.CutLast(urlPath, "/")
// base = "/blog/2024", slug = "my-post"
議論のハイライト
- 命名の議論:
LastCut(LastIndexとの一貫性)vsCutLast(CutXファミリーへの帰属)が議論され、最終的にCutLastが採用されました。@Merovius による詳細な分析では、CutXファミリーとして整理することで将来的にCutLastAny、CutLastByte、CutLastFuncなどの自然な拡張が可能になるという観点が示されました。 - 失敗時の戻り値: セパレータが見つからない場合に
s, "", falseと"", s, falseのどちらを返すべきかが議論されました。@rogpeppe が"", s, falseで実装した結果、複数箇所で誤用していたことを認め、s, "", falseの正しさを支持する実証的な証拠となりました。 CutSuffixとの混同懸念:CutLastとCutSuffixは異なる(CutSuffixは末尾の特定の接尾辞を取り除くもの)が、混同される可能性について議論されました。ただし戻り値の型が異なるため、実際の誤用は少ないと判断されました。- 実装の先行マージ: @griesemer がCLを誤って自動マージ状態にしてしまい、コメント期間中に実装が取り込まれるという異例の経緯をたどりましたが、コンセンサスに変化がないとして承認が確定しました。