コンテンツにスキップ

Week 3 (2022/10/20)

今日やること

  • 文字列
  • 型変換
  • 入出力
  • printf詳細

まずはじめに

  • 宿題の注意
  • 宿題は締切(前回の宿題の場合は、昨日の深夜23:59)を過ぎてもコミットできますが、得点に反映されないので注意してください。
  • 全般的にコメントや質問は歓迎なのでslackにどんどん書いてください。まわりの友人とも是非議論してください。
  • 色々と不都合がありましたら是非教えてください。この講義はコロナになってから始まった講義で、フルオンライン前提で作られています。今年は初の対面開催で細かいところがイマイチかもしれないです。イマイチな点があれば改善したいので是非教えてください。
    • 最後に宿題リンクを配るときに、slackで配るとマック上にリンクを移動させるのが手間?
    • GitHub codespaces(宿題を実行・記入するときのウェブエディタ)はうまく動いていますか?
    • 自動採点はうまく動いていますか?

文字について

それでは文字列を勉強していきましょう。これまで、文字を表示するときは

printf("aaa\n");
のように何気なく文字列を使ってきましたが、"aaa\n"は一体何なのでしょうか?

文字

それではまず文字について学びましょう。 c言語では、文字(英数字)は単なる小さな整数です。以下の例を見てみましょう。

char ch1 = 'a';
printf("ascii: %c, integer: %d\n", ch1, ch1);  // 'a', 97

文字一つは、シングルクオーテーション(')で囲まれた英数字一つです。 ダブルクオーテーション(")ではないので注意しましょう。 これを%cで表示すると、対応する文字が表示されます。 一方で、charは単なる整数型なので、これを%dで表示させると、'a'に割り振られた整数である97が表示されます。

このように、英数字を0から127までの整数に対応させた仕組みをASCIIコードと言います。 これをピッタリ表現するために、char型が用いられます。文字を意味する「char」という単語が8ビット整数の型名になっているのは、ここに由来します。

ここで、'a'と97は全く同じなので、先ほどのch1と以下のch2は全く同じです。

char ch2 = 97;
printf("ascii: %c, integer: %d\n", ch2, ch2);  // 'a', 97
ちなみに、大文字と小文字は違う値なので注意してください。たとえば、'A'65です。 また、英単語はアルファベット順に連続した値が割り振られています。すなわち、'B'66です。

文字列

次に文字列を勉強しましょう。文字列とは、ダブルクオーテーション(")で任意個の文字を囲ったものです。 例えば"hello"といったものです。皆さんはこれまでprintfの引数に文字列のリテラルを渡すことで文字を表示してきましたね。 文字列とは、実際はcharの配列です。

char s[6] = "hello";
ここでは要素数が6であるchar型の配列を準備し、それを文字列リテラルである"hello"で初期化しています。 文字列は、終わりの記号としてNULL文字である'\0'を準備する必要があります。 そのため、helloは5文字でありそれぞれchar一つで表現されますが、最後にもう一つ'\0'のためのcharを用意する必要があり、 結局6つの要素が必要になります。

さて、先ほど'a'は実は97という数字と同じだと言いました。同様にNULL文字である'\0'も実体はただの数字であり、それは0です。 ややこしいですが、覚えておきましょう。

ちなみに、右辺のリテラルを見れば要素数は確定できるため、s[6]6の数字は実は省略できます。 こちらの方が安全で簡単です。この表記で、必要十分な要素数(この場合6)が確保されます。

char s[] = "hello";

文字列がメモリ上でどのように表されているかを図示してみると次のようになります。 注意として、バックスラッシュ(\)はwindows環境では上図のように円記号()で表示されてしまうことがあるので注意してください。

さて、これを様々な形式で表示してみましょう。

printf("%s\n", s);  // hello

for(int i = 0; i < 6; ++i) {
    printf("%c, ", s[i]);
}
printf("\n");  // h, e, l, l, o, , 

for(int i = 0; i < 6; ++i) {
    printf("%d, ", s[i]);
}
printf("\n"); // 104, 101, 108, 108, 111, 0, 

for(int i = 0; i < 6; ++i) {
    printf("%p, ", &s[i]);
}
printf("\n"); // 0x7ffc84551842, 0x7ffc84551843, 0x7ffc84551844, 0x7ffc84551845, 0x7ffc84551846, 0x7ffc84551847,

  • まず、printf中で%sを使うと、文字列そのものを表示できます。 これこで変数として与えられるsは必ず適切な文字列(charの配列であり、最後の一個は\0)でなければならないので注意してください。
  • そして、文字列は単なる配列なので、その要素を逐一表示できます。%cで表示すれば、文字を一つずつ表示できます。
  • 整数だと思って表示すれば、これは単なる整数であることがわかります。
  • そのアドレスを表示してみれば、charは1バイトなので、1ずつずれたアドレスが表示されます。 ここで、NULL文字'\0'は文字として表示すると何も表示さず、その実際の値は0であることに注意しましょう。

ところで、上図を見ていると、文字列は単なる配列なので、次のように「配列の初期化の方式」 で文字を並べても同じだということがわかります。

char s[] = {'h', 'e', 'l', 'l', 'o', '\0'};   // char s[] = "hello"; と同じ
ここで、各文字はシングルクオートで囲まれている点、および最後にNULL文字が必要な点を忘れないようにしておきましょう。

さて、文字列を宣言するときにもし要素数が多すぎるとどうなるでしょうか?

char s2[8] = "hello";  // {'h', 'e', 'l', 'l', 'o', '\0', '\0', '\0'};
すると、後半は'\0'で埋められます。これは、配列の初期化のときに学んだ「要素を指定しないと0詰めされる」のそのままの処理です。 このように、char配列の長さが表現したい文字列より長いことは全く問題ありません。

一方、要素数が少なすぎるとどうなるでしょうか?

char s3[3] = "hello";  // {'h', 'e', 'l'};
この場合、後ろが切り取られてしまいます。この文字列は最後に'\0'が無いため、正しい文字列になっていません。 よって、このようなことをしてはいけません。

配列と文字列

次に、配列と文字列の性質について勉強していきましょう。 まず初期化についてです。char型配列にイコールで文字列リテラルを割り着ける作業は、変数の初期化でのみ有効です。 変数にあらためて代入するときは、イコールは使えません。

char s1[10];
s1 = "abc";  // 出来ない
これは配列一般に言えることで、配列の初期化の表記は初期化のときにしかできません。
int a[3];
a = {1, 2, 3};  // 出来ない
これは、「配列名」のみを書くと、それは配列の先頭要素のアドレスを意味するからです。
int a[3] = {1, 2, 3};
printf("%p %p\n", a, &a[0]); // 同じになる。例えば 0x7fffd6711a90 0x7fffd6711a90
上でわかる通り、配列名そのもの(アドレスを取り出す&演算子をつけていないことに注意)と、 配列の最初の要素のアドレスである&a[0]は同じ値になります。 これはちょっと謎だと思いますよね。 これについて、ポインタを勉強するときに再度確認します。

以上を考えると、文字列に対して==を用いた一致判定もできないことがわかります。

char s1[] = "abc";
char s2[] = "abc";
if (s1 == s2) { ... }  // 想定した挙動にならない。常に偽になる
上記のようにすると、配列の比較ではなく「アドレスの比較」になってしまい、常に偽になります。これは配列全てで同じです
int a[3] = {1, 2, 3};
int b[3] = {1, 2, 3};
if (a == b) { ... } // 想定した挙動にならない。常に偽になる

次に、「型、あるいは配列について、使用しているバイト数」を取得する演算子sizeofを見てみましょう。

printf("%lu\n", sizeof(char)); // 1
printf("%lu\n", sizeof(int));  // 4
このように、sizeofは型を引数にとり、その型の変数一つが使用するバイト数を返します。 ちなみにsizeofの返り値の型はunsigned longです。 ここでは%dで表示しても表示できますが、型が違うよと警告を受けます。 ここでは、longを意味する%l、さらにunsignedを意味する%uを 組み合わせて%luを用いると警告が出ません。

ここでsizeofのもう一つの機能として、配列名を引数にとった場合、その配列が使用するメモリ領域の合計バイト数を返します。

int a[] = {1, 2, 3};
printf("%lu\n", sizeof(a));  // 12
ここでは、4バイトであるint型が3つの配列なので、合計で12バイト使うため、12が返されます。 よって、sizeof(配列名)sizeof(配列の要素の型)で割ることで、配列の長さを得ることができます。
printf("%lu\n", sizeof(a) / sizeof(int));  // 12 / 4 = 3
さて、sizeof(char)は1なので、char型の配列すなわち文字列の場合はsizeof(char)で割らなくても 配列長を求めることが出来ます。文字列の場合、最後の'\0'まで含んだ長さになっていることに注意しましょう。
char s[] = "abc";
printf("%lu\n", sizeof(s));  // 4になる。3ではない

さて、次に、関数の引数に配列をとる方法を紹介します。

int sum_array(int a[], int n) {
    int sum = 0;
    for (int i = 0; i < n; ++i) {
        sum += a[i];
    }
    return sum;
}

int main() {
    int arr[3] = {1, 2, 3};
    printf("%d\n", sum_array(arr, 3));  // 6
}
このように、int a[]という書き方で、関数の引数に配列をとることができます。ここでは、配列の長さを示すnも渡し、 配列の要素の合計値を計算しています。

さて、ここでなぜ配列の要素数であるnを渡しているのでしょうか?先ほどのsizeofを使えば配列の長さは決定できるのでは? と思うかもしれません。しかし、関数の引数として与えられた配列に対してsizeofを適用することはできません。 なぜなら、

  • 関数の引数に配列を渡すと、配列全てがコピーされるのではなく、その先頭アドレスのみが渡される
  • それをa[]という表記で受け取る
  • 実は関数引数のa[]は来週ならうポインタそのものになっている
  • ポインタにsizeofを適用しても、配列のメモリ使用料は得られない

というロジックになっています。要点としては、関数に配列を渡すときのa[]は特殊なもの(ポインタ)になっていて、それにsizeofを適用して要素数を取り出すことはできない。 と覚えておきましょう。このあたりは次週に詳細に勉強します。

コラム

a[]という表記は、配列を宣言するとき:

int a[] = {1, 2, 3};   // 配列を作るとき
と、関数の引数のとき
int f(int a[], int n) { ... }  // 関数の引数
では実は意味が違います。後者はポインタそのものになっています
int f(int *a, int n) { ... }  // 関数の引数
これについて、ポインタを勉強するときに勉強します。

さて、文字列を引数に渡す場合はどうでしょうか?

int count_spaces(char s[]) {
    int ct = 0;
    for (int i = 0; s[i] != '\0'; ++i) {
        if (s[i] == ' ') {
            ++ct;
        }
    }
    return ct;
}

int main() {
    char s[] = "abc def gh";
    printf("%d\n", count_spaces(s));  // 2
    printf("%d\n", count_spaces("ijk lmn opq rst")); // 文字列リテラルを与えることも出来る。 3
}
この関数は、与えられた文字列の中の空白の個数を調べるものです。 文字列の場合は配列長を与える必要はありません。なぜなら、最後が'\0'で終わるという取り決めになっているからです。 次のようなイディオムで文字列を周回することができます。
for (int i = 0; s[i] != '\0'; ++i) { ... }

文字列に関する関数

これまでに、文字列は単なるcharの配列で、比較や代入が簡単ではないことを見てきました。 そのため、c言語では文字列を扱う便利関数がいくつも準備されています。

#include <stdio.h>
#include <string.h>    // string.hをinclude

int main() {
    // strlen: 文字列の長さ
    char s1[] = "abc";
    unsigned long len = strlen(s1);  // 長さを取得
    printf("sizeof(s1): %lu, strlen(s1): %lu\n", sizeof(s1), len);  // sizeof: 4, strlen: 3

    // strcpy: 文字列をコピー
    char s2[10];  // 十分に長く確保
    strcpy(s2, "hello");
    printf("%s\n", s2);  // 文字列をコピー。 s2 = "hello" 的なもの。
    printf("sizeof(s2): %lu, strlen(s2): %lu\n", sizeof(s2), strlen(s2)); // sizeof: 10, strlen: 5

    // strcat: 文字列を結合
    char s3[10] = "hoge";  // 十分に長く確保
    printf("sizeof(s3): %lu, strlen(s3): %lu\n", sizeof(s3), strlen(s3)); // sizeof: 10, strlen: 4
    strcat(s3, "fuga");  // 文字列を結合する。stringのconcatenate
    printf("%s\n", s3); // "hogefuga"
    printf("sizeof(s3): %lu, strlen(s3): %lu\n", sizeof(s3), strlen(s3)); // sizeof: 10, strlen: 8

    // strcmp: 文字列を比較
    char s4[] = "abc";
    char s5[] = "abcd";
    int cmp = strcmp(s4, s5); // 文字列の比較。s4 == s5的なもの。二つの文字列の辞書順に応じて、正か0か負になる
    printf("%d\n", cmp);  // ここでは-100
}
最初のinclude <string.h>を忘れないようにしましょう。 ここでは、文字列を扱う関数が入っているstring.hというファイルを最初に読み込んでいます。 このように、c言語では別のライブラリを使いたいときには、一番最初にincludeします。 ここで、strcpystrcatをするときは、十分に領域を長く確保しておく必要がある点に注意しましょう。

やってみよう(40分)

  • 上記を写経してみましょう。
  • int sum_array(int a[], int n) { ... } のような配列を引数にとる関数について、その内部でsizeofを使っても配列長を得ることが出来ないことを確認しましょう。
  • 0から127までの整数を、%cを用いて単語として表示していましょう。ASCIIコードにはどういうものがあるか確認できます。特に、最初のほうの数字は制御コードになっています。
  • strcpystrcatで十分な領域を確保しない場合にどのような問題が発生するでしょうか?例をあげてみましょう。例えば、十分な領域を確保せずにstrcpyを行ったあとにそれをprintf%sで表示するとどうなるでしょうか?
  • 実はprintfの最初の引数にこれまで文字列リテラルを与えてきましたが、実は文字列の変数でもOKです。次のコードが想定通り動くことを確認しましょう。
    char fmt[] = "%d\n";
    int n = 2;
    printf(fmt, n);
    
  • Cの文字列のルールは、「先頭から\0が出現するところまでを文字列とする」というだけです。そこより後ろは気にしません。また、charの配列長がいくつであっても構いません。例えば以下を考えてみましょう。
    char s[] = {'a', 'b', 'c', '\0', 'd', 'e'};
    printf("%s\n", s);  // abc になる
    
    上の例では、sは全く問題なく「"abc"という文字列」として扱えます。上記を実際に表示してみましょう。

クイズ

  1. char c = '3'のように、数字の「文字」のcが与えられたとき、数字そのものを得るにはどうすればいいでしょうか?
  2. 任意のアスキーであるchar cに対して、もしアルファベットの大文字であれば小文字に変換する以下の関数を作りましょう。
    char lower (char c) {
        /* ここを穴埋め **/
    }
    
  3. 0と、'0'と、'\0'と、"0"と、"\0"の違いを説明してください。
  4. 文字列リテラルはcharの配列なので、配列に対する操作が実行できます。次のコードは何を出力するでしょうか? printf("%c\n", "abcd"[3]);
  5. 以下を穴埋めして、文字列の長さを計算するstrlen関数を作ってみましょう
    int strlen(char s[]) {
        /* ここを穴埋め */ 
    }
    
答え
  1. 例えばint n = c - '0'とすると、数字そのものが得られます。
  2. 例えば以下 K&R 53pを元に改造。'A'との差で得られる「アルファベットオフセット」を'a'に足すことで対応小文字を取り出せる。
    char lower (char c) {
        if (c >= 'A' && c <= 'Z') {
            return c - 'A' + 'a';
        } else {
            return c;
        }
    }
    
    • 0: 整数の0。その実体は当然0
    • '0': 文字。アルファベットの0を表すASCIIコード。その実体は何らかの整数(48)
    • '\0': 文字。NULL文字を表すASCIIコード。その実体は整数の0。すなわち0 == '\0'
    • "0": 文字列。char型が2つの配列。一つ目は'0'の文字であり、二つ目はNULL文字。すなわち、 char s[2] = {'0', '\0'};。言い換えると char s[2] = {48, 0};
    • "\0": 文字列。「NULL文字が二つ」。すなわち、char s[2] = {'\0', '\0'};。言い換えると char s[2] = {0, 0};。ちなみにNULL文字そのものを文字列としてprintfしても表示されないので、printf("\0")とすると何も表示しない(エディタに注意されるかも)
  3. 'd'が出力されます。"abcd"[3]という表記は、char型の配列である{'a', 'b', 'c', 'd', '\0'}の3番目の要素を取得することを意味するからです。
  4. 例えば以下になります(K&R, 48pを参考)
    int strlen(char s[]) {
        int i = 0;
        while (s[i] != '\0')
            ++i;
        return i; 
    }
    

型変換

それでは次に型の変換について学んでいきましょう。 Cでは型が違う変数同士の演算に対し、暗黙の変換を行います。これにより、プログラマは型の細かい違いを気にせずにコーディングを行うことができます。 一方で、どのような変換が行われているかを把握していないと、おかしな挙動をするコードを書いてしまうかもしれません。 そこで、型変換について勉強していきましょう。

まず原則について見ていきましょう。Cにおいて型の変換が行われるのは次の4つの場合です。

  • 演算において型が違う場合。例:int i; float j;のときにi + j (通常の算術型変換)
  • 値の代入時の両辺の型が違う場合。例:int i;のときにfloat j = i代入時の変換
  • 関数の引数の型が呼び出し元と一致しない場合。例:int f(int a) { ... }のときにf(0.2)
  • returnの型が一致しな場合。例:int f() { return 0.2; }

ここでは上の二つを学びましょう。

通常の算術型変換

Cの変換の原則は、情報を失うことなく、「より狭い」演算から「より広い」演算にするというものです。 例えば、intcharの足し算であれば、charintに変換されます。これは情報の損失が生じません。 また、intfloatの足し算であれば、intfloatに変換されます。これは情報は損失されるのですが、floatintにするよりも損失しません。 以下が例になります。

char c = 12;     // 8 bit
short s = 34;    // 16 bit
int i = 56;      // 32 bit
float f = 1.0;   // 32 bit
double d = 2.0;  // 64 bit
i = i + c;       // i + c の計算部分で、cがintに変換される
i = i + s;       // i + s の計算部分で、sがintに変換される
f = f + i;       // f + i の計算部分で、iがfloatに変換される
d = d + f;       // d + f の計算部分で、fがdoubleに計算される

また、上の話とちょっと被りがあるのですが、 小さな整数同士の演算では整数拡張が行われます。これは、元の型がintで表現できる場合、intに変換して計算するというものです。以下の計算を考えましょう

char c1 = 10;
char c2 = 100;
char c3 = 20;
char c4 = c1 * c2 / c3; // 整数拡張。c1もc2もc3もintに変換される
printf("%d\n", c4);  // 50. OK
ここでまずc1 * c2の部分の計算はchar同士の計算なのでcharで行われるように思いますが、それだとcharの数値表現の限界(127)を超えてしまうことがわかります。 それだと困ってしまうので、Cはここで整数拡張を行います。すなわち、charでの計算はまず全てintに変換されます。 なので、ここでは右辺は全てintだとして計算されます。そのため、計算結果は全て想定したものになります。 ちなみに、int同士の演算はlongにはならないので注意してください。

代入時の変換

さて、計算結果を左辺に代入するときに、右辺と型が違う場合は、左辺の方に変換されます。左辺のほうが「広い」場合は、問題はおきません:

char c;
int i;
float f;
double d;
i = c;  // cはintに変換される
f = i;  // iはfloatに変換される
d = f;  // fはdoubleに変換される
左辺のほうが広くない場合は、情報の損失がおきたり、問題が起きます。
int i;
i = 123.45;  // iは123になる
i = -123.45; // iは-123になる
上の例では、既に習った通り、小数を整数に代入しているので小数部分が切り捨てられます。符号は残ります。

下は「広い」整数型から「狭い」整数型に値を代入しておかしくなっている例です。

char c = 1000;               // charの限界の127を超えている
printf("%d\n", c);           // ダメ。-24
1000というリテラル(型はint)はcharの限界を超えているので、代入時に値が捨てられて、おかしくなります。 ところで、ここではなぜ-24なのでしょうか?ここでは、10進数の1000の二進数表現(32 bit)は00000000|00000000|00000011|11101000です。 見やすさのため8ビットごとに区切りをいれました。 ここで、char cは8ビットなので、無理やり代入することで11101000となります。この値はcharでは10進数で-24です。 そのため、%dで表示すると-24になります。 この挙動(符号付き整数のオーバーフロー)は未定義です。私の環境(gcc, 64-bitマシン)ではそうなりましたが、他の環境では違うかもしれません。 一方で、符号なし整数のオーバーフローの場合は定義されているそうです。

int i = 5000000000;          // intの限界の2147483647を超えている
printf("%d\n", i);           // ダメ。705032704
printf("%ld\n", i);          // ダメ。705032704
printf("%d\n", 5000000000);  // ダメ。705032704
printf("%ld\n", 5000000000); // これはOK。5000000000
次はintの限界を超えている例です。5000000000intの限界を超えているので、代入時に情報の損失が起きます。 ここで、10進数の整数リテラルはintで表現できるときはintですが、それを超えるときはlongになることを覚えておきましょう。 なので、左辺の5000000000longとして適切ですが、intに代入するときに壊れてしまうというわけです。 そのため、結果として得られたi%dで表示してみると、別の値になっています。 なので、これをlong(64 bit 整数)の表示モードである%ldで表示してもやはりダメです。 ちなみに、10進数の5000000000は64bitで
00000000|00000000|00000000|00000001|00101010|00000101|11110010|00000000
ですが、これを32bitのintにすると
00101010|00000101|11110010|00000000
となって、これは705032704です(繰り返しますが、ここは処理系依存です) 面白い点として、一番最後の、「longリテラルを直接表示する」場合はうまくいっていることに注意しましょう。

floatに小数を入れる場合も注意が必要です。暗黙的にdoubleリテラルがfloatに変換されています。

float v = 3.223;   // この場合はOKだが、doubleからfloatに変換されている3.223f
float v2 = 3.223f; // 明示的にfloatのリテラルだと示すときはこちらのほうがいい

キャスト

さて、ここまでは暗黙的な型変換を見てきましたが、型変換は明示的に行うこともできます。それがキャストです。 キャストは、(型名) 変数名 という書き方で、変数を別の型に強制的に変換します。

float quotient;
int dividend = 3, divisor = 2;

// キャスト無し
quotient = dividend / divisor; // 1.000

// キャストあり
quotient = (float) dividend / divisor;  // 1.5000

上の式では、分母も分子も整数で表現したいという要請があるとします。ここで、dividend / divisorは整数の割り算なので 小数部分を切り捨ててしまいます。それがfloatに代入されるため、1.000になります。 ここで、(float) dividendとすることで、dividendを無理やりfloatにします。これにより、右辺の演算が小数の割り算となり 正しい答えになります。 ところで、キャストは単項の演算子なので、上の式は次と同じということに注意してください(すなわち、式全体にかかっているわけではない)

quotient = ((float) dividend) / divisor;

unsignedの場合

上記は全て負の数も含むsignedの話でした。unsignedを含む型変換は、話が複雑になります。 たとえばintunsigned intを含む演算では、両方をunsigned intにそろえるように変換されます。 本講義では詳しく述べませんが、第一原則として符号あり変数と符号なし変数を混ぜた演算は絶対にしないと覚えておいてください。 コンパイラが警告を出すと思います。

以下に非常にわかりにくいバグを生む例をお見せします。

int i = -10;
unsigned int u = 10;
printf("%d\n", i < u);  // なんと偽になる
上記は型がそろえられて真になりそうなものですが、なんと偽になります。 ここではiunsigned intに型変換しようとしますが、unsignedが示す通り負の値をunsigned intに変換すると おかしなことがおこります(ここでは4294967286になります)。そのため4294967286 < 10 は偽になってしまうのです。

このようなミスを防ぐために、符号なしと符号ありは必ずそろえるようにしましょう。ここでは値が十分に小さいとすると

printf("%d\n", i < (int) u);  // 真になる
でOKです。

型変換は複雑なので、より完全なルールは例えばここを参照してください。

やってみよう(30分)

  • 上記を写経してみましょう。
  • int i;として、long j = i * i;を考えてみましょう。char同士の演算はオーバーフローにならないようにそれぞれ自動的にintになるという話 (整数拡張)がありましたが、int同士の計算はそれぞれがlongになるわけではありません。そのため、iを大きくしていくと、i * iが破綻します。 ここで、破綻したあとにlongに代入されるので、jには破綻した値しか残らないことに注意しましょう。 それを実際に書いて確かめてみてください。
  • また、上の状況で(long) i * iとした場合は、((long) i) * iとなり、まずilongに変換されるため、「通常の算術型変換」により後ろのilongになり、 結果ちゃんとした値になることを確認しましょう。

クイズ

int i;
char c = /* charにおさまる何らかの値 */;
として、
i = c;
c = i;
としたとき、情報は損失するでしょうか?しないでしょうか?それを説明し、また損失する場合はその例を作ってみましょう。

答え

損失しません。ciに入れる段階で損失はありません(intのほうがより多くの情報を保持できるので)。そして、その値はcに収まるものです(-128から127))。その値を再度charに入れても、値は保持されます。ちなみに、ひっくり返すと保持されないことがあります。

int i = 300;
char c;
c = i;  // charに収まらないので値が損失
i = c;  // 損失した値をintに戻すので、やはり損失している
printf("%d\n", i);  // 例えば、44になる

入出力

さて、次はどのようにプログラムに情報を入力し、どのように出力するかを見ていきましょう。 C言語の標準ライブラリによってサポートされる入出力のモデルは非常にシンプルです。テキストの入力あるいは出力は、 その発生元や発生先などにかかわらず、文字のストリーム(流れ)として扱われます。 ストリームとは、「文字列 + 改行」を一行とすると、それが複数集まったものだと思えばOKです。

ストリームの種類

3種類の標準的なストリームが用意されています。

  • stdin: Standard input. 標準入力。キーボードからの入力だと思ってください
  • stdout: Standard output. 標準出力。端末の画面への出力だと思ってください
  • stderr: Standard error: 標準エラー出力。エラーの出力用。stdoutと同じく端末画面への出力。

我々が今回使うのはstdinstdoutです。つまり、キーボードから入力を受け取り、処理を行い、 画面に表示する、というものです。

一文字の読み書き(getchar, putchar)

ここで、まず最も基本となる一字の読み込みと書き込みを勉強しましょう。

  • int getchar(void) は、標準入力(キーボード)のストリームから一文字を取り出し、intとして返す関数です。ストリームが終わるときは、特殊記号のEOFを返します。これはstdio.hで定義されている整数で、負の値になっています。皆さんの環境では-1だと思います。
  • int putchar(int c) は、一文字cを受け取り、それを標準出力(端末の画面)に表示する関数です。もしエラーが起きれば、EOFを返します。そうでなければ、入力であるcと同じ値を返します。

それでは、読み込んだ内容を出力するだけの次の複写プログラムを考えてみましょう(K&R, p20を調整) これを複写プログラムとします。

#include <stdio.h>

int main() {
    int c = getchar();  // 一文字読み込む
    while (c != EOF) {  // 最後でなければ
        putchar(c);     // 一文字書き込む
        c = getchar();
    }
}

これをコンパイルして実行してみましょう。すると、端末が「待機中」になるとおもいます。その画面で適当に文字を入力しエンターを押すと、その内容(標準入力からの入力) が読み取られ、コピーされることがわかります。

$ gcc main.c
$ ./a.out
abcd        <- エンターを押す
abcd        
xxx         <- エンターを押す
xxx         <- CTRL + C あるいは CTRL + Dを押す
$
エンターを押すと、端末に書いた内容を流し込むという動作になります。また、エンターそのもの(改行)も入力されています。 このプログラムはEOFが来るまで終了しません。そのため、終了させるには

  • 強制終了を意味する Ctrl+C を押す
  • EOFの入力を意味する Ctrl+D を押す

のどちらかを実行してください。

ここで、エンターを押したあたりの挙動を詳細に確認しておきましょう。 getcharがあると、まずユーザが打ち込んだ文字はwhileにいきなり入るのではなくバッファされて貯め込まれます。 そこにエンターが押されると、バッファの最後に「改行」も足したうえで、全体がgetcharにどどどっと流れ込みます。 次を実行して挙動を詳細に確認してみましょう。

int c = getchar();
while (c != EOF) {    
    if (c == '\n') {
        printf("You entered new line\n");
    } else {
        printf("You enterd %c\n", c);
    }
    c = getchar();
}
これを実行してみると挙動が理解できると思います
$ ./a.out
abcd                      <- エンターを押す
You entered a
You entered b
You entered c
You entered d
You entered new line

すなわち、

  • getcharに到達すると、「入力待機モード」になる
  • 「入力待機モード」中にキーボードから入力すると、それらはバッファに貯められる。
  • エンターを押した瞬間、(1) エンターそのものもバッファに貯められる (2) そのうえで、バッファの中身がgetcharに流れ込む。上記の場合、a, b, c, d, \n が流れ込むことになる。まず最初のgetcharにはaが流れ込む。
  • getcharが連続して呼ばれている上記のようなコードの場合、バッファが空になるまでどんどん流れ込む。すなわち、上の場合\nに至るまで流れこむ。
  • その後にさらにgetcharが呼ばれている場合、再び「入力待機モード」になる

次に、次のような「入力される文字をカウント」する関数を見てみましょう(K&R, p22

#include <stdio.h>

int main() {
    long nc;
    nc = 0;
    while (getchar() != EOF) {
        ++nc;
    }
    printf("%ld\n", nc);
}
これは、入力の単語数をカウントして表示するプログラムです。
$ gcc main.c
$ ./a.out
abcd        <- エンターを押す
            <- Ctrl + D を押してEOFを送る
5           <- a, b, c, d, \n, の合計5
ここで、エンターを押しても、値が表示されないことに注意しましょう(\nに対する処理が記述されているわけではないので)。 ここではCtrl+D を押すとEOFとなり、カウント数が表示されます。

【10/22更新】 マックの場合は、最後の表示が5Dとなってしまうことがあるようです。この原因がちょっと不明なので、もしわかる人がいたら松井まで連絡ください。

コラム

マニアックですが、正確には、Ctrl+Dは状況に応じて二つの異なる挙動をします。

  • バッファに何か溜まっている場合:Ctrl+Dを押すと「バッファを流し込むだけ」という動作になります。「改行をふくまないエンター」のようなものです。上記の単語カウントプログラムで「a, b, c, Ctrl+D」とした場合に、プログラムが止まらないのは、そのためです。
  • バッファに何も溜まっていない場合:Ctrl+Dを押すと、EOFのみを送り込みます。「EOF+改行」ではないです。そして、End-of-Fileの名の通りそこでファイル読み込みが終わりなので、以降getcharで待ち受けてもすべてにEOFが自動で送り込まれるように見えます。

一つ目の「バッファに何か溜まっている場合にCtrl+Dを押す」は想定外の挙動になりえますので注意してください。

リダイレクト:ファイルからの入出力

上記はキーボードからの入力、および画面への出力でした。 これは簡単に「ファイルからの入力」および「ファイルへの出力」に変更することができます。

以下のプログラムを考えましょう。これは入力に対して字の間にスラッシュを入れて返すものです。 これを複写スラッシュプログラムとしましょう。

#include <stdio.h>

int main() {
    int c = getchar();
    while (c != EOF) {
        putchar('/');  // 複写プログラムに加え、文字の間にスラッシュをいれる
        putchar(c);
        c = getchar();
    }
}
結果は次のようになります。
$ gcc main.c
$ ./a.out
abcd        <- エンターを押す
/a/b/c/d/        
xxx         <- エンターを押す
/x/x/x/     <- CTRL + C あるいは CTRL + Dを押す
$

ここで、次のようなファイルを作ってみましょう

aaa bbb ccc
dd ee ff
これをsample.datとして保存しましょう。 そして、このファイルを複写スラッシュプログラムに入力してみましょう。
$ ./a.out <sample.dat
これは次のように空白をつけてもいいです。
$ ./a.out < sample.dat
こうすると、sample.datの中身がa.outによって処理され、その内容が表示されます。
/a/a/a/ /b/b/b/ /c/c/c/
/d/d/ /e/e/ /f/f/

このように、<を用いることで、標準入力のソース元を変更することができます。 これをリダイレクトと言います。 同様に、>を用いることで、出力先を変えることができます。

$ ./a.out >out.dat
hoge   <- ENTER
       <- CTRL + D で終了
$
こうすると、out.datという新しいファイルが出来て、その中に上記の内容が書き込まれていることがわかります。 この>を使う方法は、プログラムの出力をファイルにすることができるので何かと便利です。$ ./a.out > log.dat のように。

そして、これらは同時に使うことができます。

$ ./a.out <sample.dat >out.dat
こうすることで、処理結果がout.datに出力されることがわかります。

また、上記の表記だと常にout.datは新しいファイルとして生成されるのですが、>>を用いると「追記モード」になります。

$ ./a.out <sample.dat >>out.dat
これを何度か繰り返して、out.datの中に処理結果が何度も書き込まれていることを確認しましょう。

パイプ

さらに、標準出力と標準入力を組み合わせるパイプ(|)を紹介します。 まず準備として、標準入力を標準出力に1回だけ返すのコマンドechoを紹介します。

$ echo hogehoge
hogehoge
実はこれは最初に作った「複写プログラム」を1度だけ行った処理になっていますね。 そして、パイプを紹介します。パイプの構文は次のようになります
コマンド1 | コマンド2
これにより、コマンド1の標準出力を、コマンド2の標準入力にします。 ここでコマンドとは、./a.outのような実行可能バイナリの実行も含みます。

これを用いると、コマンドライン上でコマンドを組み合わせることができます。 次のように、echoの出力を複写スラッシュプログラムの入力にしてみましょう。

$ echo abcd | ./a.out
/a/b/c/d/
このように、「キーボードからの入力」だった標準入力が、echoの出力にとって代わられました。 別の例もみてみましょう。
$ ls
a.out  main.c
上のようにlsをするカレントディレクトリのファイルが表示されますね。これをパイプで繋いでみましょう。

$ ls | ./a.out
/a/./o/u/t/
/m/a/i/n/./c/
このようになります(ちなみに改行が追加されるのはlsの特殊仕様ですので気にしないでください)

最後に、パイプとリダイレクトを組み合わせることもできます。次をやってみましょう

$ cat main.c | ./a.out >out2.dat
こうすると、out2.datというファイルが生成され、その内容は以下のようになります
/
/#/i/n/c/l/u/d/e/ /</s/t/d/i/o/./h/>/
/
/i/n/t/ /m/a/i/n/(/)/ /{/
/ / / / /i/n/t/ /c/ /=/ /g/e/t/c/h/a/r/(/)/;/
/ / / / /w/h/i/l/e/ /(/c/ /!/=/ /E/O/F/)/ /{/
/ / / / / / / / /p/u/t/c/h/a/r/(/'///'/)/;/
/ / / / / / / / /p/u/t/c/h/a/r/(/c/)/;/
/ / / / / / / / /c/ /=/ /g/e/t/c/h/a/r/(/)/;/
/ / / / /}/
/}/

やってみよう(30分)

  • 上記を写経してみましょう。
  • EOFは負の整数になっています。実際に整数として表示して確認してみましょう。
  • 「文字列」のクイズで出来てきた、文字を小文字に変換する関数lowerを思い出しましょう。 上記の複写のプログラムとlowerを組み合わせることで、「入力文字に対して、大文字であれば小文字にして出力」とするプログラムを書いてみましょう。
  • リダイレクトを用いて、そのプログラムに何らかのテキストファイルを入力し、それをファイルとして出力してみましょう。
  • パイプとechoを用いて、そのプログラムにテキストを入力し、端末上に出力してみましょう

クイズ

  • 上で説明した複写プログラムについて、getcharを一回しか使わないように書き換えてみましょう。
答え
  • 例えば以下 K&R, p21
    int c; 
    while ((c = getchar()) != EOF) {
        putchar(c);
    }
    
    このwhileの中身は少し複雑です。どういう意味なのか考えてみましょう。

printf詳細

今日の最後に、printfの詳細を見ていきましょう。これまでに色々な使い方を見てきましたが、それらをまとめて、新しい使い方も見ていきましょう。

int i = 3;
double f = 154.423;
printf("|%d|%5d|\n", i, i);               // |3|    3|
printf("|%f|%.2f|%8.2f|\n", f, f, f);  // |154.423000|154.42|  154.42|
上記のように、%5dと指定すると、「少なくとも5桁」を表示するように空白が挿入されます。 少数に対し%.2fとすると、小数部分の桁数が2になります。%8.2fは、「全体が少なくとも8桁」で、かつ小数点以下が2桁になります。

double v = 1.3e10;
printf("|%f|%e|\n", v, v);  // |13000000000.000000|1.300000e+10|  通常出力と、e+10の形式の出力
printf("|%g|%g|\n", f, v);  // |154.423|1.3e+10|  自動で選ぶ
上は少数についてです。%eにすると、\(1.3\times 10^{10}\)を意味する1.300000e+10という表記になります。 %gは、数字の大きさに応じて普通に表示するかeを使うか自動で選んでくれます。 ちなみに、double v = 1.3e10というように、小数リテラルはeを含んで表記することが出来ます。

int n = 500;
printf("|%d|%o|%x|\n", n, n, n); // |500|764|1f4|
int n_octal = 0764;         // "0"は8進数。10進数への変換: 7 * 8^2 + 6 * 8^1 + 4 * 8^0 = 500
int n_hexadecimal = 0x1f4;  // "0x"は16進数。10進数への変換: 1 * 16^2 + 15 * 16^1 + 4 * 16^0 = 500
printf("|%d|%d|%d|\n", n, n_octal, n_hexadecimal);  // |500|500|500
整数について、%oで8進数表記、%xで16進数表記で出力できます。 ちなみに、8進数の整数リテラルは先頭に0をつけることで表現でき、16進数のリテラルは先頭に0xをつけることで表現できます。 これらは整数の表現が違うだけで、同じものを指します。

long ln1 = 123;
printf("|%d|%ld|\n", ln1, ln1);  // |123|123| OK
long ln2 = 10e10;
printf("|%d|%ld|\n", ln2, ln2);  // |1215752192|100000000000| %dではダメ
long型は、intに収まる小さい値のときは%dでも表現できますが、それを超える場合は%ldとしていなければなりません。

unsigned char c = 200; // charの範囲を超えている
unsigned short s =  32800;  // shortの範囲を超えている
unsigned int j = 2147483649;  // intの範囲を超えている
unsigned long l = 18446744073709551615;  // longの範囲を超えている
printf("|%u|%u|%u|%lu|\n", c, s, j, l);  // |200|32800|2147483649|18446744073709551615| OK
printf("|%d|%d|%d|%ld|\n", c, s, j, l);  // |200|32800|-2147483647|-1|  unsinged intとunsigned longはおかしくなる
unsignedは、%uを用います。実は、unsigned charおよびunsigned shortintの範囲を越えないので%dでも表現できます。

unsigned intおよびunsigned longについては、それぞれint, longの範囲を超える場合は、%uおよび%luを使う必要があります。

printf("|%p|\n", &n);  // |0x7ffe08a2e6b8|
printf("|%s|%c|\n", "abc", 'x'); // |abc|x|
ポインタ、文字列、文字は上記の通りです。

やってみよう(15分)

  • 上記を写経してみましょう。

宿題

それでは今週の宿題です。

  • 締切は次の授業の前日の深夜23:59までです(今回の場合、2021/10/26, 23:59)
  • 宿題リンクをslackで配付します。このリンクは公開しないでください。
  • <string.h>に含まれる文字列関数は使わないようにしてください。
  • 【10/20更新】 scanfなど、便利入力関数は使わずに、putcharでやってみてください。
  • 以下の課題について、出来る分だけやってみてください。

week3_1

日記解析プログラムを作りましょう。ターミナルから一行の文章(英語の文字列で、最後のみ改行。文字数はたかだか300文字程度)が与えられたとき、それをもとに書いた人の気分を推定します。例えば文章中にdeliciousという単語が含まれていれば、You look happy!と返してくれる、という形です。

【10/24更新】とにかく該当単語が含まれていればYou look happyで大丈夫です。すなわち、セマンティックを考慮しなくて大丈夫です。例えば:

  • undeliciousが含まれいたら? -> You look happyと表示
  • deliciousnessが含まれていたら? -> You look happyと表示
  • It does not seem deliciousの場合は? -> You look happyと表示

です。このような意味の反転を考慮したいという場合は、例えばweek3_2の自由課題で取り組んでみたりするとよいと思います。

ヒント

  • 今日習ったputcharを使って文字を一文字ずつ読み込み、char buff[1000]のようなバッファのchar配列にまず保存してみるといいです。
  • 授業で何度も言った通り、c言語において正しい文字列とは最後に\0がついているものです。そのルールを守らない場合、色々不具合が起きる可能性があるので、注意しましょう。

課題1

まずは以下を実装しましょう。ニュートラルな文章に対しては、復唱するだけで何も返しません。

$ ./a.out
I spent all day today at home watching netflix.              <- この文章を記入し、エンターを押す
You typed: I spent all day today at home watching netflix.   <- プログラムが、まず記入内容を復唱する

この操作は、リダイレクトを用いて以下のように書けます。自動採点ではこの表記を用います。

  • 入力
    $ echo "I spent all day today at home watching netflix." | ./a.out
    
  • 出力
    You typed: I spent all day today at home watching netflix.
    

課題2

次に、文章中にdeliciousが含まれていた場合、You look happy!と表示するようにしましょう。

$ ./a.out
Today I ate delicious ramen in Shimokitazawa.             <- この文章を記入し、エンターを押す
You typed: Today I ate delicious ramen in Shimokitazawa.  <- プログラムが、まず記入内容を復唱する
You look happy!                                           <- プログラムが返答を表示する

自動採点は以下です。

  • 入力
    $ echo "Today I ate delicious ramen in Shimokitazawa." | ./a.out
    
  • 出力
    You typed: Today I ate delicious ramen in Shimokitazawa.
    You look happy!
    

課題3

次に、文章中に{delicious, healthy, celebrate}のいずれか2つ以上が含まれていた場合、You look super happy!と表示するようにしましょう。重複もOKです。すなわち、「delicousが2回登場」でもOKです。

【10/21更新】 healthycelebrateが「一個だけ」含まれていた場合ですが、「You look happy!」を表示してもしなくても大丈夫です。

  • 入力1
    $ echo "Today is my child's first birthday. I am delighted that he has grown up healthy so far. I will have dinner with my family at a trendy French restaurant in the neighborhood to celebrate." | ./a.out
    
  • 出力1

    You typed: Today is my child's first birthday. I am delighted that he has grown up healthy so far. I will have dinner with my family at a trendy French restaurant in the neighborhood to celebrate.
    You look super happy!
    

  • 入力2

    $ echo "I had a delicious meal today. I haven't had such delicious food in years." | ./a.out
    

  • 出力2
    You typed: I had a delicious meal today. I haven't had such delicious food in years.
    You look super happy!
    

week3_2

自由課題です。week3_1のプログラムをベースに、改造を施し、イケてる日記分析AIを作ってみましょう。 自動採点は無いです。好きにAIを作ってみてください。面白かったものは次回か次々回に紹介するかもしれません。

プログラムを作ったら、README.mdを編集して、説明を追記してください。README.mdに文字を追加して宿題リポジトリをアップロードすると、最初のページが更新されることがわかると思います。マークダウンファイル(拡張子が.mdのファイル)の編集方法についてはマークダウンファイルの編集方法を参考にしてください。

改造の例は例えば以下ですが、自由な発想で作ってみてください。あくまでc言語でお願いします。

  • よりたくさんのポジティブ単語を考慮する
  • ネガティブ単語も考慮する
  • 単語の組み合わせを考慮する
  • 字に色をつける
  • 日記を改変する
  • かかった時間に応じてスコアを増減する

【10/20更新】 このweek3_2はc言語であればどんな機能を使ってもいいです。ただ、松井の環境で実行できるように注意してください。つまり、自分のPCでしか実行できないライブラリなどは使わないでください。基本的に、何も設定していないGoogle Cloud Shell Editorで実行できるようにしてください。

ヒント

いくつかのヒントを紹介します。

  • Week 5で詳細に紹介しますが、文字列の配列はcharの二重配列を用いて以下のように表現できます
    char msgs[3][100] = {"hoge", "fugafuga", "piyopiyopiyo"};   // 100の部分は十分に大きな値にする
    printf("%s\n", msgs[1]);  // fugafugaを表示
    
  • stdlib.hをインクルードすることで、ランダムな数字を作ることが出来ます。去年の資料を参考にしてください。
  • 同じくstdlib.hをインクルードしたうえで、以下を実行すると、画面の表示をクリア(上にスクロール)して綺麗にしてくれます。
    system("clear");
    
  • time.hをインクルードしたうえで、以下のようにすると処理の時間を計測できます(以下はlinux限定の表記なので、windowsネイティブ環境でやっている人は代替の表記を使ってください)
    // timespecという型の変数であるstartとendを準備。これを開始と終了の時刻計測に使う
    struct timespec start, end;   
    
    clock_gettime( CLOCK_REALTIME, &start);  // startに現在時刻を書き込む。
    sleep(3);  // ここでなんらかの処理。たとえば3秒待機するsleep(3)
    clock_gettime( CLOCK_REALTIME, &end);  // endに現在時刻を書き込む
    
    // startとendを元に、秒単位の差分を計算する。ここでは、秒レベルの情報と
    // nano秒レベルの情報が別々に保存されているので、それらを適切に処理している。
    double diff = (end.tv_sec - start.tv_sec) + (end.tv_nsec - start.tv_nsec) * 1e-9;
    printf("%f sec", diff);   // 例えば 3.000092 sec となる
    
  • 文字に色を付けることもできます。

答えの例

答え

week3_1

#include <stdio.h>
#include <stdlib.h>


// 関数は自由に追加していいです。

int mycmp(char buff[], int pos, char t[]) {
    // buff: 十分な長さのchar配列だとする。文字列が入っている。この中を調べる
    // pos: buff[pos]の位置から調べる
    // t: templateの文字列。これがbuff中にあるか調べる。buffは十分に長いのでi + len(t)はbuffを超えない

    // 文字列tを周回する。tは文字列なので、最後の\0チェックで周回できる。
    for (int i = 0; t[i] != '\0'; ++i) {
        // buff[pos + i]の位置から一致を調べていき、一致しなければ偽を返す
        if (buff[pos + i] != t[i]) {
            return 0;
        }
    }
    // ここまできたということはtに関して(最後の\0をのぞいて)全部一致していたということなので、真を返す
    return 1;
}


int main(int argc, char *argv[]) {
    // ==== ここから下に記入 =====

    int c = getchar();  // 一文字読み込む
    char buff[1000];
    int n = 0;
    while (c != EOF) {  // 最後でなければ
        buff[n] = (char) c;
        ++n;
        if (c == '\n') {
            break;
        }
        c = getchar();
    }
    buff[n] = '\0';   // buffを文字列として扱うためには、最後にNULL文字が必要なことに注意!

    // buffが文字列であれば、printf中で%sで簡単に表示できる。`\0`以降は関係なくなる。
    printf("You typed: %s", buff);

    int pos_level = 0;    

    for (int i = 0; i < n; ++i) {
        // buff中のある位置iから見て、単語が含まれるか調べる
        // どれかがヒットすればポジティブ度を1あげる
        if (mycmp(buff, i, "delicious") || mycmp(buff, i, "healthy") || mycmp(buff, i, "celebrate")) {
            ++pos_level;
        }
    }

    if (pos_level == 1) {
        printf("You look happy!\n");
    } else if (1 < pos_level) {
        printf("You look super happy!\n");
    }

    return 0;
}