メモリの利用

目次

はじめに

プログラミングにおけるデータ処理では,メモリの効率的な利用(メモリ管理)が肝要である.
特に,時系列データや画像処理,熱流動の数値シミュレーション,機械学習などの分野では,数MB~数GBの大規模なデータを扱うことが日常的にある.
例えば,4000 x 3000 pixel のカラー画像を 1000 枚撮影すると,非圧縮形式の場合,12 GBになる.(GB = 109 Byte) このような大容量データは,プログラム内では配列として扱うが,配列のサイズが小さい場合と大きい場合では,いろいろ注意すべき点がある.

C言語の普通の配列は,スタックと呼ばれる,数百kB〜数MB程度の比較的小さな領域に配置される. このスタック領域に画像データなど大きな配列を置くと,それだけでスタック領域を使い切ってしまい,プログラムが不正終了することがある.(これを,Stack overflow と言う)

一方,以下で説明する動的に確保されたメモリ(と,static 指定した配列)では,スタックとは別のヒープと呼ばれるメモリ領域を使用するため,PCに積まれた膨大な数GB以上の配列を利用できる. したがって,ここで扱うメモリの動的確保は大規模データの処理には必須のテクニックと言える.

配列の動的確保

C言語では,配列を宣言する際,「配列サイズは定数」と習う.
例えば,

#include <stdio.h>
    
int main(void)
{
    const int N = 10;   //  配列サイズ
    double a[N];

    a[0] = ...    // 何か処理

    return 0;
}

のように,原則はプログラムのコンパイル時に配列の要素数が確定していなければならない.
(ただし,最近のコンパイラではこの制限が緩和され,要素数にconstでない変数も指定できる.)

プログラムの処理内容によっては,コンパイル時ではなく 実行時に配列のサイズを決めたい 場合がある.
例えば,

などが,これにあたる.

これらのように,実行時に配列サイズを決めて確保する方法を,メモリの「動的確保」(Dynamic memory allocation)と呼ぶ.
これを用いれば,配列など使用したいタイミングで確保でき,そのサイズも実行時に決められ,さらに不要となったメモリをその場で解放することができる.

//
//  Stack overflowの例
//  必ずエラーが出るとは限らず,厄介なバグとなる.
//

#include <stdio.h>

int main(void)
{
//  const int N = 100;                // 小さな値 100 これだと問題ない.
    const int N = 100*1000*1000;      // 大きな値 100Mega

    double a[N];

    //  値を設定
    for(int i=0; i<N; i++) {
        a[i] = i;
    }

    //  表示
    for(int i=0; i<N; i++) {
        printf("a[%d]= %lf\n", i, a[i]);
    }

    return 0;
}

実行結果1:
> ./a.out     
zsh: segmentation fault  ./a.out

実行結果2:
> ./a.out     
>
環境によってはエラーが出ない場合があるが,正しく動作しているとは限らない.

C言語でのメモリ動的確保

C言語では,メモリを動的に確保および解放するため,stdlib.h 内のライブラリ関数,malloc() および free() を用いる.

malloc()関数, free()関数

メモリの確保には malloc() 関数を用いる. (malloc = エム・アロックかんすう,または,マロック?と読む.)
この関数は,確保したいメモリサイズを引数として渡すと,(OSにより許可されて)確保されたメモリの先頭のアドレスを返す.

// 注:引数は「要素数」ではなく「Byte数」
変数の型 *ポインタ名 = (キャスト)malloc( 確保するByte数 );

// 配列では,一般に以下のように用いる.
変数の型 *ポインタ名 = (キャスト)malloc( 要素数 * sizeof(変数型) );

使用が終了したメモリはfree()関数で解放する.

free(動的配列を指すポインタ);

具体例を示すと,要素数 1,000,000 の整数型の動的配列の確保は

#include <stdio.h>
#include <stdlib.h> // malloc用

int main(void)
{
    int n = 1000000;
    int *p = (int*)malloc(n * sizeof(int) );  // キャストして代入

    // 何か処理
    p[0] = 100;
    *(p+3) = -1;

    free(p);      // 使用済みメモリを解放
 
    return 0;
}

となる.

malloc

順に説明すると,

  1. ポインタを宣言.ここではp
  2. malloc()関数を呼び出し,引数としてメモリのバイト数を渡す.配列の要素数ではない.
  3. malloc()はメモリを確保し,その先頭アドレスを返す.
  4. 先頭アドレスを,ポインタ p に代入する.(malloc()voidポインタを返すので,キャストして代入する)

malloc()関数は,メモリ確保に失敗すると,NULL を返す.

メモリを使用後,不要となった場合は,free() で解放する必要がある. これを忘れると,使用しないメモリが確保されたままになり,他のプログラムで使用できるメモリ容量が次第に減ってゆく. これをメモリリークと呼び,深刻なバグとなる.


(実際には,main()関数が終了してプログラムの実行が終了すると,処理がOSに移り強制的にメモリを解放するので,他のアプリには悪影響が及ばないはずである. 短時間で処理が終了するプログラムではあまり問題にならないが,計算時間が長時間となる数値シミュレーションプログラム,24時間連続動作するサーバープログラム,ロボットや自動車・航空機の制御プログラムなどでは,メモリの解放忘れは致命的なバグとなる.)

このほか,メモリ確保にはcalloc(), realloc()という類似の関数もある.

配列の動的確保のサンプル

ここでは malloc() および類似の関数 calloc() の使用例を示す.

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int i;
    double *p = NULL;    // ポインタ

    int n = 100;        // 配列の要素数.constでなくてもOK.

    // 配列の動的確保
    p = (double*)malloc(n * sizeof(double) );
//  p = (double*)calloc(n, sizeof(double) );  //  callocの場合.引数の違いに注意.


    if(p == NULL) {            // 何らかの理由でメモリ確保に失敗した場合
        printf("メモリ不足!");
        exit(1);
    }

    // 普通に配列として使用できる
    p[0] = 1.23456;
    p[n-1] = 7.89012;
    for(i=0; i<5; i++) {
        printf("p[%03d] = %lf\n", i, p[i]);
    }

    printf("...\n");

    // もちろんポインタ変数としても使える
    for(i=n-5; i<n; i++) {
        printf("p[%03d] = %lf\n", i, *(p+i) );
    }
    
    free(p);    // メモリ解放を忘れずに.忘れるとメモリリークを起こす.

    return 0;
}

この例では,可能な限り大きなメモリサイズまで配列を確保できる.
コンパイラの種類やOSのビット数(32bit, 64bit)により,最大サイズは異なる.

練習問題

上のプログラムを実行して動作を確認してみよう.

参考:数GB以上のメモリの確保
数GB以上のメモリの確保 malloc 最近のPCは数GB以上のメモリを積んでいるが,本当に自分のプログラムでそのようなメモリを確保できるだろうか. 実は,少し工夫が必要である.
まず,上記のサンプルプログラムでは,配列の要素数をint型で表しているが,これだと20億程度(=232/2)で溢れてしまう.
そこで,正式には,配列のサイズを表すには専用の型 size_t型を使用する必要がある.
具体的には,
size_t n = 2LL*1000*1000*1000; //    (2 Giga elements).
int *p = (int*) malloc(n*sizeof(int));
ここで,定数の2LLは,long long 型を表す.
この例では,8GBのメモリを確保しようと試みる.
64bitOSで,64bitコンパイラを用い,PCにメモリが充分たくさん積まれていれば, さらに大きなサイズのメモリも問題なく確保できる.

C++でのメモリ動的確保

C++では,malloc(), free() と同様の機能を有する new[], delete[] 演算子が追加された.
(C++でも引き続きmalloc(),free()も使用可能)

#include <stdio.h>
#include <iostream>

int main(void)
{
    double *p = NULL;   // ポインタ.NULL でなく nullptr でもOK.
    int n = 100;        // 配列の要素数.constでなくてもOK

    p = new double[n];       // メモリの確保

    // 普通に配列として使用できる
    p[0] = 1.23456;
    p[n-1] = 7.89012;

    int i;
    for(i=0; i<5; i++) {
        printf("p[%03d] = %lf\n", i, p[i]);
    }

    printf("...\n");

    // ポインタ変数としても使える
    for(i=n-5; i<n; i++) {
        printf("p[%03d] = %lf\n", i, *(p+i) );
    }

    delete[] p;    // メモリ解放を忘れずに.忘れるとメモリリークを起こす.
    return 0;
}

練習問題

上のプログラムを実行して動作を確認してみよう.拡張子を.cppとすること.

(参考)STL(標準テンプレートライブラリ)のvector

malloc(), free()new, delete[] を使用すると,プログラム内の分岐や関数呼び出しが複雑になってくると,どうしても解放のし忘れが発生してしまう場合がある.
そこで,Standard Template Library とよばれる新しいC++用のライブラリ中に,配列の代わりに使用可能なstd::vectorという機能が追加された.
vectorを使えば,メモリの解放を自動的に行うことができるだけでなく,resize()メンバ関数を使って,配列のサイズを自由に変更したり,別の vector= 演算子だけで中身をすべてコピーできるなど,いろいろ便利な機能がある.
(STLには,このほかにも set, map などの色々なデータ構造が用意されており,一般的にコンテナと呼ぶ.)

// vector変数の定義
vector<型名> 変数名;

// 定義と同時にサイズを決定する場合
vector<型名> 変数名(要素数);

// 値の初期化も併せて行う場合
vector<型名> 変数名(要素数, 初期値);
// vector の使用例.定義の部分以外は配列と同じように扱える.
// メモリの解放は自動で行われる.

#include <iostream>
#include <vector>
using namespace std;

int main(void)
{
    int n = 100;        // 配列の要素数
    int i;
    
    vector<double> a;     // double型の vector の定義
    a.resize(n, 0.0);     // メモリの確保

//  上記の宣言は,まとめて書くこともできる
//  vector<double> a(n, 0.0);     // vectorの定義.中味を全て0.0で初期化

    // 普通に配列として使用できる
    a[0] = 1.23456;
    a[99] = 7.89012;

    // 先頭へのポインタを得る方法. 
    double *ptr = &a[0];
    *(ptr+3) = -1.0;           // ポインタとしても使用できる

    a.resize(n+2);               // 途中でメモリサイズを変更できる.

    for(i=0; i<5; i++) {
        printf("a[%03d] = %lf\n", i, a[i] );
    }
    printf("...\n");
    for(i=a.size()-5; i<a.size(); i++) {     // size()で配列の要素数が得られる
        printf("a[%03d] = %lf\n", i, a[i] );
    }

    return 0;
    // この時点で,vectorで確保されたメモリが自動的に解放される!
}

(参考)スマートポインタ

最近のC++では,ポインタにも改良が加えられている.
unique_ptr は,ポインタ自身にnewされたメモリを管理させ,不要になったタイミングで勝手に delete する振る舞いをさせることができる.

// スマートポインタ unique_ptrの例
// コンパイル時にオプションが必要な場合がある.
//  c++ -std=c++14 ????.cpp

#include <iostream>
#include <memory>
using namespace std;

int main(void)
{
    int n = 100;        // 配列の要素数
    int i;
    
    std::unique_ptr<double[]> p(new double[n]);     // unique_ptrの定義とメモリの確保

    // 普通に配列として使用できる
    p[0] = 1.23456;
    p[99] = 7.89012;

    // 先頭へのポインタを得る方法. 
    double *ptr = p.get();
    *(ptr+3) = -1.0;

    for(i=0; i<5; i++) {
        printf("p[%03d] = %lf\n", i, p[i] );
    }
    printf("...\n");
    for(i=n-5; i<n; i++) {
        printf("p[%03d] = %lf\n", i, p[i] );
    }

    return 0;
    // この時点で,確保されたメモリが自動的に解放される!
}