C++の基礎(第1回)
参照渡し, オブジェクト指向プログラミング, STL, スマートポインタなど

今日はもうちょっとだけ(?)C++の文法について学んでいきます。ι-cppでやってきた内容は主にC++のCの部分でしたが、これからやる内容は++の部分です。

今日やること

  • const修飾子
  • 参照型
  • オブジェクト指向プログラミング
  • クラスの作り方
  • 継承・純粋仮想関数
  • const参照渡し・constメンバ関数
  • ポリモーフィズム
  • オブジェクト指向によるRay-Object Intersectionの設計指針
  • 演算子オーバーロード
  • Standard Template Library(STL)
  • スマートポインタ
  • スマートポインタとstd::vectorによる物体集合の表現
  • autoとGeneric-for文

const修飾子

const修飾子を変数の前につけるとその変数は書き換え不能になります。

#include <cstdio>


int main() {
    const int x = 1;

    x = 2; //コンパイルエラー

    return 0;
}

constを使うと変数の値を設定できるのは初期化時のみになります。定数などを表すときに使えます。

参照型

参照型はポインタと類似した機能です。

#include <cstdio>


int main() {
    int x = 1;
    int& y = x;

    printf("%d\n", y);

    y = 2;
    printf("%d\n", x);

    return 0;
}

int&はintの参照型を表しています。

int& y = x;

こうするとyはxへの参照を表すようになります。これだけ見るとポインタと変わらないように見えますが、参照型自体はメモリ上に存在していません。つまり、yはメモリ上には存在しないのです。ポインタの場合には

int* p = &x;

とすると、pはxへのアドレスを持った変数としてメモリ上に確保されます。ここがポインタと参照型の大きな違いです。

つまり、参照型とはxという変数にyという別名をつけるものと考えることができます。

別名をつけているだけなので、実体はxと同じです。yを書き換えるとxの値ももちろん変わります。

y = 2;
printf("%d\n", x);

実行結果

2

参照型を使うとポインタを使わずに参照渡しを実現することができるようになります。

#include <cstdio>


void f(int& x) {
    x = 2;
}


int main() {
    int x = 1;
    f(x);
    printf("%d\n", x);

    return 0;
}

実行結果

2

ポインタの場合は(*x) = 2のように値を操作する必要がありましたが、参照型の場合は通常の変数と同じように扱うことができるので便利です。

オブジェクト指向プログラミングとは?

オブジェクト指向プログラミング(Object Oriented Programming) とは、ある目的を達成するためのソフトウェアを小さな機能群の集まりに分割し、その機能群を一つ一つを実装してから、それらを部品として組み合わせることで、最終的な大きなソフトウェアを作るというプログラミングの方法のことを言います。

複雑なソフトウェアを作ろうと思ったときにあなたならまず何を作りますか? 例えばゲームを作ろうと思ったときには、まずプレイヤーを作り、敵を作り、マップを作り、・・・といったように、機能ごとに作っていこうと思うでしょう。

オブジェクト指向プログラミングはまさにこの作成過程とマッチしたプログラミングの手法であり、小さな石を積み重ねるようにして大きなプログラムを作っていくことができます。

分割された機能群は一つ一つ機能が独立しているので、大きなチームで開発を行うときには、一人ひとりが別のクラスを担当することで効率よく開発を進められます。

さらに、複雑な機能を持った巨大なソフトウェアが小さな機能群に分割されて整理されているので、バグが起きたときにも対処がしやすいというメリットがあります。

クラス

クラス(Class) はデータと関数を同時に持つ、自分で定義できるデータ型のことを言います。上の例で言うと分割された機能群を表すのにクラスが使われます。

graph {
  node [font=Courier, shape=Box]
  Player Bullet Map
}

あなたは今シューティングゲームを作っているとしましょう。シューティングゲームを作るにはプレイヤー、弾、マップ、...などを表すクラスが必要になるでしょう。ここでは試しにプレイヤーを表すクラスを作ってみましょう。

プレイヤーは体力と残機数をデータとして持っています。さらに弾を食らったときにダメージを受ける関数が必要でしょう。それらを考えると次のようなコードになります。

class Player {
  public:
    int health; //体力
    int life; //残機
    
    //体力100、残機数3で初期化する
    Player() {
      health = 100;
      life = 3;
    };
    //クラスが破棄されるときに呼ばれる
    ~Player() {}; 
    
    //弾に当たったときにダメージを計算する
    void getDamage() {
      health -= 10; //体力が10減る
      
      //体力が0になったら
      if(health <= 0) {
        life--; //残機が一つ減る
        health = 100; //体力を100に戻しておく
      }
    };
};

コードを一つ一つ説明していきましょう。

class Player {
 ...
};

こうすることで、Playerという名前のクラスを定義できます。{}の中にPlayerクラスの定義を書いていきます。

class Player {
    public:
      int health; //体力
      int life; //残機
    ...

ここではPlayerの持つデータが定義されています。healthはプレイヤー体力、lifeはプレイヤーの残機数を表しています。クラスが持つこのようなデータのことを メンバ変数 といいます。

publicアクセス指定子 というものです。他のクラスからPlayerクラスのメンバにアクセスしたい場合があるとします。例えばゲームシステムを管理するクラスでは、プレイヤーの残機数にアクセスして、0だったらゲームオーバーにするという処理が必要になるでしょう。

アクセス指定子をpublicに指定すると、他のクラスから自由にPlayerクラスのメンバにアクセスすることができます。

    //体力100、残機数3で初期化する
    Player() {
      health = 100;
      life = 3;
    };

Player()というクラス名と同じ名前の関数が定義されています。これは コンストラクタ(Constructor) といい、Playerクラスが作られたときに必ず実行される関数になっています。 この中でプレイヤーの体力、残機数を初期化しています。

    ~Player() {};

これは デストラクタ(Destructor) と呼ばれるもので、クラスが破棄されるときに実行される関数です。今回は特にやることがないので、中身は書いていません。

    //弾に当たったときにダメージを計算する
    void getDamage() {
      health -= 10; //体力が10減る
      
      //体力が0になったら
      if(health <= 0) {
        life--; //残機が一つ減る
        health = 100; //体力を100に戻しておく
      }
    };

getDamage()という名前の関数が定義されています。この関数はプレイヤーが弾に当たったときに呼ばれ、プレイヤーの体力、残機数を更新する処理を行っています。このようにクラスに定義された関数のことを メンバ関数 といいます。

これでPlayerクラスを作ることができました。実際に使うには次のようにします。

int main() {
  //Playerを作る。コンストラクタが呼ばれる
  Player ply = Player();
  
  //Playerの体力と残機を出力
  printf("health:%d life:%d\n", ply.health, ply.life);
  
  //ダメージを与える
  ply.getDamage();
  
  //もう一回Playerの体力と残機を出力
  printf("health:%d life:%d\n", ply.health, ply.life);
}

コンストラクタを呼び出すことでPlayerクラスが 実体化(Instantiate) され、plyの中に実体化されたオブジェクトが入ります。実体化されたオブジェクトplyのことをPlayerクラスの インスタンス(Instance) といいます。

インスタンスplyに対してply.getDamage()のようにすることでメンバ関数を呼び出すことができます。

プレイヤーの体力と残機数を出力するのに毎回printf(...)と書くのは面倒です。Playerクラスの中にprint()という、プレイヤーの体力と残機数を出力してくれる関数を作ることにしましょう。

class Player {
  ...
  void print() {
    printf("%d %d\n", health, life);
  };
};

これを使うと先程のコードがもっとスッキリと書けます。

int main() {
  //Playerを作る。コンストラクタが呼ばれる
  Player ply = Player();
  
  //Playerの体力と残機を出力
  ply.print();
  
  //ダメージを与える
  ply.getDamage();
  
  //もう一回Playerの体力と残機を出力
  ply.print();
}

どうでしょうか、オブジェクト指向プログラミングの雰囲気を感じることができたでしょうか。

クラスの継承と純粋仮想関数

あなたは今、図形の処理を行うソフトウェアを作っているとします。図形を表すクラスを作らないといけないですね。図形を表すにはどんなクラスを書けばよいでしょうか?

図形には様々な種類があります。四角形(Rectangle)、三角形(Triangle)、円(Circle)などがあります。

それぞれの図形が持つデータは異なります。例えば四角形は左下と右上の2点の座標、三角形は3点の座標、円は中心座標と半径をデータとして持ちます。

しかし、図形共通の特徴として面積を返す関数があります。別々のクラスとして実装することもできるのですが、ここでは 継承(Inheritance) という仕組みを利用しましょう。

図形全体を表すFigureクラスを定義し、Rectangleクラス、Triangleクラス、CircleクラスはFigureクラスを継承したものとして作ります。こうするとFigureクラスが持っているデータや関数が、Rectangleクラスでも使うことができるようになります。

digraph hierarchy {
  nodestep=1.0
  node [fontname=Courier, shape=Box]
  edge [style=dashed]
  
  Figure -> {Rectangle Triangle Circle}
}

ここで、継承元のクラス(Figureクラス)を 基底クラス(Base Class) と言います。

Figureクラスはデータを特に定義する必要はないでしょう。ただ共通の特徴として、面積を求める関数area()が必要です。しかし、Figureクラスは何らかの図形を表しているだけで、面積を具体的に計算することはできません。そこで使うのが 純粋仮想関数(Pure Virtual Function) です。

純粋仮想関数はvirtualと、= 0というキーワードとともに、基底クラスで定義される関数のことを言います。

class Figure {
  public:
    virtual double area() = 0; //具体的な定義を書くことはできない
};

具体的な定義を書くことができないので = 0と後ろにつけるだけです。純粋仮想関数を持ったクラスは 抽象クラス(Abstract Class) と呼ばれます。抽象クラスのインスタンスを作ることはできません。

継承したクラスでarea()の中身を具体的に書いていきます。このように継承したクラスの方で、継承元のクラスの関数の定義を書き換えることを オーバーライド(Override) といいます。

class Figure {
  public:
    virtual double area() = 0;
}


class Rectangle : public Figure {
  public:
    double x1, y1, x2, y2;
    
    Rectangle(double _x1, double _y1, double _x2, double _y2) {
      x1 = _x1;
      y1 = _y1;
      x2 = _x2;
      y2 = _y2;
    };
    
    double area() {
      return (x2 - x1)*(y2 - y1);
    };
};

クラスを継承するにはクラス名の後ろに: public Figureのように書きます。こうするとFigureで定義されたものがRectangleでも自動的に定義されていることになります。

Rectangleクラスは左下の点(x1,y1)(x_1, y_1)と右上の点(x2,y2)(x_2, y_2)をメンバに持ちます。コンストラクタは受け取った引数をx1, y1, x2, y2にセットするだけです。あとは純粋仮想関数area()の中身を具体的に実装するだけです。

実際に使うには次のようにします。

int main() {
  Rectangle rect = Rectangle(1, 1, 2, 2)
  printf("%f\n", rect.area());
}

練習問題

  1. Figureクラスを継承してCircleクラスを実装してみよう
  2. Figureクラスを継承してTriangleクラスを実装してみよう

const参照渡し・constメンバ関数

関数の引数にクラスを渡してみましょう。クラスをそのまま引数として渡すと デフォルトで値渡し になります。

void printArea(Rectangle rect) {
  printf("%f\n", rect.area());
}

つまり、クラスが持つ情報が丸々コピーされたオブジェクトが別に作られ、関数からはそのオブジェクトに対してアクセスすることになります。この方法だと大きなサイズのクラスを渡した場合、コピー処理が間に入るので効率が悪くなります。そこで参照渡しでクラスを渡すことを考えます。

void printArea(Rectangle& rect) {
  printf("%f\n", rect->area());
}

こうすれば引数に渡されるのはクラスに対する参照なので、クラスのコピー処理がいちいち行われなくなり、関数呼び出しが高速化されます。

しかし、この方法には問題点があります。参照渡しなので、もし関数の中でクラスに対して何らかの書き換え操作を行うと、クラスの中身はもちろん書き換わります。printArea()のような関数の場合、ただ面積を表示するだけなので、中身を書き換える必要はありません。もし中身を書き換えるつもりのない関数で、中身を書き換えるようなコードを書いてしまってもコンパイル時にエラーにならず、バグの原因となります。

このように、中身を書き換えたくないけど参照渡しをしたいという時に使うのが const参照渡し と呼ばれる方法です。

void printArea(const Rectangle& rect) {
  printf("%f\n", rect.area());
}

Rectangle&はRectangleの参照型です。これの前にconstを付けることで、中身を書き換えない参照渡しを実現することができます。c++においてクラスを引数に渡す場合は、たいていconst参照渡しが使われます。

早速このコードを動かしたいところですが、このまま実行すると次のようなエラーが出るはずです。

fig.cpp: In function ‘void printArea(const Rectangle&)’:
fig.cpp:28:30: error: passing ‘const Rectangle’ as ‘this’ argument discards qualifiers [-fpermissive]
     printf("%f\n", rect.area());

constなRectangleを渡しているのに、呼び出しているメンバ関数area()がconstじゃないよという意味のエラーです。呼び出したメンバ関数area()がクラスの中身を変える可能性があるので、このようなエラーが出るのです。これを防ぐには呼び出しているメンバ関数area()にconstを付与する必要があります。

class Figure {
  public:
    virtual double area() const = 0;
};


class Rectangle : public Figure {
  public:
    ...
    double area() const {
      return (x2 - x1)*(y2 - y1);
    };
};

このようにconstが付与されたメンバ関数を constメンバ関数 といいます。constメンバ関数の中では、メンバの中身を書き換えることはできません。書き換えようとするとコンパイルエラーになります。

まとめると

  • const参照渡しを使うと効率良くクラスを引数に渡せる
  • ただし、中でメンバ関数を呼び出している場合は、その関数はconstメンバ関数である必要がある

ポリモーフィズム

継承元のクラスのポインタには継承したクラスのポインタを代入することができます。

int main() {
  Figure* fig;
  Rectangle rect(1, 1, 2, 2); //コンストラクタの省略構文
  fig = &rect;
}

この性質は色んなところで役に立ちます。一つの変数にRectangleやCircleを代入することができるのです。

int main() {
  Figure* fig; //Figureクラスのポインタ

  Rectangle rect(1, 1, 2, 2);
  fig = &rect; //Rectangleを代入

  Circle circle(1, 1, 1);
  fig = &circle; //Circleを代入
}

RectangleやCircleを代入した状態でarea()を呼んでみましょう。なんと表示されるでしょうか?

int main() {
  Figure* fig; //Figureクラスのポインタ

  Rectangle rect(1, 1, 2, 2);
  fig = &rect; //Rectangleを代入
  printf("%f\n", fig->area()); //Rectangleのarea()が呼ばれる

  Circle circle(1, 1, 1);
  fig = &circle; //Circleを代入
  printf("%f\n", fig->area()); //Circleのarea()が呼ばれる
}

実行結果

1.000000
6.283185

figが実際に持っているクラスの型によって関数の動作が変わるのです。Rectangleが代入されていればRectangleのarea()が実行され、Circleが代入されていればCircleのarea()が実行されます。

area()という共通のインターフェースを通じて、どんな図形の面積も表示することができるのです。こうすると、実際に図形クラスを使って何か作る必要がある人は、RectangleクラスやCircleクラスの詳細を知らなくても、Figureクラスの持つ関数を把握しておけば色んな図形の面積を計算できるようになるのです。

次に、Rectangle、Circleなどを受け取って、その面積を表示する関数void printArea(???)を考えてみましょう。今まで習ったやり方では、関数オーバーロードを使用して次のようなプログラムを書く必要があります。

void printArea(const Rectangle& rect) {
  printf("%f\n", rect.area());
}
void printArea(const Circle& circle) {
  printf("%f\n", circle.area());
}

似たようなプログラムをそれぞれの図形ごとに書かないといけないので非常に面倒です。図形クラスが100個増えた場合を想像してください。100回も似たようなコードを再び書かないといけないのです。

これを防ぐために継承元のクラスの参照型を引数として取るようにします。

void printArea(const Figure& fig) {
  printf("%f\n", fig.area());
}

これを使うと次のようなプログラムが書けます。

void printArea(const Figure& fig) {
  printf("%f\n", fig.area());
}


int main() {
  Rectangle rect(1, 1, 1);
  Circle circle(1, 1, 1);
  
  printArea(rect);
  printArea(circle);

  return 0;
}

継承元のクラスの参照型を引数として受け取るだけで、それを継承した全てのクラスに対してもprintArea()が定義されたことになります。クラスを増やした場合でも、そのクラスにarea()が定義されていればprintArea()は正しく動きます。このように、型によって関数の動作が変わる仕組みを ポリモーフィズム(Polymorphism) といいます。

オブジェクト指向によるRay-Object Intersectionの設計指針

ここでは今まで習ってきたことが実際にレイトレーサーにおいてどのように使われるのかについて見ていきます。

レイトレーサーを作成するにはまず、レイ(半直線)と物体の衝突計算を行い、物体との衝突点を求める必要があります。

物体には様々な種類があります。球や三角形、立方体などがあるでしょう。これらの物体はプログラム中では一つの配列に保持されることになりますが、配列は一つの型しかいれることができないので、このままでは物体の種類ごとに複数の配列を用意する必要があります。そこで使うのが継承元のポインタによる表現です。

継承元のポインタはそれを継承した全てのクラスを表すことができます。したがって継承元のポインタの配列を用意すれば、全ての種類の物体を一つの配列に格納できるようになります。

Shape* shapes[100]; //Objectポインタの配列

また、プログラムの構成的にも全ての物体を表すShapeクラス、それを継承するSphereクラス、Triangleクラスというように表現したほうがすっきりします。物体の種類を追加する際も、Shapeクラスを継承して新たなクラスを書くだけです。

digraph hierarchy {
  node [fontname=Courier, shape=Box]
  edge [style=dashed]
  Shape -> {Sphere Triangle Box}
}
//物体を表す抽象クラス
class Shape {
  public:
    ...
    virtual bool intersect(const Ray& ray) const = 0; //レイとの衝突計算を行う関数
};

//球
class Sphere : public Shape {
  public:
    ...
    bool intersect(const Ray& ray) const {
      ...
    };
};

//三角形
class Triangle : public Shape {
  public:
    ...
    bool intersect(const Ray& ray) const {
      ...
    };
};

全物体に対して一つのレイとの衝突計算を行うときには単純に次のようにfor文を回すだけで出来ます。

for(int i = 0; i < 100; i++) {
  //物体一つ一つに対してレイとの衝突計算を行う
  if(shapes[i]->intersect(ray)) {
    printf("Hit\n");
  }    
}

継承とポリモーフィズムを活用すると、このようにシンプルにレイトレーサーを書いていくことができます。

演算子オーバーロード

C++では自分の作ったクラスに対して+, -, *, /のような演算を定義することができます。これを 演算子オーバーロード(Operator Overload) といいます。

例として二次元ベクトルを表すクラスVec2を作ってみましょう。

class Vec2 {
  public:
    double x; //x座標
    double y; //y座標
    
    //コンストラクタ
    Vec2(double _x, double _y) {
      x = _x;
      y = _y;
    };
    
    //出力
    void print() const {
      printf("(%f, %f)", x, y);
    };
};

次のようなプログラムを書けると便利でしょう。

int main() {
  Vec2 v1(1, 1);
  Vec2 v2(2, 2);
  
  Vec3 v3 = v1 + v2;
  v3.print();
  
  return 0;
}

このためには演算子"+"をオーバーロードします。

#include <cstdio>

class Vec2 {
  public:
    double x; //x座標
    double y; //y座標
    
    //コンストラクタ
    Vec2(double _x, double _y) {
      x = _x;
      y = _y;
    };
    
    //出力
    void print() const {
      printf("(%f, %f)", x, y);
    };
};


//演算子+をオーバーロード
Vec2 operator+(const Vec2& left, const Vec2& right) {
  return Vec2(left.x + right.x, left.y + right.y);
}


int main() {
  Vec2 v1(1, 1);
  Vec2 v2(2, 2);
  
  Vec2 v3 = v1 + v2;
  v3.print();
  
  return 0;
}

operator+()という名前の関数を定義することで、演算子+をオーバーロードすることができます。leftは+の左側、rightは+の右側に対応します。要素ごとの値を足し合わせてVec2を新たに作成して返すだけです。

C++では多くの演算子をオーバーロードすることができます。詳しくはここの表を参照してみてください。 http://stlalv.la.coocan.jp/Operator.html

練習問題

  • Vec2クラスに対して-を定義してみよう
  • Vec2クラスに対してスカラー倍*を定義してみよう

Standard Template Library(STL)

STLはC++に標準で定義されている便利なプログラムが集まったライブラリーです。動的配列を実現するstd::vector、文字列を表現するstd::string、配列のソートを行うstd::sortなどの便利なクラスと関数が定義されています。ここではこの先よく使うことになるstd::vectorについて説明します。

STLの機能一覧についてはこのサイトが参考になります。 https://cpprefjp.github.io/

std::vector

動的配列 とは、あらかじめ長さの決まっていない配列のことを言います。通常の配列は最初に長さを決めないと宣言できませんが、動的配列は長さが0の状態から始まり、その後ろに次々と要素を追加していくことができます。

#include <vector> //std::vectorを使うにはこれが必要

int main() {
  std::vector<int> v; //int型のstd::vector
  
  v.push_back(1); //末尾に1を追加
  v.push_back(2); //末尾に2を追加
  v.push_back(3); //末尾に3を追加
  
  for(int i = 0; i < v.size(); i++) { //v.size()で長さを取得できる
    printf("%d ", v[i]); //通常の配列と同じようにアクセスできる
  }
  printf("\n");
  
  return 0;
}

std::vector<int>とすることでint型のstd::vectorが作られます。double型ならstd::vector<double>です。

もちろん自分で定義したクラスも要素にすることができます。

#include <vector> //std::vectorを使うにはこれが必要

int main() {
  std::vector<Vec2> v; //Vec2型のstd::vector
  
  v.push_back(Vec2(1, 1))); //末尾に(1, 1)を追加
  v.push_back(Vec2(2, 2)); //末尾に(2, 2)を追加
  v.push_back(Vec2(3, 3)); //末尾に(3, 3)を追加
  
  for(int i = 0; i < v.size(); i++) { //v.size()で長さを取得できる
    v[i].print();
  }
  printf("\n");
  
  return 0;
}

使い方に困った場合はこのサイトを参考にすると良いです。 http://vivi.dyndns.org/tech/cpp/vector.html

スマートポインタ

変数をメモリ領域に確保する方法として、動的確保というものがありましたね。

#include<cstdio>

int main() {
    int *p = new int[100];
    delete[] p;
    return 0;
}

newで動的確保した変数は使い終わったら必ずdeleteを呼んで使用しているメモリ領域を解放してあげる必要があります。

複雑なプログラムになってくると、いつdeleteを呼ぶべきかを決定するのは難しい問題になります。むやみにdeleteしてしまうと、実はまだそのオブジェクトを必要とする処理があった時に、オブジェクトが存在しないのでバグの原因になります。

これを解決してくれるのが スマートポインタ(Smart Pointer) というものです。スマートポインタを使うとポインタへの参照がなくなった時点で自動的にポインタを破棄してくれます。

std::shared_ptr

std::shared_ptrは通常のポインタと同じように使うことができるスマートポインタです。

#include <cstdio>
#include <memory>


int main() {
    std::shared_ptr<int> p = std::make_shared<int>(1);
    printf("%d\n", *p);
    return 0;
}

std::shared_ptr<int>はint型のshared_ptrという意味です。std::make_shared<int>()を呼び出すことで、オブジェクトからshared_ptrを作ることができます。

int型などの組み込み型に対してshared_ptrを使う機会はあまりないでしょう。たいての場合は自分で定義したクラスに対してshared_ptrを使用します。

#include <cstdio>
#include <string>
#include <memory>

class Person {
  public:
    int age; //年齢
    std::string name; //名前
    
    Person(int _age, const std::string& _name) {
      age = _age;
      name = _name;
    };
};


int main() {
  std::shared_ptr<Person> p = std::make_shared<Person>(Person(20, "Watson"));
  printf("%d\n", p->age);
  return 0;
}

ポインタの場合と同様に、メンバにアクセスするにはアロー演算子を使うことに注意してください。

スマートポインタの場合は明示的にdeleteを書く必要がありません。ポインタへアクセスするものがなくなったと判断された時点で自動的にポインタが破棄される仕組みになっています。途中で誤ってdeleteしてしまったり、メモリ解放忘れが起きなくなるので、とても便利です。

スマートポインタとstd::vectorによる物体集合の表現

スマートポインタを使うとレイトレーサーにおける物体を楽に管理することができるようになります。

さきほど継承とポリモーフィズムを使った例として、Shapeクラスのポインタの配列で物体の集合を表現していましたね。

Shape* shapes[100];

newして作られた物体のポインタは、最後にdeleteしてメモリ解放してあげなければならないので管理が大変です。さらに事前にどのくらいの物体が存在するかは分からないので、配列より動的配列の方が物体集合を無駄なく管理できます。すると次のようなコードが適しているでしょう。

std::vector<std::shared_ptr<Shape>> shapes;

物体を追加するには次のようにします。

shapes.push_back(std::make_shared(Sphere(center, radius)));

一つのレイと全物体で衝突判定を行うには次のようにします。

for(int i = 0; i < shapes.size(); i++) {
  if(shapes[i]->intersect(ray)) {
    printf("Hit\n");
  }
}

autoとGeneric-For文

上のコードはもっとシンプルに次のように書くことができます。

for(auto shape : shapes) {
  if(shape->intersect(ray)) {
    printf("Hit\n");
  }
}

shapeにはshapesの最初から最後までが順々に入っていきます。このようなfor文を Generic-For文 といいます。

auto をつけると自動的に型推論を行ってくれます。こうするといちいちstd::shared_ptr<Shape>と長々と型を書いてあげる必要がなくなって便利です。

autoの詳細についてはこちらを見てください。 https://cpprefjp.github.io/lang/cpp11/auto.html

練習問題

以下の問題を解いたプログラムは次回以降使うので、できるだけ自分で使いやすいように形を整えておいてください。

問1

三次元ベクトルクラスVec3を作れ。加減(+, -)とスカラー倍(*, /)を定義せよ。

問2

2つのVec3を受けとり、内積を返す関数double dot(const Vec3& v1, const Vec3& v2)を作れ。

問3

Vec3クラスに長さを求めるメンバ関数double length() const を追加せよ。

問4

作成したVec3クラスを使って、球を表すクラスSphereを作れ。球は中心座標(Vec3)と半径(double)をメンバ変数として持つとする。

問5

作成したVec3クラスを使って、直線を表すクラスRayを作れ。直線は始点(Vec3)と方向(Vec3)をメンバ変数として持つとする。

問6(レイトレ)

SphereにRayとの衝突計算を行うメンバ関数bool intersect(const Ray& ray) constを作れ。衝突したらtrueを、衝突しない場合はfalseを返す。直線の方向を前側としたときに、直線の始点より後側で衝突した場合もfalseとする。

問6の補足

Rayは数学的にはo\vec{o}を始点、d\vec{d}を方向、tRt \in \mathbb{R}を始点からの距離とすると o+td\vec{o} + t\vec{d} と表すことができる。

球をベクトルを用いて表すと、球上の点をp\vec{p}、中心をc\vec{c}、半径rrとして pc=r\|\vec{p} - \vec{c}\| = r と表すことができる。

Rayが球と交差するとき、o+td=p\vec{o} + t\vec{d} = \vec{p}となるから

o+tdc=r\|\vec{o} + t\vec{d} - \vec{c}\| = r

両辺を二乗すると

(o+tdc)(o+tdc)=r2(\vec{o} + t\vec{d} - \vec{c})\cdot(\vec{o} + t\vec{d} - \vec{c}) = r^2

これを展開すると

d2t2+2d(oc)t+oc2r2=0\|\vec{d}\|^2t^2 + 2\vec{d}\cdot(\vec{o} - \vec{c})t + \|\vec{o} - \vec{c}\|^2 - r^2 = 0

これはtに関する二次方程式になっているので

a=d2,b=2d(oc),c=oc2r2a = \|\vec{d}\|^2, b = 2\vec{d}\cdot(\vec{o} - \vec{c}), c = \|\vec{o} - \vec{c}\|^2 - r^2

とおくとat2+bt+c=0at^2 + bt + c = 0になる。