~スタック、ヒープ、スタティックの各領域と、値渡しと参照渡しを理解する~

なぜ、メモリ領域の理解が重要なのか、その理由

この記事では、当社の新人エンジニア研修の参考に C# を解説します。

前回は「 文字列を使いこなそう」について学びました。Stringというオブジェクトは色々な機能を持つメソッドで構成されていましたね。

今回はそのオブジェクトが、コンピュータのメモリをどのように利用して動作するのかを解説します。オブジェクト指向言語のアプリは、メモリをスタック領域ヒープ領域スタティック領域の三つに分けて使います。各領域はそれぞれ重要な役割を担っており、使われ方も全く違います。皆さんが三つのメモリ領域の役割と動作を理解すれば、オブジェクト指向の奥深さに触れることになります。そしてそれは、無駄無く高速でメンテナンス性も高いアプリケーションを開発するための確かな礎にもなるはずです。

まずは、各領域の概要からお話を始めたいと思います。

1. 三つのメモリ領域の概要

  1. スタック領域:次に実行される処理の場所やメソッドの引数と戻り値、ローカル変数などが置かれる
  2. ヒープ領域new演算子で生成されたインスタンス(後述)が必要に応じて複数存在する
  3. スタティック領域各クラスのスタティックメンバーが一つだけ存在する

いかがでしょうか。文章を読んだだけではイメージしづらいと思いますが、字面からも全く違う使われ方をしていそうだな、という印象を受けたのではないでしょうか。それでは、スタック領域から詳しく解説していきましょう。

1. スタック領域とは

動作の仕組みが複雑なので、初心者の方が一番躓きやすいのがスタック領域です。図で順を追って解説しますので、しっかりと付いてきてくださいね。

スタックは、他の二つの領域とは全く違う使われ方をする特殊な領域です。他の二つは言わばアプリケーションの部品が置かれる領域(詳細は後述)なのですが、それに対してスタックは、次に何をするのか、それが終わったらどこに行くのか、呼び出すメソッドに何を渡すのか、また、そのメソッドが何を返したのか、といったアプリケーションの実行順序やメソッドの入出力を制御します。つまり、アプリケーションの動作の根幹を握る、大変重要な領域なのです。常に値が変動する、一番忙しく賑やかな鉄火場です。

スタックの仕組み

スタックでは、最後に入れたデータが最初に取り出されます。また、最初に入れたデータは最後に取り出される仕組みです。この仕組みを、FILO(First In, Last Out、先入れ後出し)とかLIFO(Last In, First Out、後入れ先出し)と呼びます。まずは、どうやってこんな仕組みを作っているのかを図を使って説明しましょう(実際にはもっと複雑なことをやっているのですが、スタックの概念を理解していただくために単純化していることをご了承ください)。

箱が地面からいくつも積みあがっている姿を想像してください。これが、スタック領域の模型です。次に、今どの箱を使っているかがわかるように、箱の横に矢印があるものと考えてください。矢印が指しているのが、現在使われている箱ということです。この矢印を、スタックポインタと呼びます。

y = 2x + 1

という関数のようにxに何かをインプットしたらyがアウトプットされるブラックボックスのようなものが関数(メソッド)だ、と捉えてください。

メソッドを活用することで、共通の処理を1か所にまとめ、複数箇所で再利用できます。また、「朝のルーティン」などの名前を付けることでコードの意図も分かりやすくなります。

メソッドの基本形

一番シンプルな形として、引数なし・戻り値なしのメソッドを以下のように定義できます。

void メソッド名() {
// 命令文;
}


void は「戻り値を返さない」ことを意味します。

2. メソッドの例

C#ではMain()メソッドもメソッドとして定義されます。これはプログラムのエントリーポイントとしてランタイムから直接呼び出されるため、インスタンスがなくても呼び出せるメソッドである必要があるからです。

ここではシンプルなメソッドを例示します。以下のサンプルでは「1~10まで数える」処理をCount10()というメソッドにまとめ、Main()から呼び出しています。

namespace Chap08;
using System;

public class Example01 {
    static void Count10() {
        for (int i = 1; i <= 10; i++) {
            Console.Write(i + " ");
        }
    }
}

<実行結果>

1 2 3 4 5 6 7 8 9 10

Count10(); の呼び出しから Count10() メソッド本体へ処理がジャンプし、終わると Main() に戻ります。

コードの配置

トップレベルステートメント

using Chap08;
Example01.Count10();

上記のように Count10() を後ろに書いてもOKです。一般的に、Main()はクラスの一番下に書くのが好ましいという意見もあります。

メソッドの引数(仮引数と実引数)

引数のあるメソッドは以下のような形をしています。

<構文>

void メソッド名(型 仮引数) {
// 処理
}

()カッコの中に型と変数名を書きます。

この変数名を仮の引数という意味で「仮引数」と呼びます。なぜなら、実際に渡される値はまだ決まっていないからです。このメソッドのブロックの中では、この変数名を使って処理を記述することができるようになります。

namespace Chap08;
using System;

public class Example02 {
    static void Count(int num) {
        for (int i = 1; i <= num; i++) {
            Console.Write(i + " ");
        }
    }
}

トップレベルステートメント

using Chap08;
Example02.Count(5);
Console.WriteLine();
Example02.Count(10);

<実行結果>

1 2 3 4 5
1 2 3 4 5 6 7 8 9 10

  • Count(5)Count(10) で呼び出す時の値を「実引数(argument)」と呼び、
  • メソッド定義の Count(int num)num を「仮引数(parameter)」と呼びます。

メソッドの戻り値

戻り値(アウトプット)を持つメソッドは次のように書きます。

戻り値の型 メソッド名(仮引数列) {
// 処理
return 値;
}


return 文によって呼び出し元に値を返します。

void 以外の型を指定した場合、必ず return で対応する型の値を返さなければなりません。

例:正方形の面積を求めるメソッド

namespace Chap08;
using System;

public class Example03 {
    public static void run() {
        double area = GetAreaOfSquare(3.8);
        Console.WriteLine(area); // 14.44
    }

    static double GetAreaOfSquare(double length) {
        return length * length;
    }
}

最終的に1つのメソッド呼び出しにつき1つのreturnが実行される

ifなどで分岐し、複数のreturn文を書いてもOKです。ただし最終的に1つのメソッド呼び出しにつき、どれか1つのreturnが実行されます。

namespace Chap08;
using System;

public class Example04 {
    public static void run() {
        Console.WriteLine(IsEven(71)); // false
    }

    static bool IsEven(int num) {
        if (num % 2 == 0) {
            return true;
        } else {
            return false;
        }
    }
}

<実行結果>

false

なお、参考までに上記のisEven()メソッドは簡略化して以下のように書くこともできます。

static boolean isEven(int num) {
  return num % 2 == 0;
}

戻り値のboolean型はtrueかfalseのいずれかの値を持つものであればOKだからです。

早期return

さらにreturn文には「早期return文」と呼ばれる処理の流れを制御する使われ方があります。

早期return文とは、戻り値がvoidであるメソッドの実行中に条件を満たした場合に、その時点でメソッドを終了し、戻り値を返すためのreturn文のことを指します。

例えば、以下のメソッドは、引数として与えられた整数が正の場合に限り、その2倍の値を出力し、それ以外の場合は何も出力せずにメソッドを打ち切ります。以前学んだbreak文と似ていると感じると感じる新人エンジニアの方もいらっしゃるかもしれませんが、break文がブロックを抜けるのに対して、return文はメソッドを抜けます。

早期return 文を使用すると、条件に合わない場合にメソッドの処理を即座に終了できます。これにより、無駄な処理を減らし、コードの可読性も向上します。

static void PrintDoublePositiveNumber(int num) {
    if (num <= 0)
        return; // ここでメソッド終了、何も返さない
    
    Console.WriteLine(num * 2);
}

3. メソッドのオーバーロード (Overload)

オーバーロードとは、「同じメソッド名で、引数の数や型が異なる複数のメソッド」 を定義することです。

例: Console.WriteLine

namespace Chap08;
using System;

public class Example05 {
    public static void run() {
        Console.WriteLine("Hello");  // 引数はstring
        Console.WriteLine('A');      // 引数はchar
        Console.WriteLine(256);      // 引数はint
        Console.WriteLine(3.14);     // 引数はdouble
    }
}

WriteLine() はオーバーロードされており、string, char, int, double など様々な型を引数として受け取れるようになっています。

新人エンジニア
インプットの幅を広げるオーバーロード

自前でオーバーロード

static void Method() {
    Console.WriteLine("引数なしメソッド");
}

static void Method(int i) {
    Console.WriteLine("int型の引数: " + i);
}

static void Method(double d) {
    Console.WriteLine("double型の引数: " + d);
}

引数の型・数・並び順が異なれば、同じメソッド名Methodで定義可能です。

戻り値の型が違うだけではオーバーロードにはなりません。必ず引数シグネチャが違う必要があります。

4. メソッドを使うメリット

  1. 定形処理の再利用
    • 同じ処理を何度も書く代わりに、メソッド化して呼び出すだけにすれば、コード量削減&保守性向上。
  2. コードの意図が明確になる
    • Count10()」と書かれていれば「10まで数える処理」だとすぐ分かる。
    • もし処理が複雑でも、名前を見れば機能が推測できる。

例:同じ処理を3回呼ぶだけ

// メソッドなし
for (int i = 1; i <= 10; i++) { ... }
for (int i = 1; i <= 10; i++) { ... }
for (int i = 1; i <= 10; i++) { ... }

// メソッドあり
Count10();
Count10();
Count10();

後者のほうが見やすく、変更があれば1箇所修正で済みます。

5. メソッドチェーン

メソッドの戻り値に対して、さらにメソッドを呼び出すという連続的な書き方をメソッドチェーン(method chain)と呼びます。C#の文字列処理などでよく見かけます。

Console.WriteLine("hello".ToUpper().Substring(0, 4)); 
// => "HELL"

  1. "hello".ToUpper()"HELLO"
  2. "HELLO".Substring(0, 4)"HELL"

このように、都度インスタンスを生成(または取得)し、その戻り値に対してさらにメソッドを呼ぶ連鎖を行う書き方です。変数をいちいち作らなくても処理をまとめられる利点があります。

新人エンジニア
Substring(0, 4)の意味

6. static キーワードの意味

static修飾子は「インスタンスを生成しなくても呼び出せるメンバ」を示します。

C#ではクラス名.メソッド名() の形で呼び出します(例: Math.Abs(-3))。

インスタンスを介さないので、一度クラスが読み込まれた時点でメモリに割り当てられ、プログラム終了まで保持されます。

メモリ上でのイメージ

  1. スタティック領域(もしくは静的領域): staticメンバが置かれ、アプリ終了まで生き続ける
  2. スタック領域: ローカル変数・メソッド呼び出し(LIFO管理)
  3. ヒープ領域: インスタンス(new)で動的に生成されるオブジェクト

staticメンバは唯一無二の存在としてプログラム中どこからでもアクセスできます。一方、非staticメソッドやフィールドは各インスタンスごとに存在するため、インスタンスの作成/破棄タイミングに応じてヒープ領域を使います。

新人エンジニア
メモリの3つの領域のイメージ

<まとめ:隣の人に正しく説明できたらチェックを付けましょう>

□ メソッドは一連の処理に名前を付けたものであり、引数(インプット)・戻り値(アウトプット)を持てる

□ メソッド名が同じで、引数の型や数、並び順が異なる複数のメソッドを定義することをオーバーロード(多重定義)と呼ぶ

□ メソッドを使うことで、定形処理を再利用でき、コードの意図が分かりやすくなる

staticメソッドはクラスが読み込まれた時点でメモリに置かれ、インスタンス不要で呼び出せる

次回は、「9章. インスタンスでデータと処理を再利用可能部品にする」を学びます。

最後までお読みいただきありがとうございます。