見出し画像

コンピュータサイエンス概論 #5(6週目)

[2023/02/07] 参照渡しに関してconst参照の話を追記

年末年始はクリスマス前に風邪を引いて(流行病ではない)ぐったりしてました。

5週目はMidterm(中間テスト)で復習とテストだったようです。アメリカでは多くの授業で真ん中の週でMidtermを行いますが、行わないコースもあるし、6週目や7週目でやった上でFinal(期末テスト)の代わりに課題を出すコースもあります。FinalはFinal weekとして10週目が終わった後に通常と違う時間や場合によっては違う部屋(カンニング防止?)で予定されたりします。

4週目では関数をメインで学習しました。

5週目実装課題

5週目と6週目で2週に渡って課題があります。

問題

旅費交通費の報告書の生成プログラムを作ります。
社員に払われる経費の総額と実際の交通費の総額を計算するプログラムで、下記の入力を対話式で受け取ります。

  • 旅行日数

  • 出発日の出発時刻と到着日の到着時刻

  • 航空券の額

  • 会議・セミナー参加費

  • レンタカー・タクシー利用

    • レンタカーを借りたか、タクシーに乗ったか

      • レンタカーの場合、車の種類、レンタル代金、ガス代、走行距離、駐車料金

      • タクシーの場合いくらかかったか

  • 旅費と精算の合計額は、以下のように計算されます。

    • 航空運賃と登録料の両方が払い戻し可能

    • レンタカーを利用した場合、車種ごとの精算上限額と1マイルあたりのガソリン代は以下の表の通り

レンタカー精算表
  • レンタカーの料金の費用合計はレンタル代金合計+ガス代合計

  • 精算額上限はレンタル上限×旅行日数+ガス代上限×走行距離

  • レンタカーの場合、一日800円まで駐車料金の精算が可能

  • タクシーは1日1300円まで許可されます

  • 宿泊費は一晩12000円まで許可されます

  • 初日の食事は出発時間に応じて清算が許可されます

    • 朝食:7時より前

    • 昼食:12時より前

    • 夕食:18時より前

  • 最終日の食事は到着時間に応じて清算が許可されます

    • 朝食:8時より後

    • 昼食:13時より後

    • 夕食:19時より後

  • 食費は朝食900円、昼食1200円、夕食1600円まで清算可能です

プログラム実行例

旅行日数を入力してください: abb
不正な入力です。再入力してください: 3
初日の出発時間を入力してください: 8.00
最終日の到着時間を入力してください: 17.30
航空費を入力してください: 58600
カンファレンスまたはセミナーの料金を入力してください: 3000
移動はレンタカーですか、タクシーですか 1: レンタカー 2: タクシー: 1
レンタカーの合計料金を入力してください: 15000
ガス代を入力してください: 3333
1. セダン
2. SUV
3. バン
4. オープンカー
レンタカーの車種を入力してください: 2
合計の走行距離を入力してください: 130
1日目の駐車代を入力してください: 360
2日目の駐車代を入力してください: 120
3日目の駐車代を入力してください: 580
1日目の宿泊費を入力してください: 11000
2日目の宿泊費を入力してください: 8860

1日目に関して
1日目の朝食の精算はできません
昼食代を入力してください: 1210
夕食代を入力してください: 1860
2日目に関して
朝食代を入力してください: 1080
昼食代を入力してください: 850
夕食代を入力してください: 1218
3日目に関して
朝食代を入力してください: 440
昼食代を入力してください: 1680
3日目の夕食の精算はできません
出費の合計は: XXXXXX円です
精算可能額は: XXXXX円です
別のレポートを作成しますか? (0-いいえ or 1-はい)?: 0

課題内容

5週目の宿題は下記です

  • 問題の分析

    • ユーザーの入力/要求およびプログラムの出力は何か

    • どんな想定をしているか

    • プログラムを分解した場合のタスクとサブタスクは何か

  • プログラムのデザイン

    • プログラムの概要

      • 必要なデータとユーザーの入力タイミング

      • プログラムはどんな決定をする必要があるか

      • タスクは繰り返されるか

      • どうやってプログラムをモジュール化するか、関数はいくつ必要か、それは何か

    • どんな不正な入力が想定されるか

  • テストケース

    • 正常値

    • 異常値

    • 境界線

実装は6週(このポストの最後)になります

Reading課題

授業までに下記MITの関数に関してのテキストを読むことが求められます。日本の大学だとなんもせずに授業出て授業でノート取ってテスト前に読み返す感じの人が多い気がします(真面目な人は授業の日に復習も?)が、アメリカの授業では基本的に予習が大事です。

予習で学ぶところを先に"自分で"読んで学びます。皆が天才じゃないので当然理解しきれない部分があります。それを授業で確認して、授業でもわからなければそこで質問する。そして、もやっとかも知れませんがほぼ理解した状態で応用としての宿題で知識を使える武器として定着させる。そういうスタイルです。授業が既に復習に近い状況なので、理解は違うと思います。独学の習慣も自分で考える習慣も身に付きます。子育てする際は絶対このスタイルでした方が良いと思います。

このpdfではプレーンに書いたコードをコピペで関数に抜き出して、良い抜き出し方、悪い抜き出し方の例があったり、なぜ関数を定義するか(関数なしで処理を並べるより、その処理の塊が何をしているか関数名で表現した方が可読性が高いこととか、抜き出すことによるメンテナンス性とか)、シンタックスの話、オーバーロードとか再帰、グローバル変数、値渡し・参照渡しの話なんかもあるので初学者には良いかもです。

前期振り返り

前回・前々回の宿題を2人組でお互いコードをレビューし合うセッションがあったみたいです。他人のコードを見ること、指摘をすること、話し合うこと、一人でやることと思われがちのプログラミングですが、将来的にはというか、ほとんどの場合チームで行うわけなので、他人からどう見えるか、他人はどう考えるかを見ること、そこからの気づきは非常に大事です。

AtCoderとかでも、ただ問題を解くだけでなく、同じ言語を使ってる他の人のコードを読んで読み解く(リバースエンジニアリングする)のもかなり勉強になります。ただ、AtCoderのC++とかは結構癖の強い人が多いのですごい人が、短く書ける人が良いコードを書いているわけではないこともわかると思います。可読性とパフォーマンスはある程度のトレードオフはありますが、基本的に変数や関数の命名で両立できると思います。タイムアタックなのでそこまでできない感はあるものの。

人のコードを読めるようになるのと同時に当たり前ですが、コードのどこ部分がどういうことをしているか、なぜその順で書いて、なんでそれが必要か、できる限り説明できるようにしましょう(初心者以外は説明できないといけない。上級者を名乗るならライブラリの読んでいる関数内で何が起きてるかも説明できて欲しい)。

論理演算

  • binary演算(二項演算: a && bのように2つの値(オペランド)に対して処理を行う)

    • And/Orなど

  • Unary演算(単項演算: 一つの値を取って処理を行う)

    • Not(!flag)など

Switchの振り返り

  • switch文はstate machineに使える

    • state machineとは、ある状態から他の状態に遷移しつつ、状態に応じた処理を行うような仕組みを備えたもの

switch (x) {
  case 0:
    cout << "zero" << endl;
    break; // ①
  case 1:
    cout << "one" << endl;
    break;
  default:  // ②
    cout << "Default" << endl;
}

① loopとswitchではbreakでスコープ(一番近くの{})を抜けれる
② switchは全てのstate(状態)を網羅する必要があるので、全て書くか最後にdefaultでそれ以外をまとめる

  • breakを省略することでorにできる

switch (x) {
  case 0: // このままcase1に落ちる
  case 1:
    cout << "zero or one" << endl;
  case 2:
    cout << "two" << endl;
  default:
    cout << "Default" <<endl;
}

複数選択肢

if (x % 15 == 0) {
  cout << "FuzzBuzz" << endl;
} else if (x % 3 == 0) {
  cout << "Fuzz" << endl;
} else if (X % 5 == 0) {
  cout << "Buzz" << endl;
} else {
  cout << x << endl;
}

ifで最初の条件、else if でそれ以降の条件、残り全てを対象にelseを書きますね。ちなみに僕はFuzzBuzzはこの書き方はしませんので悪しからず。

for

例えば素数の判定で単純な方法は2からルートnまで繰り返し割ってみることです(列挙とかならエラトステネスの篩を使う)。あまり数学に関わりがない人は意識せずn-1までループするかも知れませんが、ルートnまでで十分です。

例えば2で割れる場合、対になるルートnより大きい数値があるはずです。20の約数は2, 4, 5, 10で2と10が対に4と5が対になっています。ルートnまでに割り切れないのにルートnより大きい数で割れるとしたらルートn以下の対になる数がないといけないので矛盾となり、ルートnまで確認すればよいということになります。25は2,3,4で割れず√25の5で割れます。2つの数で割るというのはそういうことです。

for (int i = 2; i <= sqrt(n); ++i) {
  if (n % i == 0) {
    return true;
}
return false;

while

n番目の素数を探してみます。繰り返しますが、普通はエラトステネスの篩を使いますが、例として。

int total = 0;
int i = 2;
while (total < n) {
  ++total;
  for (int j = 2; j <= sqrt(i); ++j) {
    if (i % j == 0) {
      --total;
      break;
    }
  }
  cout << i << ": " << total << endl;
  ++i;
}
cout << i << endl;

とここまで書いたところであれ、違う年の資料見てたぞ、と気付きましたが、まぁ、時間空いたし残しておこう。

Lecture 11/12

はい、やっと本題ですが、今回は関数をさらに深く見ていく内容になります。

グローバル変数は使わない

まずはグローバル変数を使わない、というお作法です。
グローバル変数とは関数やクラス外に定義される変数で、そのリソースにアクセス可能な全てのコードから利用可能です。グローバル変数は固定値なら良いのですが、変更可能(mutable)である場合、どこからでも変更ができる関係上、参照する全ての箇所に影響を及ぼします。カバレージ100%(全ての行、分岐がテストに網羅されている状態)でテストがちゃんと書けていればある程度安心はできますが、不安要素はそもそも使わない方がよいです。

関数ヘッダ

関数のヘッダ、つまりコメント書こうね、という話です。
関数のコメントは主に

  • 関数に関する説明

  • 引数

  • 戻り値

  • 事前条件(満たすべき条件=>ゆるい契約プログラミング)

  • 事後条件(参照渡しの引数や戻り値などについて)

を書くとされており、多くのOSS(オープンソースソフトウェア)でも採用され、そこからドキュメントの自動生成もできたりします。

事前条件に従って処理を限定する手法を契約プログラミングと言ったりします。「この条件の引数に対して正しい結果が返る」、というような感じでとらえてもらえればよいと思います。コメントで書く場合、条件外の引数を渡した場合の挙動は未定義(undefined=何が起きるかわからない)とされます。C++ではC++20で導入予定でしたが延期になりました。契約の厳しさも定義できたり。

事後条件は関数処理後に成り立つべき状態を示します。副作用と言って、グローバル変数の変更や参照渡しの値の変更(呼び出し元の値も変わる)など、関数外への影響も条件の一つでしょうが、基本的に副作用はない方が望ましいです。副作用のない関数を純粋関数と呼び、関数型言語では純粋関数の組み合わせで不変(immutable)な変数を使っていくようなイメージだったりします。不変である場合、メモリ効率が良くなったりするのですが、その辺はまた別の機会に。

デフォルト値

関数にはデフォルト値を与えられる言語もあります。たいていできますが。
デフォルト値を使う場合、引数の数が減ります。つまり、どこのデフォルト値が使われるかわかるように1個減ると最後の引数にデフォルト値が、2つ減ると最後から2つ分デフォルト値が使われる、というような指定が一般的です。pythonとかだと名前付き引数で途中の引数だけ与えるようなこともできたりします。

オーバーロード

プログラミング言語によっては同じ関数で違う引数の関数があります。そういった関数をオーバーロードと言います。

例えばC++ではabsという関数が cmath ライブラリ内にありますが、複数の数値型で定義されています。

namespace std {
  float abs(float x);             // (1)
  double abs(double x);           // (2)
  long double abs(long double x); // (3)

  double abs(Integral x);     // (4) C++11 から C++14 まで

  int abs(int x);             // (5) C++17 から
  long abs(long int x);       // (6) C++17 から
  long long abs(long long x); // (7) C++17 から
}

これは結構便利だったりするのですが、引数1つの関数を拡張する場合とかに、引数2つの関数を定義して、引数1つの関数からコピペした上で、1つの変数を引数に変えて、引数1つの関数からは引数2つの関数を固定値と一緒に移したりします。これにより、既に呼ばれてる関数はそのままに拡張できます。逆に引数減らす場合も対応できますね。

値渡しと参照渡し

値渡しと参照渡しです。
簡単に言ってしまえば、関数に「値をコピーして渡す」か、「既にある値のメモリ上の場所を渡す」かという違いです。

  • 値渡し

    • 関数が呼ばれる際に引数として値が"コピー"されます

    • 引数に変更があっても呼び出し元に影響がありません

例としては

int add(int a, int b) {
  return a + b;
}

int main() {
  int a = 1;
  int b = 4;
  std::cout << add(a, b) << std::endl;  // 5

  return 0;
}

という場合、mainの中のaとaddの中のaのアドレスは違います。
C++ではアドレスが確認できるので下記のようにしてみます。

#include <iostream>

int add(int a, int b) {
  std::cout << &a << std::endl;
  return a + b;
}

int main() {
  int a = 1;
  int b = 4;
  std::cout << &a << std::endl;
  std::cout << add(a, b) << std::endl;  // 5

  return 0;
}

結果

0x7fff1fd7bff8
0x7fff1fd7bfdc
5
  • 参照渡し

    • 関数が予備れる際に引数として呼び出し元の変数のデータを保持しているメモリ上のアドレスが渡されます

    • 呼び出し元とデータを共有しているので変更は関数終了後に反映されます

int add(int &a, int b) {
  return a + b;
}

int main() {
  int a = 1;
  int b = 4;
  std::cout << add(a, b) << std::endl;  // 5

  return 0;
}

ポインタも渡せますが、参照渡してます。参照はやや制約の厳しいポインタみたいな感じですが、気にしないでください。ポインタだと呼び出し時にポインタとして呼び出さないといけなかったりでわかりにくくなるので…。

同様にアドレス見てみましょうか。

#include <iostream>

int add(int a, int b) {
  std::cout << &a << std::endl;
  return a + b;
}

int main() {
  int a = 1;
  int b = 4;
  std::cout << &a << std::endl;
  std::cout << add(a, b) << std::endl;  // 5

  return 0;
}
0x7fff4f716648
0x7fff4f716648
5

一緒ですね。

C言語時代は入力を前に、出力を後に置く引数の順番で結果を参照に書き込んで成功したかを戻り値にするような関数が多く使われていましたが、参照渡しは想定外に渡した値が変わってしまうような、誰がやったんだと防犯カメラを確認しないといけない(バージョン管理システムのログ確認)系の事件が起こりがちなので不要な時は使わない方が良いです。

C++には単純なポインタや参照渡しだけでなくconst参照というものがあります。こちらは参照によってコピーコストを防ぎながらも副作用を取り除くという効果があります。つまり、同じアドレスの値を見るものの変更はできない、という引数の型の定義です。変更しようとするとコンパイルエラーになります。安全にコストを減らせる方法です。コンストポインタなんてのもできますが、ポインタより参照の方が使い勝手良いです。

#include <iostream>

int add(const int &a, int b) {  // <- ココ
  std::cout << &a << std::endl;
  return a + b;
}

int main() {
  int a = 1;
  int b = 4;
  std::cout << &a << std::endl;
  std::cout << add(a, b) << std::endl;  // 5

  return 0;
}

ただし、アドレスは多くの場合64bitですが、一部の数値型のように64bit以下のデータの場合コピーのコストが参照でのコピーしないメリットを上回ってしまうため、基本的に数値型はコピーで構造体は変更しない場合const参照を使う場合が多いです。本来上の例は良くないですが、定義の例として。

そのように、引数を決める時なんかはメモリの使い方からのコスト意識も身に付けると良いと思います。プログラミングスクールの自分でテストするだけのスタンドアローンのコードとかでは問題は起きませんが、これが万単位、十万、百万単位のアクセスになるとちりつもで火を噴きます。どこかのプログラミング学習商材を売りまくってた学生エンジニアが作った商材ページが即パンクしたみたいに。

Javaなどpythonなど一部の言語ではクラスは自動的に参照渡しになったりするので、関数内のインスタンス変数の変更は気を付けて行いましょう。

ポインタと参照の練習

int **p2, *p1, x = 20;

と宣言したポインタでp2がp1のポインタとなるように、p1がxのポインタとなるように、つまり p2 -> p1 -> xという参照関係ができるように定義してみましょう、という練習問題。

課題

前回の問題をプログラミングしましょう。下記がポイントになります。

  • 指定通りちゃんとプログラムが動くこと

  • インターフェース(シグニチャのみの宣言)を利用すること(C++)

  • ポインタか参照を利用すること

  • タスク、サブタスクを適切な粒度で分割すること

    • 関数は一つの目的だけを持つこと

    • コメント等を除く実行行が15~20を超えないこと

    • 20行以上になる場合、正当な理由をコメントで記述すること

    • 1行に2つのステートメントを書かないこと(int x = 10; x += 4;を一行に書くようなのはNG)

  • エラーチェック

    • 不適切な入力があったらもう一度質問すること

    • 料金や走行距離にマイナスの値は認めない

    • 旅程数で1より小さい数値を認めない

    • 出発・到着時刻が適切であること(順番やフォーマット)

  • グローバル変数は固定値のみを使ってよい

  • gotoを使わないこと

  • スタイルガイドを守ること

課題は本エントリー最初の5週目課題を参照。

この記事が気に入ったらサポートをしてみませんか?