最近の変更サイトマップ

FName::GetPlainANSIString()で正しい文字列が取れない事がある

お急ぎのあなたのために、まずは結論。FNameが保持する文字列が必要な時はToString()で生成したFStringを使うべし。GetPlainANSIString()GetPlainWIDEString()を使うとハマるから止めといた方がいい。

Unreal Engine 4の軽量文字列クラスFName公式リファレンス)のGetPlainANSIString関数/GetPlainWIDEString関数で取得できる文字列ポインタには、そのFNameインスタンスが本来持っている文字列の一部しか入っていない事がある。恐らく仕様。以下が実証コード。

TArray<FName> Names;
Names.Add(FName(TEXT("Hoge_")));
Names.Add(FName(TEXT("Hoge_0000")));
Names.Add(FName(TEXT("Hoge_0001")));
Names.Add(FName(TEXT("Hoge_10")));
Names.Add(FName(TEXT("Hoge_1200")));
Names.Add(FName(TEXT("Hoge_1300")));
Names.Add(FName(TEXT("Hoge_9999")));
Names.Add(FName(TEXT("Hoge9999")));
 
for (const auto& Name : Names)
{
    UE_LOG(LogWindows, Log, TEXT("◆%s"), *Name.ToString());
    UE_LOG(LogWindows, Log, TEXT("  PlainAnsiString=%s (%p)"), *FString(Name.GetPlainANSIString()), Name.GetPlainANSIString());
    UE_LOG(LogWindows, Log, TEXT("  [FNameEntry]"));
    UE_LOG(LogWindows, Log, TEXT("    ComparisonIndex=%d"), Name.GetComparisonIndex());
    UE_LOG(LogWindows, Log, TEXT("      [ComparisonEntry]"));
    UE_LOG(LogWindows, Log, TEXT("        Address=%p"), Name.GetComparisonNameEntry());
    UE_LOG(LogWindows, Log, TEXT("        isWide=%d"), Name.GetComparisonNameEntry()->IsWide());
    UE_LOG(LogWindows, Log, TEXT("    DisplayIndex=%d"), Name.GetDisplayIndex());
    UE_LOG(LogWindows, Log, TEXT("      [DisplayEntry]"));
    UE_LOG(LogWindows, Log, TEXT("        Address=%p"), Name.GetDisplayNameEntry());
    UE_LOG(LogWindows, Log, TEXT("        isWide=%d"), Name.GetDisplayNameEntry()->IsWide());
    UE_LOG(LogWindows, Log, TEXT("    Number=%d"), Name.GetNumber());
}

実行結果を見ると、Hoge_10, Hoge_1200, Hoge_1300, Hoge_999で見事に同一のPlainAnsiStringが返ってきているのが分かる(★の部分)

LogWindows: ◆Hoge_
LogWindows:   PlainAnsiString=Hoge_ (000000021299B988)
LogWindows:   [FNameEntry]
LogWindows:     ComparisonIndex=1029720
LogWindows:       [ComparisonEntry]
LogWindows:         Address=000000021299B978
LogWindows:         isWide=0
LogWindows:     DisplayIndex=1029720
LogWindows:       [DisplayEntry]
LogWindows:         Address=000000021299B978
LogWindows:         isWide=0
LogWindows:     Number=0
LogWindows: ◆Hoge_0000
LogWindows:   PlainAnsiString=Hoge_0000 (000000021299B9A0)
LogWindows:   [FNameEntry]
LogWindows:     ComparisonIndex=1029721
LogWindows:       [ComparisonEntry]
LogWindows:         Address=000000021299B990
LogWindows:         isWide=0
LogWindows:     DisplayIndex=1029721
LogWindows:       [DisplayEntry]
LogWindows:         Address=000000021299B990
LogWindows:         isWide=0
LogWindows:     Number=0
LogWindows: ◆Hoge_0001
LogWindows:   PlainAnsiString=Hoge_0001 (000000021299B9C0)
LogWindows:   [FNameEntry]
LogWindows:     ComparisonIndex=1029722
LogWindows:       [ComparisonEntry]
LogWindows:         Address=000000021299B9B0
LogWindows:         isWide=0
LogWindows:     DisplayIndex=1029722
LogWindows:       [DisplayEntry]
LogWindows:         Address=000000021299B9B0
LogWindows:         isWide=0
LogWindows:     Number=0
LogWindows: ◆Hoge_10
LogWindows:   PlainAnsiString=Hoge (000000021299B9E0)…★
LogWindows:   [FNameEntry]
LogWindows:     ComparisonIndex=1029723
LogWindows:       [ComparisonEntry]
LogWindows:         Address=000000021299B9D0
LogWindows:         isWide=0
LogWindows:     DisplayIndex=1029723
LogWindows:       [DisplayEntry]
LogWindows:         Address=000000021299B9D0
LogWindows:         isWide=0
LogWindows:     Number=11
LogWindows: ◆Hoge_1200
LogWindows:   PlainAnsiString=Hoge (000000021299B9E0)…★
LogWindows:   [FNameEntry]
LogWindows:     ComparisonIndex=1029723
LogWindows:       [ComparisonEntry]
LogWindows:         Address=000000021299B9D0
LogWindows:         isWide=0
LogWindows:     DisplayIndex=1029723
LogWindows:       [DisplayEntry]
LogWindows:         Address=000000021299B9D0
LogWindows:         isWide=0
LogWindows:     Number=1201
LogWindows: ◆Hoge_1300
LogWindows:   PlainAnsiString=Hoge (000000021299B9E0)…★
LogWindows:   [FNameEntry]
LogWindows:     ComparisonIndex=1029723
LogWindows:       [ComparisonEntry]
LogWindows:         Address=000000021299B9D0
LogWindows:         isWide=0
LogWindows:     DisplayIndex=1029723
LogWindows:       [DisplayEntry]
LogWindows:         Address=000000021299B9D0
LogWindows:         isWide=0
LogWindows:     Number=1301
LogWindows: ◆Hoge_9999
LogWindows:   PlainAnsiString=Hoge (000000021299B9E0)…★
LogWindows:   [FNameEntry]
LogWindows:     ComparisonIndex=1029723
LogWindows:       [ComparisonEntry]
LogWindows:         Address=000000021299B9D0
LogWindows:         isWide=0
LogWindows:     DisplayIndex=1029723
LogWindows:       [DisplayEntry]
LogWindows:         Address=000000021299B9D0
LogWindows:         isWide=0
LogWindows:     Number=10000
LogWindows: ◆Hoge9999
LogWindows:   PlainAnsiString=Hoge9999 (000000021299B9F8)
LogWindows:   [FNameEntry]
LogWindows:     ComparisonIndex=1029724
LogWindows:       [ComparisonEntry]
LogWindows:         Address=000000021299B9E8
LogWindows:         isWide=0
LogWindows:     DisplayIndex=1029724
LogWindows:       [DisplayEntry]
LogWindows:         Address=000000021299B9E8
LogWindows:         isWide=0
LogWindows:     Number=0

FNameはハッシュ付き文字列として実装されている。文字列はFNameの共用領域に格納され、各FNameインスタンスはその文字列格納領域へのインデックス=ハッシュを保持してる。FNameの同士の比較は互いのハッシュの比較、つまり整数の比較に還元されるため、通常の文字列比較より速いって仕掛けなんですな。

ただ、このハッシュ生成方法がちょっと曲者で、文字列がアンダースコア+ゼロ詰めされていない数値で終わっていたら、その部分を除いた文字列を共用領域に格納し、数値は各FNameインスタンスで保持するという方法なのだ。割と最近のバージョンアンプで変わったらしい(知人曰く4.12あたりで変わった気がすると)。なかなか破壊的な変更をしてくださりやがるな!言葉だと分かりにくいので図を作ってみた。

図を踏まえつつ改めて実行結果を見てみると、条件に合致するHoge_10, Hoge_1200, Hoge_1300, Hoge_999の中身が、その通りになっている事がお分かりいただけよう。Numberが実際の数値+1されているのは、これまた仕様で、数値分割条件を満たさないFNameインスタンス(Number == 0)との区別の為っぽい。

UE4では生成されたオブジェクトのインスタンスに対し、オブジェクト名+連番の名前を自動付与するため、大規模な開発になるとFName文字列ストアの肥大化が無視できなくなり、保持方法を変更したのだと思われる。

こんな格納の仕方で大丈夫なの!?と思うが、ふつーにFNameを使う分には何の問題もない。や、正確には大丈夫じゃなかったからこの記事書いてる訳だけど、文字列の生ポインタ取ってこねくり回すような事をしなければ大丈夫。FName文字列に対して低レベルな操作を行いたい時は、ToString()関数で生成したFStringに対して行う事をオススメする。エンジンのコードを見てもらうと分かるが、ComparisonIndexの文字列にアンダースコアと数値をくっつけて元の文字列を復元してやがるので(笑)

ちなみに、実行結果内のDisplayIndexというのは、名前の通り表示用の文字列へのインデックス。簡単に言えば、FName生成時に与えられた文字列のインデックスである。FNameは基本的にcase-preserving(内部的には大文字小文字を区別するが対外的には区別せずに扱う)なので、比較用と表示用で別々の文字列を持つ必要がある訳だ。知らずに使ってると、これも地味にハマりポイントかも。

FName abc(TEXT("abc"));
FName ABC(TEXT("ABC"));
UE_LOG(LogWindows, Log, TEXT("%s %s %s"), *abc.ToString(), (abc == ABC ? TEXT("==") : TEXT("!=")), *ABC.ToString());

念のため上記コードで確認したら、LogWindows: abc == ABCとなった。

掘ってみるとFNameには結構罠があるので注意が必要だ。

C#でローカルのDTDファイルを使うXMLリゾルバを作る

以前、C#でXmlDocumentを作る時にリゾルバでタイムアウトすると書いたが、ようやくローカルのDTDファイルを使ったXMLリゾルバが作れたので、コードをまるっと公開。尚、.NET Framework 4では殆ど同じことを行うXmlPreloadedResolverクラスが追加されているので、使えるならそっちを使うのが良い。悲しいかな、うちは.NET 3.5なのさ……

using System;
using System.Collections.Generic;
using System.Xml;
using System.IO;
 
namespace ProductionKusoGA
{
    class LocalXmlResolver : XmlResolver
    {
        public LocalXmlResolver()
        {
        }
 
        public override System.Net.ICredentials Credentials
        {
            set {  }
        }
 
        public override Uri ResolveUri(Uri baseUri, string relativeUri)
        {
            Uri uri = DocTypeManager.Instance.GetDTDURI(relativeUri);
            return uri != null ? uri : base.ResolveUri(baseUri, relativeUri);
        }
 
        public override object GetEntity(Uri absoluteUri, string role, Type ofObjectToReturn)
        {
            object entity = DocTypeManager.Instance.GetDTDStream(absoluteUri.AbsoluteUri);
            if (entity == null)
            {
                XmlUrlResolver resolver = new XmlUrlResolver();
                entity = resolver.GetEntity(absoluteUri, role, ofObjectToReturn);
            }
            return entity;
        }
 
        class DocTypeManager
        {
            public static readonly DocTypeManager Instance = new DocTypeManager();
 
            public Uri GetDTDURI(string inFPI)
            {
                Uri dtdURI = null;
                URIForFPI.TryGetValue(inFPI, out dtdURI);
                return dtdURI;
            }
 
            public FileStream GetDTDStream(string inURI)
            {
                FileStream stream = null;
                if (DTDStreamForURI.TryGetValue(inURI, out stream) == false)
                {
                    string dtdFile = null;
                    if (DTDFileForURI.TryGetValue(inURI, out dtdFile))
                    {
                        string RESOURCE_DIR = "...";
                        stream = new FileStream(Path.Combine(RESOURCE_DIR, dtdFile), FileMode.Open);
                        DTDStreamForURI.Add(inURI, stream);
                    }
                }
                return stream;
            }
 
            DocTypeManager()
            {
                URIForFPI = new Dictionary<string,Uri>();
                DTDFileForURI = new Dictionary<string,string>();
                DTDStreamForURI = new Dictionary<string, FileStream>();
 
                AddDTD("-//W3C//DTD XHTML 1.0 Strict//EN",      @"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd",       @"path/to/xhtml1-strict.dtd");
                AddDTD("-//W3C//DTD XHTML 1.0 Trasitional//EN", @"http://www.w3.org/TR/xhtml1/DTD/xhtml1-trasitional.dtd",  @"path/to/xhtml1-trasitional.dtd");
                AddDTD("-//W3C//DTD XHTML 1.0 Frameset//EN",    @"http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd",     @"path/to/xhtml1-frameset.dtd");
                AddDTD("xhtml-lat1.ent", @"http://www.w3.org/TR/xhtml1/DTD/xhtml-lat1.ent", @"path/to/xhtml-lat1.ent");
                AddDTD("xhtml-symbol.ent", @"http://www.w3.org/TR/xhtml1/DTD/xhtml-symbol.ent", @"path/to/xhtml-symbol.ent");
                AddDTD("xhtml-special.ent", @"http://www.w3.org/TR/xhtml1/DTD/xhtml-special.ent", @"path/to/xhtml-special.ent");
            }
 
            ~DocTypeManager()
            {
                foreach (var pair in DTDStreamForURI)
                {
                    if (pair.Value != null)
                    {
                        pair.Value.Dispose();
                    }
                }
            }
 
            void AddDTD(string inFPI, string inURI, string inFilepath)
            {
                URIForFPI.Add(inFPI, new Uri(inURI));
                DTDFileForURI.Add(inURI, inFilepath);
            }
 
            Dictionary<string, Uri> URIForFPI { get; set; }
            Dictionary<string, string> DTDFileForURI { get; set; }
            Dictionary<string, FileStream> DTDStreamForURI{ get; set; }
        }
    }
}

DTDファイルのパスを適宜変更し、xmlDocument.Resolver = new LocalXmlResolver();ってな感じで設定してやればおk。DTDを増やしたいときはAddDTDを増やす。DTDが内部で参照しているファイル(上記コードで言えばxhtml-lat1.entとか)も漏れなく追加する必要がある。

一応、コードのライセンスはパブリックドメインってことで。煮るなり焼くなりお好きにどうぞ。

C#のCreateDocumentTypeがタイムアウトする時の簡易対策

C#のXmlDocumentでHTMLを生成しようと、W3CのDTDを指定してXmlDocument.CreateDocumentType()するとタイムアウトやHTTPステータスコード500で例外を吐くことがある。こちとらvalidなHTMLを生成しようと真面目に指定してんのに、この仕打である(´・ω・`)。みんなW3Cを見に行って慢性的な高負荷状態になってるのが原因らしいが、まぁ当然そうなりますわな…。

コードにすると↓な感じ。

XmlDocument doc = new XmlDocument();
XmlDocumentType docType = doc.CreateDocumentType(
    "HTML",
    "-//W3C//DTD HTML 4.01 Frameset//EN",
    "https://www.w3.org/TR/html401/frameset.dtd",
    null); /* ここでエラー */

XmlResolverでローカルキャッシュしたDTDをマッピングするのが正攻法らしいのだが、ぶっちゃけ面倒。というか、調べても良くわからんかったのでパスw(分かったら追記する…多分)

とりあえずエラーを回避するだけなら、CreateDocumentType実行前にXmlDocument.ResolvernullにしてやればOKっぽい。コードにするまでもないが一応書いておく。

XmlDocument doc = new XmlDocument();
doc.Resolver = null; // 追加
XmlDocumentType docType = doc.CreateDocumentType(
    "HTML",
    "-//W3C//DTD HTML 4.01 Frameset//EN",
    "https://www.w3.org/TR/html401/frameset.dtd",
    null); /* エラーにならない */

・・・と、ここまで書いて思ったが、これで回避できるって事は自前実装したXMLリゾルバでDTD返してやればいいだけなんじゃね?

2016-04-13追記

C#のジェネリックで特殊化っぽいことをする

新年明けましておめでとうございます(遅。開設14年目となるクソゲ~製作所を本年もよろしくお願い致します。

C++のテンプレートには特殊化という、特定の型パラメータの時にテンプレートの実体を別に定義する機能がある。だが、C#版テンプレートとも言えるジェネリックでは、なんということでしょう、特殊化が使えないではありませんか!それをどうにかしてジェネリック特殊化っぽい事をしてみたっていうお話。

こんなコードがあったとする。

public class Node
{
    // 子ノード
    public List<Node> Children;
    // 特定の子ノードを取得
    public List<T> FindChildren<T>() : where Node
    {
        List<T> list = new List<T>();
        Type type = typeof(T);
        foreach (Node e in Children)
        {
            if (type == e.GetType())
            {
                list.Add(e);
            }
            list.AddRange(e.FindChildren<T>());
        }
        return list;
    }
}
 
public class DocumentNode : Node {}
public class PageNode : Node {}
public class TitleNode : Node
{
    public string Title;
    public TitleNode(string title) { Title = title; }
}
 
public class SectionNode : Node
{
    public TitleNode Title;
}
 
static void Main()
{
    DocumentNode doc = new DocumentNode();
 
    PageNode page = new PageNode();
    doc.Children.Add(page);
 
    SectionNode section1 = new SectionNode();
    section1.Title = new TitleNode("はじめに");
    page.Children.Add(section1);
 
    SectionNode section2 = new SectionNode();
    section2.Title = new TitleNode("つぎに")
    page.Children.Add(section2);
 
    // 全セクションを取得
    List<SectionNode> allSections = doc.FindChildren<SectionNode>(); //正しく取得できる
    // 全タイトルを取得
    List<TitleNode> allTitles = doc.FindChildren<TitleNode>(); //★正しく取得できない!!
 
    ...
}
◆データ構造
[DocumentNode]
 -Children
   -[PageNode]
     -Children
       -[SectionNode]
         -Title
         -Children
       -[SectionNode]
         -Title
         -Children

文章を模したデータ構造を作り、FindChildrenメソッドで型をパラメータに子ノードを取得しているが、 doc.FindChildren<SectionNode>()は正しい挙動をするものの、doc.FindChildren<TitleNode>()の方は空のリストが帰ってくる。FindChildren()Node.Childrenしか見てないので、独立したメンバSectionNode.Titleが入るはずはなく当然の挙動である。

こんな時、C++なら特殊化でFindChildren<TitleNode>専用の処理が書け同一のインタフェースを提供できるのだが、前述の通りジェネリックは特殊化が使えないの。C#的にはFindChildTitleNodes()的な別メソッドで提供するのが正しいのかもしれないが、やっぱり統一感に欠けて美しくない(そもそもこんな糞いデータ構造にすんなって話だが例ってことで許してね。)

で、知恵を振り絞ってFindChildrenを以下のようにした。

public List<T> FindChildren<T>() : where Node
{
    List<T> list = new List<T>();
    Type type = typeof(T);
    if (type == typeof(TitleNode))                           // (1)
    {
        List<T> titles = new List<T>();                      // (2)-a
        var sections = FindChildrenInternal<SectionNode>();
        section.ForEach(e => { titles.Add(e.Title as T); }); // (3)
        return titles;                                       // (2)-b
    }
 
    return FindChildrenInternal<T>();
}
 
List<T> FindChildrenInternal<T>() : where Node
{
    List<T> list = new List<T>();
    Type type = typeof(T);
    foreach (Node e in Children)
    {
        if (type == e.GetType())
        {
            list.Add(e);
        }
        list.AddRange(e.FindChildren<T>());
    }
    return list;
}

元のFindChildren()FindChildrenInternal()とし、FindChildren()には型パラメータTに応じた処理を書くようにした。C++でコンパイラが自動で行ってくれる特殊化による処理の振り分けを、実行時に手動で行うような感じかしら。原理上、実行時コストは増えるが、この程度なら大した影響はないだろう。JIT様もあることだし。

型が増えるとifと特殊化処理の嵐になるが、型ごとに処理デリゲートを作ってTypeとデリゲート辞書でも作ればすっきりするので大した問題ではない。そもそも、そんな状況は最早「特殊化」の本質から外れてるので設計から見直すべきだろう。

このコードのミソは(1)~(3)の部分。

(1)で処理すべきTの型が明確になったものの、(2)-aでList<T>としているのはコンパイルを通すため。List<TitleNode>でも良さそうに見えるが、T == TitleNodeになるとは限らないので(2)-bで型不一致エラーになる。List<TitleNode>とした場合にT = SectionNodeで呼び出した時にどうなるかを考えれば、すぐにおかしさに気づいて頂けるかなと。

(3)も一見titles.Add(e.Title)で良さそうだが、これまたTitleNodeが必ずしもTに変換可能とは限らないのでCS1503エラーとなる。よって、ちょっとアホくさいがTitleNodeを自身の型Tにキャストしてやらなければならない。

メソッドの引数に型パラメータを持てる場面では、拡張メソッドを使った特殊化もどきが使える模様。

リフレクションは楽しいなー。C++にも軽量な型情報システムがあればいいのになぁ…。

C#のstring.Trim()は全角スペースまで削って下さりやがる

C#のstringクラスにあるTrim()メソッドは、C#の三大便利関数の1つと言って良いくらい便利な関数だ。

簡単に言えば「文字列の先頭と末尾に付随する空白文字を削除してくれる関数」で、より正確には「Unicodeが定める空白文字を削除する関数」である。(更に言うと.NET Frameworkのバージョンによって細部の挙動が違う。詳細はMSDNを参照の事。)

Unicodeの空白文字ってなんぞ?というと、C#的にはChar.IsWhiteSpace(letter) == trueとなる文字である。詳細はUnicodeの規格書なりWikipediaなりを見て頂くとして、定義にはいわゆる半角スペース(U+0020)やタブ文字(U+0009)は然ることながら、全角スペース(U+3000)も含まれるのだ。つまりTrim()を使うと全角スペースも奇麗さっぱりなくなっちゃう。なんというか、C/C++の非WIDE文字な文字列操作に慣れている身からすると、直感とは異なる挙動なわけ。

今回はこれにハマった。削られちゃマズい全角スペースが見事になくなってて、Trim()の挙動を初めて知ったという(´・ω・`)

回避策は引数ありバージョンのTrim()で、全角スペースを除いた空白文字配列を渡すしかない模様。↓こんな感じで拡張メソッド化しておくと便利に使えるよ(`・ω・´)

public static class MyStringAdditions
{
    static char[] WhiteSpaceDelimiters = new char[] {
        '\u0009',  // CHARACTER TABULATION
        '\u000A',  // LINE FEED
        '\u000B',  // LINE TABULATION
        '\u000C',  // FORM FEED
        '\u000D',  // CARRIAGE RETURN
        '\u0020',  // SPACE
        '\u00A0',  // NO-BREAK SPACE
        '\u2000',  // EN QUAD
        '\u2001',  // EM QUAD
        '\u2002',  // EN SPACE
        '\u2003',  // EM SPACE
        '\u2004',  // THREE-PER-EM SPACE
        '\u2005',  // FOUR-PER-EM SPACE
        '\u2006',  // SIX-PER-EM SPACE
        '\u2007',  // FIGURE SPACE
        '\u2008',  // PUNCTUATION SPACE
        '\u2009',  // THIN SPACE
        '\u200A',  // HAIR SPACE
        '\u200B',  // ZERO WIDTH SPACE
//      '\u3000',  // IDEOGRAPHIC SPACE
        '\uFEFF' // ZERO WIDTH NO-BREAK SPACE
    };
 
    public static string TrimWithoutZenkakuSpace(this string str)
    {
        string s = str.Trim(WhiteSpaceDelimiters);
        return s;
    }
}

この件とは直接関係ないけど、TrimStartTrimEndなんてメソッドもあったんだね。取り除きたい文字の配列を渡すと、対象文字列の先頭もしくは末尾から除去してくれる。Trimの分割バージョンみたいなやつ、というよりもTrimTrimStartTrimEndの合体技と言った方がいいか。覚えといて損は無さそう。

参考サイト

start.txt · 最終更新: 2016-05-07 17:46 by decomo
CC Attribution-Noncommercial-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0