プログラミング言語のタプル。 パート1

現在、多くのプログラミング言語には、タプルのような設計があります。 どこかで、タプルはある程度言語に埋め込まれています。時々-再び、ある程度は-ライブラリを使用して実装されます。 C ++、C#、D、Python、Ruby、Go、Rust、Swift(およびErlang、F#、Groovy、Haskell、Lisp、OCamlなど)...

タプルとは何ですか? ウィキペディアはかなり正確な定義を提供します。タプルは固定長の順序付きセットです。 定義は正確ではありますが、それでも役に立たないため、ここに理由があります。ほとんどのプログラマーは、なぜこのエンティティが必要なのか疑問に思っていますか? プログラミングには、固定長と可変長の両方の多くのデータ構造があります。 単一のタイプと異なるタイプの両方の異なる値を保存できます。 あらゆる種類の配列、連想配列、リスト、構造...なぜタプルさえも? そして、タイピングが弱い言語では-さらに、タプルとリスト/ベクターの違いは完全にぼやけています...まあ、タプルに要素を追加することはできません。 これは誤解を招くかもしれません。 したがって、深く掘り下げて、タプルが実際に必要な理由、他の言語構成要素との違い、理想的な(または理想に近い)プログラミング言語でタプルの完全な構文とセマンティクスを形成する方法を理解する価値があります。



最初の部分では、一般的であまりプログラミング言語ではないタプルとタプルのような構造を見ていきます。 第二部では、タプルの最も普遍的な構文とセマンティクスを一般化し、拡張し、提案しようとします。



ウィキペディアが言及しなかった最初の重要なこと:タプルはコンパイル時の構造です。 つまり、これはコンパイル段階でいくつかのオブジェクトを結合する一種のエンティティです。 これは非常に重要です。 タプルは、Cおよびアセンブラーでも、すべてのプログラミング言語で暗黙的に使用されます。 同じC、C ++、任意のコンパイル済み言語でそれらを探しましょう。

したがって、関数の引数のリストはタプルです。

構造体または配列の初期化リストもタプルです。

テンプレートまたはマクロの引数リストもタプルです

構造の説明、さらには通常のコードブロックもタプルです。 その要素のみがオブジェクトではなく、構文構造です。



プログラムには、一見思われるよりもはるかに多くのタプルがあります。 しかし、それらはすべて暗黙的です。 とにかく、それらはある種の構文構造にしっかりとねじ込まれています。 古い言語でのタプルの明示的な使用は提供されていません。 より現代的な言語では、明示的な使用のいくつかの機会が現れ始めています-すべてではありません。 ここでは、主に値のタプル(変数または定数)を検討します。 おそらく次のパートでは、任意の構文要素のタプルを検討します。



最も明白なものから始めましょう-関数から複数の値を返します。 学校時代以来、私はこの不正に驚いていました。なぜ関数はあなたが好きなだけ多くの値を取り、1つだけを返すことができるのでしょうか? 実際、なぜy = x * xは通常の放物線であり、y = sqrt(x)はある種のハーフカットガベージですか? これは数学的調和の侵害ではないでしょうか? プログラミングでは、もちろん構造オブジェクトを返すことができますが、本質は同じままです。1つのオブジェクトが返され、複数のオブジェクトは返されません。



複数のリターンの即時実装はGoにあります。 関数は明示的に複数の値を返すことができます。 この構文を使用すると、これらの複数の値を複数の変数に割り当てたり、1つの操作でグループの割り当てや引数の並べ替えを実行したりできます。 ただし、割り当て以外のグループアクションは提供されません。

func foo() (r1 int, r2 int) { return 7, 4 } x, y := foo() x, y = 1, 2 x, y = y, x
      
      





注目すべき興味深い機能は、ある関数の複数の戻り値を別の関数に「バッチ」転送することです。

 func bar(x int, y int) { } bar(foo())
      
      







このようなパケット送信自体は非常に興味深いものです。 一方では、彼女はとてもエレガントに見えます。 しかし、他方では、「暗黙的」すぎて、普遍的ではありません。 たとえば、3番目の引数をbarに追加して、「バッチ」送信を通常の送信と組み合わせようとすると、

 bar(foo(), 100)
      
      





その後、何も動作しません-コンパイルエラー。



別の興味深い側面は、戻り値を使用しないことです。 C / C ++を思い出してください。 それら(および他のほとんどの言語-Java、C#、ObjC、D ...)では、関数を呼び出すときに返された値を安全に無視することができました。 Goでは、これも可能です。また、唯一の戻り値とグループの両方を無視できます。 ただし、最初の戻り値を使用して2番目の値を暗黙的に無視しようとすると、コンパイルエラーが発生します。 無視することは可能ですが、明示的に-特殊文字「_」を使用します。

 x, _ := foo()
      
      





つまり 「すべてまたは何も」の原則が機能します。すべての戻り値を無視するか、すべての戻り値を使用できます。



Rustにも同様の機能があります。 同様に、関数は複数の値を返すことができます。 新しい値で初期化することもできます。 この場合、複数の割り当て自体は存在せず、初期化のみが可能です。 同様に、未使用の値に「_」文字を使用できます。 同様に、戻り値を完全に無視することも、すべてを完全に取得することもできます。 タプルも比較できます:

 let x = (1i, 2i, 3i); let y = (2i, 3i, 4i); if x == y { println!("yes"); } else { println!("no"); }
      
      





この事実に注意してください:割り当てとは異なり、タプルで最初の操作に遭遇しました。 別の興味深い可能性がここで観察されます-名前付きタプルの作成と、その後の「全体としての」使用。



Swiftでは、可能性は一般的に似ています。 興味深いものから-ポイントを介した定数インデックスによるタプルの要素へのアピール。 タプル要素に名前を割り当て、それらを通して要素にアクセスする機能。



 let httpStatus = (statusCode: 200, description: "OK") print("The status code is \(httpStatus.0)") print("The status code is \(httpStatus.statusCode)")
      
      







そのようなタプルはすでに構造に近いですが、それでも構造ではありません。 そして、ここでは例から離れて自分の考えに移りたいと思います。 タプルと構造の違いは、タプルがデータ型ではなく、下位レベルであるということです。 タプルは、(おそらく名前付きの)コンパイル時オブジェクトの(おそらく名前付きの)グループであると言えます。 この時点で、C / C ++言語を思い出してください。 最も単純な配列と構造体の初期化構造は次のようになります。

 int arr[] = {1, 2, 3}; Point3D pt = {1, 2, 3};
      
      





この場合の初期化リストは一般に同一であることに注意してください。 それでも、完全に異なるデータオブジェクトを初期化します。 通常、この動作はデータ型では非典型的です。 しかし、これは別の興味深い機能に近いものです。これは、プログラミング言語で時々見られる(ただしめったにない) 構造型です。 中括弧内の構造は、典型的なタプルです。 ちなみに、Cには構造フィールドの名前付き初期化があります(ちなみに、アイデアはSwiftに非常に似ています)。これは、C ++ 17ではまだドラッグされていません。

 Point3D pt = {.x=1, .y=2, .z=3};
      
      







C ++では、それらはわずかに異なる方向に進みました。「統一された初期化構文と初期化リスト」の概念を導入しました。 構文的には、これらはオブジェクトの初期化に使用できるものと同じタプルです。 古い機能に加えて、統一された初期化構文により、オブジェクトを関数に転送し、タプルの形式で関数から戻ることができます。

  Point3D pt{10,20,30}; //    Point3D foo(Point3D a) { return {1, 2, 3}; //  "" } foo( {3,2,1} ); //  ""
      
      







別の興味深い機能は、ベクトルやリストなどの動的データ構造を初期化するために使用される初期化リストです。 C ++の初期化リストは統一されている必要があります。つまり、リストのすべての要素は同じ型でなければなりません。 技術的には、このようなリストはメモリ内の定数配列を形成し、std :: initializer_list反復子によってアクセスされます。 std :: initializer_listテンプレートタイプは、同種のタプル(および実際には定数配列)への特別なコンパイラ固有のインターフェイスであると言えます。 もちろん、初期化リストはコンストラクターで使用できるだけでなく、関数やメソッドの引数としても使用できます。 C ++で最初にリテラル配列に対応し、この配列の長さに関する情報を含むテンプレートデータ型がある場合、std :: initializer_listの役割に完全に適合すると思います。



また、標準C ++ライブラリ(およびBoost)には、テンプレートを使用して実装されたタプルがあります。 このような実装は言語の一部ではないため、構文はやや面倒で普遍的ではありません。 そのため、タプルのタイプは、すべてのフィールドのタイプを使用して明示的に宣言する必要があります。 関数std :: make_tupleは、オブジェクトの構築に使用されます。 「既存の変数から」「オンザフライ」でタプルを作成するには、別のテンプレート-tieを使用し、定数インデックスを必要とする特別なテンプレートメソッドを使用して要素へのアクセスを実行します。

 std::tuple<int,char> t1(10,'x'); auto t2 = std::make_tuple ("test", 3.1, 14, 'y'); int myint; char mychar; std::tie (myint, mychar) = t1; // unpack elements std::tie (std::ignore, std::ignore, myint, mychar) = t2; // unpack (with ignore) std::get<2>(t2) = 100; char mychr = std::get<3>(t2);
      
      





この例では、特殊値std :: ignoreを使用したアンパックを使用しています。 これは、GoおよびRustの関数からのグループリターンで同じ目的に使用されるアンダースコア文字「_」と正確に一致します。



同様の方法で(C ++と比較して簡略化されていますが)、タプルはC#で実装されます。 作成の場合、メソッドTuple.Create()、テンプレートクラスのセットTuple <>が要素へのアクセスに使用されます-固定名Item1 ... item8のフィールド(それにより、定数インデックスを取得します)。



D言語には、非常に豊富なタプルサポートがあります。 tupleコンストラクトを使用して、タプルを形成し、関数を含めて-複数のリターンを実行できます。 タプル要素にアクセスするには、定数インデックスによるインデックス付けが使用されます。 Tupleテンプレートを使用してタプルを設計することもできます。これにより、名前付きフィールドを持つタプルを作成できます。

 auto t = Tuple!(int, "number", string, "message")(123, "hello"); writeln("by index 0 : ", t[0]); writeln("by .number : ", t.number); writeln("by index 1 : ", t[1]); writeln("by .message: ", t.message);
      
      







タプルは関数で渡すことができます。 これを行うには、範囲を使用したインデックス付けが使用されます。 構文的には、1つの引数が渡されているように見えますが、実際には、タプルは一度に複数の引数に拡張されます。 同時に、Dでは、Goとは異なり、関数の引数とタプル要素の数が正確に等しい必要はありません。つまり、単一の引数とタプルの転送を混在させることができます。

 void bar(int i, double d, char c) { } auto t = tuple(1, "2", 3.3, '4'); bar(t[0], t[$-2..$]);
      
      





Dには、タプルに関連する他の多くの機能があります-コンパイル段階でタプルを走査するためのコンパイル時foreach、AliasSeqテンプレート、tupleof演算子...



そして最後に、C言語のあまり知られていない拡張機能であるタプルの実装を検討します-CForAllまたはC∀ 新しいプログラミング言語をダウンロードし、私が到達できるすべてのものをダウンロードします



Cのタプルは、オブジェクトのリストを角括弧で囲むことにより、言語レベルで宣言できます。 タプル型も同じ方法で作成されます-型のリストは角括弧で囲まれます。 オブジェクトとタプル型は明示的に宣言できます。 タプルは、引数リストに展開される関数に渡すことができます(Goとは異なり、関数のタプルと引数リストが正確に一致する場合のみ可能です)。

 [ int, int ] w1; // -    [ int, int, int ] w2; // -    void f (int, int, int); // ,    f( 1, 2, 3 ); //   f( [ 1, 2, 3 ] ); // -     f( w1, 3 ) //    f( w2 ) // -
      
      





別の興味深いトピックは、ネストされたタプルとそれらの開示のルールです。 ネストは、C / C ++でも使用されます-要素が配列および構造体でもある構造体の配列を初期化する場合。 C∀には、「タプル強制」と呼ばれるルールがあります。特に、内部構造を持つタプルを開示(平坦化)し、逆も同様です。次の部分で)。 そして、これはすべて割り当てにのみ適用され、他の操作でこれらの機能を使用することについては言及されていません。

 [ a, b, c, d ] = [ 1, [ 2, 3 ], 4 ];
      
      





C∀は、グループと複数の割り当ての両方を提供します。

 [ x, y, z ] = 1.5; [ x, y, z ] = [ 1, 2, 3 ];
      
      





さらに、タプルを使用して構造体フィールドにアクセスする

 obj.[ f3, f1, f2 ] = [ x , 11, 17 ];
      
      





コンパイラーがないため、これらすべての機能を実際にテストすることはできませんでしたが、これは確かに優れた参考資料です。 実際、記事の次の部分はこれらの考察に専念します。



All Articles