🌠

👽C言語で学ぶプログラミング:構造体編

に公開

C言語における構造体は、関連する異なるデータ型の変数を一つにまとめるための強力な機能です。これにより、プログラム内で情報をより論理的かつ効率的に扱うことが可能になります。本記事では、構造体の基本的な導入から、関数やポインタ、配列との連携まで、その活用方法をZennの記事形式で分かりやすく解説します。


1. 構造体とは?なぜ使うのか?

プログラムを実行する際、様々な情報を記憶するための「変数」が必要です。変数は「名前」「型」「値」「住所」の4つの要素を持ち、それぞれが独立した「箱」として機能します。

しかし、現実世界の対象(例えば「人」や「点」など)は、単一の変数だけでは表現しきれない複数の属性を持つことがほとんどです。例えば、人を表現するには「年齢 (int)」「身長 (double)」「体重 (double)」「名前 (char配列)」など、複数の異なる型の情報が必要です。

このような関連するデータ(変数)をひとまとめにしたデータ構造構造体です。構造体を用いることで、型の異なる複数の変数を「一つの箱の中を仕切りで分けている」ようなイメージで扱えるようになります。これは、プログラマが自由に定義できるデータ型であり、関連するデータを一つの単位として扱うことを可能にします。

構造体を使う利点としては、主に以下の点が挙げられます:

  • 宣言する変数の個数を減らせる: 関連する情報を個別の変数で持つ代わりに、一つの構造体変数で管理できます。
  • 関数の引数の個数を減らせる: 関連する複数の情報を関数に渡す際に、構造体変数一つを引数として渡すだけで済みます。
  • 各変数の意味・意図が明確になる: 関連するデータがグループ化されることで、コードの可読性と保守性が向上します。

2. 構造体の定義とメンバ

構造体を利用するためには、まずその「型」を定義する必要があります。この定義は、どのような変数(メンバ)を構造体の中に持つかを指定するものです。

構造体の宣言の基本形

構造体は struct キーワードを用いて宣言します。

struct 構造体タグ名 {
    メンバのリスト; // セミコロン区切りで変数宣言を並べる
};
  • 構造体タグ名: 構造体の「型」の名前を自分で決めます。
  • メンバ: 構造体に含まれる個々のデータ要素(変数)を指します。メンバの個数や型は任意で、様々なデータ型を自由に組み合わせることができます。

例:struct humanstruct point

人のデータを表現する struct human と、平面上の点を表現する struct point の定義を見てみましょう。

// 人を表す構造体
struct human {
    int age;          // 年齢 (int型)
    double height;    // 身長(cm) (double型)
    double weight;    // 体重(kg) (double型)
    char name;    // 名前 (char配列)
};

// 平面上の点を表す構造体
struct point {
    int x;            // x座標 (int型)
    int y;            // y座標 (int型)
};

ここでは age, height, weight, namestruct human のメンバであり、x, ystruct point のメンバです。


3. 構造体変数とメンバへのアクセス (ドット演算子)

構造体の型を定義したら、その型を使って構造体変数を宣言できます。

構造体変数の宣言

struct キーワードと構造体タグ名を用いて変数を宣言します。

struct point d;      // struct point型の変数 d を準備
struct human bunri;  // struct human型の変数 bunri を準備

これにより、指定された構造体の「箱」がメモリ上に確保されます。

メンバへのアクセス (ドット演算子 . )

構造体変数の個々のメンバにアクセスするには、ドット演算子 (.) を使用します。

// struct point の例
struct point d;
d.x = 10;      // dのxメンバに10を代入
d.y = d.x * 2; // dのyメンバにd.xの値の2倍を代入

printf("%d, %d\n", d.x, d.y); // 出力: 10, 20

d.xd.y は、それぞれが独立した変数として扱われます。

文字列を扱うメンバの場合、strcpy 関数を使用することが一般的です。

#include <stdio.h>
#include <string.h> // strcpyを利用するため

struct human {
    int age;
    double height;
    double weight;
    char name;
};

int main(void) {
    struct human bunri;
    bunri.height = 170.8;
    bunri.weight = 60.4;
    bunri.age = 32;
    strcpy(bunri.name, "Bunri"); // 文字列をコピー

    printf("%s, %g, %g, %d\n", bunri.name, bunri.height, bunri.weight, bunri.age);
    return 0;
}

bunri.name は配列型メンバのため、直接代入 (bunri.name = "Bunri") はできません。文字列のコピーには strcpy 関数が必要です。


4. 構造体の初期化と代入

構造体変数は、宣言と同時に初期値を設定したり、同じ型の別の構造体変数に代入したりすることができます。

構造体変数の初期化

配列と同様に、カンマ (,) で区切られた初期値を {} で囲んで順に並べることで、各メンバに初期値を設定できます。

struct human bunri = { 32, 170.8, 60.4, "BUNRI", };

この例では、age に 32、height に 170.8、weight に 60.4、name に "BUNRI" がそれぞれ初期化されます。

構造体変数の代入

同じ型の構造体変数であれば、通常の代入文 (=) で、メンバの値をまとめて代入できます。この際、各メンバの値が全てコピーされます。

struct human bunri = { 32, 170.8, 60.4, "BUNRI", };
struct human nihon;

nihon = bunri; // bunriの全メンバの値がnihonにコピーされる

printf("%s, %g, %g, %d\n", nihon.name, nihon.height, nihon.weight, nihon.age);
// 出力: BUNRI, 170.8, 60.4, 32

これは、個々のメンバに値をコピーする手間を省き、コードを簡潔にします。


5. 構造体のネスト (入れ子)

構造体のメンバには、他の構造体変数を採用することができます。これを**構造体のネスト(入れ子)**と呼びます。

例:struct studentstruct address を含める

// 住所と電話番号を表す構造体
struct address {
    char str;  // 住所文字列
    char tel;  // 電話番号文字列
};

// 学生情報を表す構造体 (addressをメンバに持つ)
struct student {
    int age;
    double height;
    double weight;
    char name;
    struct address addr; // struct address型のメンバ
};

struct studentaddr というメンバを持ち、この addr メンバ自体が struct address 型の構造体です。

ネストされたメンバへのアクセス

ネストされた構造体のメンバにアクセスするには、複数のドット演算子を連結して使用します。

int main(void){
    struct student bunri = {
        32, 170.8, 60.4, "BUNRI",
        { "3-25-40, Sakurajosui, Setagaya-ku, Tokyo","03-5317-9746", }, // addrの初期化部分
    };

    printf("%s\n", bunri.addr.str); // bunriのaddrメンバのstrメンバにアクセス
    printf("%s\n", bunri.addr.tel); // bunriのaddrメンバのtelメンバにアクセス
    // 出力:
    // 3-25-40, Sakurajosui, Setagaya-ku, Tokyo
    // 03-5317-9746

    return 0;
}

bunri.addr.str のように、変数名、ネストされた構造体メンバ名、その中のメンバ名を順にドットで繋いでアクセスします。


6. 構造体と関数 (値渡し)

構造体変数を関数に渡す際、基本的な挙動は他の変数と同じく値渡しです。

構造体変数の値渡し

関数を呼び出す側では、実引数の「式」が評価され、その**値(構造体全体のコピー)**が関数側の仮引数に渡されます。

#include <stdio.h>

struct human {
    int age;
    double height;
    double weight;
    char name;
};

void printHuman(struct human p) { // p は bunri のコピー
    printf("%s, %f, %f, %d\n", p.name, p.height, p.weight, p.age);
}

int main(void) {
    struct human bunri = {32, 170.8, 60.4, "BUNRI",};
    printHuman(bunri); // bunri の値(コピー)が p に渡される
    return 0;
}

printHuman 関数内の pmain 関数の bunri とは別の変数です。そのため、printHuman 関数内で p のメンバを変更しても、main 関数の bunri には影響しません。

これは、大規模な構造体を渡す際に、多くの時間とメモリを消費するという問題点にもなり得ます。

構造体を返す関数

関数が構造体を戻り値として返すことも可能です。

struct human input( ) {
    struct human h;
    printf("Input Name "); scanf("%s", h.name);
    printf("Input Height "); scanf("%lf", &h.height);
    printf("Input Weight "); scanf("%lf", &h.weight);
    printf("Input Age "); scanf("%d", &h.age);
    return h; // h の値(コピー)が戻り値として返される
}

int main(void) {
    struct human v;
    v = input(); // input 関数が返した構造体の値が v に代入される
    printHuman(v);
    return 0;
}

return h; の動作は、h を評価した(構造体全体のコピー)が返されることを意味します。


7. 構造体とポインタ

値渡しによるコピーのコストや、呼び出し元の変数を関数内で変更したい場合など、構造体でもポインタが非常に有用です。

構造体へのポインタ

構造体変数のアドレスを保持するポインタ変数を宣言できます。

struct dCircle {
    double x; // 中心x座標
    double y; // 中心y座標
    double r; // 半径
};

int main(void) {
    struct dCircle c1 = {4.0, 5.2, 2.1};
    struct dCircle *ptr; // struct dCircle型のポインタ変数ptrを宣言

    ptr = &c1; // c1のアドレスをptrに代入
    // ...
    return 0;
}

struct dCircle *ptr; のように、構造体タグ名の後に * をつけることで、その構造体型へのポインタを宣言します。

メンバへのアクセス (アロー演算子 ->)

構造体へのポインタを使ってメンバにアクセスする方法は2通りあります。

  1. 間接演算子 * とドット演算子 . を組み合わせる: (*ptr).x のように記述します。*ptr でポインタが指す構造体変数自体を参照し、その後ドット演算子でメンバにアクセスします。この際、*ptr括弧で囲む必要があります (*ptr.x*(ptr.x) と解釈されエラーになるため)。
  2. アロー演算子 -> を使う: ptr->x のように記述します。これは (*ptr).x と全く同じ意味であり、構造体ポインタのメンバアクセスで一般的に使用される簡潔な表記です。
// アロー演算子を使った例
ptr->x = 1.1;
ptr->y = ceil(ptr->y);
ptr->r = floor(ptr->r);

printf("%f, %f, %f\n", ptr->x, ptr->y, ptr->r); // ptr経由で変更された値が出力される
printf("%f, %f, %f\n", c1.x, c1.y, c1.r);       // c1の値も変更されている
// 出力:
// 1.100000, 6.000000, 2.000000
// 1.100000, 6.000000, 2.000000

ptr->xc1.x も同じメモリ上の場所を参照しているため、どちらで変更しても結果は同じです。

構造体ポインタを引数とする関数

関数内で呼び出し元の構造体変数を変更したい場合、その構造体へのポインタを引数として渡します。

void setXYR(struct dCircle *ptr, double x, double y, double r) {
    ptr->x = x; // アロー演算子を使って呼び出し元の構造体のメンバを変更
    ptr->y = y;
    (*ptr).r = r; // ドット演算子を使う形でもOK
}

void setXYR_ng(struct dCircle v, double x, double y, double r) { // 値渡しなので変更は反映されない
    v.x = x;
    v.y = y;
    v.r = r;
}

int main(void) {
    struct dCircle c;
    setXYR(&c, 1.1, 2.2, 3.3); // cのアドレスを渡す
    printf("(%g,%g,%g)\n", c.x, c.y, c.r);
    // 出力: (1.1,2.2,3.3) -> 呼び出し元のcが変更された

    setXYR_ng(c, 0.1, 0.2, 0.3); // cの値をコピーして渡す
    printf("(%g,%g,%g)\n", c.x, c.y, c.r);
    // 出力: (1.1,2.2,3.3) -> 呼び出し元のcは変更されない

    return 0;
}

setXYR 関数では、ポインタ ptr を介して main 関数の c のメンバが直接変更されます。一方、setXYR_ng は値渡しのため、c のコピーに変更が加えられるだけで、元の c は変更されません。

構造体ポインタを返す関数

関数は構造体へのポインタを返すこともできます。

struct dCircle* larger(struct dCircle *c1, struct dCircle *c2) {
    if (c1->r > c2->r) {
        return c1; // 半径が大きい方の円へのポインタを返す
    } else {
        return c2;
    }
}

int main(void) {
    struct dCircle c1 = { 4.1, 5.2, 2.1 };
    struct dCircle c2 = { 3.4, 3.5, 0.8 };

    struct dCircle *p = larger(&c1, &c2); // 半径が大きいc1へのポインタがpに代入される
    p->x = 0.1; // pが指す構造体 (c1) のxメンバを変更
    p->y = 0.1;

    printf("(%g, %g, %g)\n", c1.x, c1.y, c1.r);
    // 出力: (0.1, 0.1, 2.1) -> c1が変更された

    return 0;
}

この例では、larger 関数は二つの円の半径を比較し、大きい方の円を指すポインタを返します。返されたポインタ p を介してメンバを変更すると、それが指す元の構造体 (c1) の値が更新されます。

注意点: 関数内で宣言された局所変数へのポインタを返すのは非常に危険です。局所変数は関数が終了すると使えなくなるため、そのアドレスを返しても無効なポインタとなり、予期せぬ動作を引き起こす可能性があります。上記の例では、c1c2main 関数の局所変数であり、larger 関数が返すのはこれらのアドレスであるため問題ありません。


8. ポインタ型メンバを持つ構造体

構造体のメンバとして、ポインタ変数を採用することも可能です。

ポインタ型メンバの例

#include <stdio.h>

struct s_value {
    int v;    /* 通常の変数 */
    int *sum; /* ポインタ変数 */
};

void add_n(struct s_value s, int n) { // 値渡し
    s.v += n;
    *s.sum += n; // ポインタが指す先の値を変更
}

int main() {
    int sum = 0;
    struct s_value s1 = { 1, &sum };  // sumのアドレスで初期化
    struct s_value s2 = { 10, &sum }; // sumのアドレスで初期化

    // s1.sum と s2.sum は異なる変数だが、格納している値は同じ(sumへのポインタ)
    // よって、*s1.sum と *s2.sum は同じ変数 sum を指す

    *s1.sum = *s1.sum + s1.v; // sum = 0 + 1 = 1
    printf("s1.v == %2d, *s1.sum == %2d\n", s1.v, *s1.sum); // 出力: s1.v ==  1, *s1.sum ==  1

    *s2.sum = *s2.sum + s2.v; // sum = 1 + 10 = 11
    printf("s2.v == %2d, *s2.sum == %2d\n", s2.v, *s2.sum); // 出力: s2.v == 10, *s2.sum == 11

    add_n(s2, 10); // s2 のコピーが渡される
    // 関数内での s.v += n は s2.v に影響しない
    // 関数内での *s.sum += n は *s2.sum (つまり sum) に影響する
    printf("s2.v == %2d, *s2.sum == %2d\n", s2.v, *s2.sum); // 出力: s2.v == 10, *s2.sum == 21 (sum = 11 + 10 = 21)

    return 0;
}

この例では、s1.sums2.sum は異なるポインタ変数ですが、どちらも同じ sum 変数(アドレス)を指しています。そのため、どちらか一方のポインタを介して *s.sum の値を変更すると、もう一方のポインタを介してもその変更が反映されます。

add_n 関数に s2 が値渡しされる際、s2.v の値と s2.sum の値(sum のアドレス)がコピーされます。関数内で s.v を変更しても元の s2.v には影響しませんが、*s.sum を変更すると、ポインタが指す先の sum 変数が変更されるため、main 関数内の sum の値も更新されます。

配列型メンバとポインタ型メンバの動作の違い

特に文字列を扱う際に、char 配列をメンバとする場合と char ポインタをメンバとする場合で動作に違いが生じます。

#include <stdio.h>
#include <string.h> // strcpyを利用するため

struct full_name {
    char l_name;/* 苗字 : 配列 */
    char *f_name;   /* 名前 : ポインタ */
};

void str_cpy(char *src, char *dst){
    while(*src != '\0'){ *dst++ = *src++; } *dst = '\0';
}

void set_name(struct full_name n, char l_name[], char *f_name){ // n は値渡し
    str_cpy(l_name, n.l_name); // n.l_name (配列) に値をコピー
    n.f_name = f_name;         // n.f_name (ポインタ) にアドレスを代入
}

void print_out(struct full_name n){
    printf("%s %s\n", n.l_name, n.f_name);
}

int main(void){
    char l_name_main[] = "myoji";
    char f_name_main[] = "namae";

    struct full_name n;
    str_cpy(l_name_main, n.l_name); // 配列メンバにコピー
    n.f_name = f_name_main;         // ポインタメンバにアドレスを設定
    print_out(n); // 出力: myoji namae

    // メンバの値を直接修正
    str_cpy("MYOJI", n.l_name); // n.l_name の値を "MYOJI" に変更
    str_cpy("NAMAE", n.f_name); // n.f_name が指す先の値を "NAMAE" に変更
    print_out(n); // 出力: MYOJI NAMAE

    // 関数経由で値を修正? (set_name は値渡し)
    set_name(n, "XXX", "YYY"); // n のコピーが set_name に渡される
    print_out(n); // 出力: MYOJI NAMAE (main関数内のnは変更されない)

    return 0;
}

set_name 関数が値渡しされるため、関数内の n.l_namen.f_name の変更は、main 関数内の n には反映されません。しかし、n.f_name = f_name; のようにポインタにアドレスを代入する操作は、n のコピー内で行われるため、仮にポインタが指す先の値自体を変更する操作 (str_cpy(f_name, n.f_name); のように n.f_name を書き換え対象として使う場合) であれば、それは元の変数が指す場所のデータも変更することになります。この例では、set_name 内で n.f_name = f_name; と行われていますが、f_name は "YYY" を指すリテラルのアドレスであり、str_cpy(f_name, n.f_name) ではなく n.f_name = f_name; とポインタの代入が行われているため、main 関数内の n.f_name は元の f_name_main を指したままとなります。

もし set_name の中で str_cpy("YYY", n.f_name); と呼び出し、かつ n.f_namemain 関数で f_name_main のアドレスをコピーされていた場合、main 関数内の f_name_main の内容が "YYY" に変更されることになります。これは、ポインタ型メンバの「値のコピー」が「アドレスのコピー」であるため、複数のポインタが同じ場所を指す可能性があるからです。


9. 構造体の配列

複数の構造体変数をまとめて管理したい場合、構造体の配列を作成できます。

構造体配列の宣言と初期化

配列と同様に、「構造体の型名」の後に配列名と要素数を指定して宣言します。初期化も、各構造体要素を {} で囲み、それらをさらに外側の {} で囲みます。

#include <stdio.h>

struct student {
    char name;
    int eng;
    int math;
};

int main(void) {
    int i;
    struct student data = { // struct student型の配列dataを宣言・初期化
        {"Bob",   70, 45,},
        {"Mary",  62, 31,},
        {"Jack",  80, 79,},
        {"Betty", 90, 41,},
        {"Tom",   40, 71,},
    };

    for (i = 0; i < 5; i++) {
        printf("%7s, %3d, %3d\n", data[i].name, data[i].eng, data[i].math);
    }
    return 0;
}

data{"Bob", 70, 45} という struct student 型の変数であり、data.name で "Bob" にアクセスできます。

構造体配列の要素を関数に渡す

構造体配列の個々の要素を関数に渡す場合、その要素のアドレスをポインタとして渡すことで、呼び出し元の値を変更可能にします。

#include <stdio.h>
#define N 3

struct student {
    char name;
    int eng;
    int math;
};

void input(struct student* s) { // struct studentへのポインタを受け取る
    scanf("%s %d %d", s->name, &s->eng, &s->math); // アロー演算子でメンバにアクセス
}

void output(struct student* s) {
    printf("%7s, %3d, %3d\n", s->name, s->eng, s->math);
}

int main(void) {
    struct student data[N];
    for (int i = 0; i < N; i++) {
        input(&data[i]); // data[i] のアドレスを渡す
    }
    for (int i = 0; i < N; i++) {
        output(&data[i]);
    }
    return 0;
}

input(&data[i]); のように、各要素のアドレスを input 関数に渡すことで、関数内でその要素のメンバの値をキーボードから入力できます。

構造体配列全体を関数に渡す

配列を関数に渡すときと同様に、構造体配列の**先頭アドレス(ポインタ)**を渡すことで、配列全体を関数で処理できます。

void input_list(struct student* s, int n) { // struct studentへのポインタと配列の長さを受け取る
    for (int i = 0; i < n; i++) {
        // s[i].name, &s[i].eng, &s[i].math のようにアクセス可能
        // あるいはポインタ演算とアロー演算子を組み合わせて
        scanf("%s %d %d", (s+i)->name, &(s+i)->eng, &(s+i)->math);
    }
}

void output_list(struct student* s, int n) {
    for (int i = 0; i < n; i++) {
        printf("%7s, %3d, %3d\n", s[i].name, s[i].eng, s[i].math);
    }
}

int main(void) {
    struct student data[N];
    input_list(data, N); // 配列名 data (先頭アドレス) を渡す
    output_list(data, N);
    return 0;
}

input_list(data, N); では、data という配列名が先頭要素へのポインタとして関数に渡されます。関数内では s[i] のように配列として扱うこともできますし、*(s+i)(s+i)-> のようにポインタ演算を利用することも可能です。


10. 構造体配列をメンバに持つ構造体

構造体の中には、他の構造体の配列をメンバとして持つことができます。

例:struct polygon (多角形) と struct point (点)

#include <stdio.h>
#include <math.h>
#define N 1024

/* XY平面上の点 */
struct point {
    double x;
    double y;
};

/* 多角形 */
struct polygon {
    int n;             // 頂点数
    struct point vs[N]; // struct point型の配列をメンバとして持つ
};

/* 多角形の初期化 */
void init(struct polygon* p) { // 多角形へのポインタを受け取る
    scanf("%d", &p->n); // 頂点数を入力
    for (int i = 0; i < p->n; i++) {
        scanf("%lf %lf", &p->vs[i].x, &p->vs[i].y); // 各頂点の座標を入力
    }
}

/* 2点間距離を計算する補助関数 */
double dist(struct point* p1, struct point* p2) {
    double dx = p1->x - p2->x;
    double dy = p1->y - p2->y;
    return sqrt(dx * dx + dy * dy);
}

/* 周長を計算 */
double perimeter(struct polygon* p) {
    double sum = 0;
    for (int i = 0; i < p->n; i++) {
        sum += dist(&p->vs[i], &p->vs[(i + 1) % p->n]); // 頂点間の距離を合計
    }
    return sum;
}

int main(void) {
    struct polygon p;
    init(&p);
    printf("perimeter = %f\n", perimeter(&p));
    return 0;
}

struct polygonstruct point 型の配列 vs をメンバとして持っています。p->vs[i].x のように、アロー演算子と配列添字、ドット演算子を組み合わせてメンバにアクセスします。&p->vs[i] は、vs 配列の i 番目の struct point 要素のアドレスを指します。


まとめ

本記事では、C言語の構造体について、以下の主要な概念を学びました。

  • 構造体の導入: 関連する異なる型のデータを一つにまとめる「箱」としての役割。
  • 構造体の定義とメンバ: struct キーワードで新しいデータ型を定義し、その内部に「メンバ」として変数を宣言する方法。
  • 構造体変数とドット演算子: 定義した構造体型で変数を宣言し、変数名.メンバ名 の形式(ドット演算子)でメンバにアクセスする方法。
  • 構造体の初期化と代入: {} を使った初期化と、同じ型の構造体変数間での一括代入。
  • 構造体のネスト: 構造体のメンバとして別の構造体を含める方法と、変数名.メンバ名.メンバ名 の形式でアクセスする方法。
  • 構造体と関数 (値渡し): 構造体を関数に渡す際に、構造体全体のコピーが渡される「値渡し」の挙動。
  • 構造体とポインタ: 構造体のアドレスを指すポインタ変数を宣言し、ポインタ変数->メンバ名 の形式(アロー演算子)でメンバにアクセスする方法。関数内で元の構造体を変更するためにポインタを引数として渡す重要性。また、関数から構造体へのポインタを返す際の注意点(局所変数へのポインタを返さない)。
  • ポインタ型メンバを持つ構造体: 構造体内部にポインタ変数をメンバとして持つ場合、そのポインタが指す先のデータが共有される特性。
  • 構造体の配列: 構造体型の変数を複数扱うために配列として宣言・初期化する方法と、各要素や配列全体を関数に渡す方法。
  • 構造体配列をメンバに持つ構造体: 構造体の中に別の構造体の配列を含める方法。

構造体を使いこなすことで、C言語でのデータ管理がより整理され、大規模なプログラムも開発しやすくなります。ぜひこれらの概念を理解し、実践的なプログラミングで活用してみてください。

Discussion