web-dev-qa-db-ja.com

多次元配列を正しく割り当てる

この質問の目的は、Cで多次元配列を動的に正しく割り当てる方法についてのリファレンスを提供することです。これは、Cプログラミングの本でも誤解され説明が不十分なトピックです。正しく取得してください。


プログラミングの先生/本/チュートリアルから、多次元配列を動的に割り当てるための正しい方法は、ポインターツーポインターを使用することだと教えられました。

しかし、SO=のいくつかの高レプユーザーは、これは間違って悪い習慣であると教えてくれます。ポインターツーポインターは配列ではなく、実際に配列を割り当てているわけではなく、コードは不必要に遅いです。

これは、多次元配列を割り当てるように教えられた方法です:

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

int** arr_alloc (size_t x, size_t y)
{
  int** pp = malloc(sizeof(*pp) * x);
  assert(pp != NULL);
  for(size_t i=0; i<x; i++)
  {
    pp[i] = malloc(sizeof(**pp) * y);
    assert(pp[i] != NULL);
  }

  return pp;
}

int** arr_fill (int** pp, size_t x, size_t y)
{
  for(size_t i=0; i<x; i++)
  {
    for(size_t j=0; j<y; j++)
    {
      pp[i][j] = (int)j + 1;
    }
  }

  return pp;
}

void arr_print (int** pp, size_t x, size_t y)
{
  for(size_t i=0; i<x; i++)
  {
    for(size_t j=0; j<y; j++)
    {
      printf("%d ", pp[i][j]);
    }
    printf("\n");
  }
}

void arr_free (int** pp, size_t x, size_t y)
{
  (void) y;

  for(size_t i=0; i<x; i++)
  {
    free(pp[i]);
    pp[i] = NULL;
  }
  free(pp);
  pp = NULL;
}


int main (void)
{
  size_t x = 2;
  size_t y = 3;
  int** pp;

  pp = arr_alloc(x, y);
  pp = arr_fill(pp, x, y);
  arr_print(pp, x, y);
  arr_free(pp, x, y);

  return 0;
}

出力

1 2 3
1 2 3

このコードは問題なく機能します!どうして間違っているのでしょうか?

50
Lundin

質問に答えるために、まずいくつかの概念を明確にする必要があります。配列とは何ですか、どのように使用できますか?そして、配列ではない場合、質問のコードは何ですか?


配列とは何ですか?

配列の正式な定義は、C標準ISO 9899:2011 6.2.5/20 Typesにあります。

配列型は、要素型と呼ばれる特定のメンバーオブジェクト型を持つ、連続して割り当てられた空でないオブジェクトのセットを記述します。

平易な英語では、配列は、隣接するメモリセルに連続して割り当てられた同じタイプのアイテムのコレクションです。

たとえば、3つの整数_int arr[3] = {1,2,3};_の配列は、次のようにメモリに割り当てられます。

_+-------+-------+-------+
|       |       |       |
|   1   |   2   |   3   |
|       |       |       |
+-------+-------+-------+
_

それでは、多次元配列の正式な定義はどうでしょうか?実際、それは上記で引用したものとまったく同じ定義です。再帰的に適用されます。

2D配列を割り当てる場合、_int arr[2][3] = { {1,2,3}, {1,2,3} };_は次のようにメモリに割り当てられます。

_+-------+-------+-------+-------+-------+-------+
|       |       |       |       |       |       |
|   1   |   2   |   3   |   1   |   2   |   3   |
|       |       |       |       |       |       |
+-------+-------+-------+-------+-------+-------+
_

この例にあるのは、実際には配列の配列です。 2つのアイテムを持つ配列。各アイテムは3つの整数の配列です。


配列は他の配列と同様の型です

Cの配列は通常、通常の変数と同じ型システムに従います。上記のように、他のタイプの配列を持つことができるように、配列の配列を持つことができます。

また、n-次元配列には、プレーンな1次元配列と同じ種類のポインター演算を適用できます。通常の1次元配列では、ポインター演算の適用は簡単です。

_int arr[3] = {1,2,3};
int* ptr = arr; // integer pointer to the first element.

for(size_t i=0; i<3; i++)
{
  printf("%d ", *ptr); // print contents.
  ptr++; // set pointer to point at the next element.
}
_

これは「アレイ減衰」によって可能になりました。 arrが式内で使用された場合、最初の要素へのポインターに「減衰」しました。

同様に、array pointerを使用することにより、非常に同じ種類のポインター演算を使用して、配列の配列を反復処理できます。

_int arr[2][3] = { {1,2,3}, {1,2,3} };
int (*ptr)[3] = arr; // int array pointer to the first element, which is an int[3] array.

for(size_t i=0; i<2; i++)
{
  printf("%d %d %d\n", (*ptr)[0], (*ptr)[1], (*ptr)[2]); // print contents
  ptr++; // set pointer to point at the next element
}
_

再び配列の減衰がありました。タイプ_int [2][3]_であった変数arrは、最初の要素へのポインターに減衰しました。最初の要素は_int [3]_であり、そのような要素へのポインタはint(*)[3]-配列ポインタとして宣言されています。

多次元配列を操作するには、配列ポインターと配列の減衰を理解する必要があります。


配列が通常の変数と同じように動作する場合が多くあります。 sizeof演算子は、通常の変数と同じように(非VLA)配列に対して機能します。 32ビットシステムの例:

int x; printf("%zu", sizeof(x));は_4_を出力します。
int arr[3] = {1,2,3}; printf("%zu", sizeof(arr));は_12_を出力します(3 * 4 = 12)
int arr[2][3] = { {1,2,3}, {1,2,3} }; printf("%zu", sizeof(arr));は_24_を出力します(2 * 3 * 4 = 24)


他のタイプと同様に、配列はライブラリー関数と汎用APIで使用できます。配列は連続して割り当てられるという要件を満たしているため、たとえば、memcpyを使用して安全にコピーできます。

_int arr_a[3] = {1,2,3};
int arr_b[3];
memcpy(arr_b, arr_a, sizeof(arr_a));
_

memsetstrcpybsearch、およびqsortなどの他の同様の標準ライブラリ関数が機能する理由は、連続した割り当てでもあります。これらは、連続して割り当てられた配列で動作するように設計されています。したがって、多次元配列がある場合は、bsearchおよびqsortを使用して効率的に検索および並べ替えを行うことができます。すべてのプロジェクトのホイール。

配列と他の型との間の上記の一貫性はすべて、特に汎用プログラミングを行う場合に活用したい非常に良いことです。


配列ではない場合、ポインターツーポインターとは何ですか?

次に、問題のコードに戻ります。このコードでは、ポインターからポインターへの異なる構文を使用しました。不思議なことは何もありません。これは、型へのポインターへのポインターであり、それ以上でもありません。配列ではありません。 2D配列ではありません。厳密に言えば、配列を指すために使用することも、2D配列を指すために使用することもできません。

ただし、ポインターツーポインターを使用して、配列全体を指すのではなく、ポインターの配列の最初の要素を指すことができます。そして、それが質問でどのように使用されているのか-配列ポインタを「エミュレート」する方法として。質問では、2つのポインターの配列を指すために使用されます。そして、2つのポインターのそれぞれは、3つの整数の配列を指すために使用されます。

これは、ルックアップテーブルと呼ばれます。これは、抽象データ型(ADT)の一種であり、プレーン配列の下位概念とは異なるものです。主な違いは、ルックアップテーブルの割り当て方法です。

_+------------+
|            |
| 0x12340000 |
|            |
+------------+
      |
      |
      v
+------------+     +-------+-------+-------+
|            |     |       |       |       |
| 0x22223333 |---->|   1   |   2   |   3   |
|            |     |       |       |       |
+------------+     +-------+-------+-------+
|            | 
| 0xAAAABBBB |--+
|            |  | 
+------------+  |  
                |
                |  +-------+-------+-------+
                |  |       |       |       |
                +->|   1   |   2   |   3   |
                   |       |       |       |
                   +-------+-------+-------+
_

この例の32ビットアドレスは構成されています。 _0x12340000_ボックスは、ポインターツーポインターを表します。ポインターの配列の最初の項目へのアドレス_0x12340000_が含まれています。その配列の各ポインターには、整数の配列の最初の項目を指すアドレスが含まれています。

そして、ここから問題が始まります。


ルックアップテーブルバージョンの問題

ルックアップテーブルは、ヒープメモリ全体に散在しています。 malloc()への各呼び出しは、必ずしも他のセルに隣接して配置されるとは限らない新しいメモリ領域を提供するため、隣接するセルに連続して割り当てられたメモリではありません。これにより、多くの問題が発生します。

  • 期待どおりにポインター演算を使用することはできません。ルックアップテーブル内の項目にインデックスを付けてアクセスするために、ポインター演算の形式を使用できますが、配列ポインターを使用してこれを行うことはできません。

  • Sizeof演算子は使用できません。ポインターツーポインターで使用すると、ポインターツーポインターのサイズがわかります。最初に指した項目に使用すると、ポインターのサイズがわかります。どちらも配列のサイズではありません。

  • 配列型(memcpymemsetstrcpybsearchqsortなどを除く標準ライブラリ関数は使用できません。オン)。このような関数はすべて、配列を入力として取得し、データを連続して割り当てることを想定しています。ルックアップテーブルをパラメーターとして呼び出すと、プログラムのクラッシュなど、未定義の動作バグが発生します。

  • mallocを繰り返し呼び出していくつかのセグメントを割り当てると、ヒープ フラグメンテーション が発生し、その結果、RAMメモリの使用率が低下します。

  • メモリが分散しているため、ルックアップテーブルを反復処理するときにCPUはキャッシュメモリを利用できません。データキャッシュを効率的に使用するには、メモリの連続したチャンクが必要です。これは、ルックアップテーブルの設計が、実際の多次元配列よりもアクセス時間が大幅に遅いことを意味します。

  • malloc()の呼び出しごとに、ヒープを管理するライブラリコードは、空き領域がある場所を計算する必要があります。同様に、free()の呼び出しごとに、実行する必要があるオーバーヘッドコードがあります。したがって、パフォーマンスのために、これらの関数への呼び出しはできるだけ少ない方が望ましい場合がよくあります。


ルックアップテーブルはすべて悪いですか?

ご覧のとおり、ポインターベースのルックアップテーブルには多くの問題があります。しかし、それらはすべて悪いわけではなく、他のツールと同様のツールです。正しい目的に使用する必要があります。配列として使用する必要がある多次元配列を探している場合、ルックアップテーブルは明らかに間違ったツールです。しかし、それらは他の目的に使用できます。

ルックアップテーブルは、すべてのディメンションを個別に完全に可変サイズにする必要がある場合に適切な選択です。このようなコンテナは、たとえばC文字列のリストを作成するときに便利です。その後、メモリを節約するために、上記の実行速度のパフォーマンスの低下をとることがしばしば正当化されます。

また、ルックアップテーブルには、多次元配列全体を再割り当てする必要なしに、実行時にテーブルの一部を再割り当てできるという利点があります。これを頻繁に行う必要がある場合、ルックアップテーブルは、実行速度の点で多次元配列よりも優れている場合があります。たとえば、連鎖ハッシュテーブルを実装するときに、同様のルックアップテーブルを使用できます。


多次元配列を動的に適切に割り当てる方法は?

現代のCで最も簡単な形式は、単純に可変長配列(VLA)を使用することです。 _int array[x][y];_ここで、xおよびyは、実行時の事前の配列宣言で値が与えられた変数です。ただし、VLAにはローカルスコープがあり、プログラムの期間中は持続しません-自動ストレージ期間があります。そのため、VLAは一時配列に便利で高速に使用できますが、問題のルックアップテーブルを普遍的に置き換えるものではありません。

割り当てられたストレージ期間を取得するように、多次元配列を本当に動的に割り当てるにはmalloc()/calloc() /を使用する必要がありますrealloc()。以下に例を示します。

最新のCでは、VLAへの配列ポインターを使用します。プログラムに実際のVLAが存在しない場合でも、このようなポインターを使用できます。プレーンな_type*_または_void*_よりもそれらを使用する利点は、型の安全性が向上することです。 VLAへのポインターを使用すると、配列を使用して関数にパラメーターとして配列の次元を渡すことができ、変数と型の両方を一度に安全にできます。

残念ながら、VLAへのポインタを持つ利点を使用するために、関数の結果としてそのポインタを返すことはできません。したがって、配列へのポインターを呼び出し元に返す必要がある場合は、パラメーターとして渡す必要があります( 動的メモリアクセスは関数内でのみ動作します で説明されている理由のため)。これはCの優れたプラクティスですが、コードを少し読みにくくします。次のようになります。

_void arr_alloc (size_t x, size_t y, int(**aptr)[x][y])
{
  *aptr = malloc( sizeof(int[x][y]) ); // allocate a true 2D array
  assert(*aptr != NULL);
}
_

aへのポインターを使用したこの構文は少し奇妙で威圧的に見えるかもしれませんが、さらに次元を追加しても、これより複雑になることはありません。

_void arr_alloc (size_t x, size_t y, size_t z, int(**aptr)[x][y][z])
{
  *aptr = malloc( sizeof(int[x][y][z]) ); // allocate a true 3D array
  assert(*aptr != NULL);
}
_

次に、そのコードを、ルックアップテーブルバージョンにもう1つのディメンションを追加するためのコードと比較します。

_/* Bad. Don't write code like this! */
int*** arr_alloc (size_t x, size_t y, size_t z)
{
  int*** ppp = malloc(sizeof(*ppp) * x);
  assert(ppp != NULL);
  for(size_t i=0; i<x; i++)
  {
    ppp[i] = malloc(sizeof(**ppp) * y);
    assert(ppp[i] != NULL);
    for(size_t j=0; j<y; j++)
    {
      ppp[i][j] = malloc(sizeof(***ppp) * z);
      assert(ppp[i][j] != NULL);
    }
  }

  return ppp;
}
_

さて、thatは「3つ星のプログラミング」の読みにくい混乱の1つです。そして、4つの次元さえ考慮しません...


真の2D配列を使用したバージョンの完全なコード

_#include <stdlib.h> #include <stdio.h> #include <assert.h> void arr_alloc (size_t x, size_t y, int(**aptr)[x][y]) { *aptr = malloc( sizeof(int[x][y]) ); // allocate a true 2D array assert(*aptr != NULL); } void arr_fill (size_t x, size_t y, int array[x][y]) { for(size_t i=0; i<x; i++) { for(size_t j=0; j<y; j++) { array[i][j] = (int)j + 1; } } } void arr_print (size_t x, size_t y, int array[x][y]) { for(size_t i=0; i<x; i++) { for(size_t j=0; j<y; j++) { printf("%d ", array[i][j]); } printf("\n"); } } int main (void) { size_t x = 2; size_t y = 3; int (*aptr)[x][y]; arr_alloc(x, y, &aptr); arr_fill(x, y, *aptr); arr_print(x, y, *aptr); free(aptr); // free the whole 2D array return 0; } _

75
Lundin

Cには多次元配列がありませんprimitiveデータ型として)。ただし、配列の配列(または他の集約の配列)およびポインターの配列を使用できます。

可能なアプローチは、何らかの理由で 抽象データ型(おそらく 柔軟な配列メンバー 、これは実装のトリックの1つであり、 this answer のような他のアプローチを使用できます。

抽象データ型を提案することはできません。これは、宿題のテキストに依存しているためです。 抽象的なデータ型を設計する必要があります(紙の上で)、後で実装する必要があります。

ADTに必要なすべての操作を(紙上またはボード上に)リストしたら、それらの実装は簡単です。

このコードは問題なく機能します!どうして間違っているのでしょうか?

その文は一貫していません(間違ったw.r.t.どの仕様ですか?)...

すべての警告とデバッグ情報を使用してコンパイルすることをお勧めします(例 withgcc -Wall -Wextra -g with [〜#〜] gcc [〜#〜] )、警告が表示されなくなるまでコードを改善するため、デバッガーを使用するためにgdb(何が起こっているのかを理解するために)プログラム)および valgrind などのその他のツール。