0
1

C、C++を理解する8題

Last updated at Posted at 2024-07-31

Cはもともとアセンブラの代替品だった

Cはもともと、UNIXを開発するために作られた言語でした。
初期のUNIXはアセンブリ言語といい、機械語とほぼ1:1になるような言語で書かれていましたが、それではコーディングの技術的な難易度が非常に高い、などの問題がありました。

そこで、アセンブリ言語ではない言語が必要と考えられ、Bという言語が開発されることになったのです。しかし、Bは直接機械語コードを生成するタイプの言語ではなかったため処理速度が大変遅く、結局UNIX本体の記述に用いられることはありませんでした。

その後、Bの改良版としてNB(NewB)という言語が誕生し、これが、後にCと呼ばれるようになります。1973年、UNIXはほぼ全体がCで書き直されました。

その後、Cは爆発的に普及し、OSのみならずアプリの開発にも幅広く使われるようになりました。

この記事では、そんなC、およびC++の文法を理解する問題を出題、解説してみたいと思います。

【問題】#include

以下のコードについて、b.cppでは#include <iostream>がないが、エラーになりますか?

a.cpp
#include <iostream>
using namespace std;
b.cpp
#include "a.cpp"

int main(){
    cout << "main" << endl;
};

【解答】 エラーになりません。

Javaであれば、例えばjava.util.ArrayListをA.javaとB.javaで使うのであれば、両ファイルでimportしなければなりません。たとえクラスAがクラスBをimportしていようが継承していようが、それは変わりない、つまり、importはファイル単位で完結します。

しかしC、C++では、b.cppがa.cppを#includeしていれば、a.cppの#include がb.cppにも作用します(引き継がれる)。
b.cppで敢えて#include <iostream>を書く必要はありません(※書いても不都合はない)。つまり、Javaに比べて依存関係が複雑になりがちです。ひとつのファイルを編集すると、それをincludeしているファイルが芋づる式にエラーになるということがよくあります。

また、Javaのimportはディレクトリ構造と密接に結びついていますが、C、C++の#includeはそうではありません。ファイル名のみの記述で済みます。

【問題】ポインタ

以下のコードの実行結果を答えよ。

1, 5 10
2, 10 5
3, 10 10
4, 5 5
5, エラーになる

problem.cpp
#include <stdio.h>

void swap(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

int main() {
    int x = 5, y = 10;
    int* p1 = &x;
    int *p2 = &y;
    swap(p1, p2);
    printf("%d %d\n", *p1, *p2);
    return 0;
}

解答 2

ポインタに関する問題です。swap関数を使用して、2つの整数変数xとyの値を入れ替えています(ポインタの解説サンプルコードとしてswapはよくありがちですね)。

ポインタの使い方を文法として理解するときは、*(宣言子、間接参照演算子)と&(アドレス演算子)の使い方を押さえることが重要です。

特に紛らわしいのが*の用法です。
C、C++の*には3つの意味があります。

*の3つの意味
1、宣言子        :ポインタ変数の定義時に使用される。変数がポインタ型であることを示す。
2、乗算演算子    :定数や変数に対して乗算を行うことを示す。
3、間接参照演算子:ポインタが指し示す番地のメモリを示す。

乗算演算子はすぐに見分けがつきますが、宣言子と間接参照演算子は紛らわしいですね。見分け方としては*の左にデータ型が存在するかどうかにより区別しましょう。宣言のときの*は、式の中に現れる演算子の*とは、まったくの別物です。

問題文のコードについて見てみると、まず、int* p1 = &x; int *p2 = &y;ですが、p1はxのアドレス(メモリの番地)を指し、p2はyのアドレス(メモリの番地)を指します。
*は変数名に型名につけても変数名につけても同じポインタ変数としての定義になります。これも紛らわしい要因のひとつですね。
※ただし、型名* 変数名のように*を型の方に寄せて書くと、複数の変数を同時に宣言することはできません。

例:intへのポインタ型の変数を2つ宣言、にはなっていない!
int* hoge_p, piyo_p;

次にswap関数の処理ですが、
int temp = *a; により、temp に*a(つまり x の値 5)を代入します。
*a = *b; により、*a(つまり x の値)に *b(つまり y の値 10)を代入します。これで x の値は 10 になります。
*b = temp; により、*b(つまり y の値)に temp(つまり 5)を代入します。これで y の値は 5 になります。

特に間接参照演算子が理解しにくい部分だと考えているのですが、*a = *b;*b = temp;ですが、a = *b;b = temp;ではerror: invalid conversion from 'int' to 'int*'というエラーになります(コンパイラにもよりますが、g++の場合)。

この時、参照先メモリの読み書きをしたいのか、アドレス(メモリの番地)の読み書きをするのか、を強く意識する必要があります。*を付けることで「ポインタから間接的にメモリにアクセスしますよ」という意味になります。

【問題】前方宣言

以下のコードについて、誤っている箇所はあるか?
1.class A;でエラーになる。
2.b.hに#include "aaa.h"が必要。
3.誤りはない。

a.h
class A {
public:
    int x;
};
b.h
class A;

class B {
public:
    A* a;
};

解答 3

クラスの前方宣言に関する問題です。

前方宣言とは、C++において、特定の識別子(変数、関数、クラスなど)がプログラムの後の部分で定義されることをコンパイラに伝える方法です。これにより、コンパイラは完全な定義を知らなくても、その識別子の存在を認識し、プログラムの残りの部分でその識別子を適切に処理できるようになります。

前方宣言はポインタや参照のようなクラスの完全な定義を必要としないメンバ変数(今回はA* a;)を宣言する場合に使用されます。

前方宣言の長所は以下です。
・クラス定義をヘッダーファイルにインクルードせずに済むため、ファイル間の依存関係を減らすことができます。
・依存関係が減ることにより、コンパイル時間の短縮にも繋がります。
・相互参照が可能になります。例として以下。

a.h
class ClassB;  // ClassBの前方宣言

class ClassA {
public:
    ClassB* b;
};
b.h
class ClassA;  // ClassAの前方宣言

class ClassB {
public:
    ClassA* a;
};

【問題】const

以下のコードはエラーになるか?
1,メソッドaaaでエラーになる
2,メソッドbbbでエラーになる
3,エラーにならない

#include <iostream>
using namespace std;

void aaa(int* const a){
    *a = *a + 1;
    cout << *a << endl;
}

void bbb(const int* b){
    *b = *b + 1;
    cout << *b << endl;
}

int main(){
    int v = 1;
    aaa(&v);
    bbb(&v);
}

解答 2

constの「位置」によって意味が異なります。具体的には、ポインタと、その指す先の値のどちらが定数であるかが違います。

int* const a は「ポインタaは定数であり、変更できない」を意味します。a*の値は変更可能です。
const int* aは「ポインタaが指す先の値は定数であり、変更できない」を意味します。a自体は変更可能です(別のアドレスを指すようにすることは可能)。

わかりづらいですが、constのすぐ右側が定数になると考えるとわかりやすくなるかと思います。

【問題】構造体

C++における構造体について、誤っているものを選べ。

1,構造体は継承可能である。
2,構造体にメソッドを定義することは可能である。
3,構造体のデフォルトのアクセス指定子はクラスと同じである。
4,構造体にコンストラクタは定義可能である。

【解答】 3

C++において、クラスと構造体はほぼ同じであり、デフォルトのアクセス指定子が唯一の違いです(構造体はpublic、クラスはprivate)。
しかし、アクセス修飾子は明示的に指定することが推奨されており、実際のコードでの使用頻度はそれほど高くないでしょう。

【問題】パディングとアライメント

以下のコードにおいて、構造体StructAAAのバイトサイズはいくつになるか?
なお、各データ型のバイトサイズは以下とする。

int:4
short:2
ポインタ変数:8
#include <iostream>
using namespace std;

struct StructBBB{
    int value1;
    int value2;
    short value3;
    short value4;
};

struct StructAAA{
    char value0;
    int value1;
    int value2;
    short value3;
    StructBBB* value4;
    StructBBB value5;
};

int main(){
    StructAAA structAAA;
    cout << sizeof(structAAA) << endl;
}

解答 40

まず、StructAAAに内包されているStructBBBのサイズは、

    int value1;→4バイト
    int value2;→4バイト
    short value3;→2バイト
    short value4;→2バイト

であり、そのまま加算すると12になります。

ではStructAAAはどうでしょうか?

    char value0;→1バイト
    int value1;→4バイト
    int value2;→4バイト
    short value3;→2バイト
    StructBBB* value4;→8バイト
    StructBBB value5;→12バイト

そのまま加算すると、31ですが、実際の挙動はそうはなりません。
これには、アライメントを理解する必要があります。
アラインメント(メモリアラインメント)とは、変数がメモリ上で配置される際の「位置調整」のことを指します。
CPUの都合によって,コンパイラが適当に境界調整(アライメント)を行い,構造体にパディングを挿入する挙動となるのです。

char value0 は1バイトです。次にint型が続くため、3バイトのパディングが必要です。
short value3 は2バイトです。次にポインタ型が続くため、2バイトのパディングが必要です。

パディング分を合わせると、31 + 3 + 2 = 36バイトになります。

image.png

また、構造体の全体サイズは、構造体のメンバ変数の中で最大バイトの倍数にする必要があります。今回はStructBBB* value4ですね。そのため、構造体のサイズは8バイトの倍数になります。36は8の倍数ではないので、さらに4バイトのパディングが追加されます。

したがって、StructAAA の最終的なサイズは 40バイトになります。

このアラインメントは実は特定のプログラミング言語に起因する問題ではなく、メモリアクセスを高速にするためのハードウェア(CPU)に起因する問題です。C/C++ のアラインメントに関する仕様は,CPUの制約を「代弁」しているに過ぎません。詳しくはこちらを参照。

しかし、データサイズの決まり方を知っておくことは、知識として必要と考え問題にしてみました。

【問題】static

以下のコードの実行結果を選べ。
1,正常に実行され、0が返る。
2,正常に実行され、3が返る。
3,エラーになる。

aaa.cpp
static int fileScopeVar = 0;

void incrementFileScopeVar() {
    fileScopeVar++;
}
bbb.cpp
#include <iostream>
using namespace std;

extern void incrementFileScopeVar();

int main() {
    incrementFileScopeVar();
    incrementFileScopeVar();
    incrementFileScopeVar();
    std::cout << fileScopeVar;
    return 0;
}

解答 3

ここでのポイントはstaticの意味です。

C++において、staticには3種類の意味があり、文脈によって異なります。
・メソッド内のstatic変数
・クラスメンバのstatic変数
・ファイルスコープのstatic変数

** 1、メソッド内のstatic変数 **
メソッド内で宣言されたstatic変数は、そのメソッド内でのみアクセス可能ですが、変数の寿命はプログラムの実行期間中ずっと続きます。初回の関数呼び出し時に初期化され、その後の関数呼び出しでは同じインスタンスが使用されます。

sample.cpp
#include <iostream>

void foo() {
    static int count = 0; // メソッド内のstatic変数
    count++;
    std::cout << "Count: " << count << std::endl;
}

int main() {
    foo(); // 出力: Count: 1
    foo(); // 出力: Count: 2
    foo(); // 出力: Count: 3
    return 0;
}

** 2,クラスメンバのstatic変数 **
クラス内で宣言されたstatic変数は、そのクラスの全インスタンスで共有されます。すなわち、クラスの全インスタンスが同じstaticメンバ変数を共有します。これはJavaと同様なので理解しやすいですね。

なお、Cにはクラスメンバのstatic変数は存在しません(そもそもクラスの概念がないため)。

sample.cpp
#include <iostream>

class MyClass {
public:
    static int sharedValue; // クラスメンバーのstatic変数

    void increment() {
        sharedValue++;
    }
};

int MyClass::sharedValue = 0; // staticメンバ変数の定義

int main() {
    MyClass obj1, obj2;
    obj1.increment();
    obj2.increment();

    std::cout << "Shared Value: " << MyClass::sharedValue << std::endl; // 出力: Shared Value: 2
    return 0;
}

** 3,ファイルスコープのstatic変数 **
関数やクラスの外で宣言されたstatic変数は、その変数が宣言されたファイル内でのみアクセス可能です。
今回の問題はこれに該当します。

fileScopeVarのスコープはaaa.cppに限定されているため、aaa.cppから参照することはできず、エラーになります。

ファイルスコープのstatic変数は、C++におけるクラスのprivateメンバ変数と、ある点では似ていますが、異なる点もあります。

【共通点】
両者とも、外部からの直接アクセスを防ぐために使用されます。ファイルスコープのstatic変数は他の翻訳単位からのアクセスを防ぎ、privateメンバ変数はクラス外からのアクセスを防ぎます。

【相違点】
・ファイルスコープが異なります。static変数のスコープはファイル全体ですが、privateメンバ変数のスコープはクラスの内部です。
・寿命が異なります。ファイルスコープのstatic変数はプログラムの実行期間中ずっと存在しますが、privateメンバ変数はクラスのインスタンスの寿命に依存します。

【問題】コンストラクタ

main関数の中でコンストラクタを実行したい。

コンストラクタが実行されるものは以下のうちどれか?
1,ClassA a;
2,ClassA* a;
3,ClassA a = new ClassA();
4,ClassA* a = new ClassA();

sample.cpp
class ClassA{

};

int main(){
}

解答 1と4

まず、Javaと同様、ClassAにデフォルトコンストラクタが定義されていなくても、他のコンストラクタが全く定義されていない場合には、コンパイラは暗黙的にデフォルトコンストラクタを生成します。

ClassA a;はClassAのデフォルトコンストラクタを呼び出しています。
ClassA* a;は単にClassA型のオブジェクトを指すポインタ変数aを宣言しています。この時点ではポインタは初期化されていないので、どのメモリアドレスを指しているかは未定義です。
ClassAのオブジェクトを作成しポインタ変数ClassA* aに格納したい場合は、ClassA a = new ClassA();と、明示的なコンストラクタの実行をする必要があります。

※メモリとの関係
ClassA a;のように自動変数(スタック変数)としてオブジェクトを生成する場合、メモリはスタック領域に割り当てられます。スタック変数はスコープを抜けると自動的に破棄され、メモリも自動的に解放されます。したがって、明示的にdelete 演算子を使用してメモリ解放をする必要はありません。
new ClassA()のようにnew演算子を使用してオブジェクトを生成する場合、そのオブジェクトはヒープ領域に動的に割り当てられます。スタック領域と異なり、ヒープ領域のサイズはプログラムの実行中に変化します。new演算子を使うたびにヒープ領域が拡張され、delete演算子を使うたびに縮小します。不要になった際には、メモリリークを回避するため、deleteを使ってメモリを解放してあげる必要があります。

なお、選択肢3ClassA a = new ClassA();はそもそも型エラーのため実行できません。

0
1
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1