1. ホーム
  2. c

C言語における配列の返送不可能性とは、実際にはどのような意味なのでしょうか?

2023-12-09 01:09:37

質問

C言語が配列を返せないといういつもの質問を再現するのではなく、もう少し深く掘り下げてみたいのです。

こんなことはできません。

char f(void)[8] {
    char ret;
    // ...fill...
    return ret;
}

int main(int argc, char ** argv) {
    char obj_a[10];
    obj_a = f();
}

しかし、私たちはできる。

struct s { char arr[10]; };

struct s f(void) {
    struct s ret;
    // ...fill...
    return ret;
}

int main(int argc, char ** argv) {
    struct s obj_a;
    obj_a = f();
}

で、gcc -Sで生成されたASMのコードをざっと見ていたところ、スタックで動いているようで、アドレス指定 -x(%rbp) をアドレス指定し、他の C 関数の戻り値と同じように動作しているようです。

配列を直接返すことの何が問題なのでしょうか?つまり、最適化や計算の複雑さの観点からではなく、構造体層なしでそれを行う実際の能力の観点からです。

おまけのデータです。私は Linux と gcc を x64 Intel 上で使用しています。

どのように解決するのですか?

まず第一に、はい、配列を構造体にカプセル化し、その構造体に対して何でもすることができます(代入、関数から返す、など)。

第二に、あなたが発見したように、コンパイラは構造体を返す(または割り当てる)コードを出すのがほとんど困難ではありません。 ですから、それは配列を返すことができない理由でもありません。

できない根本的な理由は、ぶっちゃけた話 配列は C 言語では二流のデータ構造であるためです。 . 他のデータ構造はすべて一級です。 この意味でのquot;first-class"とquot;second-class"はどのように定義されるのでしょうか。 単純に、2級の型は割り当てられないということです。

(次の質問は、quot;配列以外に第二級のデータ型はあるのでしょうか?"答えは、quot;特にない、関数を数えない限り"だと思います。)

配列を返せない(代入できない)ことと密接に関係しているのは、配列型の値も存在しないことです。 配列型のオブジェクト(変数)はありますが、その値を取ろうとすると、すぐに配列の最初の要素へのポインタが返されます。 [脚注: より正式には、配列には rvalues は存在しません。しかし、配列型のオブジェクトは lvalue と考えることができますが、非代入可能なものです]。

つまり、配列に代入できないという事実はさておき、代入しようとする値を生成することさえできないのです。 もしあなたが

char a[10], b[10];
a = b;

と書いているようなものです。

a = &b[0];

左側は配列、右側はポインタということになり、配列が何らかの形で代入可能であったとしても、大規模な型の不一致が発生することになります。 同様に(あなたの例から)、もし私たちが

a = f();

で、関数の定義内のどこかに f() があります。

char ret[10];
/* ... fill ... */
return ret;

まるで最後の一行が

return &ret[0];

に代入する配列の値はありません。 a に代入する配列の値もなく、単にポインタがあるだけです。

(関数呼び出しの例では、私たちはまた、非常に重要な問題として ret はローカル配列であり、Cで返そうとすると危険です。この点については後で詳しく説明します)

さて、あなたの質問の一部は、おそらく "なぜこの方法なのか "、また "配列を割り当てられないのなら、なぜ を割り当てることができるのでしょうか。

以下は私の解釈と意見ですが、Dennis Ritchie が彼の論文で述べていることと一致しています。 C 言語の発展 .

配列の非代入性は、3つの事実から生じます。

  1. C 言語は、構文的にも意味的にもマシンハードウェアに近いことを意図しています。 C の基本的な操作は、1 つまたは 1 つ少ないプロセッサ サイクルを要する 1 つまたは 1 つ少ない機械命令にコンパイルされるはずです。

  2. 配列は常に特別であり、特にポインターに関連する方法では、この特別な関係は C の前身である B 言語における配列の扱いから発展し、大きな影響を受けました。

  3. 構造体は当初 C ではありませんでした。

ポイント2により、配列の代入は不可能であり、ポイント1により、いずれにせよ不可能なはずで、単一の代入演算子である = は、N 千要素の配列をコピーするために N 千サイクルを要するようなコードに展開すべきではありません。

そして、私たちはポイント3にたどり着き、本当に矛盾を導くことになるのです。

Cが構造体を手に入れたとき、最初は構造体も完全にファーストクラスではありませんでした、つまり、代入したり返したりすることができませんでした。 しかし、そうできなかった理由は、最初のコンパイラーがコードを生成するのに十分賢明でなかったからにほかなりません。 配列の場合のように、構文的または意味的な障害はなかったのです。

そして、ずっと目標は構造体をファーストクラスにすることであり、これは比較的早い段階で達成されました。 コンパイラーは追いつき、K&R の初版が印刷されようとしていた頃、構造体を割り当てたり返したりするコードを生成する方法を学びました。

しかし、疑問は残ります。もし初歩的な操作が少数の命令とサイクルにコンパイルされることになっているならば、なぜその議論は構造体の割り当てを認めないのでしょうか。 そして、その答えは、そう、矛盾しているのです。

私は、(これは私の側での推測に過ぎませんが) 考え方は次のようなものだったと思います: "ファーストクラスの型は良く、セカンドクラスの型は残念なものです。 配列は二流のままだが、構造体ならもっとうまくいく。 高価なコードを使わないというのは、ルールというよりガイドラインに近いですね。 配列は大きくなることが多いですが、構造体は数十バイト、数百バイトと小さいので、代入しても通常 高価になります。

そのため、高価なコードを使わないというルールの一貫した適用が道半ばになりました。 C 言語は、とにかく、完全に規則的で一貫していたことはありません。 (それどころか、成功した言語の大部分は、人間であれ人工物であれ、そうではありません)。

これらすべてを考慮した上で、「もしCが が配列の代入と返送をサポートしたらどうなるでしょうか? そして、その答えは、式における配列のデフォルトの動作、つまり、最初の要素へのポインタに変わる傾向があるということを無効にする何らかの方法を含む必要があります。

90 年代のいつか、IRC では、まさにこれを行うためのかなりよく考えられた提案がありました。 それは、配列式を [ ] または [[ ]] といった具合に。 今日、その提案についての言及を見つけることができないようです(誰かがリファレンスを提供してくれるとありがたいのですが)。 ともあれ、以下の3つのステップを踏むことで、C言語を拡張して配列の代入を可能にすることができると思うのです。

  1. 代入演算子の左辺で配列を使用することの禁止を削除します。

  2. 配列値を持つ関数の宣言の禁止を解除する。 元の質問に戻って、以下のように char f(void)[8] { ... } を合法にします。

  3. (これは大きな問題です。) 式の中で配列に言及する方法があり、最終的に真で代入可能な値 (a rvalue ) を生成する方法があります。 議論するために、私は arrayval( ... ) .

[余談] 今日は、" キー定義 "という配列/ポインタ対応の

式に現れる配列型のオブジェクトへの参照は、(3つの例外を除いて)その最初の要素へのポインタに分解されます。

3つの例外とは、配列がオペランドとして sizeof 演算子、あるいは & 演算子であるか、あるいは文字配列の文字列リテラル初期化子である場合です。 ここで議論している仮想的な修正の下では、4つ目の例外があります。それは、配列がこの新しい arrayval 演算子のオペランドである場合です。]

ともあれ、これらの修正を施せば、次のような書き方ができるようになります。

char a[8], b[8] = "Hello";
a = arrayval(b);

(もちろん、もし ab は同じ大きさではありませんでした)。

関数のプロトタイプが与えられると

char f(void)[8];

また

a = f();

を見てみましょう。 f の仮想的な定義を見てみましょう。 私たちは次のようなものを持つかもしれません。

char f(void)[8] {
    char ret[8];
    /* ... fill ... */
    return arrayval(ret);
}

なお、(仮想的な新しい arrayval() 演算子を除いて) これは Dario Rodriguez が最初に投稿したものとほぼ同じであることに注意してください。 また、配列の代入が合法である仮想の世界では、次のようなものがあることにも注意してください。 arrayval() のようなものが存在していた場合、これは実際に動作するのです。 特に、これは ではなく は、ローカル配列へのすぐに無効なポインタを返すという問題に悩まされることはありません。 ret . を返すことになります。 コピー を返すので、全く問題はありません。これは明らかに合法的な

int g(void) {
    int ret;
    /* ... compute ... */
    return ret;
}


最後に、" Are there any other second-class types?" という横道にそれた質問に戻りますが、関数が配列のように、それ自身として (つまり、関数または配列として) 使用されていないときに自動的にアドレスを取得し、同様に関数型の rvalue がないことは偶然以上のことだと思います。 しかし、これは、C で関数が 2 階級の型と呼ばれるのを聞いたことがないため、単なる思いつきです (もしかしたら、あったかもしれませんが、私は忘れてしまいました)。


脚注: なぜならコンパイラが であり、通常、構造体を割り当てるための効率的なコードを生成する方法を知っているので、ポイント a からポイント b へ任意のバイトをコピーするためにコンパイラの構造体コピー機構を共同利用することは、やや一般的なトリックでした。

#define MEMCPY(b, a, n) (*(struct foo { char x[n]; } *)(b) = \
                         *(struct foo *)(a))

の最適化されたインラインバージョンとほぼ同じ挙動をします。 memcpy() . (そして実際、このトリックは今日でも最新のコンパイラの下でコンパイルして動作します)。