プログラミングと開発方法論と情報セキュリティ(主に初心者向け、言語はC#)、その他、ゲーム、思想等。

開発方法論萌え

C# 応用

C#応用編第2回 – いよいよクラス登場!

更新日:


前回は、クラスを解説する上で必要な前提事項を説明しました。

今回は、いよいよクラスが登場します!

前提

本記事は、こちらの記事の続編となります。

Add() 関数の問題点

前回このようなプログラムを作成しました。


using System;

class Program
{
  static void Main(string[] args)
  {
    int[] hoge = new int[0];
    Console.WriteLine("好きな数だけ数値を入力してください。");
    while (true)
    {
      string str = Console.ReadLine();
      if (str == "")
        break;

      hoge = Add(hoge, int.Parse(str));
    }

    int sum = 0;
    foreach (int a in hoge)
    {
      sum += a;
    }

    Console.WriteLine("合計は {0} になりました。", sum);
  }

  static int[] Add(int[] array, int a)
  {
    // 既にある配列+1の要素を持つ配列を作成する。
    int[] newArray = new int[array.Length + 1];

    // 既にある要素を複製する。
    for (int i = 0; i < array.Length; i++)
    {
      newArray[i] = array[i];
    }

    // 新規の要素を追加する。
    newArray[array.Length] = a;

    return newArray;
  }
}

 

ここで作成した Add() 関数には問題点があります。

Add() 関数を呼び出すたびに、要素が追加されます。それ自体は良いのですが、「既存の要素を複製」するという操作が発生してしまいます。

これはかなり重たい処理です。加えて要素の追加の度に、配列をメモリ上に確保する必要があります。

頻繁にメモリ上に領域を確保すれば GCer(ガベージコレクタ)に負担をかけることになります。

GCer(ガベージコレクタ)に負担を書ければ、プログラムのパフォーマンスが低下します。

こういう問題に対処するため、C#の標準の動的配列クラス(System.Collections.Generic.List<T>)には、工夫が入っています。

System.Collections.Generic.List<T> の解決方法

System.Collections.Generic.List<T> は、Add() 関数が呼ばれたとき、配列の要素数が足りなければ、裏で要素数を倍にした配列を作ります。

そして、配列の要素数のどこまでが使用済みかを記憶しているのです。

これで、要素の追加の度に配列をメモリ上に確保しなくてもよく、GCer への負担を軽減することができます。

ここで、裏で抱えている配列の要素数を「物理的な要素数」と呼ぶことにします。表に公開されている要素数を「論理的な要素数」と呼ぶことにします。

Length プロパティは論理的な要素数を返しますし、List<T> に対してforeach 文を使ったときも論理的な要素数に基づいて処理をします。添え字アクセスをしたとき、論理的な要素数以上の添え字が指定された場合は例外を発生させる処理が入っています。

Add() 関数を改良してみる

前回自作した Add() 関数を、配列の要素数が足りなければ、裏で要素数を倍にした配列を作るように改良する方法を考えてみます。

改良を加えたコードはこうなります。


static int[] Add(int[] array, int length, int a)
{
  if (array.Length < length)
  {
    // 物理的な要素数が足りている場合は新規の要素を追加するだけで良い。
    array[length] = a;
    return array;
  }
  else
  {
    // 物理的な要素数が足りない場合は配列を拡張する。

    int[] newArray;
    if (array.Length == 0)
    {
      // 配列の要素が 0 の場合は要素数が 1 の配列を作成する。
      newArray = new int[1];
    }
    else
    {
      // 既にある配列の倍の要素を持つ配列を作成する。
      newArray = new int[array.Length * 2];
    }

    // 既にある要素を複製する。
    for (int i = 0; i < array.Length; i++)
    {
      newArray[i] = array[i];
    }

    // 新規の要素を追加する。
    newArray[length] = a;

    return newArray;
  }
}

 

しかし、この改良を加えたことで新たな問題が発生します。

赤字の部分に着目して欲しいのですが、Add() 関数の引数がひとつ増えています。配列の要素のどこまでが使用済みであるか論理的な要素数を、呼び出し元に教えてもらわなければならないのです。

引数が増えると、各引数が何を意味しているのかが分かりにくくなり、プログラムが錯乱してしまいます。

もちろんですが、配列のどこまでが使用済みなのか、論理的な要素数を記憶するための変数が Main() 関数に新たに必要となるのです。

更に、これに伴い配列の要素全部に対して処理をするという性質を持つ foreach 文は使えなくなります。

改良を加えた Add() 関数を使うように変更を加えた Main() 関数はこうなります。(赤字の部分に注目。)


static void Main(string[] args)
{
  int[] hoge = new int[0];
  int length = 0;
  Console.WriteLine("好きな数だけ数値を入力してください。");
  while (true)
  {
    string str = Console.ReadLine();
    if (str == "")
      break;

    hoge = Add(hoge, int.Parse(str));
    length++;
  }

  int sum = 0;
  for (int i = 0; i < length; i++)
  {
    sum += hoge[i];
  }

  Console.WriteLine("合計は {0} になりました。", sum);
}

 

このコードは大きな問題を抱えています。Add() 関数を使う側、即ち Main() 関数の側で、length を宣言して、Add() 関数を呼び出すたびにインクリメント(++)しなければならないのです。

この、length を宣言することと、インクリメントすることを、プログラマが忘れてしまうと正常に動作しません。

Add() に限らず、関数にはプログラムの部品としての役割が期待されますが、Add() 関数の動作に必要な値である length を、関数の外部で管理しなければならいので、部品としては「弱い」のです。

そこで、length を Add() 関数の中で管理する方法を考えてみます。

参照渡し(ref)使う

こういう場合、関数の戻り値が1つしか使えないことがネックになります。実はC#には疑似的に関数の戻り値を増やす機能があります。それが参照渡しです。

out と言うキーワードを使うことで、関数に疑似的に戻り値を追加することができます。また、ref と言うキーワードを使うことで、引数に戻り値としての役割を兼ねさせることができます。

では、先ほどのプログラムを ref を使って書き換えてみます。非常に錯乱した分かりにくいプログラムになりますが、全部を理解する必要はありません。「分かりにくいこと」が分かればこの段階では十分です。

なお、この書き方は Java では使えません。Java には参照渡しの機能がありません。

(赤字の部分に注目)


using System;

class Program
{
  static void Main(string[] args)
  {
    int[] hoge = new int[0];
    int length = 0;
    Console.WriteLine("好きな数だけ数値を入力してください。");
    while (true)
    {
      string str = Console.ReadLine();
      if (str == "")
        break;

      hoge = Add(hoge, ref length, int.Parse(str));
    }

    int sum = 0;
    for (int i = 0; i < length; i++)
    {
      sum += hoge[i];
    }

    Console.WriteLine("合計は {0} になりました。", sum);
  }

  static int[] Add(int[] array, ref int length, int a)
  {
    if (array.Length < length)
    {
      // 物理的な要素数が足りている場合は新規の要素を追加するだけで良い。
      array[length] = a;
      length++;

      return array;
    }
    else
    {
      // 物理的な要素数が足りない場合は配列を拡張する。

      int[] newArray;
      if (array.Length == 0)
      {
        // 配列の要素が 0 の場合は要素数が 1 の配列を作成する。
        newArray = new int[1];
      }
      else
      {
        // 既にある配列の倍の要素を持つ配列を作成する。
        newArray = new int[array.Length * 2];
      }

      // 既にある要素を複製する。
      for (int i = 0; i < array.Length; i++)
      {
        newArray[i] = array[i];
      }

      // 新規の要素を追加する。
      newArray[length] = a;
      length++;

      return newArray;
    }
  }
}

 

これで、length を Main() 関数の側でインクリメントする必要が無くなりました。即ち、インクリメントする処理を Add() 関数に移転しました。

これでもまだ問題が残っています。length を Main() 関数の側で、配列 array とは別に宣言しなければならないのです。

ここでいよいよクラスの出番です。

クラスを定義(宣言)する

この事例のように、「戻り値が複数欲しい!」という場合は、クラスを使うと解決する場合が多いです。

クラスは、部品としての機能を果たすに足りるだけの要素をまとめたものです。

ここでは、配列の本体である array と、配列の使用済みの要素数を表す length の2つの要素をまとめる必要があります。

これをプログラムコードで表現すると次のようになります。


class IntList
{
  int[] array;
  int length;
}

 

これで、配列の本体である array と、配列の使用済みの要素数を表す length の2つの要素をまとめることができるようになりました。

ただし、このままでは array と length にクラスの外部からアクセスすることができません。そこで public アクセス指定子を付与します。


class IntList
{
  public int[] array;
  public int length;
}

 

これでクラスを使う準備は完了です。IntList の部分がクラスに付与された名前(クラス名)となります。

では、このクラスを使う側の処理を考えてみます。クラスを使うことで ref キーワードを排除することができます。

クラスを使う

new 演算子

クラスを定義(宣言)しただけでは使えません。

クラスとは設計図であり、クラスを定義(宣言)しただけでは、メモリ上にクラスの実体がないからです。

メモリ上にクラスの実体を確保するには new 演算子を使います。

new 演算子を使ってクラス IntList を元に、メモリ上に実態を確保するコードはこうなります。


IntList hoge;
hoge = new IntList();

 

細かく見ていくと、まず IntList hoge により、hoge と言う名前の変数(データを入れるための箱)が確保されます。この箱は参照型なので、メモリ上の座標(アドレス)が入ります。

次に new [クラス名]() とすることで、クラスの実体をメモリ上に確保します。new 演算子はメモリ上に確保したクラスの実体の座標(アドレス)を返します。

この座標(アドレス)が変数 hoge に代入されるのです。

ここで、メモリ上に確保したクラスの実体を IntList クラスの「インスタンス」と言います。

インスタンスにアクセスする

既に述べましたが、クラスはインスタンスの設計図です。

IntList クラスのインスタンスは IntList クラスを定義した際の変数 array と変数 length を持っています。

これらの変数にアクセスするには [クラス型の変数名].[クラスで定義(宣言)した変数名] とします。

new 演算子でインスタンスを宣言した直後の段階では、array には「指す先が無い」ことを表す null が、length には 0 が代入されています。

このインスタンスを利用するにあたり length は 0 のままで良いですが、array の方には実体のある配列を代入して初期化してあげる必要があります。

IntList クラスのインスタンスが持っている array を要素数が 0 の配列で初期化するコードはこうなります。


hoge.array = new int[0];

 

IntList クラスのインスタンスを使う準備が整いました。

このインスタンスを使って ref キーワードを使ったプログラムを書き直してみます。

ref キーワードを排除する

IntList クラスを利用したプログラムはこのようになります。

インスタンスが持つ各変数にアクセスするには、[クラス型の変数名].[クラスで定義(宣言)した変数名] とすることを意識しながら読んでください。


using System;

class Program
{
  static void Main(string[] args)
  {
    IntList hoge = new IntList();
    hoge.array = new int[0];
    Console.WriteLine("好きな数だけ数値を入力してください。");
    while (true)
    {
      string str = Console.ReadLine();
      if (str == "")
        break;

      Add(hoge, int.Parse(str));
    }

    int sum = 0;
    for (int i = 0; i < hoge.length; i++)
    {
      sum += hoge.array[i];
    }

    Console.WriteLine("合計は {0} になりました。", sum);
  }

  static void Add(IntList intList, int a)
  {
    if (intList.length < intList.array.Length)
    {
      // 物理的な要素数が足りている場合は新規の要素を追加するだけで良い。
      intList.array[intList.length] = a;
      intList.length++;
    }
    else
    {
      // 物理的な要素数が足りない場合は配列を拡張する。

      int[] newArray;
      if (intList.array.Length == 0)
      {
        // 配列の要素が 0 の場合は要素数が 1 の配列を作成する。
        newArray = new int[1];
      }
      else
      {
        // 既にある配列の倍の要素を持つ配列を作成する。
        newArray = new int[intList.array.Length * 2];
      }

      // 既にある要素を複製する。
      for (int i = 0; i < intList.array.Length; i++)
      {
        newArray[i] = intList.array[i];
      }

      // 新規の要素を追加する。
      newArray[intList.length] = a;
      intList.array = newArray;
      intList.length++;
    }
  }
}

class IntList
{
  public int[] array;
  public int length;
}

 

ここで、ものすごく重要な点があります。赤字の部分に注目してください。Add() 関数の戻り値が消滅しています。

クラスを利用したことにより Main() 関数側で length 変数を管理する必要が無くなりましたが、更にその副次的作用として Add() 関数は戻り値を返す必要が無くなりました。

この理由は、参照型の性質を考えれば分かります。

もし分からない方はコメントをください。その際は、手書きで図を付けます。

新たな問題点

ここで、また別の問題が発生していますが、次回以降で解決していきます。

  • Add() 関数の中で変数にアクセスする際に intList. を前に付けなければならないのは面倒です。
    これは this 参照(this ポインタ)を使うことで解決します。
  • hoge.array を Main() 関数の側で初期化しなければならないのは面倒です。
    これはコンストラクタを使うことで解決します。
  • 依然として foreach 文が使えません。
    これは列挙子を使うことで解決します。

今回は以上です。

次回は、カプセル化について解説します。

 


管理人が学習に利用した書籍

管理人が学習に利用した書籍を紹介させていただきます。

私がお勧めしたいのは独習C#第3版です。独習C#新版と言うのもありますが、第3版とは異なる著者が書いた本です。

Amazon さん

楽天さん

 

-C#, 応用

Copyright© 開発方法論萌え , 2020 All Rights Reserved Powered by STINGER.