配列

いくつかのデータをひとまとめ(コレクション)にして取り扱いたいことはよくある. 配列は同じ型のデータを固定個まとめて扱うことを可能にする.

using System;

class ArrayExample
{
    static void Main()
    {
        // intの配列型の nums の宣言.nums は要素5の配列.
        // 一般に各要素の型が T であるような配列の型は T[].
        // new T[n] で要素数がnであるような配列オブジェクトを生成できる.
        int[] nums = new int[5];

        // 配列の各要素への代入
        nums[0] = 1;
        nums[1] = 2;
        nums[2] = 3;
        nums[3] = 4;
        nums[4] = 5;

        // nums[5] = 6 は例外発生.

        // 各要素を表示する.numsの要素数は nums.Length で取得可.
        for (int i = 0; i < nums.Length; i++ )
        {
            // nums[i] は nums の第i要素
            Console.WriteLine("nums[" + i + "] = " + nums[i]);
        }

        // 中括弧を用いると配列の生成時に要素を初期化可.
        // この場合は要素数を省略できる.書く場合は中括弧の中身の要素数と一致しなければならない.
        int[] nums2 = new int[] { 1, 2, 3, 4, 5 };

        // 要素について繰り返しを行いたければ foreach 文 を使用可能
        foreach (int n in nums2)
        {
            Console.WriteLine(n);
        }

        // *宣言時は* 中括弧があれば new int[] も不要
        int[] nums3 = {1, 2, 3, 4, 5};

        // 多次元配列(ここでは二次元)の例
        double[,] mat1 = { {1,2,3}, {4,5,6} };

        // Length は要素数.
        Console.WriteLine( "mat1.Length = " + mat1.Length ); // mat1.Length = 6

        // 配列 a の次元数は a.Rank
        Console.WriteLine( "nums.Rank = " + nums.Rank ); // nums.Rank = 1
        Console.WriteLine( "mat1.Rank = " + mat1.Rank ); // mat1.Rank = 2

        // 第d次元の要素数は GetLength(d) で取得可
        Console.WriteLine( "mat1.GetLength(0) = " + mat1.GetLength(0) ); // mat1.GetLength(0) = 2
        Console.WriteLine( "mat1.GetLength(1) = " + mat1.GetLength(1) ); // mat1.GetLength(1) = 3

        double[,] mat2 = new double[ mat1.GetLength(1), mat1.GetLength(0) ];
        // 転置する
        for (int i = 0; i < mat1.GetLength(0) ; i++)
        {
            for (int j = 0; j < mat1.GetLength(1) ; j++)
            {
                mat2[j,i] = mat1[i,j];
            }
        }

        Console.WriteLine(mat1.ToString()); // 残念ながら ToString()はあまりよきにはからってくれない

        // mat2を表示する.
        for (int i = 0; i < mat2.GetLength(0) ; i++)
        {
            for (int j = 0; j < mat2.GetLength(1) ; j++)
            {
                if (j != 0)
                {
                    Console.Write(", ");
                }
                Console.Write(mat2[i,j]);
            }
            Console.WriteLine();
        }
    }
}

なお,オブジェクトの配列や配列の配列も作ることができる.

using System;

class MyObject
{
    public override string ToString()
    {
        return "It's me, MyObject.";
    }
}
class ArrayExample2
{
    static void Main()
    {
        // MyObject の配列
        MyObject[] myobjects = {
            new MyObject(), new MyObject(), new MyObject()
        };

        Console.WriteLine( myobjects[1] ); // It's me, MyObject.

        // int[] の 配列(構文に注意)
        int[][] arrayOfArrays = new int[3][];

        // 各要素の配列のサイズは違っていてもよい.
        arrayOfArrays[0] = new int[] {1, 2, 3} ;
        arrayOfArrays[1] = new int[] {4, 5};
        arrayOfArrays[2] = new int[] {6, 7, 8, 9};
        // arrayOfArrays[1] = {4,5} は構文エラー.new int[]の部分が省略できるのは宣言時だけ.

        Console.WriteLine( arrayOfArrays[1][1] ); // 5
    }
}

Note

配列の代入には少し注意が必要である.

using System;

class ArrayExample3
{
    static void Main()
    {
        int[] a = {1,2,3};
        int[] b = a;

        a[1] = 22;

        for (int i = 0; i < b.Length; i++)
        {
        Console.WriteLine("b[i] = " + b[i]);
        }
        // 出力:
        // b[0] = 1
        // b[1] = 22
        // b[2] = 3
    }
}

これはabの値が配列そのものではなく配列への参照であり,それぞれ同じ配列への参照であるためである.C#においては,こうした,その型の値が実体そのものでなく実体への参照であるような型を参照型(reference type)といい,たとえば配列型やクラス型,そして文字列型は参照型である.一方で,intbool等はその型の値は実体(整数や真偽値)そのものである(ように振る舞う).そのような型を値型(value type)という.

複合的な型やユーザ定義型が必ずしも参照型ということはなく,組型(tuple type)や構造体型(structure type)は値型となる(いずれも本演習では未登場).本演習ではこの区別には深入りしないが,興味のある人はhttps://docs.microsoft.com/en-us/dotnet/csharp/fundamentals/types/や,そこから辿れる文章等を調べてみるとよい.

プロパティ(property)

上で nums.Lengthnums.Rank は一見フィールドに見える.が,実際にはこれらはプロパティと呼ばれるものである.

インスタンスoのプロパティPはフィールドのようにo.Pで読み出したりo.P = eで書き込んだりできる.しかし,フィールドと異なるのはプロパティは読み出しや書き込みのアクセス範囲を独立して指定することができ(特に読み出し専用や書き込み専用にできる),またメソッドのように読み出しや書き込みの際に処理を実行できる.

using System;

class Student
{
    private int score = 0;
    // Scoreプロパティ
    public int Score {
        // 読み出し時に呼ばれる処理
        get { return score;  }
        // 書き込み時に呼ばれる処理
        set { score = value; } // value は o.Score = e の e の値を指す変数
    }

    // Nameプロパティ
    // フィールドと同様の動作をするプロパティについては,getやsetの内容を省略可
    // auto-implemented property と呼ばれる.
    public string Name { get; set; } = ""; // auto-implemented propertyには初期値を指定可
}
class PropertyExample
{
    static void Main()
    {
        Student s = new Student();
        s.Name  = "Taro Tohoku";
        s.Score = 95;
        Console.WriteLine( s.Name + ": " + s.Score );
    }
}

プロパティにすることにより,たとえば代入される値の内容を検査し適切に例外を投げることができるようになる.

using System;

class Student
{
    private int score = 0;
    public int Score {
        get { return score;  }
        set {
            if (0 <= value && value <= 100)
            {
                score = value;
            }
            else
            {
                // 例外(次回説明)を投げる.
                throw new ArgumentException("Scoreは0から100までの範囲内の整数でなければならない");
            }
        }
    }

    public string Name { get; set; } = "";
}
class PropertyExample1
{
    static void Main()
    {
        Student s = new Student();
        s.Name  = "Taro Tohoku";
        s.Score = 105; // 例外発生
        Console.WriteLine( s.Name + ": " + s.Score );
    }
}

また,他のプロパティやフィールドから計算される読み出し専用のプロパティを持つこともできる.

using System;

class Student
{
    private int score = 0;
    public int Score {
        get { return score;  }
        set {
            if (0 <= value && value <= 100)
            {
                score = value;
            }
            else
            {
                // 例外(次回説明)を投げる.
                throw new ArgumentException("Scoreは0から100までの範囲内の整数でなければならない");
            }
        }
    }
    public string Name { get; set; } = "";

    public string ScoreRank
    {
        // getのみ定義する.
        get {
            if (Score >= 90)
            {
                return "AA";
            }
            else if (Score >= 80)
            {
                return "A";
            }
            else if (Score >= 70)
            {
                return "B";
            }
            else if (Score >= 60)
            {
                return "C";
            }
            else
            {
                return "D";
            }
        }
    }
}
class PropertyExample2
{
    static void Main()
    {
        Student s = new Student();
        s.Name  = "Taro Tohoku";
        s.Score = 95;
        Console.WriteLine( s.Name + ": " + s.Score + " (" + s.ScoreRank + ")");
    }
}

実際に LengthRank は配列オブジェクトの読み出し専用のプロパティである.

setのみを定義することで書き込み専用のプロパティを作成することもできる.また,getsetの前にprivate 等のアクセス指定子を指定することで,読み出しや書き込みを行える範囲をコントロールすることも可能である.

「読み出し専用」なプロパティにはいくつかのバリエーションがある.場合によって使い分けよう.

宣言の形 説明
public int X { get; } Xは当該クラスのコンストラクタ内でのみ変更可能
public int X { get; init; } Xは当該クラスのコンストラクタ内とオブジェクト初期化子でのみ変更可能
public int X { get; private set; } Xは当該クラス内でのみ変更可能(オブジェクト初期化子では変更不可)

前回前々回でいくつかのクラスはT GetXXX()void SetXXX(T x)といったメソッドを持っていたが,こういったメソッドはプロパティとして実装することができる.

List <T>

配列は基本的ながら強力なデータ構造の一つではあるが,サイズが生成後に変更できないのは不便であることが多い.生成後のサイズを増減させたい場合は System.Collections.Generic.List<T> を用いるとよい.

using System;
// System.Collections.Generic.List<T> を単に List<T> と書けるようにする.
using System.Collections.Generic;

class ListExample
{
    static void Main()
    {
        // 要素がstring型であるようなListを作成.
        // なお,List<T>のTは型パラメータの名前.
        List<string> ns = new List<string>();

        ns.Add("apple");
        ns.Add("orange");
        ns.Add("banana");

        // 要素を先頭から走査するにはforeach文を利用可能
        foreach (string n in ns) {
            Console.WriteLine(n);
        }

        ns[1] = "mikan"; // 配列のようにインデックスアクセス可能.

        for (int i = 0; i < ns.Count; i++) {
            Console.WriteLine( "ns[" + i + "] = " + ns[i] );
        }

        // 指定された要素を取り除くことができる
        // ただし,O(Count - 要素のインデックス)時間かかる
        ns.Remove("mikan");

        Console.WriteLine("After ns.Remove(\"mikan\")");
        for (int i = 0; i < ns.Count; i++) {
            Console.WriteLine( "ns[" + i + "] = " + ns[i] );
        }

        // 指定されたインデックスの要素を取り除ける
        // ただし,O(Count - 要素のインデックス)時間かかる
        ns.RemoveAt(1);
        Console.WriteLine("After ns.RemoveAt(1)");
        for (int i = 0; i < ns.Count; i++) {
            Console.WriteLine( "ns[" + i + "] = " + ns[i] );
        }

        // 指定されたインデックスの要素を追加できる
        // ただし,最良でもO(Count - 要素のインデックス)時間,最悪O(Count)時間かかる.
        ns.Insert(0, "melon");
        Console.WriteLine("After ns.Insert(0,\"melon\")");
        for (int i = 0; i < ns.Count; i++) {
            Console.WriteLine( "ns[" + i + "] = " + ns[i] );
        }

        // 要素がintであるようなListを作成.
        List<int> ms1 = new List<int> ();

        List<int> ms2;
        // 作成時の要素を指定可能
        ms2 = new List<int> {1, 2, 3};
        for (int i = 0; i < ms2.Count; i++) {
            Console.WriteLine( "ms2[" + i + "] = " + ms2[i] );
        }

        // 配列のときと同様の書き方はできない(以下はエラーになる)
        // List<int> ms3 = {1, 2, 3};
    }
}

上記で出てきたメソッド,プロパティ,コンストラクタのまとめ

名前 説明
List<T>() ジェネリッククラス(後述)List<T>のコンストラクタ.0要素のリストを作成する.
void Add(T) リストの末尾に要素を追加する.
bool Remove(T) 与えられた要素と同じ要素のうちもっとも最初に出現するものを取り除く.返り値は削除に成功した場合はtrue,そうでなければ(典型的には与えられた要素がリストに存在しない 場合)false
void RemoveAt(int) 与えられたインデックスの要素を取り除く.
int Count リストの要素数を返すプロパティ.読み出し専用.

Note

List<T>における配列のようなインデックスアクセスは, public T this[int index] というインデクサーと呼ばれるプロパティのようなものとして定義されている(より正確には[]の間に引数を取る以外はプロパティと同じ).

ユーザ定義のクラスでもインデクサーを含むことができる.また,インデクサーもオーバロード可能である.

Note

List<T> は最後尾でない場所に対する要素の削除や追加が重い(末尾への要素の追加はO(1)時間で済む場合とO(Count)時間かかる場合があるが,ならしO(1)).その代わり,インデックスアクセスがO(1)時間で可能である.

他の「T型の要素の列」を扱うためのクラスに LinkedList<T> がある.こちらは着目している要素の前後への挿入や,その要素の削除がO(1)で可能である.その代わりにインデックスアクセスができず,着目している要素へのアクセスは先頭あるいは末尾から辿っていく必要がありO(Count)時間かかる.目的によって使いわけよう.

ジェネリクス(generics)

List<T>のような型パラメータ(T)を取るようなクラスはジェネリッククラスと呼ばれる.ジェネリクスは「ジェネリック…」の総称である.

ユーザ自身もジェネリッククラスを作成することが可能である.

using System;

class MyPair<T, U>
{
    public T Fst { get; set; }
    public U Snd { get; set; }

    public MyPair (T fst, U snd)
    {
        Fst = fst;
        Snd = snd;
    }
}
class GenericsExample
{
    static void Main()
    {
        MyPair<string, int> mp = new MyPair<string,int>("orange", 23);
        mp.Fst = "apple";
        Console.WriteLine("(" + mp.Fst + ", " + mp.Snd + ")"); // (apple, 23)

        MyPair<string, MyPair<int, bool>> nested =
            new MyPair<string, MyPair <int, bool>>("orange", new MyPair<int, bool>(24, true));
        Console.WriteLine("(" + nested.Fst + ", " + nested.Snd.Fst + ", " + nested.Snd.Snd + ")"); // (orange, 24, true)

        // C# 9.0から,生成するオブジェクトの型が明らかな場合は new の後の型名を省略できる.
        MyPair<string, MyPair<int, bool>> nested2 = new ("orange", new (24, true));
    }
}

ジェネリクスもポリモーフィズムを実現する重要な言語機能の一つである.

練習問題

プレイリストを管理するためのクラス PlayList を作成しなさい.このクラスは曲名のリスト(List<T>を用いて実装する必要はないが,してもよい)を管理し,以下のpublicなコンストラクタ・メソッドを持つ.

名前 説明
PlayList(string[] titles) 曲目がtitlesで与えらるようなプレイリストを作成するコンストラクタ.
void PrintTrack(int i) i番目の曲目を標準出力に表示する.
void PrintAllTracks() 全ての曲目を標準出力に表示する.

動作確認用のクラス

class ListExercise
{
    static void Main()
    {
        PlayList pl = new PlayList (new string[] { "赤とんぼ", "アルプス一万尺", "荒城の月" });
        pl.PrintTrack(0); // 赤とんぼ
        pl.PrintTrack(1); // アルプス一万尺
        pl.PrintTrack(2); // 荒城の月

        // 各自の定めた表示形式にて "赤とんぼ", "アルプス一万尺", "荒城の月" の情報を含む出力がなされる.
        pl.PrintAllTracks();
    }
}

foreach文

上でも出てきていたが,配列や List<T>等のコレクションにはforeach文が利用可能である.

foreach (型あるいはvar 変数 in コレクションオブジェクト) 繰り返される文(ループ本体)

上では

foreach (string n in ns) {
    Console.WriteLine(n);
}   

のような形で出てきていたが,要素の型は多くの場合はコレクションオブジェクトの型から定まると思われるので,

foreach (var n in ns) {
    Console.WriteLine(n);
} 

のようにvarを使うのが楽であろう.

直感的にはforeach (var x in xs) { ... }はコレクションxsの各要素をxとして...内の処理を繰り返す.

より正確な言い方をすれば,foreachを使用するにはinの右に書くオブジェクトが,System.Collections.Generics.IEnumerable<T> インタフェース (か,その非ジェネリック版のSystem.Collections.IEnumerable)を実装している型である必要がある.

Caution

foreachを使用する際はループボディにおいて走査しているコレクションを変更しないようにする.たとえば,List<T>については以下とある.

An enumerator remains valid as long as the collection remains unchanged. If changes are made to the collection, such as adding, modifying, or deleting elements, the enumerator is irrecoverably invalidated and the next call to MoveNext or IEnumerator.Reset throws an InvalidOperationException.

インタフェース

インタフェースは,大雑把にはオブジェクトが持っているべきインスタンスメソッドやフィールド等を規定するものである.クラスが,これらのインタフェースの規定するメソッドやフィールドを実装しているとき,そのクラスが当該インタフェースを実装していると言う.ユーザはクラスがどのインタフェースを実装しているかを陽に与えなければならない.クラスはたかだか一つのクラスを継承できるが,インタフェースは複数実装可能である.

実装の際は単に基底クラスと同様に:の右にインタフェース名を書けばよい.

class A : BaseClass, Interface1, Interface2 
{
    // ...
}

インタフェースも継承と同じく再利用性と高める.たとえばforeach文はよい例であり,IEnumerable<T>(か,その非ジェネリック版のIEnumerable)を実装している型ならばどんな型に対してもforeach文を利用可能である.

List<T>Remove(T)メソッドもインタフェースを利用している.与えられた引数がIEquatable<T>を実装しているクラスのインスタンスであれば,そのインタフェースで規定されているメソッドEquals(T)を使って要素の等価性を判定する.そうでなければ,仮想メソッドであるObject.Equals メソッドが用いられる(参考).

本演習ではインタフェースにはこれ以上はあまり立ち入らない.

コレクション初期化子

List<T>など,Addメソッドを持ち,なおかつIEnumerableを実装しているクラス(IEnumerable<T>でもよい)に対しては, コレクション初期化子を利用することができる.

たとえば,

// コレクション初期化子を伴う場合は,引数なしコンストラクタの()は省略化
List<string> ns = new List<string> { "apple", "orange", "banana" };

のようにすることで,

List<string> ns = new List<string>();

ns.Add("apple");
ns.Add("orange");
ns.Add("banana");

と同じ挙動をより簡潔に書ける.

Note

上記は少し正確でなく

lhs = new C(p1, p2, p3) { e1, e2 };

と同様に動作するコードは以下である.

C _c = new C(p1, p2, p3);
_c.Add( e1 ); 
_c.Add( e2 ); 
lhs = _c; 

名前空間

(読みとばしてもよい.)

さて,先程 using System.Collections.Generic;というusing System;以外のusingが出てきたので,名前空間について説明しておく.

C#において,クラス名やメソッド名などの名前はいずれかの名前空間に属している.たとえば,ConsoleSystemという名前空間に属している.また,上記のMyPairArrayExample等は「空」の名前空間(グローバル名前空間)に属している.名前空間は名前空間を含むことができる.たとえば,SystemCollectionsという名前空間を含んでいて,そのCollectionsGenericという名前空間を含んでいる.上記のList<T>はそのGenericに含まれている.

名前空間にある名前にアクセスするには

名前空間.名前

のように"."を用いる.たとえば,System.Consoleなどのようにする.名前空間がネストされている場合は,その分だけ"."で名前空間をつなげて書く.たとえば,System.Collections.Generic.List<T> など.System.Consoleなどの名前空間が明示された名前を完全修飾名と呼ぶ.なお,上記のMyPairArrayExampleはグローバル名前空間に属するのでそれ自身が完全修飾名となる.

なので,usingを用いなくてもConsoleList<T>を用いたプログラムを書くことができる.

class NameSpaceExample
{
    static void Main()
    {
        System.Collections.Generic.List<int> ns =
            new System.Collections.Generic.List<int>() { 1, 2, 3 };

        for (int i = 0; i < ns.Count ; i++)
        {
            System.Console.WriteLine( "ns[" + i + "] = " + ns[i]);
        }
    }
}

いちいち完全修飾名を書くのは煩わしいので,指定の名前空間に属する名前を修飾なしに使える(「名前をインポートする」と言う)ようにするのがusingディレクティブである. usingを使うことにより,上記のプログラムは以下のように書ける.

using System;
using System.Collections.Generic;

class NameSpaceExample2
{
    static void Main()
    {
        List<int> ns = new List<int>() { 1, 2, 3 };

        for (int i = 0; i < ns.Count ; i++)
        {
            Console.WriteLine( "ns[" + i + "] = " + ns[i]);
        }
    }
}

usingはその名前空間に含まれる名前空間はインポートしない.なので以下はエラーになる.

using System;

class NameSpaceExample3
{
    static void Main()
    {
        // エラー
        Collections.Generic.List<int> ns = new Collections.Generic.List<int>() { 1, 2, 3 };
    }
}

namespaceディレクティブを用いることで,その名前空間に名前を定義することができる.

namespace N1 {
    // N1に属するA
    class A { protected int m; }
}

namespace N1 {
    namespace N2 {
        // N1.N2 に属する A
        // 違う名前空間であれば同じ名前が属することができる.
        class A { static protected int n = 0; }
        // N1.N2 に属する B
        class B {}
    }
    class C : N2.B {}
    // この A は N1.A のほう
    class D : A { private int f() { return m; } }
}

namespace N1.N2 {
    // この A は N1.N2.A
    class D : A {
        // Mainメソッドはどの名前空間に属するクラスに定義されててもよい
        static void Main()
        {
            // 完全修飾することで N1 の A にアクセスできる
            object o = new N1.A();
            System.Console.WriteLine(n);
        }
    }
}