C#の電話帳アプリで学ぶDictionaryとファイル読み込み|初学者向けリファクタリング入門

広告

Windows Formsで「名前を選ぶと電話番号が表示される」小さな電話帳アプリを題材に、Dictionary、ファイル読み込み、イベント処理を学びます。後半では、元のコードを少しずつ安全で読みやすい形へリファクタリングします。

data.txtからDictionaryへ電話帳データを読み込み、名前の選択から電話番号表示までを示す図
電話帳アプリのデータの流れ

このアプリでできること

data.txt に保存された名前と電話番号を読み込み、画面の一覧に名前を表示します。利用者が名前を選ぶと、その人の電話番号が表示されます。

データは次の順番で移動します。

  1. data.txt に「名前,電話番号」を保存する
  2. StreamReader が1行ずつ読む
  3. Dictionary に名前と電話番号を登録する
  4. 名前をリストに表示する
  5. 選ばれた名前を使って電話番号を検索する

事前に用意するdata.txt

山田,090-1234-5678
佐藤,080-9876-5432
鈴木,070-1111-2222

1行が1人分のデータです。カンマの左側が名前、右側が電話番号です。このように区切り文字で値を並べる形式はCSVに似ています。ただし、今回の単純な Split(',') では、値そのものにカンマを含む本格的なCSVは正しく扱えません。

元コードの全体像

public partial class Form1 : Form
{
    Dictionary<string, string> phoneBook;

    public Form1()
    {
        InitializeComponent();

        phoneBook = new Dictionary<string, string>();
        ReadFromFile();

        foreach (KeyValuePair<string, string> data in phoneBook)
        {
            nameList.Items.Add(data.Key);
        }
    }

    private void ReadFromFile()
    {
        using (System.IO.StreamReader file =
            new System.IO.StreamReader(@"..\..\data.txt"))
        {
            while (!file.EndOfStream)
            {
                string line = file.ReadLine();
                string[] data = line.Split(',');
                phoneBook.Add(data[0], data[1]);
            }
        }
    }

    private void NameSelected(object sender, EventArgs e)
    {
        string name = nameList.Text;
        phoneNumber.Text = phoneBook[name];
    }
}

1. Dictionaryは「キーから値を探す」入れ物

Dictionary<string, string> phoneBook;

Dictionary<TKey, TValue> は、キーと値を組み合わせて保存するコレクションです。この電話帳では次の対応になります。

Dictionaryの役割このアプリのデータ
キー TKey名前
TValue電話番号

たとえば、次のコードは「山田」というキーに電話番号を登録します。

phoneBook.Add("山田", "090-1234-5678");

登録後は、キーを角括弧に書いて値を取得できます。

string number = phoneBook["山田"];

配列が numbers[0] のように番号で要素を探すのに対して、Dictionaryは phoneBook["山田"] のように意味のあるキーで探せるのが特徴です。

Dictionaryのキーは重複できません。同じ名前を Add で2回登録すると例外が発生します。現実の電話帳では同姓同名があり得るため、本格的な設計では名前以外の一意なIDをキーにする方法も検討します。

2. コンストラクターで行っていること

public Form1()
{
    InitializeComponent();
    phoneBook = new Dictionary<string, string>();
    ReadFromFile();

    foreach (KeyValuePair<string, string> data in phoneBook)
    {
        nameList.Items.Add(data.Key);
    }
}

コンストラクターは、Form1 が作られるときに実行されます。

  • InitializeComponent() が画面上の部品を準備する
  • new Dictionary<string, string>() が空の電話帳を作る
  • ReadFromFile() がファイルの内容を電話帳へ入れる
  • foreach が各キー、つまり名前をリストへ追加する

この順番は重要です。画面部品を準備する前に nameList を操作したり、Dictionaryを作る前にデータを追加したりすることはできません。

KeyValuePair<string, string> はDictionary内の1組のデータを表します。data.Key が名前、data.Value が電話番号です。ここでは名前だけを一覧に出すため、data.Key を使っています。

3. usingでファイルを安全に閉じる

using (StreamReader file = new StreamReader(@"..\..\data.txt"))
{
    // ファイルを読む処理
}

ファイルを開いたままにすると、別の処理がそのファイルを使えないことがあります。using のブロックを抜けると、途中でエラーが起きた場合も含めて StreamReader が後片付けされ、ファイルが閉じられます。

while (!file.EndOfStream)
{
    string line = file.ReadLine();
}

EndOfStream はファイルの終端に着いたかを表します。終端でない間、ReadLine() で1行ずつ読みます。

string[] data = line.Split(',');
phoneBook.Add(data[0], data[1]);

Split(',') は文字列をカンマで分割します。山田,090-1234-5678 なら、data[0]山田data[1]090-1234-5678 です。

4. イベント処理で選択に反応する

private void NameSelected(object sender, EventArgs e)
{
    string name = nameList.Text;
    phoneNumber.Text = phoneBook[name];
}

Windows Formsはイベント駆動で動きます。プログラムが上から下まで一度実行されて終わるのではなく、「利用者が名前を選んだ」というイベントが起きたときに NameSelected が呼ばれます。

処理は次の2段階です。

  1. nameList.Text から選択された名前を取得する
  2. その名前をキーとしてDictionaryを検索し、電話番号を表示する

Visual Studioのプロパティ画面で、ListBoxなどの SelectedIndexChanged イベントと NameSelected が接続されている必要があります。

元コードで起こり得る問題

教材としては分かりやすいコードですが、入力ファイルがいつも正しいとは限りません。

状況元コードで起こること
ファイルが存在しないFileNotFoundException
空行がある配列の要素不足などでエラー
カンマがないdata[1] でエラー
同じ名前が2行あるAdd でエラー
一覧で何も選ばれていない存在しないキーを検索する可能性
実行場所が変わる..\..\data.txt が別の場所を指す可能性

ここで大切なのは、「例外をすべて消す」ことではありません。想定できる入力ミスには対処し、対処できない問題は利用者に分かる形で伝えることです。

リファクタリングとは

リファクタリングは、外から見た動作を変えずにコードの内部構造を改善することです。単に短くする作業ではありません。

  • 名前から役割が分かる
  • 1つのメソッドが1つの仕事を担当する
  • 不正なデータで簡単に停止しない
  • 後から変更しやすい
  • テストしやすい

次の改善は、小さな段階に分けると理解しやすくなります。

改善1:フィールドを宣言と同時に初期化する

private readonly Dictionary<string, string> phoneBook
    = new Dictionary<string, string>();

private は、このフィールドを Form1 の外から直接使わせないことを表します。readonly は、コンストラクター後に別のDictionaryへ入れ替えないことを表します。中の要素を追加・削除することはできます。

宣言と初期化が同じ場所にあるため、「使う前に new し忘れる」危険も減ります。

改善2:TryGetValueで安全に検索する

private void NameSelected(object sender, EventArgs e)
{
    string selectedName = nameList.SelectedItem?.ToString();

    if (selectedName is null)
    {
        phoneNumber.Text = string.Empty;
        return;
    }

    if (phoneBook.TryGetValue(selectedName, out string number))
    {
        phoneNumber.Text = number;
    }
    else
    {
        phoneNumber.Text = "登録されていません";
    }
}

phoneBook[name] は、キーがなければ例外を投げます。TryGetValue は検索に成功したかを bool で返し、成功した場合だけ値を受け取れます。

「失敗する可能性が普通にある検索」には、Try... という名前のメソッドがよく使われます。

改善3:1行を解析する処理を分ける

ファイルを読む処理と、1行が正しいか判断する処理は別の仕事です。分けると読みやすく、解析部分だけをテストできます。

private static bool TryParseEntry(
    string line,
    out string name,
    out string phoneNumber)
{
    name = string.Empty;
    phoneNumber = string.Empty;

    if (string.IsNullOrWhiteSpace(line))
    {
        return false;
    }

    string[] parts = line.Split(',', 2);
    if (parts.Length != 2)
    {
        return false;
    }

    name = parts[0].Trim();
    phoneNumber = parts[1].Trim();

    return name.Length > 0 && phoneNumber.Length > 0;
}

Split(',', 2)2 は、最大2個に分ける指定です。Trim() は前後の余分な空白を取り除きます。戻り値が false なら、その行は登録に使えません。

改善4:ファイルパスを明確にする

string filePath = Path.Combine(AppContext.BaseDirectory, "data.txt");

@"..\..\data.txt" は、実行方法やプロジェクト設定によって参照先が変わりやすい書き方です。AppContext.BaseDirectory を基準にすると、実行ファイルと同じフォルダーにある data.txt を参照できます。

Visual Studioでは data.txt のプロパティを次のように設定します。

  • ビルド アクション:Content
  • 出力ディレクトリにコピー:新しい場合はコピーする

改善後のコード例

次は、ここまでの改善をまとめた例です。空行や壊れた行は読み飛ばし、重複する名前は後の行で上書きします。

using System;
using System.Collections.Generic;
using System.IO;
using System.Windows.Forms;

namespace PhoneBook
{
    public partial class Form1 : Form
    {
        private readonly Dictionary<string, string> phoneBook
            = new Dictionary<string, string>();

        public Form1()
        {
            InitializeComponent();
            LoadPhoneBook();
            DisplayNames();
        }

        private void LoadPhoneBook()
        {
            string filePath = Path.Combine(
                AppContext.BaseDirectory,
                "data.txt");

            if (!File.Exists(filePath))
            {
                MessageBox.Show(
                    "data.txtが見つかりません。",
                    "読み込みエラー",
                    MessageBoxButtons.OK,
                    MessageBoxIcon.Error);
                return;
            }

            foreach (string line in File.ReadLines(filePath))
            {
                if (!TryParseEntry(line, out string name, out string number))
                {
                    continue;
                }

                phoneBook[name] = number;
            }
        }

        private static bool TryParseEntry(
            string line,
            out string name,
            out string phoneNumber)
        {
            name = string.Empty;
            phoneNumber = string.Empty;

            if (string.IsNullOrWhiteSpace(line))
            {
                return false;
            }

            string[] parts = line.Split(',', 2);
            if (parts.Length != 2)
            {
                return false;
            }

            name = parts[0].Trim();
            phoneNumber = parts[1].Trim();

            return name.Length > 0 && phoneNumber.Length > 0;
        }

        private void DisplayNames()
        {
            nameList.Items.Clear();

            foreach (string name in phoneBook.Keys)
            {
                nameList.Items.Add(name);
            }
        }

        private void NameSelected(object sender, EventArgs e)
        {
            string selectedName = nameList.SelectedItem?.ToString();

            if (selectedName is null)
            {
                phoneNumber.Text = string.Empty;
                return;
            }

            phoneNumber.Text = phoneBook.TryGetValue(
                selectedName,
                out string number)
                    ? number
                    : "登録されていません";
        }
    }
}

新しいコードの役割分担

メソッド担当する仕事
LoadPhoneBookファイルから電話帳を読み込む
TryParseEntry1行を名前と電話番号に分解する
DisplayNames名前を画面の一覧へ表示する
NameSelected選択された名前の電話番号を表示する

メソッド名を順番に読むだけで、アプリの動きが想像しやすくなりました。これが「意図が伝わるコード」の大きな利点です。

さらに一歩:画面を固めない非同期読み込み

データが数件なら同期読み込みで十分です。しかし、ファイルが大きい場合や読み込みに時間がかかる場合、画面が一時的に反応しなくなることがあります。その場合は asyncawait を使えます。

private async Task LoadPhoneBookAsync()
{
    string filePath = Path.Combine(AppContext.BaseDirectory, "data.txt");

    if (!File.Exists(filePath))
    {
        return;
    }

    string[] lines = await File.ReadAllLinesAsync(filePath);

    foreach (string line in lines)
    {
        if (TryParseEntry(line, out string name, out string number))
        {
            phoneBook[name] = number;
        }
    }
}

ただし、コンストラクター自体を async にはできません。Windows Formsでは Form.Load イベントなどから呼び出します。

private async void Form1_Load(object sender, EventArgs e)
{
    await LoadPhoneBookAsync();
    DisplayNames();
}

小さなファイルに非同期処理を加えると、かえって複雑になります。「必要だから使う」ことが大切です。

初学者向け練習問題

理解を深めるには、コードを少しずつ変更して動きを確かめるのが効果的です。まず自分で考えてから「サンプル回答」を開いてください。

練習1:名前を五十音順に並べる

名前の一覧を五十音順に並べてみましょう。ヒントはLINQの OrderBy です。

private void DisplayNames()
{
    nameList.Items.Clear();

    foreach (string name in phoneBook.Keys.OrderBy(name => name))
    {
        nameList.Items.Add(name);
    }
}

OrderBy でキーを並べ替えてからリストへ追加します。ファイルの先頭に using System.Linq; が必要です。

練習2:不正な行数を数える

読み飛ばした不正な行数を数え、読み込み後に表示してみましょう。

private void LoadPhoneBook()
{
    int invalidLineCount = 0;
    string filePath = Path.Combine(AppContext.BaseDirectory, "data.txt");

    foreach (string line in File.ReadLines(filePath))
    {
        if (!TryParseEntry(line, out string name, out string number))
        {
            invalidLineCount++;
            continue;
        }

        phoneBook[name] = number;
    }

    MessageBox.Show($"読み飛ばした行: {invalidLineCount}件");
}

解析に失敗したときだけカウンターを増やします。正常なデータの登録処理とは分けて考えるのがポイントです。

練習3:画面から電話番号を追加する

名前と電話番号を入力し、追加ボタンで電話帳へ登録する機能を作ってみましょう。

private void AddButton_Click(object sender, EventArgs e)
{
    string name = inputName.Text.Trim();
    string number = inputPhoneNumber.Text.Trim();

    if (name.Length == 0 || number.Length == 0)
    {
        MessageBox.Show("名前と電話番号を入力してください。");
        return;
    }

    if (phoneBook.ContainsKey(name))
    {
        MessageBox.Show("同じ名前がすでに登録されています。");
        return;
    }

    phoneBook.Add(name, number);
    nameList.Items.Add(name);

    inputName.Clear();
    inputPhoneNumber.Clear();
}

この例では入力欄を inputNameinputPhoneNumber、追加ボタンのイベントを AddButton_Click としています。実際のコントロール名に合わせて変更してください。

練習4:電話帳をdata.txtへ保存する

画面から追加したデータを data.txt に保存してみましょう。

private void SavePhoneBook()
{
    string filePath = Path.Combine(AppContext.BaseDirectory, "data.txt");

    IEnumerable<string> lines = phoneBook.Select(
        entry => $"{entry.Key},{entry.Value}");

    File.WriteAllLines(filePath, lines);
}

Dictionaryの各要素を「名前,電話番号」の1行へ変換し、File.WriteAllLines でまとめて保存します。System.LinqSystem.Collections.Generic が必要です。

練習5:同姓同名を扱えるようにする

同じ名前の人を複数登録できるデータ構造を考えてみましょう。

private readonly Dictionary<int, Contact> phoneBookById
    = new Dictionary<int, Contact>();

private void AddSampleContacts()
{
    phoneBookById.Add(1, new Contact(1, "佐藤", "090-1111-2222"));
    phoneBookById.Add(2, new Contact(2, "佐藤", "080-3333-4444"));
}

internal class Contact
{
    public int Id { get; }
    public string Name { get; }
    public string PhoneNumber { get; }

    public Contact(int id, string name, string phoneNumber)
    {
        Id = id;
        Name = name;
        PhoneNumber = phoneNumber;
    }
}

名前ではなく重複しないIDをDictionaryのキーにします。2人の「佐藤」を別々の連絡先として登録できます。

まとめ

この電話帳アプリには、C#の大切な考え方がコンパクトに詰まっています。

  • Dictionary はキーと値の対応を保存する
  • StreamReaderFile.ReadLines でファイルを1行ずつ読める
  • using はファイルなどの後片付けを保証する
  • Windows Formsはイベントをきっかけに処理する
  • TryGetValue や入力チェックで予想できるエラーを防げる
  • 処理を役割ごとのメソッドに分けると、読みやすく変更しやすくなる

最初から完璧なコードを書く必要はありません。まず動かし、問題になりそうな点を1つ見つけ、動作を確かめながら小さく改善する。その繰り返しが、リファクタリングとプログラミングの理解を深めます。

訪問数 8 回, 今日の訪問数 8回

広告