saturating_add と saturating_int – 新しい関数と新しい型?

オーバーフローではなく飽和する整数演算を行いたいとします。組み込みの 01 はそのように動作しないので、自分で何かを転がす必要があります。 関数または新しい 26 33 をオーバーロードした型 ?46 はどうですか? 対 53 ?または 6479 ?

新しい動作を実装する関数をいつ提供する必要があり、いつラッパー型を記述する必要がありますか?長所と短所を見てみましょう.

新しい関数を書く

飽和加算が必要な場合は、87 と記述します。;アトミックに何かをロードするには、96 と書くだけです;最適化されていないものを保存するには、105 と記述します .

これは単純明快な解決策であり、一部の人にとってはここで投稿を終了できますが、理想的とは言えません。

欠点 #1:既存の名前/演算子を再利用できない

次のコードは、オーバーフロー (未定義) 動作で何かを計算します:

int x = …;
int result = x * 42 + 11;

これは同じコードですが、飽和動作を使用しています:

int x = …;
int result = saturating_add(saturating_mul(x, 42), 11);

どちらのバージョンが読みやすいですか?

119 として および 128 132 にはすでに意味があります s、飽和演算に使用することはできません。関数を使用する必要があります。これは、適切な演算子構文を失い、代わりにネストされた関数呼び出しを把握する必要があることを意味します。

この問題は言語レベルで解決できます。たとえば、Swift には 147 があります。 オーバーフローと 151 でエラーが発生します 新しい構文を定義することで、関数呼び出しに頼る必要がなくなります。もちろん、これは本質的に、言語自体で作業しないユーザーに限定されるか、定義できる言語が必要です。ただし、Swift でさえ飽和演算子はなく、C++ には何もありません。

代わりに新しい 162 を書くことにした場合 型、172 をオーバーロードできます と 184 目的の機能を実装する

struct saturating_int
{
    int value;

    explicit saturating_int(int v)
    : value(v) {}

    explicit operator int() const
    {
        return value;
    }

    friend saturating_int operator+(saturating_int lhs, saturating_int rhs);
    friend saturating_int operator*(saturating_int lhs, saturating_int rhs);
    …
};

その場合、飽和演算を実行するコードは通常のコードとほとんど同じに見えますが、型を変更するだけで済みます:

int x = …;
auto result = int(saturating_int(x) * 42 + 11);

欠点 #2:汎用コードを直接使用できない

これは実際には最初の欠点と同じです:操作の新しい名前を考案する必要があり、既存の名前を再利用できないため、汎用コードはそのままでは機能しません.C++ では、テンプレートはダックを使用します-入力し、構文に基づいて操作を呼び出します。構文が使用できない場合、または必要な機能を実行しない場合、それらを使用できません。

たとえば、 195 を使用して 関数、207 は使用できません 212 を呼び出すので、直接 代わりに、222 を呼び出すカスタム操作を渡す必要があります。 .

デメリット #3:行動を強制できない

特別なアドレス 234 に書き込むことで、ある種の組み込み周辺機器 (LED など) を制御したいとします。 .次のコードにはバグがあります:

const auto led = reinterpret_cast<unsigned char*>(0xABCD);
*led = 1; // turn it on
std::this_thread::sleep_for(std::chrono::seconds(1));
*led = 0; // turn it off

コンパイラは 246 を読んでいる人を見ることができないので 255 に書き込まれます 、最適化して取り除くことができるデッド ストアと見なします。コンパイラは、LED をオンにする追加の副作用があることを認識しません。保存する必要があります!

正しい修正は、揮発性ストアを使用することです。これは、ストアを最適化してはならないことをコンパイラに伝えます。架空の 260 によって実装されているとしましょう。 関数:

const auto led = reinterpret_cast<unsigned char*>(0xABCD);
volatile_store(led, 1); // turn it on
std::this_thread::sleep_for(std::chrono::seconds(1));
volatile_store(led, 0); // turn it off

これで動作しますが、 273 を使用することを手動で覚える必要があります 287 とは対照的に 忘れたら、誰も思い出させてくれません。

揮発性がポインター型の一部である実際の C++ では、これは問題ではありません。 、すべてのロード/ストアは自動的に揮発性であり、覚えておく必要はありません.型システムに入れることで、特定の動作の一貫した使用を強制できます.

デメリット #4:追加の状態を保存できない

特定のメモリ アドレスに値をアトミックにロードできる汎用関数を書きたいとします。

template <typename T>
T atomic_load(T* ptr);

最新の CPU では、304 の場合、この関数の実装は簡単です。 .313 の場合 、トリッキーになり、 321 の場合 1KiB のデータをアトミックにロードできる命令がないため、不可能です。

まだ 337 C++ 標準ライブラリのすべての 349 で機能します 、それらが自明にコピー可能である限り.どうやってそれを管理していますか?

考えられる実装の 1 つは次のようになります。

template <typename T>
class atomic
{
    T value;
    mutable std::mutex mutex;

public:
    T load() const
    {
        std::lock_guard<std::mutex> lock(mutex);
        return value;
    }
};

アトミック アクセスの新しい型を定義すると、そこに追加のメンバーを配置できます。この場合は、アクセスを同期するためのミューテックスです。型を変更できない関数しかない場合、これはできません。

新しい型を書く

したがって、これらの欠点に基づいて、動作を微調整したいときに新しい型を作成することにします.A 359369370 .いくつかの無料の関数と比較すると定型文ですが、既存の演算子の美しさ、必要に応じて追加の状態を追加できる柔軟性、および型システムが提供する安全性の保証があるため、それだけの価値があります。

しかし、新しい状況も理想的ではありません。

欠点 #1:あらゆる場所でコンバージョン

飽和算術演算を実行したいが、オーバーフローが必要な場合があるとします。動作は型によって提供されるため、動作を変更するには型を変更する必要があります:

int x = …;
saturating_int y = saturating_int(x) * 42;
int z = int(y) + 11;
saturating_int w = saturating_int(z) * 2;

387 の場合 、これは実際には問題ではありません。コンパイラはそれらを最適化します。しかし、より大きな型の場合は?これらの変換のすべてが加算される可能性があり、貧弱な CPU は常にデータを移動する必要があります。

欠点 #2:さまざまなタイプ

396 407 ではありません .確かに、それらを関連付けるために変換演算子を提供できますが、これは 413 の場合には役に立ちません と 425 :それらはまったく無関係なタイプです。

435 を渡さなければならないことに不満を言ったことを思い出してください。 448 へ ?さて、459 から始めると 460 とは対照的に あなたはまだ運が悪いです。唯一のオプションは、C++20 の範囲を使用して、478 を有効にするビューを提供することです。 483 の範囲に または、カスタム操作を提供するだけです。

同様の問題は、値をどこかに保存することにした場合にも発生します。496 として保存しますか? 、それが何であるか、または 504 として それがどのように使用されるか?タイプが異なるため、いずれかを選択する必要があります。

根本的な問題

ここで、私たちがしなければならない根本的な問題のトレードオフがあります:論理的には、関数を書くことによって行われる振る舞いを提供したいのですが、OOP モデルでは、それを適切に行うために型が必要です.

C++ では、常にこのトレードオフについて検討する必要があります。ただし、状況を改善するために行うことができる言語の変更がいくつかあります。

解決策 #1:「レイアウト」と「タイプ」を区別する

今、511 および 528 CPUにとっては本質的に同じですが、機能だけが重要です.したがって、この基本的なレイアウトは言語で推論できると想像できます.C++ 20には、すでに「レイアウト互換型」の概念があります. 、労働組合にとって重要です。その上に構築しましょう。

538 を想像できます レイアウトを損なわずにオブジェクトのタイプを変更する演算子:

int x = …;
auto y = layout_cast<saturating_int>(x);

これはアセンブリ命令を生成しません。CPU には何も変化がなく、論理的に 542 の有効期間を終了します。 .558 568 と同じアドレスに存在する新しいオブジェクトになりました 同じビット パターンを格納しますが、型が異なります。唯一の効果は、571 の異なるオーバーロード解決です。 .

これはコンテナにも拡張できます:

std::vector<int> x = …;
auto y = layout_cast<std::vector<saturating_int>>(x);

繰り返しますが、論理的には 583 の束の間に違いはありません と 597 の束 s であるため、CPU は何もする必要はありません。型だけが変更されています。

これにより、実際のランタイム パフォーマンスに影響を与えることなく動作を変更できます。

解決策 2:動作を別のエンティティにパッケージ化する

Scala はこの問題に対して興味深い見解を持っています。600 を検討してください。 これは、初期値だけでなく、「加算」がどのように実行されるかを制御する追加の操作も必要とします。数学的にはモノイドと呼ばれ、「加算」と「加算」の同一性を記述します。617 の場合 、つまり 625 です と 630 .ただし、644 の場合もあります と 654 .そのため、669 入力の範囲と使用するモノイドを受け入れます。

Scala では、Monoid を特別な方法で、暗黙のパラメーターとして渡すことができます。彼らの Web サイトの例を引用すると、次のようになります。

abstract class Monoid[A] {
  def add(x: A, y: A): A
  def unit: A
}

object ImplicitTest {
  implicit val stringMonoid: Monoid[String] = new Monoid[String] {
    def add(x: String, y: String): String = x concat y
    def unit: String = ""
  }

  implicit val intMonoid: Monoid[Int] = new Monoid[Int] {
    def add(x: Int, y: Int): Int = x + y
    def unit: Int = 0
  }

  def sum[A](xs: List[A])(implicit m: Monoid[A]): A =
    if (xs.isEmpty) m.unit
    else m.add(xs.head, sum(xs.tail))

  def main(args: Array[String]): Unit = {
    println(sum(List(1, 2, 3)))       // uses intMonoid implicitly
    println(sum(List("a", "b", "c"))) // uses stringMonoid implicitly
  }
}

最初に 676 を定義します 加算と単位を持つインターフェイスとして、文字列と int に対してそれを実装し、リストを合計するジェネリック関数を記述します。呼び出しサイトで渡す必要のない暗黙のパラメーターとしてモノイドを受け入れます。代わりに、コンパイラは最も近い 689 を検索します 値を渡してください。

同じ原則を私たちの問題にも適用できます。たとえば、696 を定義できます。 と 707 次に、必要なものを示すために何かを使用します。これにより、 712 のルックアップが変更されます と 727

もちろん、これには、Rust が trait で持っているように、「コンパイル時インターフェース」を簡単に指定する方法が必要です。しかし、C++ は C++0x の概念に反対することを決定したため、現在そのようなものを追加することは不可能になっています.

結論

動作を変更するために新しい型を書くことは、新しい関数を書くよりも厳密に強力です。そのため、新しい型を書かなければならない状況では )、選択は簡単です。

それ以外の場合はすべてトレードオフです。

異なる動作を混在させる必要がある場合がよくありますか?新しい動作を誤って忘れないようにすることが重要ですか?そうであれば、新しい型を書きます。そうでなければ、関数を書きます。

レイアウトを動作から分離する何らかの方法がある理想的な世界では、これは問題になりません.しかし、それがないため、トレードオフを受け入れる必要があります.もちろん、両方を提供することもできますバージョン。これは、Rust が 748 で行うことです。 と 752 .