コンテンツにスキップ

Week 3 (2023/10/19)

今日やること

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

まずはじめに

  • 宿題の注意
  • 全般的にコメントや質問は歓迎なのでslackにどんどん書いてください。まわりの友人とも是非議論してください。

文字について

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

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 s1[3] = "abcdefg";  // {'a', 'b', 'c'}になる
char s2[3] = "hijklm";   // {'h', 'i', 'j'}になる
printf("%s\n", s1);
printf("%s\n", s2);
上記では配列長が足りないため、最後に'\0'が入りません。なのでs1, s2は正しい文字列になっていません。ここでprintするとおかしなことになります。 例えば私の環境では以下のようになりました。
abchij
hij
ここでは、メモリ上ではs1の直後にs2があり、s2の直後は0になっているため、上記のようになってしまうのだと考えらえます。すなわち、printfは0がくるまでは文字列だとおもってabchijと表示しています。

配列と文字列

次に、配列と文字列の性質について勉強していきましょう。 まず初期化についてです。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にします。これにより、右辺の演算が小数の割り算となり 正しい答えになります。 ところで、キャストは単項の演算子なので、上の式は次と同じということに注意してください(すなわち、式全体にかかっているわけではない)

【10/21更新】単項というよりも、キャストは演算の優先度が高いので、割り算よりも先に実行される、というのが正しい説明でした。

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 entered %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となり、カウント数が表示されます。

コラム

マックの場合は、最後の表示が5Dとなってしまうことがあるようです。これはCtrl+Dを押す際に表示される^Dが重なって表示されれてしまうらしいです。これについては気にしないでください。

コラム

正確には、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までです(今回の場合、2023/10/25, 23:59)
  • 宿題リンクをslackで配付します。このリンクは公開しないでください。
  • ちなみに、<string.h>に含まれる文字列関数は使わないようにしてください。また、<string.h>に限らず、ライブラリは何もincludeせずに全て自前で書いてみましょう
  • また、scanfなど、便利入力関数は使わずに、getcharでやってみてください。
  • 以下の課題について、出来る分だけやってみてください。

week3_1

文字と数字を与えると、それを数直線上に表示するプログラムを書きましょう。main.cにはほとんど何も書いてありません。それを編集して以下を実現してください。

ヒント:難しい場合は、getcharを使って文字を一文字ずつ読み込み、char buff[1000]のようなバッファのchar配列にまず保存してみるといいかもしれません。どのような場合でも、プログラムに入力される文字数は1000個以下ぐらいを仮定して大丈夫です(異常に長い文章みたいなコーナーケースは考えなくていいです)

タスクA

まずは準備です。./a.outを実行すると、入力待ちになります。ここで、Ctrl+Dを押してEOFを与えると、以下の数直線を表示してプログラムが終了するようにしてください。

  0    5   10   15
--+----+----+----+-->

【10/23更新】上記では数字が赤く表示されていますが、数字の色を変更する必要はありません。

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

  • 入力
    $ echo -n "" | ./a.out
    
  • 出力
      0    5   10   15
    --+----+----+----+-->
    

ここでは「echo ""」により、「何もない」というものが表示されます。それをパイプを使って./a.outに入力します。echo-nオプションは、改行を出力しないというものです(単純にEOFのみが送られます)

タスクB

さて、それでは本題に入りましょう。./a.outを実行すると入力待ちになります。ここでa,3と入力しEnterを押します。するとまた入力待ちになります。ここでCtrl+Dを押してEOFを送ります。すると以下が表示され、プログラムが終了します。

  0    5   10   15
--+--a-+----+----+-->

すなわち、数直線上の「3」の位置に「a」が表示されます。このように、「aという文字」「その位置」を入力すると数直線上に表示するプログラムを書いてください。 数字は1以上9以下を仮定して大丈夫です。

より正確に書いておくと、実行手順は以下になります

  • ./a.outEnterを入力してプログラム実行開始。入力待ち状態になる。
  • a」を入力
  • ,」を入力
  • 3」を入力
  • Enter」を入力
  • 最初に戻り、まだ待機状態が続いている
  • Ctrl+D」を入力
  • 数直線が表示され、プログラムが終了

これに関する自動採点としては、上記の手順がcase2.txtというファイルに保存されているので、それを読み込んでリダイレクトで入力します。

  • 入力
    $ ./a.out < case2.txt
    
  • 出力
      0    5   10   15
    --+--a-+----+----+-->
    

ちなみに、タスクBが解ける場合、そのプログラムはタスクAも解ける、という感じになっています。なので、最終的に作ったプログラムは、全てのタスクを解けるようになっています。

タスクC

次に、複数の点をプロットできるようにしましょう。./a.outを実行し、a,3を押してEnterを行ったあと、次はa,5を入力しEnterを押し、そしてCtrl+DでEOFを与えるとします。すると結果が次のようになるようにしてください。

  0    5   10   15
--+--a-a----+----+-->

より正確な実行手順は以下になります

  • ./a.outEnterを入力してプログラム実行開始。入力待ち状態になる。
  • a」を入力
  • ,」を入力
  • 3」を入力
  • Enter」を入力
  • 最初に戻り、まだ待機状態が続いている
  • a」を入力
  • ,」を入力
  • 5」を入力
  • Enter」を入力
  • 最初に戻り、まだ待機状態が続いている
  • Ctrl+D」を入力
  • 数直線が表示され、プログラムが終了

ここで、入力される2つの数字は別のものだと仮定してOKです。自動採点は以下です。

  • 入力
    $ ./a.out < case3.txt
    
  • 出力
      0    5   10   15
    --+--a-a----+----+-->
    

タスクD

次に、「b」も入力できるようにしましょう。すなわち、b,7のように入力すると、数直線上にbを表示するようにしましょう。すなわち、「a,4」「b,7」「a,3」と入力した場合は

  0    5   10   15
--+--aa+-b--+----+-->

となるようにしてください。

  • 入力
    $ ./a.out < case4.txt
    
  • 出力
      0    5   10   15
    --+--aa+-b--+----+-->
    

全ての数字は異なると仮定して大丈夫です。

タスクE

最後に、15以下の二桁の数字も対応できるようにしましょう。すなわち、入力可能な数字は1以上15以下で、かつ全ての数字が違うものです。「a,2」,「a,10」,「b,13」,「a,8」,「b,5」とした場合、以下のようになるようにしてください。

  0    5   10   15
--+-a--b--a-a--b-+-->
  • 入力
    $ ./a.out < case5.txt
    
  • 出力
      0    5   10   15
    --+-a--b--a-a--b-+-->
    

week3_2

自由課題です。week3_1のプログラムをベースに、改造を施し、何か面白いものを作ってみましょう。 自動採点は無いです。面白かったものは次回か次々回に紹介するかもしれません。

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

改造の例は例えば以下ですが、自由な発想で作ってみてください。

  • 数直線の長さを変更する
  • ゼロや負の数など、より幅広い数字を受け付ける
  • 数直線や記号に色をつける
  • 同じ数字を複数回入力すると、何かが起こる
  • x, yの二次元にする
  • 入力時間に制限を設ける

何を作ってもらってもいいのですが、あくまでc言語でお願いします。好きなライブラリをincludeしてもらって大丈夫です。ただ、松井の環境で実行できるように注意してください。つまり、自分の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>
// 上記以外は何もincludeしないでください


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

int char2num(char c) {
    // 文字である「'3'」とかを数字の「3」にする
    return c - '0';
}

int isnum(char c) {
    // 入力のcが、数字を表すかどうか調べる
    int n = char2num(c);
    if (0 <= n && n < 10) {
        return 1;
    } else {
        return 0;
    }
}


int main() {
    // ==== ここから下に記入 =====

    char buff[100] = {0};
    int i = 0;    
    int c = getchar();

    // EOFが出るまで全てbuffに詰める
    while (c != EOF) {  // 最後でなければ
        buff[i++] = c;
        c = getchar();
    }

    // a, bの出現位置の記録用
    int a[100] = {0};
    int b[100] = {0};
    int j = 0;  // aの配列の記入位置の記録用
    int k = 0;  // bの配列の記入位置の記録用

    for (int n = 0; n < 100; ++n) {  // nでbuffを周回
        if (buff[n] == 'a') {  // 'a' が出た場合は、buffの先を見て、数字を取得。
            if (isnum(buff[n + 3])) {  // 2桁の場合
                a[j++] = char2num(buff[n + 2]) * 10 + char2num(buff[n + 3]);
            } else {  // 1桁の場合
                a[j++] = char2num(buff[n + 2]);
            }
        } else if (buff[n] == 'b') {   // 'b'も同様
            if (isnum(buff[n + 3])) {
                b[k++] = char2num(buff[n + 2]) * 10 + char2num(buff[n + 3]);
            } else {                
                b[k++] = char2num(buff[n + 2]);
            }
        } else {
            continue;
        }
    }


    char bar[] = "--+----+----+----+-->";
    // 数直線上に'a', 'b'を記載
    for (int n = 0; a[n] != 0; ++n) {
        bar[2 + a[n]] = 'a';
    }
    for (int n = 0; b[n] != 0; ++n) {
        bar[2 + b[n]] = 'b';
    }

    printf("  0    5   10   15\n");
    printf("%s\n", bar);

    return 0;
}