合計値を持つカスタムコレクションの作り方

解決


Yoshi  2007-08-10 23:49:54  No: 137135

VB.NET2005です。

A:おもしろかった 100件
B:まあまあだった 200件

というアンケートのデータがあり、A,Bの件数をパーセンテージで表示したいです。

データ構造に配列やListなどを使用せずに、自分でコレクションを作れば、合計を求めるための
ループ処理が不要になるのでは?と思い、

CollectionBase クラス
http://msdn2.microsoft.com/ja-jp/library/system.collections.collectionbase(VS.80).aspx

を参考に初めてカスタムコレクションを作りました。

で、一応希望通りに動くものができたのですが、我ながらダメだな〜という感じがしておりまして、
アドバイスしていただきたく投稿させていただきました。あつかましいお願いですがもしよろしければ…

-----------------以下、私が作ったものの説明です。

アンケートデータは MyData 型としました。(下のほうのコードを参照して下さい)

で、MyData型のコレクション MyDataCollection を作り、

        Dim collection As New MyDataCollection
        collection.Add(New MyData("A", 100))
        collection.Add(New MyData("B", 300))
        Console.WriteLine(collection(0).Percent) '→ 25% と表示されること。

このような使い方ができることを目指しました。
基本的な仕組みとして、MyDataCollectionは内部で合計値を保持し、Addされると加算される方法を取りました。

ダメな点というのは、割合を算出するために MyData型 に Percent という項目を持たせたのですが、
書こうと思えばこんなことも書けてしまうのがう〜む…という感じなのです。

        Dim mydata As New MyData("A", 100)
        mydata.Percent = "100%"

こんな風に書くことができないようにしたいのですが、何かよい方法はありますでしょうか?

-----------------以下コードです

Public Class Form1

    Public Class MyData
        Public Name As String
        Public Value As Integer
        Public Percent As String '***←Percentを持たせた
        Public Sub New(ByVal n As String, ByVal v As Integer)
            Name = n
            Value = v
        End Sub
    End Class

    Public Class MyDataCollection
        Inherits CollectionBase

        Private _Sum As Integer

        Default Public Property Item(ByVal index As Integer) As MyData '***←MyDataを返す際にPercentに計算結果を入れる
            Get
                Dim w_CalcData As MyData
                w_CalcData = CType(List(index), MyData)
                w_CalcData.Percent = CInt(w_CalcData.Value / _Sum * 100) & "%"
                Return w_CalcData
            End Get
            Set(ByVal value As MyData)
                List(index) = value
            End Set
        End Property

        Public Shadows Function Add(ByVal cd As MyData) As MyData
            MyBase.List.Add(cd)
            _Sum += cd.Value
            Return cd
        End Function

    End Class

    Private Sub Form1_Load(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MyBase.Load
        Dim collection As New MyDataCollection
        collection.Add(New MyData("A", 100))
        collection.Add(New MyData("B", 300))
        Console.WriteLine(collection(0).Percent)
    End Sub

End Class


Yoshi  2007-08-10 23:53:35  No: 137136

MyDataCollection の Item プロパティの Get に変なローカル変数名が入ってしまいました。
w_CalcData → work_MyData と読み替えてください m(_ _)m


魔界の仮面弁士  2007-08-11 01:25:31  No: 137137

> Console.WriteLine(collection(0).Percent) '→ 25% と表示されること。

それだと、コレクションを介さずに使った場合、Percent の値を想像しにくくないですか?
(0 を返すというのも、意味が変わってしまうし…)

  x = New MyData("A", 100)
  y = x.Percent  'この値は何をかえすべき?

それと、パーセンテージを算出できるのは、個々の MyData ではなく、
総計を知っている MyDataCollection のはずです。
となれば、MyData 側に値を保持させる仕様にするのは、不自然なようにも感じます。

たとえば、コレクション側に GetPercent(index) メソッドを持たせてみてはどうでしょう?
メソッドなら、値の代入も防げますし。

> 書こうと思えばこんなことも書けてしまうのがう〜む…という感じなのです。
それだけの話なら、Percent を ReadOnly なフィールド(またはプロパティ)にすれば
済むでしょうけれど……それ以前に、もっと大きな問題があります。

一番の問題は、現行の仕様では、
  collection(0).Value = 5
のように行ったとしても、_Sum フィールドが更新されない点です。

値を後から編集させたくないのであれば、Value を ReadOnly にすべきですし、
読み書き可能にするのであれば、Value に対して変更が行われた場合に、
それを MyDataCollection に通知するための仕組みが必要でしょう。
(たとえば、MyData に ValueChange イベントを実装するなど)


Yoshi  2007-08-11 01:38:34  No: 137138

>それだと、コレクションを介さずに使った場合、Percent の値を想像しにくくないですか?
>(0 を返すというのも、意味が変わってしまうし…)

>となれば、MyData 側に値を保持させる仕様にするのは、不自然なようにも感じます。

なるほど!読んだ瞬間そうだと思いました。

>一番の問題は、現行の仕様では、
>  collection(0).Value = 5
>のように行ったとしても、_Sum フィールドが更新されない点です。

うわ〜ほんとですね。(^^;;

>読み書き可能にするのであれば、Value に対して変更が行われた場合に、
>それを MyDataCollection に通知するための仕組みが必要でしょう。
>(たとえば、MyData に ValueChange イベントを実装するなど)

ValueChangeを使う発想に再び感動しました。(私が無知なだけかも)

早速、新たにコードを書くことにします。
ものすごく参考になりました。ありがとうございます。まずはお礼まで…


魔界の仮面弁士  2007-08-11 03:13:10  No: 137139

>> となれば、MyData 側に値を保持させる仕様にするのは、不自然なようにも感じます。
> なるほど!読んだ瞬間そうだと思いました。
とはいえ、MyData 側に Percentage を実装した方が使いやすいこともあるでしょう。

その場合は、MyData 側をこのように実装するのも手かと。
  ・コレクション未登録時は、例外を発生させるようにする。
  ・Percentage 値は固定的に持たせるのではなく、内部で
    親コレクションに問い合わせるような仕組みにする。

>(たとえば、MyData に ValueChange イベントを実装するなど)

これ、「ValueChanged」イベントの誤記です。m(_ _;)m
変更後イベントなので、過去形にすべきということで。

# まぁ、意図は伝わったようですが。


Yoshi  2007-08-11 03:37:35  No: 137140

アドバイスを参考にコードを書き直したら望みどおりのものができました。
突っ込まれどころは沢山ありそうな気はしますが(^^;、せっかくですので晒します。

ちなみに、MyDataCollection と MyData の両方のクラスで RaiseEvent を行いました。

MyData クラスの Property Value の Set 時にChangedイベントを発生させただけでは足りなかったので、
MyDataCollection の Mybase.List.Add 時にもイベントを発生させるようにしました。

イベント関係について不勉強なのでもっと良い方法があるかもしれません。(というか、あるに違いない(^^;;)
もっと精進しますです。ともあれ、きっかけを教えていただきありがとうございました。

Public Class Form1

    Public Class MyData
        Public Event ValueChanged()

        Public Name As String
        Private _Value As Integer
        Public Property Value() As Integer
            Get
                Return _Value
            End Get
            Set(ByVal value As Integer)
                _Value = value
                RaiseEvent ValueChanged()
            End Set
        End Property
        Public Sub New(ByVal n As String, ByVal v As Integer)
            Name = n
            Value = v
        End Sub
    End Class

    Public Class MyDataCollection
        Inherits CollectionBase

        Private Event ListChanged()
        Private _Sum As Integer

        Default Public Property Item(ByVal index As Integer) As MyData
            Get
                Return CType(List(index), MyData)
            End Get
            Set(ByVal value As MyData)
                List(index) = value
                RaiseEvent ListChanged()
            End Set
        End Property

        Public Sub New()
            AddHandler ListChanged, AddressOf SumRecalc
        End Sub

        Public Sub SumRecalc()
            _Sum = 0
            For Each item As MyData In MyBase.List
                _Sum += item.Value
            Next
        End Sub

        Public Shadows Function Add(ByVal cd As MyData) As MyData
            MyBase.List.Add(cd)
            RaiseEvent ListChanged()
            AddHandler cd.ValueChanged, AddressOf SumRecalc
            Return cd
        End Function

        Public Function Percent(ByVal index As Integer) As Integer
            Dim per As Integer
            per = CInt(CType(MyBase.List(index), MyData).Value * 100 / _Sum)
            Return per
        End Function

    End Class

    Private Sub Form1_Load(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MyBase.Load
        Dim collection As New MyDataCollection
        collection.Add(New MyData("A", 100))
        collection.Add(New MyData("B", 300))
        Console.WriteLine(collection.Percent(0) & "%") '25%→OK
        collection(0).Value = 200
        Console.WriteLine(collection.Percent(0) & "%") '40%→OK
    End Sub

End Class


Yoshi  2007-08-11 03:50:22  No: 137141

>> なるほど!読んだ瞬間そうだと思いました。
>とはいえ、MyData 側に Percentage を実装した方が使いやすいこともあるでしょう。
そう言われると確かにDataGridViewとかにバインドするときとか使いやすそうです。
(意見が変わりすぎ→自分(^^;;

>その場合は、MyData 側をこのように実装するのも手かと。
>  ・コレクション未登録時は、例外を発生させるようにする。
>  ・Percentage 値は固定的に持たせるのではなく、内部で
>    親コレクションに問い合わせるような仕組みにする。

親に問い合わせる、というのは、個々のMyDataに親へのポインタを持っておく、ということでしょうか?
おおよそこのような感じと理解しました。

    Public Class MyData
        Public Name As String
        Public Value As Integer
        Private _MyDataCollection As MyDataCollection = Nothing

        Public ReadOnly Property Percent() As Integer
            Get
                If _MyDataCollection Is Nothing Then
                    '例外を発して戻る(例外の書き方を良く知らない→勉強する(^^;;
                End If
                Return CInt((Value / _MyDataCollection.Sum * 100))
            End Get
        End Property
        Public Sub New(ByVal n As String, ByVal v As Integer, ByVal p As MyDataCollection)
            Name = n
            Value = v
            _MyDataCollection = p
        End Sub
    End Class

たしかにこれならクライアントコードは楽!だと思います。
それではまたいろいろ試してみます。ありがとうございました。


Yoshi  2007-08-11 05:19:57  No: 137142

MyData側にPercentを実装してみました。

'※このコードは勉強中のもので、発展途上中です。おそらく不備満載ですのでご注意を。(つっこみ歓迎)
Public Class Form1

    Public Class MyData
        Public Event ValueChanged()
        Private _MyDataCollection As MyDataCollection

        Private _Name As String
        Public Property Name() As String
            Get
                Return _Name
            End Get
            Set(ByVal value As String)
                _Name = value
            End Set
        End Property
        Private _Value As Integer
        Public Property Value() As Integer
            Get
                Return _Value
            End Get
            Set(ByVal value As Integer)
                _Value = value
                RaiseEvent ValueChanged()
            End Set
        End Property
        Public ReadOnly Property Percent() As Integer
            Get
                If _MyDataCollection Is Nothing Then
                    Throw New ApplicationException("親不在")
                End If
                Return CInt(Value * 100 / _MyDataCollection.Sum)
            End Get
        End Property
        Public Sub New(ByVal n As String, ByVal v As Integer, ByRef p As MyDataCollection)
            Name = n
            Value = v
            _MyDataCollection = p
        End Sub
    End Class

    Public Class MyDataCollection
        Inherits CollectionBase

        Private Event ListChanged()
        Public Sum As Integer

        Default Public Property Item(ByVal index As Integer) As MyData
            Get
                Return CType(List(index), MyData)
            End Get
            Set(ByVal value As MyData)
                List(index) = value
                RaiseEvent ListChanged()
            End Set
        End Property
        Public Sub New()
            AddHandler ListChanged, AddressOf SumRecalc
        End Sub
        Public Shadows Function Add(ByVal cd As MyData) As MyData
            MyBase.List.Add(cd)
            RaiseEvent ListChanged()
            AddHandler cd.ValueChanged, AddressOf SumRecalc
            Return cd
        End Function
        Public Sub SumRecalc()
            Sum = 0
            For Each item As MyData In MyBase.List
                Sum += item.Value
            Next
        End Sub
    End Class

    Private Sub Form1_Load(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MyBase.Load
        Dim collection As New MyDataCollection
        collection.Add(New MyData("A", 100, collection))
        collection.Add(New MyData("B", 300, collection))
        Console.WriteLine(collection(0).Percent & "%") 'A:25% 
        collection(0).Value = 200
        Console.WriteLine(collection(0).Percent & "%") 'A:40%

        Dim dgv As New DataGridView
        dgv.DataSource = collection
        dgv.Dock = DockStyle.Fill
        Controls.Add(dgv)

        Dim mydata As New MyData("C", 500, Nothing)
        'Console.WriteLine(mydata.Percent) '例外発生
    End Sub

End Class


魔界の仮面弁士  2007-08-11 19:57:14  No: 137143

> 親に問い合わせる、というのは、個々のMyDataに親へのポインタを持っておく、ということでしょうか?
ですます。

親を保持するといった場合、たとえば親クラスそのものの参照を保持させる以外にも、
親クラスの総計取得メソッドのデリゲートインスタンスを保持させるとか、あるいは、
コレクションクラスそのものではなく、そのさらに上位のオブジェクトを保持させる場合もあります。

たとえば、TreeNode クラスを見てみると、TreeNodeCollection に Add すると、
TreeView という読み込み専用プロパティが、親コントロールを返す仕様になっていますね。

> Public Class MyData
>    Sub New(ByVal n As String, ByVal v As Integer, ByRef p As MyDataCollection)
ByRef にする必要は無いと思いますよ。ByVal で十分な気がします。

> Throw New ApplicationException("親不在")
ApplicationException を直接投げるのではなく、その派生クラスを投げるようにします。

> Return CInt(Value * 100 / _MyDataCollection.Sum)
/ 演算子を使うと、結果が Double になります。整数値を得るなら、
\ 演算子を用いた方が良いでしょう。

> collection.Add(New MyData("A", 100, collection))
悪くは無いですが、上記のような使い方がメインになるのであれば、
Add(MyData) ではなく、Add(String, Integer)にして
  collection.Add("A", 100)
という実装にした方が使いやすいかも知れません。

ただいずれの実装にしても、利用者が誤って
  collection1.Add(New MyData("A", 100, collection2))
という使い方をした場合や、
  Dim x As New MyData("A", 100, collection)
  collection.Add(x)
  x.Value = 200
  collection.Add(x)
といった使い方をされた場合などを考慮する必要があります。

たとえば、このような感じですかね。
  Sub Add(ByVal data As MyData)
    If data Is Nothing Then
      Throw New ArgumentNullException(…)    'Nothing はダメ
    ElseIf data.owner IsNot Nothing Then
      If data.owner Is Me Then
        Throw New すでに同じコレクションに登録済み例外
      Else
        Throw New すでに他のコレクションに登録済み例外
      End If
    End If
    '以下略

# なお、上記の data.owner は、親コレクションを返す Friend メンバをイメージしています。


Yoshi  2007-08-14 04:37:00  No: 137144

ありがとうございます。レスを頂いて感激です。

>たとえば、TreeNode クラスを見てみると、TreeNodeCollection に Add すると、
>TreeView という読み込み専用プロパティが、親コントロールを返す仕様になっていますね。

そうか〜、既存のクラスの中で親子関係があるものをイメージして参考にすれば良かったですね。

>> Public Class MyData
>>    Sub New(ByVal n As String, ByVal v As Integer, ByRef p As MyDataCollection)
>ByRef にする必要は無いと思いますよ。ByVal で十分な気がします。

うぁ、クラスなのでこの場合はByValで良いのですね…

>> Throw New ApplicationException("親不在")
>ApplicationException を直接投げるのではなく、その派生クラスを投げるようにします。

派生クラスが用意されているのですね。便利だなぁ。
今回の場合はApplicationExceptionよりもNullReferenceExceptionの方がしっくりきそうだと思いました。

>> Return CInt(Value * 100 / _MyDataCollection.Sum)
>/ 演算子を使うと、結果が Double になります。整数値を得るなら、
>\ 演算子を用いた方が良いでしょう。

そんな便利な演算子があったんですかーorz
今グラフィック描画関係のテストアプリを作っているのですが、座標計算の結果をCIntかけまくりで読みにくいったら…

>> collection.Add(New MyData("A", 100, collection))
>悪くは無いですが、上記のような使い方がメインになるのであれば、
>Add(MyData) ではなく、Add(String, Integer)にして
>  collection.Add("A", 100)
>という実装にした方が使いやすいかも知れません。

全く同感です。TreeNodeCollection.Addを調べてみると、Stringを引数にするものがあって、更に納得。
それと、よく考えるとコレクションのAddメソッドにcollectionを渡す必要は無かったですね。自分自身なんだから知ってるはず。

>  Dim x As New MyData("A", 100, collection)
>  collection.Add(x)
>  x.Value = 200
>  collection.Add(x)
>といった使い方をされた場合などを考慮する必要があります。

これは全く考慮していませんでした。
確かに!起きたら嫌すぎのバグです。

こんな不整合を防ぐために
>        Throw New すでに同じコレクションに登録済み例外
と、更に
>        Throw New すでに他のコレクションに登録済み例外
のチェックが必要なのですね。これもすごく勉強になりました。

今回は本当にありがとうございました。良いコードを書けるようにもっと勉強します(^^)


魔界の仮面弁士  2007-08-14 06:18:51  No: 137145

後から気づいた部分があるので、さらに追加。

> Public Class MyDataCollection
(中略)
>    Public Sum As Integer

Public 変数にしていますが、合計値を外部から操作できてしまうのは
問題があるので、ここは、ReadOnly Property とした方が安全でしょう。

また、ここが Integer 型ですと、Integer.MaxValue などの値を登録されたときに、
容易にオーバーフローしてしまいます。Long 型などの方が無難かと。

>>> Return CInt(Value * 100 / _MyDataCollection.Sum)
これも同様。「100」ではなく、「100L」にしておかないと、
Value が Integer.MaxValue だった場合にオーバーフローしますよ。

>> / 演算子を使うと、結果が Double になります。整数値を得るなら、
>> \ 演算子を用いた方が良いでしょう。

とはいえ、実際にはプロパティ値も含めて Double の方が良い気がしますけれどね。
なぜなら、同じ値を 100 個登録した時は、それぞれが「1%」なので 計100% ですが、
1000 個登録した時は、それぞれが「0%」に切り捨てられ、計 0% のように見えてしまうので…。

> そんな便利な演算子があったんですかーorz
英語圏だと、『/演算子』と『\演算子』のように見えるので分かりやすいのですが、
日本語だと、『/演算子』と『¥演算子』のように見えるので、見落としがちかも。(^^;

あと、\ の場合は、端数部(今回は整数演算なので、この端数とは小数部のこと)が切り捨てられますが、
/ と CInt の組み合わせの場合は、小数部が丸められる(四捨五入では無い)ことに注意が必要です。

> それと、よく考えるとコレクションのAddメソッドにcollectionを渡す必要は無かったですね。
> 自分自身なんだから知ってるはず。
ん? Add には渡していませんよね。

Yoshi さんが書かれたソースは、
  collection.Add(New MyData("A", 100, collection))
であって、
  collection.Add(New MyData("A", 100), collection)
では無かったはず。

あと、ListChanged イベントを受けてループで再計算していますが、このとき、
ListChanged イベントの引数で、差分値(NewValue - OldValue な値)を渡すようにすれば、
ループさせることなく、合計値を更新できるようになるかと思います。


Yoshi  2007-08-15 08:28:47  No: 137146

返信ありがとうございます。

>…
>>>> Return CInt(Value * 100 / _MyDataCollection.Sum)
>これも同様。「100」ではなく、「100L」にしておかないと、
>Value が Integer.MaxValue だった場合にオーバーフローしますよ。
成程〜、計算途中のオーバーフローに無防備でした。そういえば昔これ系のバグを出した記憶が…(汗
100Lという書き方は勉強になりました。

>とはいえ、実際にはプロパティ値も含めて Double の方が良い気がしますけれどね。
>なぜなら、同じ値を 100 個登録した時は、それぞれが「1%」なので 計100% ですが、
>1000 個登録した時は、それぞれが「0%」に切り捨てられ、計 0% のように見えてしまうので…。
うわ、本当ですね。このケース、考え付きませんでした。

>ん? Add には渡していませんよね。
思いっきり早とちりしました。自分のコードなのに(^^;;;

>あと、ListChanged イベントを受けてループで再計算していますが、このとき、
>ListChanged イベントの引数で、差分値(NewValue - OldValue な値)を渡すようにすれば、
>ループさせることなく、合計値を更新できるようになるかと思います。
やっぱり引数が使えるのですね。要素数やアクセス数が増えてくるとループ負荷も馬鹿にならないかと思いますので、試してみます。


※返信する前に利用規約をご確認ください。




  


  このエントリーをはてなブックマークに追加