KEINOS
@KEINOS (KEINOS)

Are you sure you want to delete the question?

Leaving a resolved question undeleted may help others!

[Golang] 組み合わせ絵文字を含んだ文字列の反転

解決したいこと

"2️⃣"のような rune 時に [50, 65039, 8419] となる組み合わせ絵文字を1つの文字として認識させたい

  • OS: 不問、Go: v1.15、質問者 Go 暦: 初学(配列とスライスの違いがやっとわかった程度)
  • 解決済み → TL; DR

やりたいこと

hogeegoh のように、Go 言語で UTF-8 文字列を反転させる(逆順にする)だけの関数を作りたいのですが、emoji や組み合わせ絵文字を含んだ場合でも動作するようにしたいのです。

つまり 🙏2️⃣👌👁👁👌2️⃣🙏 の逆順にしたいと言うことです。しかし、何をしても 👁👌⃣️2🙏 と崩れてしまいます。

「golang 文字列 反転」でググってみたのですが、問題を解決する Qiita 記事が見つけられませんでした。UTF-8 の可変バイト単位でなく Unicode のコードポイント単位かもしれないと、薄ら感じてはいるのですが、、、

パッケージを使うにしても、この関数のためだけにと言うジレンマがあり、できれば Pure Go で実装したいのですが煩雑になりそうな気がしています。しかし「実は...簡単な解決方法が...あるのでは?」と Qiita の GURU に Qiita 方が良いと思い質問してみました。

目から鱗な、スマートな解決方法があった場合は、別途 Qiita 記事にしたいと思いますので、アドバイスよろしくお願いします。の回答をいただいたので、質問の文末に TL; DR でまとめました。

問題のコード

Qiita でググったところ、UTF-8 の文字コードは可変長であるため UTF-8/マルチバイト文字列を扱う時は rune で変換しておくのが定石と言うことで、下記のようなコードを組みました。

文字列の反転関数
func reverseString(input string) (result string) {
    var strRuned = []rune(input)

    for _, v := range strRuned {
        result = string(v) + result
    }

    return
}

上記は、1〜4 バイトの「単体で完結する文字」の場合は問題ないのですが、"2️⃣"のような rune 時に [50, 65039, 8419] となる組み合わせ絵文字(?)の場合に失敗します。

発生している問題・エラー

下記の簡易テストを実行すると、最後の「🙏2️⃣👌👁」で反転に失敗します。

func main(){
    fmt.Println("Go version :", runtime.Version())

    // test(<input>, <expect>)
    test("abcdefg", "gfedcba")          // -> OK
    test("表示機能テスト", "トステ能機示表")  // -> OK
    test("🙏🙇👉🎁", "🎁👉🙇🙏")          // -> OK
    test("🙏2️⃣👌👁", "👁👌️2️⃣🙏")          // -> NG
}
実行結果
$ go run sample.go
[OK] Input:abcdefg      -> Output:gfedcba
[OK] Input:表示機能テスト  -> Output:トステ能機示表
[OK] Input:🙏🙇👉🎁      -> Output:🎁👉🙇🙏
[ng] Input:🙏2️⃣👌👁      -> Output:👁👌⃣️2🙏        Expect:👁👌2️⃣🙏
- Input : []int32{128591, 50, 65039, 8419, 128076, 128065}
- Expect: []int32{128065, 128076, 50, 65039, 8419, 128591}
- Actual: []int32{128065, 128076, 8419, 65039, 50, 128591}

失敗したテストの値をみると「🙏2️⃣👌👁」を rune した際の 128591, 50, 65039, 8419, 128076, 128065 を4つのチャンク、つまり「128591」「50, 65039, 8419」「128076」「128065」と4つに分けられないため、スライスごとに反転してしまいます。そのためデータとしては当然の結果なのですが、意図する結果ではありません。

自分で試したこと(全て NG)

1文字ごとにアクセス
func reverseString(input string) (result string) {

    for _, val := range input {
        result = string([]rune{val}) + result
    }

    return
}
utf8.DecodeRuneInStringで1文字ずつ切り取っていく
import "unicode/utf8"

func reverseString(input string) (result string) {

    var chunk = input

    for len(chunk) > 0 {
        v, size := utf8.DecodeRuneInString(chunk)
        result = string(v) + result

        chunk = chunk[size:]
    }

    return
}
StackOverflowのアンサー
func reverseString(input string) string {
    r := []rune(input)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}
StackOverflowのアンサー
func reverseString(input string) (result string) {

    for _, v := range input {
        defer func(runed rune) { result += string(runed) }(v)
    }
    return
}

参考にした文献


TL; DR(解決した方法)

"2️⃣"のような rune 時に [50, 65039, 8419] となる組み合わせ絵文字を1つの文字として認識させたい

Pure Go で書く方法も StackOverflow で見つけたのですが、回答にあったパッケージを使ったスマートな方法を採用することにしました。

以下、いずれも @uasi さんのコメントより。必読です。

  1. 人間にとって自然な1文字に見えるコードポイントの並びを書記素クラスタ(grapheme cluster)と呼ぶ
  2. 書記素クラスタの境界でコードポイント列を区切るアルゴリズムは Unicode Text Segmentation という仕様で定められている
  3. このアルゴリズムを使って文字列を分割し、それを反転・結合すればやりたいことを達成できそう
  4. 変換テーブルがそれなりに巨大、Unicode のバージョンアップでアルゴリズムが修正されたりもするので、自前で実装するより専用のパッケージを使った方がいいでしょう。

Go ならこれがいいと思います
https://github.com/rivo/uniseg

パッケージを使ったスマートな方法
import "github.com/rivo/uniseg"

func reverseString(input string) (result string) {

    var grClusters = uniseg.NewGraphemes(input)

    for grClusters.Next() {
        result = string(grClusters.Runes()) + result
    }

    return
}

いささか冗長な書き方

Qiita 記事ではないのですが、StackOverflow にビルトイン・パッケージのみで解決する方法もありました。

反転するためだけに使うのは、いささか冗長なので回答にあった外部パッケージを使うことにしました。

どうやら、スライスごとに unicode.Is() で変換可能か確認しながら、別のスライスに変換可能な単位(おそらく書記素クラスタ毎のブロック)で代入し、最後にフリップさせて文字列として結合しているようです。何をしているか解析仕切れていないのですが、語学のため理解次第 Qiita 記事にしたいと思います。

PureGoのいささか冗長な書き方
import (
    "unicode"
)

func reverseString(input string) string {

    textRunes := []rune(input)
    textRunesLength := len(textRunes)
    if textRunesLength <= 1 {
        return input
    }

    i, j := 0, 0
    for i < textRunesLength && j < textRunesLength {
        j = i + 1
        for j < textRunesLength && isMark(textRunes[j]) {
            j++
        }

        if isMark(textRunes[j-1]) {
            // Reverses Combined Characters
            reverse(textRunes[i:j], j-i)
        }

        i = j
    }

    // Reverses the entire array
    reverse(textRunes, textRunesLength)

    return string(textRunes)
}

func reverse(runes []rune, length int) {
    for i, j := 0, length-1; i < length/2; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
}

// isMark determines whether the rune is a marker
func isMark(r rune) bool {
    return unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) || unicode.Is(unicode.Mc, r)
}

0

1Answer

Unicode では2つ以上のコードポイントで1つの文字を表すことがあります。2️⃣ がそうですし、たとえば á を「『a』のコードポイント」と「『´』のコードポイント」の並びで表すこともできます。

人間にとって自然な1文字に見えるコードポイントの並びを書記素クラスタ(grapheme cluster)と呼びます。書記素クラスタの境界でコードポイント列を区切るアルゴリズムは Unicode Text Sgmentation という仕様で定められています。このアルゴリズムを使って文字列を分割し、それを反転・結合すればやりたいことを達成できます。

ただ、自前で実装するより専用のパッケージを使った方がいいでしょう。 変換テーブルがそれなりに巨大ですし、 Unicode のバージョンアップでアルゴリズムが修正されたりもするので。 Go ならこれがいいと思います
https://github.com/rivo/uniseg

参考: https://hydrocul.github.io/wiki/blog/2015/1025-unicode-grapheme-clusters.html

2Like

Comments

  1. @KEINOS

    Questioner

    > 1文字に見えるコードポイントの並びを
    > 書記素クラスタ(grapheme cluster)
    > と呼びます。

    おぉぉ! なるほど、なるほど! 素晴らしい! `grapheme cluster` で一気に検索の幅が広がりました!

    > Go ならこれがいいと思います
    > https://github.com/rivo/uniseg

    バッチリンコでございます。おそらく、これがベスト・アンサー!

    - https://paiza.io/projects/xP0XYuCpalVEu8L3EFXasA @ paiza.IO

Your answer might help someone💌