記事内に広告が含まれています

【ワレコC#講座】クラスなど参照型オブジェクトのディープコピー作成方法【3通りBenchmark】

この記事は約17分で読めます。
スポンサーリンク

写真 C#でDictionaryをコピーしたら挙動がおかしいので悩んでいる人

C#のDictionaryのコピーを作成したのだが、それで少しトラブった。

その備忘録的メモ。

まあ、C#に慣れている人ならそんな間違いはしないような単純なミスかもしれない。

では本題に入ろう。

スポンサーリンク
スポンサーリンク

背景

ワテが実際にトラブルで使っていたDictionaryは 

 Dictionary<int, string> 型

だったので、同じようなDictionaryデータを作ってみた。

static Dictionary<int, string> ゼッケンDic = new Dictionary<int, string>
{
 //    No.           Name
    {  3   ,   "TSEGAY, Yemane"         },  //  1位
    {  1   ,   "MAKAU, Patrick"         },  //  2位
    { 21   ,   "KAWAUCHI, Yuki"         },  //  3位
    { 72   ,   "SONODA, Hayato"         },  //  4位
    {  5   ,   "MESEL, Amanuel"         },  //  5位
    {  4   ,   "SZOST, Henryk"          },  //  6位
    {  8   ,   "COOLSAET, Reid"         },  //  7位
    { 54   ,   "BARANOVSKYY, Dmytro"    },  //  8位
    { 24   ,   "ASMEROM, Yared"         },  //  9位
    { 28   ,   "MAEDA, Kazuhiro"        },  // 10位
};

2016年12月4日(日) に開催された福岡朝日国際マラソンの出場選手のゼッケン番号と名前で作ったDictionaryだ。

三位に入賞した川内優輝選手(ゼッケン21番)の名前もあるのが分かる。

 

Dictionaryのコピーでワテの失敗

ワテが実際に直面した失敗は、ゼッケンDicが有った場合にその中のValueデータが少しだけ異なるDictionaryが必要になったので、こんなふうにして別のDictionary ゼッケン2Dic を作成した。

var ゼッケン2Dic = ゼッケンDic;
ゼッケン2Dic[3] = "川内 優輝";

一行目でコピー(したつもり)。

二行目で新Dictionary ゼッケン2Dic のKey=3の選手名を変更する。

その結果、現状の

    {  3   ,   "TSEGAY, Yemane"         },  //  1位

から

    {  3   ,   "川内 優輝"             },  //  1位

に変更した(はず)。

まあ、確かにこれでゼッケン2Dic のKey=3の選手名は 川内 優輝 選手に変わるのであるが、問題はオリジナルのデータ ゼッケンDic も同じく変更されてしまう。

要するに、Dictionaryで作成したデータは値型(value type)ではなくて参照型(reference type)なので単純にイコールで代入しても両者が指す実体は同じオブジェクトになるのだ。

これはワテも良く知っているので、こういう間違いは滅多にやらない。

普段、Dictionaryをコピーするなどと言う操作は滅多にやらないのだが(いや記憶に無いのでやった事も無いかな)、今回はたまたまよく似た二つのDictionaryのValueが一つだけ異なるDictionaryを作る場面に遭遇したので、冒頭のような安易な単純代入と言う失敗をしてしまった。

さて、本題はここからだ。

C#でDictionaryのコピーを作成する方法

今回の単純ミスが切っ掛けとなり、Dictionaryのコピーを作成する方法を調べてみたのだ。

その結果、以下の二種類の方式を見付けた。

C#でDictionaryのコピーを作成する

Dictionaryのコンストラクタを使う方式

Dictionaryのコンストラクタを使って新規にDictionaryを作成する。

var ゼッケン2Dic = new Dictionary<int, string>(ゼッケンDic);
ゼッケン2Dic[3] = "川内 優輝"; 

確かにこれだとオリジナルの ゼッケンDicは変更される事なく、複製で作成したゼッケン2Dicのみデータが書き変わる。

LINQでコピーする方式

var ゼッケン2Dic = (from x in ゼッケンDic select x)
                     .ToDictionary(x => x.Key, x => x.Value);
ゼッケン2Dic[3] = "川内 優輝";  

LINQのループでキーと値を順番に取り出してそれを使って新辞書を作成する訳だ。

確かにこのLINQ方式でもオリジナルの ゼッケンDicは変更される事なく、複製で作成したゼッケン2Dicのみデータが書き変わる。

さてこれで一件落着かと思ったのだが、そうでは無かった。

それは、ふと気になったのでもう少し複雑な例で試してみたのだ。

クラスを値に持つDictionaryのコピーを作成すると問題発覚

まずこんなクラスを定義して、

class 選手
{
    public string Name { get; set; }
    public string 所属 { get; set; }
    public 選手(string Name2, string 所属2) // コンストラクタ
    {
        Name = Name2;
        所属 = 所属2;
    }
}

こんなDictionaryを作成した。

var FukuokaDic = new Dictionary<int, 選手> { 
     //順位                        氏名英語                     所属
     {  1 , new 選手( Name2:"TSEGAY, Yemane"     , 所属2:"エチオピア" )},
     {  2 , new 選手( Name2:"MAKAU, Patrick"     , 所属2:"ケニア"     )},
     {  3 , new 選手( Name2:"KAWAUCHI, Yuki"     , 所属2:"埼玉県庁"   )},
     {  4 , new 選手( Name2:"SONODA, Hayato"     , 所属2:"黒崎播磨"   )},
     {  5 , new 選手( Name2:"MESEL, Amanuel"     , 所属2:"エリトリア" )},
     {  6 , new 選手( Name2:"SZOST, Henryk"      , 所属2:"ポーランド" )},
     {  7 , new 選手( Name2:"COOLSAET, Reid"     , 所属2:"カナダ"     )},
     {  8 , new 選手( Name2:"BARANOVSKYY, Dmytro", 所属2:"ウクライナ" )},
     {  9 , new 選手( Name2:"ASMEROM, Yared"     , 所属2:"SEISA" )},
     { 10 , new 選手( Name2:"MAEDA, Kazuhiro"    , 所属2:"九電工"     )},
     { 11 , new 選手( Name2:"ABERA, Melaku"      , 所属2:"黒崎播磨"   )},
 };

福岡国際マラソンの順位の数字をKeyにして、Valueにはその選手の名前と所属を入れたClassを持つ Dictionary<int, 選手> だ。

これで同じ実験をしてみた。

Dictionaryのコンストラクタを使う方式

var FukuokaDic_COPY = new Dictionary<int, 選手>(FukuokaDic);
FukuokaDic_COPY[1].Name = "川内 優輝";     // コピー元のデータも変わってしまう。

LINQでコピーする方式

var FukuokaDic_COPY = (from x in FukuokaDic select x)
                               .ToDictionary(x => x.Key, x => x.Value);
FukuokaDic_COPY[1].Name = "川内 優輝";    // コピー元のデータも変わってしまう。

その結果、どちらの方式でやってもコピー元のオリジナルデータも変わってしまう。

ん?

ああそうか、Class自体が値型ではなく参照型なのでこれらの方式はClassには利用出来ないのだ。

前述の Dictionary<int, string> の場合には Valueがstringでありそれは値型だったので上手くいった訳だ。

正しくは、

前述の Dictionary<int, string> の場合には Valueがstringでありそれは参照型だがC#のstringは値型のように振る舞うので上手く行ったのだ。

と言うべきか?

C#のstring型に関する補足

C#のstringは値型では無くて参照型だとのご指摘を”名無し”様よりお寄せ頂きました。詳細は本文末尾のコメント欄を参照下さい。

stringが参照型にもかかわらず振る舞いとしては値型のように振る舞うので、見かけ上はstringは値型だと思っていても問題は無さそうです(ワテの理解では)。

その辺りの議論は例えば、この辺りの説明が役に立つと思います。

Attention Required! | Cloudflare

このスタックオーバーフローのQ&Aを引用させて頂くと以下の通り。

質問 In C#, why is String a reference type that behaves like a value type?

回答

Strings aren’t value types since they can be huge, and need to be stored on the heap. Value types are (in all implementations of the CLR as of yet) stored on the stack. Stack allocating strings would break all sorts of things: the stack is only 1MB for 32-bit and 4MB for 64-bit, you’d have to box each string, incurring a copy penalty, you couldn’t intern strings, and memory usage would balloon, etc…

Google翻訳に掛けると以下の通り。

文字列は巨大になる可能性があるので値型ではなく、ヒープに格納する必要があります。 値型は(まだCLRのすべての実装において)スタックに格納されています。

つまりまあ、上記のような理由でstringはヒープに格納されるので分類上は参照型なのだが、挙動としては値型のように振る舞うと思っていて良いのかな?たぶん。

さて、本題に戻って次に進もう。

値型でも参照型でもDictionaryをDeepコピーする方法はあるのか?

それでは、Valueが値型でも参照型でもどんな場合でも Dictionary<int, class> をDeep コピーする方法はあるのかな?

と思って調べたら有った。

MemoryStreamとSerialize/DeserializeでClassなどのオブジェクトをコピーする

public static T DeepClone<T>(T obj)
{
    using (var ms = new MemoryStream())
    {
        var formatter = new BinaryFormatter();
        formatter.Serialize(ms, obj);
        ms.Position = 0;
 
        return (T)formatter.Deserialize(ms);
    }
}

う~ん、これを実行するためには、事前準備としてクラスの先頭に [Serializable] を追加する。
シリアライズ出来るようにする為に必要な宣言だ。詳細は各自で調査して頂きたい(ワテも詳しくは説明できない)。

[Serializable]
class 選手
{
    public string Name { get; set; }
    public string 所属 { get; set; }
    public 選手(string Name2, string 所属2)
    {
        Name = Name2;
        所属 = 所属2;
    }
}

その後で、

var FukuokaDic_COPY = Class3.DeepClone(FukuokaDic);
FukuokaDic_COPY[1].Name = "川内 優輝";

とすると、オリジナルは変更される事なくコピー作成したDictionaryのみデータが書き変わる。

MemoryStream, BinaryFormatter, Serialize, Deserialize, Serializableなどに関しては各自調査して頂きたい。BinaryFormatterとは別にDataContractSerializerと言うのも有ったりするので、シリアライズ・デシリアライズを研究してみたい人は調査すると良いかも。

ワテも過去に両方を試したが、どっちが良いとか、どっちが速いとか、そう言うのは良く分からない。適当に使い分けている。

さて、これで一件落着なのだが、もう一つ気になる点がある。

 

C#の場合、クラスなどのオブジェクトにはコピー コンストラクターが提供されていないけれど独自に作成する事は出来る。とは言ってもワテもあまり使わない。

で、今回それも調べてみた。

それを使うと上で紹介したMemoryStream, BinaryFormatterを使う方式のコピーをしなくても良い。

クラスにコピー コンストラクターを追加する方法でも良い

class 選手
{
    public string Name { get; set; }
    public string 所属 { get; set; }
    // Copy constructor.
    public 選手(選手 anotherPerson)  // このコピーコンストラクタを追加しておく
    {
        Name = anotherPerson.Name;
        所属 = anotherPerson.所属;
     }
    // Instance constructor.
    public 選手(string Name2, string 所属2)
    {
        Name = Name2;
        所属 = 所属2;
    }
}

コピーコンストラクタと言うのはまあ見ての通り、全部のメンバをコピーすると言う単純なものだ。

 

追加したコピーコンストラクタを使ってLINQの中で元データ(x.Value)のコピーをnew作成する。

var FukuokaDic_COPY = (from x in FukuokaDic select x)
                           .ToDictionary(x => x.Key, x => new 選手(x.Value));
 
FukuokaDic_COPY[1].Name = "川内 優輝"; 

そうやって FukuokaDic_COPY を作成すると、値を変更しても元データには影響しない。

なるほど、そう言う事か。

ICloneableインターフェイスでも良い

あるいはICloneableインターフェイスをクラスに実装しても良い。

こんな感じか。

class 選手 : ICloneable
{
    public string Name { get; set; }
    public string 所属 { get; set; }
 
    // Instance constructor.
    public 選手(string Name2, string 所属2)
    {
        Name = Name2;
        所属 = 所属2;
    }
 
    public object Clone()   // このメソッドを追加しておく
    {
        return (選手)this.MemberwiseClone();
    }
 
}

このClone() メソッドをLINQの中で使うと、こんな風に書ける。

var FukuokaDic_COPY = (from x in FukuokaDic select x)
             .ToDictionary(x => x.Key, x => (選手)x.Value.Clone());
 
FukuokaDic_COPY[1].Name = "川内 優輝";

注意点としては Clone()の戻り値型は objectなので、それを利用する場合にはキャストをしてやる必要がある。この例では (選手) にキャストしている。

この実行結果も、もちろん元データは変更されず、コピー作成したデータのみ中身が変わる。

と言う事で、まあ、ワテの備忘録的なメモをズラズラとここまで書いてきたのであるが、それを飽きずにここまで読んで頂いた皆さんにも感謝したい。

もし何か勘違いなどありましたらお知らせください。

さて、ここまでやったのでどの方法が速いのか気になる人も多いだろう。

と言う事で試してみた。

ベンチマーク

ワテのマシンで実行時間を計測してみた。

スペックは以下の通り。

  • Windows10 Pro 64
  • CPU Core i7-4770K Haswell (3.5GHz)
  • MEM CFD W3U1600HQ-8GC11 [DDR3 PC3-12800 8GB 2枚組] 計32GB
  • SSD Crucial Micron製 750GB

マシンの詳細は

【ワテ本番機】Win8をWin10に無料アップグレード完了

に記載している。

ビルド環境は

  • Visual Studio Community 2015
  • .NET4.6.2
  • Releaseビルド
  • Any CPU

それぞれの手法をforループで10,000回実行した。

具体的には以下のコマンドを単純に繰り返した。

1. コピーコンストラクタとLINQ
var FukuokaDic_COPY = (from x in FukuokaDic select x)
                           .ToDictionary(x => x.Key, x => new 選手(x.Value));
2. ICloneableインターフェイスとLINQ
var FukuokaDic_COPY = (from x in FukuokaDic select x)
             .ToDictionary(x => x.Key, x => (選手)x.Value.Clone());
3. MemoryStream と BinaryFormatter
public static T DeepClone<T>(T obj)
 ...

 

ベンチマーク結果

選手クラスのコピー作成方法 一万回実行に要した時間
1. コピーコンストラクタを使ってLINQ 17[ミリ秒]
2. ICloneableインターフェイスとLINQ 16[ミリ秒]
3. MemoryStream と BinaryFormatter 960[ミリ秒]

念のために実行順を 

1 -> 2 -> 3

2 -> 3 -> 1

3 -> 1 -> 2

と変えてみたが結果はほぼ同じ。

計測には、

using System.Diagnostics;

にある、

Stopwatch stopwatch = Stopwatch.StartNew();

を使った。ミリ秒の精度で計測出来るのでベンチマーク実験には便利だ。

まとめ

MemoryStream と BinaryFormatterでシリアライズ・デシリアライズする方法は激遅い。

まあ、そりゃあそうだろうなあ。

なぜなら、詳しくは知らないが、シリアライズやデシリアライズの処理って言うのは、データを文字列として扱って処理しているんだと思う。

そうやってシリアライズした結果のデータをファイル出力なども出来るし。あるいはファイルから読み取ってデシリアライズすれば元のデータが復元出来る。

例えば今の場合ならば、選手classのデータをシリアライズしてファイル出力しておけば、あとからそのファイルを読み取ってデシリアライズすれば選手classデータを復元出来るのだ。

まあ、シリアライズ・デシリアライズを使えば色んなヘンテコな事が出来るので便利ではあるが、速度的には遅い。

と言う事で、DeepClone()関数はジェネリック(T)の引数を取れるので汎用性はあるが、まあ、速度を優先する処理には向いていない。

 

結論としては、クラスなどの参照型オブジェクトのディープコピーを作りたい場合には、

たまにコピーを作成する程度なので速度なんて気にならないから手っ取り早くやりたいんなら

MemoryStream と BinaryFormatter

のシリアライザー方式が手軽だ。

 

一方、頻繁にコピー操作をやるので速度の速い方式が良いなら

コピーコンストラクタを追加する
ICloneableインターフェイスを追加する

のどちらかが良さそう。

スポンサーリンク
コメントを読む

この記事には読者の方からコメントが 2件あります。
興味ある人はこのページ下部にあるコメントを参照下さい。

C#Visual Studio
スポンサーリンク
シェアする
warekoをフォローする
スポンサーリンク

コメント

  1. 名無し より:

    >Valueがstringでありそれは値型だったので上手くいった訳だ
    stringは参照型です。ただ、不変な型なので値を変更する(様に見える操作をする)と、値が変更された新しいインスタンスを作り、そこへの参照を返す様になっています。
    https://docs.microsoft.com/ja-jp/dotnet/api/system.string?view=netframework-4.8

    • wareko より:

      名無し様
      この度は小生のサイトにコメントありがとうございました。
      さてC#のstring型が値型では無くて参照型との情報ありがとうございました。
      さっそく教えて頂いたURLなど見てみましたが、stringは参照型なのですね!
      この10年以上、全く知らなかったですw
      でも、挙動としては値型のように振る舞うような説明がありましたので、現実的には値型みたいなもんだと思っていても問題ないんですよね?
      実際、私の場合、この10年以上もstring型は値型だと思ってC#プログラミングしていますが、その事でトラブルに見舞われた経験は有りませんし。
      いずれにしましても大変貴重な情報をお寄せ頂きまして大変ありがとうございました。
      ではまた何かお気付きの点や間違いなどありましたら、お気軽にお教えください。