課題の進め方

基本的には適当なプロジェクト/ソリューションを作って課題を進めた上で,Program.csのみ(特に他に指示がなければ)を提出する.

他の人(受講者・非受講者両方)に解答内容(一部でも)を見せない,そして他の人の解答内容(一部でも)を見ないようお願いします.特に,公開の場所に解答を置かないようお願いします.githubやbitbucket等は使える人は使えばよいと思いますが,privateレポジトリにするようにお願いします.

プロジェクト/ソリューションの作成

Note

再掲:プロジェクトは一つの実行形式やライブラリを作成するためのコード等を全てまとめたものであり,ソリューションは関連するプロジェクトをまとめたもの(参考:What are solutions and projects in Visual Studio).

適当な名前(たとえば課題3の解答なのでQ3にするなど)のフォルダを適当な場所に作成し, VSCodeで作成したフォルダを開く.そして,VSCode内のターミナルで以下を実行する.

dotnet new console -o .

課題の実施

Program.csを問題文の指示の通りに編集する(課題によっては他のファイルも.ただし提出・採点手続きの簡略化のため提出する.csファイルはProgram.csのみ).作成した.csファイルには先頭部分に学籍番号と名前をコメントとして含めること.なので,たとえば学籍番号Z0TB9999の東北 大学さんの提出ファイルは

// Z0TB9999
// 東北 大学

という行から始まる.

提出

できあがった Program.csをClassroom内の当該回の「課題」より提出する.最初のステップで作成したフォルダにあるはず.また問題文に指示がある場合はそのファイル(例:課題4のitems.txt)も提出する.提出前には以下を確認しよう.

    • ただし,友人の解答を見ない(一部でも),そして友人に解答を見せない(一部でも)ようお願いします

基本課題

その1

品目と個数の組をファイルから読みとり,それを個数が降順(大きい順)になるように並び変えたものを出力したい.すなわち,中身が

Chocolate,2
Chips,4
Candy,5

であるようなファイルを読んで,

Candy,5
Chips,4
Chocolate,2

ような出力を行いたい.

そのために,一旦Dictionary<string, int>を作成し,その各要素をバリューが降順になるようなキーの順で標準出力に出力することを考える.後者の処理については,この課題のスコープではないのでこちらで用意した以下を使うものとする.

// DictionaryHelper.cs
// Program.csと同じディレクトリに置く
// このファイルの中身は変更しない.また,提出物に含めない

using System;
using System.Collections.Generic;

// staticクラスはインスタンスを持たないようなクラス…ただの名前空間の切り分け
static class DictionaryHelper
{
    // KとVでパラメタ化されたジェネリックメソッド
    //
    // 辞書の中身をバリューの降順で表示する.実装の詳細は説明しない.
    public static void PrintByDescendingValues<K, V>(Dictionary<K, V> dict)
        // 直感的にはKやVは順序比較可能であることを制約している
        where K : IComparable // キーに対する CompareTo のため
        where V : IComparable // バリューに対する CompareTo のため
    {
        // LINQの提供するextension methodを使うともうすこし簡潔に書ける
        var sortedKeys = new List<K>(dict.Keys);

        // sortedKeysを実際にソートする
        // 比較関数として匿名関数式を利用.匿名関数式については次回少し触れる
        sortedKeys.Sort((k1, k2) =>
        {
            int res = dict[k2].CompareTo(dict[k1]);
            // 個数が同じ場合は品目名の順
            if (res == 0) { return k1.CompareTo(k2); }
            return res;
        });

        // ソート済みのキーの順に辞書の中身を表示
        foreach (var k in sortedKeys)
        {
            Console.WriteLine("{0}, {1}", k, dict[k]);
        }
    }
}

前者の処理を実現するため,以下のプログラムの空欄を埋めて完成させよ.

// あなたの学籍番号
// あなたの名前

using System;
using System.IO; 
using System.Collections.Generic;

class Program
{
    static Dictionary<string, int> readDictionary(IEnumerable<string> lines) 
    {
        Dictionary<string, int> dict = new Dictionary<string, int>(); 
        // 実装する.
        //
        // linesには入力ファイルの各行が格納されている.
        // linesの各要素から品目名と個数の対応を読みとり,ディクショナリ dict に追加する.
        // 各行の詳細なフォーマットについては後述

        // 以下のようにlinesについてはforeachが使える.
        // linesについてこのメソッドで行うことはこれで十分.
        //
        // 実装方針によっては,この処理の前後に何か書くかもしれないし,
        // この処理を何かで囲むこともあるかもしれないし,あるいはそのまま使うかもしれない.
        foreach(var line in lines) {
            // ...
        }

        return dict; 
    }

    static void procFile(string filepath)
    {
        // 当然,filepathが開けなければ例外が発生するが *その1では*無視
        var lines = File.ReadAllLines(filepath);        
        DictionaryHelper.PrintByDescendingValues(readDictionary(lines));
    }

    // 変更しない
    static void Main(string[] args)
    {
        if (args.Length < 1) {
            Console.WriteLine("引数の数が不足しています:ファイルパスが必要です.");
            return;
        }
        procFile( args[0] );
    }
}

動作確認例

  1. まず,入力ファイルを準備する(items.txtという名前だとする).VSCodeで以下の中身のitems.txt.csprojと同じフォルダに作成する.

    Chocolate,2
    Chips,0
    Candy,5

    コマンドラインに慣れている人はq4_items1.txtcurlか何かで取ってきたのでよい.

  2. .csprojと同じフォルダで以下を行う.$はプロンプトを表し,そこから右がユーザが入力する部分を表す($自体は入力しない).

    $ dotnet run -- items.txt
    Candy, 5
    Chocolate, 2
    Chips, 0

自身がプログラムの動作確認に使用した入力ファイル(上記のitems.txt)も一つ以上提出物に含めること(発展課題も同じ).動作利便性および環境由来のトラブル回避のため,ファイル名の拡張子を除いた部分に使用してよい文字は英語アルファベット小文字,_,数字のみとする.

  • OKな例:q4_items20.txtq4_ex1.txtなど
  • NGな例:Q4.txt課題4.txtなど

入力ファイルの形式

  • 各行が

    品目名,個数

    という形になっている.基本課題においては,品目名,,,および個数の前後に余計な空白は含まれない(発展課題で変更あり)

  • 個数はintで表現可能な数に比べて十分に小さいとする(個数の処理でオーバフローを考える必要はない).簡便のため負数や0は許すことにする

  • 品目名は,および前後の空白は含まない.たとえば(空白文字からなる文字列)やX(空白文字,'X',空白文字からなる文字列)などはここで言う品目名ではない.途中の(非改行)空白は含んでもよい(例:Ruby Chocolate).

  • 基本課題では各行の品目名に重複を許さない.

  • 基本課題では上記の形式でないファイルが与えられたときの挙動は考えなくてよい(上記を満たす入力ファイルについて正しく動けばよい).

Note

上記においてプログラムにコマンドライン引数を渡すためにdotnet runコマンドを利用していたが,同様のことをVSCodeで行うのは少々大変である.

  1. アクティビティーバー(ウィンドウ一番左に表示されるアイコンの並んだバー)の虫のついた再生ボタンをクリックして,サイドバーを「実行とデバッグ」に切り替える.

  2. サイドバー内の「launch.jsonファイルを作成します」(create a launch.json file)の部分がクリック可能なのでクリックする.

  3. その後の「デバッガの選択」ではC#を選ぶ

  4. launch.jsonファイルがエディタで開かれ,カーソルが"configurations":の後の[``]内のところに来ているはず.そこで,coreまで打つといろいろ補完されるので,候補から .NET: Launch Executable File (Console)を選ぶ.

  5. []の内に以下のようなテンプレートが挿入されるので,<target-framework><project-name.dll>を適切に置換える.

    {
        "name": ".NET Core Launch (console)",
        "type": "coreclr",
        "request": "launch",
        "preLaunchTask": "build",
        "program": "${workspaceFolder}/bin/Debug/<target-framework>/<project-name.dll>",
        "args": [],
        "cwd": "${workspaceFolder}",
        "stopAtEntry": false,
        "console": "internalConsole"
    }
    • <target-framework>.csproj内のTargetFrameworkの記述と合わせる.net8.0など.
    • <project-name.dll>は「プロジェクト名.dll」に置換える..csprojファイルのベースがプロジェクト名になっているはずなので,Q4.csprojという名前ならQ4.dllとする.
  6. "preLaunchTask""build"から"dotnet: build"に書き換える.ファイルは以下のようになっているはず(プロジェクト名がQ4の場合).

    {
        "name": ".NET Core Launch (console)",
        "type": "coreclr",
        "request": "launch",
        "preLaunchTask": "dotnet: build",
        "program": "${workspaceFolder}/bin/Debug/net8.0/Q4.dll",
        "args": [],
        "cwd": "${workspaceFolder}",
        "stopAtEntry": false,
        "console": "internalConsole"
    }
  7. "args"にコマンドライン引数をjsonリストとして記述する.たとえば[ "items.txt" ]などとする.これで,"F5"で実行する場合はargsに指定されたコマンドライン引数が実行プログラムに渡されるようになる.ターミナルから実行するのに比べたこの方法の利点は"C# Dev Kit"の提供するデバッグ機能が使用できることである.一方で,異なる引数をプログラムに渡すためには都度launch.jsonの修正が必要.

Caution

この方法でも,エディタで.csファイルを開いたときに右上に表示される実行ボタンではコマンドライン引数が渡されない.ざっと調べた限りではこちらの設定方法が見付からなかったので,もしこちらの設定の変更方法をご存知の方がいらっしゃればお伺いできればと思います.

Tip

個数の部分を処理するには,int.Parse(string)あるいはint.TryParse(string, out int)を利用するとよい.

int.Parse(string)の使い方は以下のプログラムを参考にせよ.

using System; 

// 参考:https://docs.microsoft.com/en-us/dotnet/api/system.int32.parse?view=net-6.0#system-int32-parse(system-string)
class IntParseExample 
{
    static void TestIntParse(string s) 
    {           
        try 
        {
            int n = int.Parse(s); 
            Console.WriteLine(s + " ==> " + n);
        }
        catch(FormatException e)
        {
            Console.WriteLine(s + ": ill-formed");
        }
        catch(OverflowException e)
        {
            Console.WriteLine(s + ": overflow");
        }
        catch(ArgumentNullException e) 
        {
            Console.WriteLine("the input is null");
        }
    }

    static void Main() 
    {
        TestIntParse("1234");
        TestIntParse("-1234");
        TestIntParse("0xbeef");
        TestIntParse("+34");
        TestIntParse("3e10");
        TestIntParse("011"); 
        TestIntParse("1,000");
        TestIntParse(string.Join("", new string[] { "1", "000", "000", "000", "000" }));
        TestIntParse("  42  "); // 半角空白
        TestIntParse(" 42 "); // 全角空白
    }
}

上のコードの出力

1234 ==> 1234
-1234 ==> -1234
0xbeef: ill-formed
+34 ==> 34
3e10: ill-formed
011 ==> 11
1,000: ill-formed
1000000000000: overflow
  42   ==> 42
 42 : ill-formed

int.TryParse(string, out int)の使用例は以下.例外ではなくパースの成否が真偽値として返ってくるのは便利かもしれないが,out修飾子なる本演習の範囲外の概念が出てくる.

using System; 

class IntTryParseExample 
{
    static void TestTryParse(string s) 
    {
        if (int.TryParse(s, out int res))
        {
            Console.WriteLine(s + " ==> " + res);
        }    
        else 
        {
            Console.WriteLine(s + ": TryParse failed.");
        }
        
        // ここもresのスコープ内.以下をコメントアウトしてみよう.
        // Console.WriteLine("res = " + res);
    }
    static void Main() 
    {
        TestTryParse("1234");
        TestTryParse("-1234");
        TestTryParse("0xbeef");
        TestTryParse("+34");
        TestTryParse("3e10");
        TestTryParse("011"); 
        TestTryParse("1,000");
        TestTryParse(string.Join("", new string[] { "1", "000", "000", "000", "000" }));
        TestTryParse("  42  "); // 半角空白
        TestTryParse(" 42 "); // 全角空白
    }
}

上のコードの出力

1234 ==> 1234
-1234 ==> -1234
0xbeef: TryParse failed.
+34 ==> 34
3e10: TryParse failed.
011 ==> 11
1,000: TryParse failed.
1000000000000: TryParse failed.
  42   ==> 42
 42 : TryParse failed.

その2

基本課題その1で作成したプログラムのprocFileを変更し,ファイルのオープンおよびファイルからの読取時に発生した例外を適切に捕捉し,ファイルが正常に開けなかった旨を標準出力に通知するようにせよ.少なくとも以下は捕捉するようにする.

  • ArgumentException
  • UnauthorizedAccessException
  • IOException

Tip

File.ReadAllLines()で発生する可能性のある例外を捕捉する

動作確認の例

以下のようなパスを与えてみるとよい

  • 空文字列

    • Powershellだとdotnet run -- '""'のようにする必要がある.bash/zshならdotnet run -- ""でよい.
  • 存在しないファイルへのパス

  • 存在しないフォルダ以下のファイルへのパス

  • 読み取り権限をはずしたファイルへのパス.作り方は以下.

    1. とりあえず適当な内容のファイル(unreadable.txtとする)を作る.以下はターミナルで行っているが想定しているが,どの方法を使ってもよい.

      echo "" > unreadable.txt
    2. 作成したファイルの読み取り権限を削除する.以下のいずれかを実行する.

      • Macの場合

        chmod a-r unreadable.txt
      • Windowsの場合(コマンドラインから)

        icacls.exe unreadable.txt /deny ${env:username}:R
      • Windowsの場合(エクスプローラーから)

        unreadable.txtを右クリックし「プロパティ」を選択.その後,「セキュリティ」タブにおいて,「編集」をクリックして出てくるウィンドウにおいて,自身のユーザをクリックし,「アクセス許可」欄の「読み取り」の「拒否」にチェックを入れて,「OK」を押してウィンドウを閉じる.その後,さらに「OK」を選択して「プロパティ」ウィンドウを閉じる.

        Tip

        ターミナルからエクスプローラーで現在のフォルダを開くには以下のコマンドを実行する.

        explorer.exe . 
    3. 無事読み取り権限が削除できたので,そのパスを作成したプログラムに渡してみる.

      dotnet run -- unreadable.txt
    4. unreadable.txtはVSCodeからは消せないかもしれない(Windows環境で確認.Macだと消せた).そのときはrmコマンドを試みる.

      rm unreadable.txt
  • (Windows) "???.txt" などのファイル名として無効な文字を含むパス

  • 次のような長いファイル名を含むパス

    looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong.txt
    • MacだとPathTooLongExceptionが,WindowsだとIOExceptionが発生するはず

発展課題

Important

本課題を完了できたのならば本課題の解答のみを提出すればよく,基本課題の解答は提出する必要はない.

基本課題で作成したreadDictionaryに以下の二つの拡張を行え.

  • 引数として渡すテキストファイルに同じ品目が数含まれていた場合にそれらの個数を合計するようにせよ.

    Tip

    ディクショナリにキーが含まれているかどうかを確認するにはContainsKey(TKey)メソッドを用いることができる.たとえば式

    dict.ContainsKey( k ) 

    の評価結果はkdictに含まれていればtrue,そうでなければfalseである.

  • 入力テキストファイルにコメントや余計な空白,空行が入っていても正常に読みこめるようにせよ.品目名は空白で始まったり終わったりはしなかったことを思い出してほしい(途中に空白を含んでいてもよい).ここで,"#"で始まる行をコメントとする("#"が行頭でない場合はコメントでないのに注意).また,空行は空白を含んでいてもよい.また,ある行において指定されたフォーマットで解釈することに失敗した場合には,readDictionaryはその行以前までの行を読み取った結果から得られる辞書を返すようにする.ここで,指定されたフォーマットで解釈できない行とは,たとえば以下のようなものである.

    • コメント行ではない行で,を含まない,あるいは2個以上含む行.たとえば:

         #コメントでなくコンマを含まない行
      Chocolate , 12, Candy , 34 
    • コメント行ではない行で,を厳密に1つ含んでいるが,,の右側に整数以外が来ている.たとえば:

      Candy, Three

      ここで整数以外とはint.Parse()が失敗するものとする.

    Tip

    空行かどうかの判定にはstring.IsNullOrWhiteSpace(string)が利用できる.あるいはTrim()してからLengthが0かどうかチェックしてもよい.

期待される動作例

入力テキストファイルとその中身 期待される出力

q4_items2.txt

Chocolate,2
Chips,14
Candy,5
Chocolate,40
Chips,-14
Chocolate, 42
Candy, 5
Chips, 0

q4_items3.txt

#コメント行
Chocolate,  2
   Chips ,14  

Candy,5
Chocolate,40
Chocolate, 42
Chips, 14
Candy, 5

q4_items4.txt

# コメント行
Chocolate,  2
   Chips ,14  
# 空行は空白を含んでいてもよい
      
Candy,5
Chocolate,40
      #コメントでない行
Candy,25
Chocolate, 42
Chips, 14
Candy, 5

q4_items5.txt

# 品目名は空白を含んでいてよい
Ruby Chocolate, 6
# 品目名が数字なのは許される
100 , 5
# 品目名に#は許される
 ####  , 9
 # なのでこれも正しく処理できる行, 3
# 個数の部分にはInt32.Parse()が失敗するようなものは許さない
something,ten
####, 9
Ruby Chocolate, 6
100, 5
# なのでこれも正しく処理できる行, 3