はじめに
これは、Visual Basic Advent Calendar 2023の8日目の記事となります。
現在、仕事で作成しているアプリケーションで、正常パターンでは問題ないのですが一旦エラー画面から戻って再度入力し直すと値が表示されなくなったり、照合値が変更されたりする不具合がありました。
調査をすると、Dictionary型で保持している値をエラー画面から戻った際に元に戻しているのですが、この時に値が書き換わってしまっていたのです。そう、シャローコピーの罠に嵌っていたわけです。
【2023/12/09追加】
C# を併記するようにしました。
環境
- Windows 11 Home
- Visual Studio 2022
- Visual Basic
- C#
- .NET 8.0
値型と参照型
シャローコピーとディープコピーを理解する上で、型の違いを理解しておく必要があります。
代入はシャローコピーではない
【2024/01/17追加】
シャローコピー
シャローとは浅いという意味を持ちます。反対後として深いを意味するディープがあります。
参照型のオブジェクトの中身は参照先(メモリ上の位置やサイズなど)を表すデータ(アドレス)となります。参照型のシャローコピーは「参照先はどこか」という情報の複製になるので、複製元オブジェクトと複製先オブジェクトが同じ場所を参照している状態になります。
単純な代入
VB
Sub Main(args As String())
Dim dict As Dictionary(Of String, String) = New Dictionary(Of String, String)
dict.Add("A", "Foo")
' 代入
Dim dictCopy = dict
dict("A") = "Bar"
Console.WriteLine("Original = " + dict("A"))
Console.WriteLine("dictCopy = " + dictCopy("A"))
End Sub
C#
static void Main(string[] args)
{
Dictionary<string, string> dict = new();
dict.Add("A", "Foo");
// 代入
var dictCopy = dict;
dict["A"] = "Bar";
Console.WriteLine("Original = " + dict["A"]);
Console.WriteLine("dictCopy = " + dictCopy["A"]);
}
結果
コピー先である dictCopy
にコピーした後にコピー元を書き換えると、dictCopy
の値も変更されてしまい両方とも Bar
になってしまいます。
Original = Bar
dictCopy = Bar
コンストラクタを使用した代入
VB
Sub Main(args As String())
Dim dict As Dictionary(Of String, String) = New Dictionary(Of String, String)
dict.Add("A", "Foo")
' コピーコンストラクタ
Dim dictCopy = New Dictionary(Of String, String)(dict)
dict("A") = "Bar"
Console.WriteLine("Original = " + dict("A"))
Console.WriteLine("dictCopy = " + dictCopy("A"))
End Sub
C#
static void Main(string[] args)
{
Dictionary<string, string> dict = new();
dict.Add("A", "Foo");
// コピーコンストラクタ
var dictCopy = new Dictionary<string, string>(dict);
dict["A"] = "Bar";
Console.WriteLine("Original = " + dict["A"]);
Console.WriteLine("dictCopy = " + dictCopy["A"]);
}
結果
今度はコピー先である dictCopy
にコピーした後にコピー元を書き換えても、dictCopy
の値は変更されません。
このことからシャローコピーでありながら、ディープコピーが実現されたことになります。
参照型である文字列が別インスタンスに変更されたことで、コピー元を書き換えても値が変更されなくなったのです。
Original = Bar
dictCopy = Foo
理由
string型は参照型なのですが、string型はコンストラクタによって、変数コピーの際は値型の挙動をとるような仕組みになっています。
Dictionary型の内容が、string型で構成されているなら、コンストラクタによるシャローコピーであってもディープコピーと同じ動きになるわけです。
ディープコピー
ディープとは深いという意味を持ちます。
クラスなどの参照型を使用した場合、コンストラクタを使用した代入でもシャローコピーとなってしまいます。
コンストラクタを使用した代入
VB
Sub Main(args As String())
Dim dict As Dictionary(Of String, RefTest) = New Dictionary(Of String, RefTest)
Dim t1 As New RefTest
t1.Name = "Foo"
dict.Add("A", t1)
Dim dictCopy = New Dictionary(Of String, RefTest)(dict)
t1.Name = "Bar"
Console.WriteLine("Original = " + dict("A").Name)
Console.WriteLine("dictCopy = " + dictCopy("A").Name)
End Sub
Public Class RefTest
Public Property Name As String = ""
End Class
C#
static void Main(string[] args)
{
Dictionary<string, RefTest> dict = new();
RefTest t1 = new();
t1.Name = "Foo";
dict.Add("A", t1);
Dictionary<string, RefTest> dictCopy = new(dict);
t1.Name = "Bar";
Console.WriteLine("Original = " + dict["A"].Name);
Console.WriteLine("dictCopy = " + dictCopy["A"].Name);
}
public class RefTest
{
public string Name { get; set; } = "";
}
結果
コピー先である dictCopy
にコピーした後にコピー元を書き換えると、dictCopy
の値も変更されてしまい両方とも Bar
になってしまいます。
Original = Bar
dictCopy = Bar
JSONシリアライズを使用した代入
複製元オブジェクトと複製先オブジェクトが違う新しいオブジェクトを生成することで、ディープコピーを実現させます。
拡張メソッドにジェネリック関数のDeepCopy
を追加しています。
VB
Sub Main(args As String())
Dim dict As Dictionary(Of String, RefTest) = New Dictionary(Of String, RefTest)
Dim t1 As New RefTest
t1.Name = "Foo"
dict.Add("A", t1)
Dim dictCopy = New Dictionary(Of String, RefTest)(dict)
t1.Name = "Bar"
Console.WriteLine("Original = " + dict("A").Name)
Console.WriteLine("dictCopy = " + dictCopy("A").Name)
End Sub
' 拡張メソッド
<Extension()>
Public Function DeepCopy(Of T)(ByVal src As T) As T
Dim jsonSerializerOptions = New JsonSerializerOptions() With {
.ReferenceHandler = ReferenceHandler.Preserve,
.WriteIndented = True
}
Dim jsonData = JsonSerializer.Serialize(src, jsonSerializerOptions)
Return JsonSerializer.Deserialize(Of T)(jsonData, jsonSerializerOptions)
End Function
Public Class RefTest
Public Property Name As String = ""
End Class
C#
static void Main(string[] args)
{
Dictionary<string, RefTest> dict = new();
RefTest t1 = new();
t1.Name = "Foo";
dict.Add("A", t1);
Dictionary<string, RefTest> dictCopy = dict.DeepCopy();
t1.Name = "Bar";
Console.WriteLine("Original = " + dict["A"].Name);
Console.WriteLine("dictCopy = " + dictCopy["A"].Name);
}
public static class Utiliry
{
// 拡張メソッド
public static T DeepCopy<T>(this T src)
{
var jsonSerializerOptions = new JsonSerializerOptions()
{
ReferenceHandler = ReferenceHandler.Preserve,
WriteIndented = true
};
var jsonData = JsonSerializer.Serialize(src, jsonSerializerOptions);
#pragma warning disable CS8603 // Null 参照戻り値である可能性があります。
return JsonSerializer.Deserialize<T>(jsonData, jsonSerializerOptions);
#pragma warning restore CS8603 // Null 参照戻り値である可能性があります。
}
}
public class RefTest
{
public string Name { get; set; } = "";
}
結果
今度はコピー先である dictCopy
にコピーした後にコピー元を書き換えても、dictCopy
の値は変更されません。
このことからディープコピーが実現されたことになります。
Original = Bar
dictCopy = Foo
但し、JSONシリアライズしているだけあって遅いです。ま、遅いといっても何万回も繰り返すわけではないので気にする必要はないでしょう。
最後に
仕事で作成しているアプリケーションでは、Dictionary型の内容が string型のみで構成されているので「コンストラクタを使用した代入」で問題ないわけです。
アプリケーションは C#で作成しており、記事用に Visual Basic に書き換えてみました。