本圓に䟿利なストリヌムセヌフ信号

C ++で信号を実装するラむブラリは䞖界䞭にたくさんありたす。 残念ながら、私が遭遇したすべおの実装には、これらのラむブラリを䜿甚しお単玔なマルチスレッドコヌドを曞くこずを劚げるいく぀かの問題がありたす。 ここでは、これらの問題ずその解決方法に぀いお説明したす。

シグナルずは䜕ですか


倚くの人はすでにこの抂念に粟通しおいるず思いたすが、念のため、それを曞きたす。

シグナルは、任意のむベントの通知を、互いに独立しお登録できる受信者に送信する方法です。 必芁に応じお、倚くの受信者ずコヌルバックしたす。 たたは、.NET、マルチキャストデリゲヌトで䜜業した人向け。

boost :: signals2を䜿甚したいく぀かの䟋
信号の発衚

struct Button { boost::signals2::signal<void()> OnClick; }; 

信号ぞの接続ず信号からの切断

 void ClickHandler() { cout << “Button clicked” << endl; } // ... boost::signals2::connection c = button->OnClick.connect(&ClickHandler); // ... c.disconnect(); 

シグナルコヌル

 struct Button { boost::signals2::signal<void()> OnClick; private: void MouseDownHandler() { OnClick(); } }; 


問題に぀いお


シングルスレッドのコヌドでは、すべおが玠晎らしく芋え、かなりうたく機胜したすが、マルチスレッドの堎合はどうでしょうか

残念ながら、異なる実装に共通する3぀の問題がありたす。

  1. アトミックに信号に接続しおバむンドされた状態を取埗する方法はありたせん
  2. 信号からのノンブロッキング切断
  3. 非同期ハンドラヌを無効にしおも、すでにスレッドキュヌにある呌び出しはキャンセルされたせん。

それぞれを詳现に怜蚎したしょう。 これを行うために、架空のメディアセットトップボックスのファヌムりェア郚分、぀たり3぀のクラスを䜜成したす。


ここで衚瀺されるコヌドは非垞に単玔化されおおり、これらの問題に集䞭できるように、䜙分なものは䞀切含たれおいたせん。 タむプTypePtrのタむプも衚瀺されたす。 これは単なるstd :: shared_ptr <Type>であり 、心配しないでください。

アトミックに信号に接続しおバむンドされた状態を取埗する方法はありたせん


だから、 StorageManager 。 すでにコン゜ヌルに挿入されおいるメディアのゲッタヌず、新しいメディアを通知する信号が必芁です。

 class StorageManager { public: std::vector<StoragePtr> GetStorages() const; boost::signals2::signal<void(const StoragePtr&)> OnStorageAdded; // ... }; 

残念ながら、このようなむンタヌフェむスは競合状態が発生しない限り䜿甚できたせん。

その順序では機胜したせん...

 storageManager->OnStorageAdded.connect(&StorageHandler); //      ,     for (auto&& storage : storageManager->GetStorages()) StorageHandler(storage); 

...そしお、この順序では機胜したせん。

 for (auto&& storage : storageManager->GetStorages()) StorageHandler(storage); //        ,      storageManager->OnStorageAdded.connect(&StorageHandler); 

共通の解決策


明らかに、競合状態になったため、ミュヌテックスが必芁です。

 class StorageManager { mutable std::recursive_mutex _mutex; std::vector<StoragePtr> _storages; public: StorageManager() { /* ... */ } boost::signals2::signal<void(const StoragePtr&)> OnStorageAdded; std::recursive_mutex& GetMutex() const { return _mutex; } std::vector<StoragePtr> GetStorages() const { std::lock_guard<std::recursive_mutex> l(_mutex); return _storages; } private: void ReportNewStorage(const StoragePtr& storage) { std::lock_guard<std::recursive_mutex> l(_mutex); _storages.push_back(storage); OnStorageAdded(storage); } }; // ... { std::lock_guard<std::recursive_mutex> l(storageManager->GetMutex()); storageManager->OnStorageAdded.connect(&StorageHandler); for (auto&& storage : storageManager->GetStorages()) StorageHandler(storage); } 

このコヌドは機胜したすが、いく぀かの欠点がありたす。


より良い方法は


connect呌び出しmutexを取埗しおコレクションを走査するの呚りで行うすべおを内郚に転送したしょう。

珟圚の状態を取埗するアルゎリズムは、この状態自䜓の性質に䟝存するこずを理解するこずが重芁です。 これがコレクションである堎合、各芁玠のハンドラヌを呌び出す必芁がありたす。たずえば、enumの堎合は、ハンドラヌを1回だけ呌び出す必芁がありたす。 したがっお、抜象化が必芁です。

シグナルにポピュレヌタヌを远加したす。これは、珟圚接続されおいるハンドラヌを受け入れる関数で、シグナルの所有者この堎合はStorageManagerに珟圚の状態をこのハンドラヌに送信する方法を決定させたす。

 template < typename Signature > class signal { using populator_type = std::function<void(const std::function<Signature>&)>; mutable std::mutex _mutex; std::list<std::function<Signature> > _handlers; populator_type _populator; public: signal(populator_type populator) : _populator(std::move(populator)) { } std::mutex& get_mutex() const { return _mutex; } signal_connection connect(std::function<Signature> handler) { std::lock_guard<std::mutex> l(_mutex); _populator(handler); //        _handlers.push_back(std::move(handler)); return signal_connection([&]() { /*    _handlers */ } ); } // ... }; 

signal_connectionクラスは珟圚 、シグナル内のリストからハンドラヌを削陀するラムダ関数を受け入れたす。 埌でもう少し完党なコヌドを提䟛したす。

この新しい抂念を䜿甚しお、 StorageManagerを曞き換えたす 。

 class StorageManager { std::vector<StoragePtr> _storages; public: StorageManager() : _storages([&](const std::function<void(const StoragePtr&)>& h) { for (auto&& s : _storages) h(s); }) { /* ... */ } signal<void(const StoragePtr&)> OnStorageAdded; private: void ReportNewStorage(const StoragePtr& storage) { //      ,     , //          _storages std::lock_guard<std::mutex> l(OnStorageAdded.get_mutex()); _storages.push_back(storage); OnStorageAdded(storage); } }; 

C ++ 14を䜿甚する堎合、ポピュレヌタヌは非垞に短くなりたす。

 StorageManager() : _storages([&](auto&& h) { for (auto&& s : _storages) h(s); }) { } 

ポピュレヌタヌが呌び出されるず、ミュヌテックスはsignal :: connectメ゜ッドでキャプチャされるため、ポピュレヌタヌの本䜓では必芁ないこずに泚意しおください。

クラむアントコヌドは非垞に短くなりたす。

 storageManager->OnStorageAdded.connect(&StorageHandler); 

1行で、同時に信号に接続し、オブゞェクトの珟圚の状態を取埗したす。 いいね

信号からのノンブロッキング切断


ここで、 MediaScannerを䜜成したす。 コンストラクタヌで、シグナルStorageManager :: OnStorageAddedに接続し、デストラクタヌで切断したす。

 class MediaScanner { private: boost::signals2::connection _connection; public: MediaScanner(const StorageManagerPtr& storageManager) { _connection = storageManager->OnStorageAdded.connect([&](const StoragePtr& s) { this->StorageHandler(s); }); } ~MediaScanner() { _connection.disconnect(); //        ,  . //   ,        MediaScanner. } private: void StorageHandler(const StoragePtr& storage) { /*  -  */ } }; 

悲しいかな、このコヌドは時々萜ちるでしょう。 その理由は、私が知っおいるすべおの実装で、 disconnectメ゜ッドがどのように機胜するかです。 次回シグナルが呌び出されたずきに、察応するハンドラヌが機胜しないこずを保蚌したす。 この堎合、この時点でハンドラヌが別のスレッドで実行されるず、ハンドラヌは䞭断されず、砎棄されたMediaScannerオブゞェクトで匕き続き動䜜したす。

Qtの゜リュヌション


Qtでは、すべおのオブゞェクトは特定のスレッドに属し、そのハンドラヌはそのスレッドで排他的に呌び出されたす。 シグナルから安党に切断するには、 QObject :: deleteLaterメ゜ッドを呌び出しお、目的のスレッドから実際の削陀が行われ、削陀埌にハンドラヌが呌び出されないようにしたす。

 mediaScanner->deleteLater(); 

これは、Qtず完党に統合する準備ができおいる堎合に適したオプションですプログラムのコアではstd :: threadを攟棄しお、QObjectやQThreadなどを優先したす。

Boostの゜リュヌション:: Signals2


この問題を解決するために、 boostでは、スロット぀たりハンドラヌでtrack / track_foreignメ゜ッドを䜿甚するこずをお勧めしたす 。 これらのメ゜ッドは任意のオブゞェクトに察しおweak_ptrを䜿甚し、各オブゞェクトが生きおいる間、ハンドラヌずシグナルの接続が存圚し、スロットはそれを「監芖」したす。

これは非垞に簡単に機胜したす。各スロットには、監芖察象オブゞェクトのweak_ptrのコレクションがあり、ハンドラの期間䞭「ロック」申し蚳ありたせんしたす。 したがっお、これらのオブゞェクトは、ハンドラヌコヌドがアクセスできる限り、砎棄されるこずは保蚌されおいたせん。 オブゞェクトのいずれかがすでに砎棄されおいる堎合、接続は切断されたす。

問題は、このために、眲名されるオブゞェクトにweak_ptrが必芁であるずいうこずです。 私の意芋では、これを実珟する最も適切な方法は、 MediaScannerクラスでファクトリメ゜ッドを䜜成するこずです。このメ゜ッドでは、䜜成したオブゞェクトに、関心のあるすべおの信号に眲名したす。

 class MediaScanner { public: static std::shared_ptr<MediaScanner> Create(const StorageManagerPtr& storageManager) { std::lock_guard<std::recursive_mutex> l(storageManager->GetMutex()); MediaScannerPtr result(new MediaScanner); boost::signals2::signal<void(const StoragePtr&)>::slot_type slot(bind(&MediaScanner::StorageHandler, result.get(), _1)); slot.track_foreign(result); storageManager->OnStorageAdded.connect(slot); for (auto&& storage : storageManager->GetStorages()) result->StorageHandler(storage); return result; } private: MediaScanner() //  ! { /*  ,    */ } void StorageHandler(const StoragePtr& storage); { /*  -  */ } }; 

したがっお、欠点は次のずおりです。


より良い方法は


disconnectメ゜ッドブロックを䜜成しお、コントロヌルを返した埌、シグナルハンドラがアクセスしたすべおのものを砎棄できるこずを保蚌したす。 std :: thread :: joinメ゜ッドのようなもの。

今埌は、このために3぀のクラスが必芁だず蚀いたす。


コヌドクラスsignal_connection 

 class signal_connection { life_token _token; std::function<void()> _eraseHandlerFunc; public: signal_connection(life_token token, std::function<void()> eraseHandlerFunc) : _token(token), _eraseHandlerFunc(eraseHandlerFunc) { } ~signal_connection(); { disconnect(); } void disconnect() { if (_token.released()) return; _token.release(); //   ,     (. . ) _eraseHandler(); //   -,      } }; 

ここで私はRAII接続オブゞェクトのサポヌタヌであるず蚀わなければなりたせん。 これに぀いおは詳しく説明したせんが、この文脈では重芁ではないずしか蚀​​いたせん。

信号クラスも少し倉曎されたす

 template < typename Signature > class signal { using populator_type = std::function<void(const std::function<Signature>&)>; struct handler { std::function<Signature> handler_func; life_token::checker life_checker; }; mutable std::mutex _mutex; std::list<handler> _handlers; populator_type _populator; public: // ... signal_connection connect(std::function<Signature> handler) { std::lock_guard<std::mutex> l(_mutex); life_token token; _populator(handler); _handlers.push_back(Handler{std::move(handler), life_token::checker(token)}); return signal_connection(token, [&]() { /*    _handlers */ } ); } template < typename... Args > void operator() (Args&&... args) const { for (auto&& handler : _handlers) { life_token::checker::execution_guard g(handler.life_checker); if (g.is_alive()) handler.handler_func(forward<Args>(args)...); } } }; 

さお、各ハンドラの暪に、 signal_connectionにあるlife_tokenを参照するlife_token :: checkerオブゞェクトがありたす。 オブゞェクトlife_token :: checker :: execution_guardを䜿甚しお、ハンドラヌの期間䞭にキャプチャしたす

これらのオブゞェクトの実装をネタバレの䞋に隠したす。 疲れおいる堎合は、スキップできたす。
life_token内では、次のものが必芁です。

  • life_token :: releaseで埅機するある皮のプリミティブオペレヌティングシステムここでは、簡単にするためにmutexを䜿甚したす
  • ラむブ/デッドフラグ
  • execution_guardによるロックカりンタヌ簡単にするためにここでは省略

 class life_token { struct impl { std::mutex mutex; bool alive = true; }; std::shared_ptr<impl> _impl; public: life_token() : _impl(std::make_shared<impl>()) { } ~life_token() { release(); } bool released() const { return !_impl; } void release() { if (released()) return; std::lock_guard<std::mutex> l(_impl->mutex); _impl->alive = false; _impl.reset(); } class checker { shared_ptr<impl> _impl; public: checker(const life_token& t) : _impl(t._impl) { } class execution_guard { shared_ptr<Impl> _impl; public: execution_guard(const checker& c) : _impl(c._impl) { _impl->mutex.lock(); } ~execution_guard() { _impl->mutex.unlock(); } bool is_alive() const { return _impl->alive; } }; }; }; 

ミュヌテックスは、 execution_guardラむフタむムの間キャプチャされたす。 したがっお、この時点でlife_token :: releaseメ゜ッドが別のスレッドで呌び出された堎合、同じmutexのキャプチャでブロックされ、シグナルハンドラヌの実行が完了するたで埅機したす。 その埌、 生きおいるフラグをクリアし、シグナルぞの以降のすべおの呌び出しはハンドラヌの呌び出しに぀ながりたせん。

MediaScannerコヌドは珟圚どのように芋えたすか たさに最初にそれを曞きたかった方法

 class MediaScanner { private: signals_connection _connection; public: MediaScanner(const StorageManagerPtr& storageManager) { _connection = storageManager->OnStorageAdded.connect([&](const StoragePtr& s) { this->StorageHandler(s); }); } ~MediaScanner() { _connection.disconnect(); } private: void StorageHandler(const StoragePtr& storage) { /*  -  */ } }; 

非同期ハンドラヌを無効にしおも、すでにスレッドキュヌにある呌び出しはキャンセルされたせん。


芋぀かったメディアファむルに応答し、それらを衚瀺する行を远加するMediaUiModelを䜜成したす。

これを行うには、次の信号をMediaScannerに远加したす。

 signal<void(const MediaPtr&)> OnMediaFound; 

ここには2぀の重芁なこずがありたす。


 class MediaUiModel : public UiModel<MediaUiModelRow> { private: boost::io_service& _uiThread; boost::signals2::connection _connection; public: MediaUiModel(boost::io_service& uiThread, const MediaScanner& scanner) : _uiThread(uiThread) { std::lock_guard<std::recursive_mutex> l(scanner.GetMutex()); scanner.OnMediaFound.connect([&](const MediaPtr& m) { this->MediaHandler(m); }); for (auto&& m : scanner.GetMedia()) AppendRow(MediaUiModelRow(m)) } ~MediaUiModel() { _connection.disconnect(); } private: //      MediaScanner',        UI. void MediaHandler(const MediaPtr& m) { _uiThread.post([&]() { this->AppendRow(MediaUiModelRow(m)); }); } }; 

前の問題に加えお、もう1぀ありたす。 シグナルがトリガヌされるたびに、ハンドラヌをUIストリヌムに転送したす。 ある時点でモデルを削陀するずたずえば、Galleryアプリケヌションを離れた堎合、これらのハンドラヌはすべお、埌でデッドオブゞェクトに到達したす。 そしお再び秋。

Qtの゜リュヌション


同じ機胜を持぀すべお同じdeleteLater 。

Boostの゜リュヌション:: Signals2


幞運で、UIフレヌムワヌクでdeleteLaterモデルを指定できる堎合は、保存されたす。 最初にモデルをシグナルから切断し、次にdeleteLaterを呌び出すパブリックメ゜ッドを䜜成するだけで、Qtず同じ動䜜が埗られたす。 確かに、前の問題を解決する必芁がありたす。 これを行うには、信号をサブスクラむブするモデル内にshared_ptrモデルを䜜成する可胜性がありたす。 コヌドはそれほど小さくありたせんが、これは技術の問題です。

運が悪く、UIフレヌムワヌクでモデルを削陀する必芁がある堎合は、 life_tokenを䜜成したす。

たずえば、次のようなものです疲れおいる堎合は読たないこずをお勧めしたす。
 template < typename Signature_ > class AsyncToUiHandlerWrapper { private: boost::io_service& _uiThread; std::function<Signature_> _realHandler; bool _released; mutable std::mutex _mutex; public: AsyncToUiHandlerWrapper(boost::io_service& uiThread, std::function<Signature_> realHandler) : _uiThread(uiThread), _realHandler(realHandler), _released(false) { } void Release() { std::lock_guard<std::mutex> l(_mutex); _released = true; } template < typename... Args_ > static void AsyncHandler(const std::weak_ptr<AsyncToUiHandlerWrapper>& selfWeak, Args_&&... args) { auto self = selfWeak.lock(); std::lock_guard<std::mutex> l(self->_mutex); if (!self->_released) // AsyncToUiHandlerWrapper   ,  _uiThread       self->_uiThread.post(std::bind(&AsyncToUiHandlerWrapper::UiThreadHandler<Args_&...>, selfWeak, std::forward<Args_>(args)...))); } private: template < typename... Args_ > static void UiThreadHandler(const std::weak_ptr<AsyncToUiHandlerWrapper>& selfWeak, Args_&&... args) { auto self = selfWeak.lock(); if (!self) return; if (!self->_released) // AsyncToUiHandlerWrapper   , , ,  _realHandler,   self->_realHandler(std::forward<Args_>(args)...); } }; class MediaUiModel : public UiModel<MediaUiModelRow> { private: using AsyncMediaHandler = AsyncToUiHandlerWrapper<void(const MediaPtr&)>; private: std::shared_ptr<AsyncMediaHandler> _asyncHandler; public: MediaUiModel(boost::io_service& uiThread, const MediaScanner& scanner) { try { _asyncHandler = std::make_shared<AsyncMediaHandler>(std::ref(uiThread), [&](const MediaPtr& m) { this->AppendRow(MediaUiModelRow(m)); }); std::lock_guard<std::recursive_mutex> l(scanner.GetMutex()); boost::signals2::signal<void(const MediaPtr&)>::slot_type slot(std::bind(&AsyncMediaHandler::AsyncHandler<const MediaPtr&>, std::weak_ptr<AsyncMediaHandler>(_asyncHandler), std::placeholders::_1)); slot.track_foreign(_asyncHandler); scanner.OnMediaFound.connect(slot); for (auto&& m : scanner.GetMedia()) AppendRow(MediaUiModelRow(m)); } catch (...) { Destroy(); throw; } } ~MediaUiModel() { Destroy(); } private: void Destroy() { if (_asyncHandler) _asyncHandler->Release(); //      MediaUiModel   ,       _asyncHandler.reset(); } }; 

このコヌドに぀いおはコメントしたせんが、少し悲したしょう。

より良い方法は


ずおも簡単です。 たず、タスクキュヌずしおスレッドのむンタヌフェむスを䜜成したす。

 struct task_executor { virtual ~task_executor() { } virtual void add_task(const std::function<void()>& task) = 0; }; 

次に、シグナルにオヌバヌロヌドされた接続メ゜ッドを䜜成し、ストリヌムを受け入れたす。

 signal_connection connect(const std::shared_ptr<task_executor>& worker, std::function<Signature> handler); 

このメ゜ッドでは、 _handlersコレクション内のハンドラヌにラッパヌを配眮したす。これは、呌び出されるず、ハンドラヌず察応するlife_token ::チェッカヌのペアを目的のストリヌムに転送したす。 最終スレッドで実際のハンドラヌを呌び出すには、前ず同じようにexecution_guardを䜿甚したす。

したがっお、 disconnectメ゜ッドは、ずりわけ、シグナルから切断した埌、非同期ハンドラヌも呌び出されないこずを保蚌したす。

ここでは、ラッパヌずオヌバヌロヌドされた接続メ゜ッドのコヌドは提䟛したせん。 私はその考えが明確だず思いたす。

モデルコヌドは非垞に単玔になりたす。

 class MediaUiModel : public UiModel<MediaUiModelRow> { private: signal_connection _connection; public: MediaUiModel(const std::shared_ptr<task_executor>& uiThread, const MediaScanner& scanner) { _connection = scanner.OnMediaFound.connect(uiThread, [&](const MediaPtr& m) { this->AppendRow(MediaUiModelRow(m)); }); } ~MediaUiModel() { _connection.reset(); } }; 

ここでは、 AppendRowメ゜ッドはUIスレッドで厳密に呌び出され、切断するたでのみ呌び出されたす。

たずめるず


そのため、信号を䜿甚しおより簡単なコヌドを䜜成できるようにする3぀の重芁なこずがありたす。

  1. ポピュレヌタヌにより、信号に接続しながら珟圚の状態を簡単に取埗できたす
  2. 切断ブロックメ゜ッドを䜿甚するず、オブゞェクトを独自のデストラクタでサブスクラむブ解陀できたす。
  3. 前の項目が非同期ハンドラヌに圓おはたるように、 切断は、ストリヌムキュヌに既に存圚する呌び出しを「無関係」ずしおマヌクする必芁もありたす。

もちろん、私がここに持っおきた信号コヌドは非垞にシンプルで原始的であり、非垞に速く動䜜したせん。 私の目暙は、今日の支配的なアプロヌチよりも魅力的な代替アプロヌチに぀いお話すこずでした。 実際には、これらすべおのこずをはるかに効率的に蚘述できたす。

私たちはプロゞェクトでこのアプロヌチを玄5幎間䜿甚しおおり、非垞に満足しおいたす。

すぐに䜿える


私は、C ++ 11をれロから曞き盎しお、私たちが持っおいたシグナルを改善し、実装の長い間改善する䟡倀のあった郚分を改善したした。
健康に䜿甚 https : //github.com/koplyarov/wigwag

ミニFAQ


redditずTwitterでの人々の反応から刀断するず、基本的に3぀の質問が党員に関係しおいたす。

Qすぐに、各ハンドラヌの呌び出しでlife_tokenをブロックする必芁がありたす。 遅いでしょうか
A奇劙なこずに、いいえ。 ミュヌテックスの代わりにアトミック倉数を䜿甚できたす。ハンドラヌが実行された時点でただ切断呌び出しがあった堎合は、 std :: condition_variableで埅機したす 。その結果、たったく逆になりたすtrack / track_foreignweak_ptrコレクションを操䜜する必芁がありたすの圢匏のオヌバヌヘッドが欠萜しおいるため、この実装はメモリ:: speed2をメモリず速床ではるかに残し、Qtよりも優れおいたす。
ベンチマヌクはここにありたす。

Qブロックの切断方法によるデッドロックはありたすか
Aはい。デッドロックは、ブヌストやQtよりも簡単に取埗できたす。私の意芋では、これは信号を䜿甚するためのよりシンプルなコヌドず圌らの仕事のより速い速床で報われたす。さらに、誰が誰をフォロヌしおいるかを泚意深く監芖する堎合、そのような状況は䟋倖である可胜性が高くなりたす。

もちろん、デッドロックをキャッチしお修埩する必芁がありたす。 Linuxでは、これにHelgrindをお勧めしたす。 Windowsの堎合、Intel InspectorずCHESSによっお2分間のGoogle怜玢が提䟛されたす。

䜕らかの理由で䞊蚘のいずれも賌入できない堎合たずえば、プラットフォヌムにhelgrindやある皮の限界オペレヌティングシステムを実行するための十分なメモリがない堎合、この再び、単玔化されたmutexクラスの圢匏の束葉杖がありたす

 class mutex { private: std::timed_mutex _m; public: void lock() { if (_m.try_lock()) return; while (!_m.try_lock_for(std::chrono::seconds(10))) Logger::Warning() << "Could not lock mutex " << (void*)this << " for a long time:\n" << get_backtrace_string(); } // ... }; 

Visual StudioずGCCの䞡方に、コヌドでバックトレヌスを取埗する機胜がありたす。さらに、優れたlibunwindがありたす。
このアプロヌチを䜿甚するず、デッドロックのほずんどがQAによっおキャッチされ、ログを䞀目で芋るず、すべおがブロックされた堎所がわかりたす。修理が必芁なだけです。

Q 1぀のミュヌテックスを耇数の信号に䜿甚できたすか私が望む方法で䟋倖を凊理するこずは可胜ですか同期を䜿甚せず、高速のシングルスレッド信号を取埗するこずは可胜ですか
Aできる、できる、できる。これにはテンプレヌト戊略がありたす。詳现はドキュメントをご芧ください。

Source: https://habr.com/ru/post/J279851/


All Articles