Bagaimana saya bisa melakukan operasi "mulai dengan" yang peka budaya dari tengah string?

106

Saya memiliki persyaratan yang relatif tidak jelas, tetapi rasanya harus memungkinkan menggunakan BCL.

Untuk konteks, saya mengurai string tanggal / waktu dalam Waktu Noda . Saya mempertahankan kursor logis untuk posisi saya dalam string input. Jadi, sementara string lengkapnya mungkin "3 Januari 2013" kursor logisnya mungkin berada di 'J'.

Sekarang, saya perlu mengurai nama bulan, membandingkannya dengan semua nama bulan yang diketahui untuk budaya:

  • Peka budaya
  • Tidak peka kasus
  • Hanya dari titik kursor (bukan nanti; saya ingin melihat apakah kursor "melihat" nama bulan kandidat)
  • Segera
  • ... dan saya perlu tahu setelah itu berapa banyak karakter yang digunakan

The kode saat untuk melakukan hal ini umumnya bekerja, menggunakan CompareInfo.Compare. Ini efektif seperti ini (hanya untuk bagian yang cocok - ada lebih banyak kode di hal yang nyata, tetapi tidak relevan dengan kecocokan):

internal bool MatchCaseInsensitive(string candidate, CompareInfo compareInfo)
{
    return compareInfo.Compare(text, position, candidate.Length,
                               candidate, 0, candidate.Length, 
                               CompareOptions.IgnoreCase) == 0;
}

Namun, itu bergantung pada kandidat dan wilayah yang kami bandingkan memiliki panjang yang sama. Baik-baik saja di sebagian besar waktu, tetapi tidak baik dalam beberapa kasus khusus. Misalkan kita memiliki sesuatu seperti:

// U+00E9 is a single code point for e-acute
var text = "x b\u00e9d y";
int position = 2;
// e followed by U+0301 still means e-acute, but from two code points
var candidate = "be\u0301d";

Sekarang perbandingan saya akan gagal. Saya bisa menggunakan IsPrefix:

if (compareInfo.IsPrefix(text.Substring(position), candidate,
                         CompareOptions.IgnoreCase))

tapi:

  • Itu mengharuskan saya membuat substring, yang sebaiknya saya hindari. (Saya melihat Noda Time secara efektif sebagai pustaka sistem; kinerja parsing mungkin penting untuk beberapa klien.)
  • Itu tidak memberi tahu saya seberapa jauh untuk memajukan kursor sesudahnya

Pada kenyataannya, saya sangat curiga ini tidak akan sering muncul ... tetapi saya benar - benar ingin melakukan hal yang benar di sini. Saya juga sangat ingin dapat melakukannya tanpa menjadi ahli Unicode atau menerapkannya sendiri :)

(Dibesarkan sebagai bug 210 di Noda Time, jika ada yang ingin mengikuti kesimpulan akhirnya.)

Saya suka ide normalisasi. Saya perlu memeriksanya secara rinci untuk a) kebenaran dan b) kinerja. Dengan asumsi saya dapat membuatnya berfungsi dengan benar, saya masih tidak yakin bagaimana apakah itu layak untuk diubah secara keseluruhan - ini adalah jenis hal yang mungkin tidak akan pernah benar-benar muncul dalam kehidupan nyata, tetapi dapat merusak kinerja semua pengguna saya: (

Saya juga telah memeriksa BCL - yang tampaknya tidak menangani ini dengan baik. Kode sampel:

using System;
using System.Globalization;

class Test
{
    static void Main()
    {
        var culture = (CultureInfo) CultureInfo.InvariantCulture.Clone();
        var months = culture.DateTimeFormat.AbbreviatedMonthNames;
        months[10] = "be\u0301d";
        culture.DateTimeFormat.AbbreviatedMonthNames = months;

        var text = "25 b\u00e9d 2013";
        var pattern = "dd MMM yyyy";
        DateTime result;
        if (DateTime.TryParseExact(text, pattern, culture,
                                   DateTimeStyles.None, out result))
        {
            Console.WriteLine("Parsed! Result={0}", result);
        }
        else
        {
            Console.WriteLine("Didn't parse");
        }
    }
}

Mengubah nama bulan khusus menjadi hanya "tempat tidur" dengan nilai teks "bEd" akan menguraikan dengan baik.

Oke, beberapa poin data lagi:

  • Biaya penggunaan Substringdan IsPrefixsignifikan tetapi tidak mengerikan. Pada contoh "Jumat 12 April 2013 20:28:42" di laptop pengembangan saya, itu mengubah jumlah operasi parse yang dapat saya jalankan dalam sedetik dari sekitar 460K menjadi sekitar 400K. Saya lebih suka menghindari perlambatan itu jika memungkinkan, tetapi itu tidak terlalu buruk.

  • Normalisasi kurang layak dari yang saya kira - karena tidak tersedia di Perpustakaan Kelas Portabel. Saya berpotensi menggunakannya hanya untuk build non-PCL, memungkinkan build PCL menjadi sedikit kurang tepat. Hit kinerja pengujian untuk normalisasi ( string.IsNormalized) menurunkan kinerja menjadi sekitar 445K panggilan per detik, yang dapat saya tangani. Saya masih tidak yakin itu melakukan semua yang saya butuhkan - misalnya, nama bulan yang mengandung "ß" harus cocok dengan "ss" di banyak budaya, saya percaya ... dan normalisasi tidak melakukan itu.

Jon Skeet
sumber
Meskipun saya memahami keinginan Anda untuk menghindari pukulan kinerja saat membuat substring, mungkin yang terbaik adalah melakukannya, tetapi di awal permainan dengan menggeser semuanya ke bentuk normalisasi unicode yang dipilih PERTAMA dan kemudian mengetahui bahwa Anda dapat berjalan "poin demi poin ". Mungkin bentuk-D.
IDisposable
@IDisposable: Ya, saya bertanya-tanya tentang itu. Jelas saya bisa menormalkan nama bulan itu sendiri sebelumnya. Setidaknya saya bisa melakukan normalisasi sekali saja. Saya ingin tahu apakah prosedur normalisasi memeriksa apakah ada yang perlu dilakukan terlebih dahulu. Saya tidak memiliki banyak pengalaman dalam normalisasi - pasti satu cara untuk diperhatikan.
Jon Skeet
1
Jika Anda texttidak terlalu lama, Anda bisa melakukannya if (compareInfo.IndexOf(text, candidate, position, options) == position). msdn.microsoft.com/en-us/library/ms143031.aspx Tapi jika textsangat lama itu akan membuang banyak waktu untuk mencari di luar yang dibutuhkan.
Jim Mischel
1
Cukup lewati menggunakan Stringkelas sama sekali dalam contoh ini dan gunakan secara Char[]langsung. Anda akhirnya akan menulis lebih banyak kode, tetapi itulah yang terjadi ketika Anda menginginkan kinerja tinggi ... atau mungkin Anda harus memprogram dalam C ++ / CLI ;-)
intrepidis
1
Akankah CompareOptions.IgnoreNonSpace tidak menangani ini secara otomatis untuk Anda? Ini terlihat dengan saya (dari docco, tidak dalam posisi untuk tes dari iPad ini maaf!) Seolah-olah ini mungkin ( yang ?) Penggunaan-kasus untuk pilihan itu. " Menunjukkan bahwa perbandingan string harus mengabaikan karakter kombinasi tanpa spasi, seperti diakritik. "
Sepster

Jawaban:

41

Saya akan mempertimbangkan masalah dari banyak <-> satu / banyak kasus yang muncul terlebih dahulu dan secara terpisah dari penanganan bentuk Normalisasi yang berbeda.

Sebagai contoh:

x heiße y
  ^--- cursor

Cocok heissetetapi kemudian terlalu banyak menggerakkan kursor 1. Dan:

x heisse y
  ^--- cursor

Cocok, heißetetapi kemudian menggerakkan kursor 1 terlalu sedikit.

Ini akan berlaku untuk karakter apa pun yang tidak memiliki pemetaan satu-ke-satu yang sederhana.

Anda perlu mengetahui panjang substring yang benar-benar cocok. Tapi Compare, IndexOf.. dll membuang informasi itu. Ini bisa dimungkinkan dengan ekspresi reguler tetapi implementasinya tidak melakukan pelipatan huruf besar-kecil dan karenanya tidak cocok ßdengan ss/SSmode tidak peka huruf besar kecil .Comparedan .IndexOfmemang demikian. Dan mungkin akan mahal untuk membuat regex baru untuk setiap kandidat.

Solusi paling sederhana untuk ini adalah dengan hanya menyimpan string secara internal jika bentuk terlipat case dan melakukan perbandingan biner dengan kandidat yang melipat case. Kemudian Anda dapat menggerakkan kursor dengan benar hanya .Lengthkarena kursor tersebut untuk representasi internal. Anda juga mendapatkan sebagian besar kinerja yang hilang karena tidak harus menggunakan CompareOptions.IgnoreCase.

Sayangnya tidak ada kasus fungsi kali lipat built-in dan orang miskin kasus lipat tidak bekerja baik karena tidak ada pemetaan kasus penuh - yang ToUppermetode tidak berubah ßmenjadi SS.

Misalnya ini berfungsi di Java (dan bahkan di Javascript), diberikan string yang ada dalam Bentuk Normal C:

//Poor man's case folding.
//There are some edge cases where this doesn't work
public static String toCaseFold( String input, Locale cultureInfo ) {
    return input.toUpperCase(cultureInfo).toLowerCase(cultureInfo);
}

Menyenangkan untuk dicatat bahwa perbandingan kasus abaikan Java tidak melakukan lipatan kasus penuh seperti C # CompareOptions.IgnoreCase. Jadi mereka berlawanan dalam hal ini: Java melakukan pemetaan kasus penuh, tetapi pelipatan casing sederhana - C # melakukan pemetaan kasus sederhana, tetapi pelipatan casing penuh.

Jadi kemungkinan Anda memerlukan perpustakaan pihak ke-3 untuk melipat case string Anda sebelum menggunakannya.


Sebelum melakukan apa pun, Anda harus memastikan bahwa string Anda dalam bentuk normal C. Anda dapat menggunakan pemeriksaan cepat pendahuluan ini yang dioptimalkan untuk skrip Latin:

public static bool MaybeRequiresNormalizationToFormC(string input)
{
    if( input == null ) throw new ArgumentNullException("input");

    int len = input.Length;
    for (int i = 0; i < len; ++i)
    {
        if (input[i] > 0x2FF)
        {
            return true;
        }
    }

    return false;
}

Ini memberikan positif palsu tetapi bukan negatif palsu, saya tidak berharap itu memperlambat 460k parses / s sama sekali saat menggunakan karakter skrip Latin meskipun itu perlu dilakukan pada setiap string. Dengan positif palsu Anda akan menggunakan IsNormalizeduntuk mendapatkan negatif benar / positif dan hanya setelah itu normalisasi jika perlu.


Jadi kesimpulannya, prosesnya adalah memastikan bentuk C normal dulu, lalu case fold. Lakukan perbandingan biner dengan string yang diproses dan gerakkan kursor saat Anda memindahkannya saat ini.

Esailija
sumber
Terima kasih untuk ini - Saya perlu melihat normalisasi bentuk C lebih detail, tetapi ini adalah petunjuk yang bagus. Saya rasa saya bisa hidup dengan "itu tidak bekerja dengan benar di bawah PCL" (yang tidak memberikan normalisasi). Menggunakan pustaka pihak ke-3 untuk melipat kasing akan berlebihan di sini - saat ini kami tidak memiliki ketergantungan pihak ketiga, dan memperkenalkan satu hanya untuk kotak sudut yang bahkan tidak ditangani oleh BCL akan menyebalkan. Agaknya pelipatan kasus peka terhadap budaya, btw (mis. Turki)?
Jon Skeet
2
@JonSkeet ya, bahasa Turki layak mendapatkan modenya sendiri dalam pemetaan casefold: P Lihat bagian format di header CaseFolding.txt
Esailija
Jawaban ini tampaknya memiliki kelemahan mendasar, yaitu menyiratkan bahwa karakter dipetakan ke ligatur (dan sebaliknya) hanya saat melipat huruf. Ini bukan kasusnya; ada ligatur yang dianggap sama dengan karakter terlepas dari casingnya. Misalnya, di bawah budaya en-US, æadalah sama dengan ae, dan sama dengan ffi. Normalisasi-C tidak menangani ligatur sama sekali, karena hanya mengizinkan pemetaan kompatibilitas (yang biasanya dibatasi untuk menggabungkan karakter).
Douglas
KC- dan KD-normalization menangani beberapa ligatur, seperti , tapi melewatkan yang lain, seperti æ. Masalah ini diperburuk oleh perbedaan antar budaya - æsama dengan di aebawah en-US, tetapi tidak di bawah da-DK, seperti yang dibahas dalam dokumentasi MSDN untuk string . Dengan demikian, normalisasi (dalam bentuk apapun) dan pemetaan kasus bukanlah solusi yang memadai untuk masalah ini.
Douglas
Koreksi kecil untuk komentar saya sebelumnya: C-normalization hanya mengizinkan pemetaan kanonik (seperti untuk menggabungkan karakter), bukan pemetaan kompatibilitas (seperti untuk ligatur).
Douglas
21

Lihat apakah ini memenuhi persyaratan ..:

public static partial class GlobalizationExtensions {
    public static int IsPrefix(
        this CompareInfo compareInfo,
        String source, String prefix, int startIndex, CompareOptions options
        ) {
        if(compareInfo.IndexOf(source, prefix, startIndex, options)!=startIndex)
            return ~0;
        else
            // source is started with prefix
            // therefore the loop must exit
            for(int length2=0, length1=prefix.Length; ; )
                if(0==compareInfo.Compare(
                        prefix, 0, length1, 
                        source, startIndex, ++length2, options))
                    return length2;
    }
}

compareInfo.Comparehanya tampil sekali sourcedimulai dengan prefix; jika tidak, maka IsPrefixkembali -1; jika tidak, panjang karakter yang digunakan dalam source.

Namun, saya tidak tahu kecuali selisih length2oleh 1dengan kasus berikut:

var candidate="ßssß\u00E9\u0302";
var text="abcd ssßss\u0065\u0301\u0302sss";

var count=
    culture.CompareInfo.IsPrefix(text, candidate, 5, CompareOptions.IgnoreCase);

update :

Saya mencoba meningkatkan sedikit kinerja, tetapi tidak terbukti apakah ada bug dalam kode berikut:

public static partial class GlobalizationExtensions {
    public static int Compare(
        this CompareInfo compareInfo,
        String source, String prefix, int startIndex, ref int length2, 
        CompareOptions options) {
        int length1=prefix.Length, v2, v1;

        if(0==(v1=compareInfo.Compare(
            prefix, 0, length1, source, startIndex, length2, options))
            ) {
            return 0;
        }
        else {
            if(0==(v2=compareInfo.Compare(
                prefix, 0, length1, source, startIndex, 1+length2, options))
                ) {
                ++length2;
                return 0;
            }
            else {
                if(v1<0||v2<0) {
                    length2-=2;
                    return -1;
                }
                else {
                    length2+=2;
                    return 1;
                }
            }
        }
    }

    public static int IsPrefix(
        this CompareInfo compareInfo,
        String source, String prefix, int startIndex, CompareOptions options
        ) {
        if(compareInfo.IndexOf(source, prefix, startIndex, options)
                !=startIndex)
            return ~0;
        else
            for(int length2=
                    Math.Min(prefix.Length, source.Length-(1+startIndex)); ; )
                if(0==compareInfo.Compare(
                        source, prefix, startIndex, ref length2, options))
                    return length2;
    }
}

Saya menguji dengan kasus tertentu, dan perbandingannya turun menjadi sekitar 3.

Ken Kin
sumber
Saya benar - benar lebih suka tidak perlu melakukan loop seperti ini. Memang dengan keluar awal itu hanya perlu melakukan loop jika menemukan sesuatu, tapi saya masih lebih suka tidak harus membuat perbandingan 8 string hanya untuk mencocokkan "Februari" misalnya. Rasanya pasti ada cara yang lebih baik. Juga, IndexOfoperasi awal harus melihat seluruh string dari posisi awal, yang akan menjadi masalah kinerja jika string input panjang.
Jon Skeet
@JonSkeet: Terima kasih. Mungkin ada sesuatu yang bisa ditambahkan untuk mendeteksi jika loop bisa dikurangi. Saya akan memikirkan itu.
Ken Kin
@ JonSkeet: Apakah Anda mempertimbangkan untuk menggunakan refleksi? Sejak saya menelusuri ke dalam metode, mereka jatuh ke dalam pemanggilan metode asli tidak jauh.
Ken Kin
3
Memang. Noda Time tidak ingin terlibat dalam bisnis detail Unicode :)
Jon Skeet
2
Saya telah memecahkan masalah serupa sekali seperti ini (penyorotan string pencarian dalam HTML). Saya melakukannya dengan cara yang sama. Anda dapat menyesuaikan loop dan strategi pencarian dengan cara yang membuatnya selesai dengan sangat cepat dengan memeriksa kasus yang mungkin terjadi terlebih dahulu. Hal yang menyenangkan tentang ini adalah tampaknya sepenuhnya benar dan tidak ada detail Unicode yang bocor ke kode Anda.
usr
9

Ini sebenarnya mungkin tanpa normalisasi dan tanpa menggunakan IsPrefix.

Kita perlu membandingkan jumlah elemen teks yang sama dengan jumlah karakter yang sama, tetapi tetap mengembalikan jumlah karakter yang cocok.

Saya telah membuat salinan MatchCaseInsensitivemetode dari ValueCursor.cs di Noda Time dan memodifikasinya sedikit sehingga dapat digunakan dalam konteks statis:

// Noda time code from MatchCaseInsensitive in ValueCursor.cs
static int IsMatch_Original(string source, int index, string match, CompareInfo compareInfo)
{
    unchecked
    {
        if (match.Length > source.Length - index)
        {
            return 0;
        }

        // TODO(V1.2): This will fail if the length in the input string is different to the length in the
        // match string for culture-specific reasons. It's not clear how to handle that...
        if (compareInfo.Compare(source, index, match.Length, match, 0, match.Length, CompareOptions.IgnoreCase) == 0)
        {
            return match.Length;
        }

        return 0;
    }
}

(Hanya disertakan untuk referensi, itu adalah kode yang tidak dapat dibandingkan dengan benar seperti yang Anda ketahui)

Varian berikut dari metode tersebut menggunakan StringInfo.GetNextTextElement yang disediakan oleh framework. Idenya adalah untuk membandingkan elemen teks dengan elemen teks untuk menemukan kecocokan dan jika ditemukan mengembalikan jumlah sebenarnya dari karakter yang cocok dalam string sumber:

// Using StringInfo.GetNextTextElement to match by text elements instead of characters
static int IsMatch_New(string source, int index, string match, CompareInfo compareInfo)
{
    int sourceIndex = index;
    int matchIndex = 0;

    // Loop until we reach the end of source or match
    while (sourceIndex < source.Length && matchIndex < match.Length)
    {
        // Get text elements at the current positions of source and match
        // Normally that will be just one character but may be more in case of Unicode combining characters
        string sourceElem = StringInfo.GetNextTextElement(source, sourceIndex);
        string matchElem = StringInfo.GetNextTextElement(match, matchIndex);

        // Compare the current elements.
        if (compareInfo.Compare(sourceElem, matchElem, CompareOptions.IgnoreCase) != 0)
        {
            return 0; // No match
        }

        // Advance in source and match (by number of characters)
        sourceIndex += sourceElem.Length;
        matchIndex += matchElem.Length;
    }

    // Check if we reached end of source and not end of match
    if (matchIndex != match.Length)
    {
        return 0; // No match
    }

    // Found match. Return number of matching characters from source.
    return sourceIndex - index;
}

Metode itu berfungsi dengan baik setidaknya menurut kasus pengujian saya (yang pada dasarnya hanya menguji beberapa varian string yang Anda berikan: "b\u00e9d"dan "be\u0301d").

Namun, metode GetNextTextElement membuat substring untuk setiap elemen teks sehingga implementasi ini membutuhkan banyak perbandingan substring - yang akan berdampak pada kinerja.

Jadi, saya membuat varian lain yang tidak menggunakan GetNextTextElement tetapi melompati karakter yang menggabungkan Unicode untuk menemukan panjang kecocokan aktual dalam karakter:

// This should be faster
static int IsMatch_Faster(string source, int index, string match, CompareInfo compareInfo)
{
    int sourceLength = source.Length;
    int matchLength = match.Length;
    int sourceIndex = index;
    int matchIndex = 0;

    // Loop until we reach the end of source or match
    while (sourceIndex < sourceLength && matchIndex < matchLength)
    {
        sourceIndex += GetTextElemLen(source, sourceIndex, sourceLength);
        matchIndex += GetTextElemLen(match, matchIndex, matchLength);
    }

    // Check if we reached end of source and not end of match
    if (matchIndex != matchLength)
    {
        return 0; // No match
    }

    // Check if we've found a match
    if (compareInfo.Compare(source, index, sourceIndex - index, match, 0, matchIndex, CompareOptions.IgnoreCase) != 0)
    {
        return 0; // No match
    }

    // Found match. Return number of matching characters from source.
    return sourceIndex - index;
}

Metode itu menggunakan dua pembantu berikut:

static int GetTextElemLen(string str, int index, int strLen)
{
    bool stop = false;
    int elemLen;

    for (elemLen = 0; index < strLen && !stop; ++elemLen, ++index)
    {
        stop = !IsCombiningCharacter(str, index);
    }

    return elemLen;
}

static bool IsCombiningCharacter(string str, int index)
{
    switch (CharUnicodeInfo.GetUnicodeCategory(str, index))
    {
        case UnicodeCategory.NonSpacingMark:
        case UnicodeCategory.SpacingCombiningMark:
        case UnicodeCategory.EnclosingMark:
            return true;

        default:
            return false;
    }
}

Saya belum melakukan benchmarking, jadi saya tidak begitu tahu apakah metode yang lebih cepat sebenarnya lebih cepat. Saya juga belum melakukan pengujian tambahan.

Tapi ini harus menjawab pertanyaan Anda tentang bagaimana melakukan pencocokan substring sensitif budaya untuk string yang mungkin menyertakan karakter gabungan Unicode.

Ini adalah kasus uji yang saya gunakan:

static Tuple<string, int, string, int>[] tests = new []
{
    Tuple.Create("x b\u00e9d y", 2, "be\u0301d", 3),
    Tuple.Create("x be\u0301d y", 2, "b\u00e9d", 4),

    Tuple.Create("x b\u00e9d", 2, "be\u0301d", 3),
    Tuple.Create("x be\u0301d", 2, "b\u00e9d", 4),

    Tuple.Create("b\u00e9d y", 0, "be\u0301d", 3),
    Tuple.Create("be\u0301d y", 0, "b\u00e9d", 4),

    Tuple.Create("b\u00e9d", 0, "be\u0301d", 3),
    Tuple.Create("be\u0301d", 0, "b\u00e9d", 4),

    Tuple.Create("b\u00e9", 0, "be\u0301d", 0),
    Tuple.Create("be\u0301", 0, "b\u00e9d", 0),
};

Nilai tupelnya adalah:

  1. String sumber (tumpukan jerami)
  2. Posisi awal dalam sumber.
  3. Tali korek api (jarum).
  4. Panjang kecocokan yang diharapkan.

Menjalankan pengujian tersebut pada tiga metode menghasilkan hasil ini:

Test #0: Orignal=BAD; New=OK; Faster=OK
Test #1: Orignal=BAD; New=OK; Faster=OK
Test #2: Orignal=BAD; New=OK; Faster=OK
Test #3: Orignal=BAD; New=OK; Faster=OK
Test #4: Orignal=BAD; New=OK; Faster=OK
Test #5: Orignal=BAD; New=OK; Faster=OK
Test #6: Orignal=BAD; New=OK; Faster=OK
Test #7: Orignal=BAD; New=OK; Faster=OK
Test #8: Orignal=OK; New=OK; Faster=OK
Test #9: Orignal=OK; New=OK; Faster=OK

Dua pengujian terakhir menguji kasus ketika string sumber lebih pendek dari string yang cocok. Dalam hal ini metode asli (waktu Noda) akan berhasil juga.

Mårten Wikström
sumber
Terima kasih banyak untuk ini. Saya perlu memeriksanya secara mendetail untuk melihat seberapa baik kinerjanya, tetapi ini terlihat seperti titik awal yang bagus. Lebih banyak pengetahuan tentang Unicode (dalam kode itu sendiri) daripada yang saya harapkan akan diperlukan, tetapi jika platform tidak melakukan apa yang diperlukan, tidak banyak yang dapat saya lakukan tentang itu :(
Jon Skeet
@JonSkeet: Senang bisa membantu! Dan ya, substring yang cocok dengan dukungan Unicode seharusnya sudah termasuk dalam kerangka kerja ...
Mårten Wikström