このブログは、株式会社フィックスターズのエンジニアが、あらゆるテーマについて自由に書いているブログです。
C++11と言えば、昔のC++(03)から色々あって多くの機能が追加されとても便利になったバージョンです。さらに、C++14は11では間に合わなかった・忘れていた色々な便利なものを補填したもので、Effective Modern C++輪読会は、『Effective Modern C++』を教科書にしながらこのC++11/14について学ぶ会になっています。
時は既に2016年、gccもclangもMSVC++も概ねC++14が使えるようになっており、もはやC++14が使えないコンパイラにはC++コンパイラを名乗る資格はないと言っても過言ではない時代です(※個人的な意見であり弊社エンジニアの総意ではありません)。C++14が現代機械だとすると、C++11は戦後直後ぐらいの道具、C++03あたりは江戸時代ぐらいの遺物という感覚でしょうか(※あくまで個人的な感覚です)。
そんな状況で、Effective Modern C++輪読会と言うとちょっと今更感はあるのですが、こういうものはなんとなくの理解で進んでしまうことが多いので、この勉強会では学ぶことはけっこうありました。特に、今回のstd::move
やstd::forward
は、C++11で導入された機能の中で便利だと聞くけどいまいちよく分からないものNo1(※個人比)です。既に世の中にはもっと優れた記事がたくさんあるのは確かですが、色々な観点からの説明があるに越したことはないと思っているので、せっかくなので記事にして残しておきたいと思います。
一方で、C++といえばマサカリを投げ合う文化で有名ですが(?)、この記事も、大きな間違いはないとは思ってますが、マサカリを投げる隙がまったくない自信はありません・・・(先述の通り難しいところだと思いますし)。もし何か誤りなど見つけられた方は、記事にコメントいただくか、Twitter:@LWisteriaまで直接ご連絡ください。
ムーブと転送の説明に入る前に、用語の軽い復習をしておきます。
式には右辺値(rvalue)と左辺値(lvalue)があります。厳密な定義は複雑で難しいですが、概ね「アドレスを取得できる式=左辺値、できない=右辺値」と覚えておけば良いです。
int z = f(x, g(y));
ここで、
x
, y
, z
:左辺値g(y)
, f(x, g(y))
:右辺値です。
あるいは、これも厳密ではないですが、左辺値は名前のついた変数、右辺値は一時オブジェクトのように覚えてもいいです。
左辺値・右辺値それぞれの参照は左辺値参照(lvalue reference)と右辺値参照(rvalue reference)で、*通常は*、前者がT&
で後者がT&&
と書かれます。
int& y = x; // 左辺値参照
int&& y = g(x); // 右辺値参照
ここであえて*通常は*と言ったのは、T&&
で宣言しても左辺値になることがあるからです。
void f(int&& v)
{
// vはT&&(右辺値参照)で宣言しているのに、アドレス取得できるので、実はvは左辺値です!
// ∵すべての関数の引数は左辺値だから
std::cout << &v << std::endl;
}
このようになる詳しい仕組みは後述しますが、この先、説明中にT&&が出てきても右辺値とは限らないことがあることは念頭においておいてください(逆に、T&&が毎回出てきた時に、毎回、それが右辺値か左辺値かを考えていると、理解が進むかもしれません)。
ムーブ(move)は、コピー(copy)の対になる概念で、コピーが複製なのに対して、ムーブは置換(複製&破棄)を意味します。
RPGツクールで物体の移動をやりたい時に「移動」というコマンドがなくて「複製してから消せばいい」と書いてあったあれがムーブです。
このムーブを実現するコンストラクタおよび代入演算子をムーブコンストラクタとムーブ代入演算子と呼びます。
{
const int y = x;
const int z = x;
int ret = 0;
ret = y+z;
}
↑の時、代入演算=のうち、y=x
の=はコピー代入である必要があります。一方、z=x
, ret=0
, ret=y+z
の=は、(コピー代入でもいいですが)x
やy+z
の結果を捨ててもいい(後で使わない)のでムーブ代入にしてもよいはずです。
このように、同じ「代入」という演算は、コピーの意味とムーブの意味があります。「ムーブの意味にすること」をムーブセマンティクス(で解釈する|をとる)、と言います(※実はあんまり厳密な説明はできてないので、yohさんの『本当は怖くないムーブセマンティクス』(C++アドベントカレンダー2012 15日目)あたりを参考にしてください)。
転送(forward)とは、受け取った引数をそのまま別の関数に受け渡すことです。
void f(int x)
{
g(x);
}
そして完全転送(perferct forward)とは、仮引数の値だけでなくその他の情報(型)も含めて完全に実引数に渡すことです。↑の例だと、f(z);
と呼び出した時に、z
の情報をそのままg()
に伝え、g(z);
と呼び出したことと等価になるような転送の場合を、完全転送と呼びます。
ですので、この例では完全転送を実現できていません。もしint& z;
と宣言されていたら、f(z)
はg((int)z)
と勝手に参照&を外されていることになるからです。
C++でムーブを実現するための機能がstd::move
です。しかし、その名前に反してstd::move
はムーブしません。
std::move(x)
はxをムーブできるようにするだけです。
この「ムーブできる」とはどういう状態かというと、先に上げた例だと
{
const int y = x; // ムーブできない
const int z = x; // ムーブできる
int ret = 0; // ムーブできる
ret = y+z; // ムーブできる
}
となっており、ここで右辺に着目すると、左辺値x
はムーブできない場合もあるが、右辺値0
やy+z
は必ずムーブできています。
それもそうで、ムーブできるのは、右辺を廃棄して良い時なので、右辺値(≒一時オブジェクト)なら必ずムーブ可能ということです。
つまり、std::move
は右辺値にキャストすれば実現できます。実際に使ってみるとこんな感じです。
f(x+1); // x+1は右辺値
f(x); // xは左辺値
f(std::move(x)); // moveするとxが右辺値になる
ここで、std::move
は実際にはムーブせずにムーブできるようにする(キャストする)だけと言っているのは、つまりstd::moveしてもムーブにならないことがあるからです。
どういう時かと言うと、const値をstd::move
した時です。
static void Foo(const Pointer p)
{
Pointer q(std::move(p)); // moveしたのに、コピーコンストラクタが呼ばれる!
}
なぜこんなことが起きるかと言うと、ほぼ全てのムーブコンストラクタ・代入演算子は、非const参照を引数とするからです。
もう少し詳しく言うとムーブとは「代入元を破棄する」処理のことなので、引数(代入元)を操作(破棄)するため、非const参照を必要とします。
// コピーコンストラクタ
Pointer(const Pointer& f)
{
this->ptr = f.ptr;
}
// ムーブコンストラクタ
Pointer(Pointer&& f)
{
this->ptr = f.ptr;
// ムーブしたあとは廃棄する
f.ptr = nullptr;
}
なので、私のような手が勝手にconstをつけるような人は特にconst値はムーブできないよと手に教えこむ必要がありますので、注意しましょう。
なお、「ほぼ」と書いたのは、規格上は別にconst右辺値参照を引数にすることもできるからですが、それはもはやただのコピーなので意味がないです。
(※復習:左辺値参照をとる関数に、右辺値を渡しても問題ない。逆に、右辺値参照をとる関数に、左辺値は渡せない。なので、大体の場合、const右辺値参照を取る関数は不便なだけで、const左辺値参照を取る関数があれば済む)
先述の通り、std::move
の正体はただ右辺値にキャストするだけの関数です。
ではどうやって右辺値にキャストしているかというと、こんな感じになります。
template<typename T>
decltype(auto) move(T&& param)
{
return static_cast<std::remove_reference_t<T>&&>(param);
}
やっていることは
auto
ではなくdecltype(auto)
T&
だと左辺値しか受け取れないので、T&&
にする(※T&&
は後述の通り右辺値参照とは限らない)。T
が左辺値参照だとdecltype(param)
(つまりT&&
)も左辺値参照になってしまうので、確実に右辺値参照(&&
)にキャストという流れです。
と、こんな感じで「ムーブできるようにする」意味からstd::move
という名前になりましたが、やってることは「右辺値にキャストする」なのでstd::rvalue_cast
みたいな名前も提案されていたようです。
個人的にはstd::movable
とかが良かったんじゃないかなぁと思ってます。
このように、C++11以降はムーブが使えるようになったので、ムーブを使えば速くなる!と言われますが、そうじゃない場合もあるので過度に期待し過ぎないほうがよいと言われています。そうじゃない場合というのは、ムーブできない場合もあるからです。
まずそもそも、ムーブ演算(コンストラクタ、代入)は、明示的に実装してあるか、そうでないなら
の全ての条件が満たされた時にしか暗黙的に生成されません。
つまり、ムーブしたくなるような複雑で大きいものでは、だいたいどれかの条件にひっかかるので、明示的に実装していないとムーブ演算なんて存在しないことがほとんどです。
また、もしムーブ演算があっても、ムーブがコピーより軽いかどうかは実装依存です。例えば、std::vector
は確かにムーブはポインタの付け替えで済むので高速ですが、std::array
は中身を複製しないといけないのでムーブもコピーも同じぐらいです。
遅くなることはまぁまずないですが、実装依存なのでそういうムーブ演算を存在させることは可能ですね。
なので、ムーブを使えばコピーより必ず速くなるとは思わない方が良いです。
さらに、ムーブ演算が存在し、確実にムーブがコピーより軽い実装でも、例外安全性のstrong保証(途中で例外が発生しても必ず操作前に巻き戻って返せる性質)を必要とするアルゴリズムでは、例外を投げない(noexcept
がついた)ムーブ演算でなければ、ムーブ演算を使えません。
というように、ムーブ演算が効果を発揮しない場合を列挙するとキリがありませんので、あんまり期待しても仕方ないよね、という話でした。
とは言うものの、とあるコンパイラではC++11にしただけで1.x倍になったという事例もあるらしいので、やっぱりムーブのおかげで速くなることも結構あります。
どっちやねん!って感じになりますが、「期末試験よくできたかもと思ったけど点数低かったら傷つくから、点数は低いものだと思っておこう」ぐらいの感じでいればよいのかなと思います。
std::forward
は「完全転送した呼び出しにする」関数です。
完全転送とは、最初に述べた通り、仮引数の元の実引数の完全な型情報も他の関数の実引数に渡すことです。
ここで、f
が多重定義されていて、そのf
を呼び、かつそれ以外の共通の処理をまとめたテンプレート関数F
を考えましょう。
C++03以前の場合、まず、
template<typename T>
static void F(T v)
{
// ここには何か共通の処理をして
f(v); // オーバーロードされた関数も呼ぶ
}
と書いてしまうと思いますが、これは完全転送できていません。
なぜなら、F(x)
と呼んだ時にx
はv
にコピーされるため、f
に渡る引数は別の実体を指します(中身はコピーされているので同じに見えますが)。
それにそもそも、コピーコストが無視できないオブジェクトだと、コピーが走るのは避けたいです。
では、コピーされるのが問題だから参照にすればいいでしょうか?
template<typename T>
static void F(T& v)
{
f(v);
}
いいえ、これはそもそも失敗です。なぜなら、右辺値を渡せないからです。
さて、そこで、C++11からは右辺値も参照が使えます!
template<typename T>
static void F(T&& v)
{
f(v);
}
これで右辺値が渡せます。ただし、まだ不完全です。
なぜなら、復習のところでも書きましたが、仮引数は必ず左辺値なので、F
を経由すると右辺値参照を取るf
を呼べないのです。よし分かったじゃあstd::move
すればいいだろう、と思いつくかもしれませんが、それはそれで今度は左辺値参照を取るf
を呼べません。
つまり、完全転送を実現するには、F
の実引数が右辺値ならf
の右辺値も右辺値に、左辺値なら左辺値にと適切にキャストする必要があります。
それを実現するのがstd::forward
です。
template<typename T>
static void F(T&& v)
{
f(std::forward<T>(v));
}
これで完全転送できました。めでたしめでたし。
つまるところ、std::forward<T>(v)
とは、vの
キャストする関数ということです。std::move
と同じく、実はstd::forward
もただのキャストであり、std::foward
自体が転送するわけではないです。
std::forward
を使った完全転送
template<typename T>
static void F(T&& v)
{
f(std::forward<T>(v));
}
で、std::forward
はT
を受け取って、参照元が右辺値か左辺値かを判断していると書きました。つまり、「T
に参照元が右辺値か左辺値かの情報が入っている」ということです。
ただ、別に新しい型みたいなのが追加されるわけではないです。実は、T&&の型推論をする時には、左辺値を代入されたらTは参照、右辺値を代入された非参照にするような仕様になっています。
例挙げると、
template<typename T>
static void F(T&& v)
{
f(std::forward<T>(v));
}
int x;
F(x); // xは左辺値なので、Tはint&
F(1); // 1は右辺値なので、Tはint
といった感じです。
ただ、実際にT
がどっちなのかを直接観測するのは難しいのですが、TypeDisplayerのテクニックを使うと一応知ることができます。
なので、std::forward<T>
は
T
が参照の時、v
の参照元は左辺値だったと判断して、左辺値参照を返すT
が非参照の時、v
の参照元は右辺値だったと判断して、右辺値参照を返すという挙動をすれば良いわけです。
ではそんな挙動をどうやって実現しているのでしょうか。そこで参照の圧縮(reference collapsing)の出番です。
参照の圧縮ってなんのこっちゃと思うかもしれませんが、簡単に言うと、「実はコンパイル時には参照の参照があって、参照の参照を普通の参照に変換しているんだ!(なんだってー(AA略」みたいな話です。
参照の参照というのは、普通はコンパイルできない
int& && y = x;
みたいなやつのことです。こんなの出てこないだろうと思うかもしれませんが、例えばテンプレートとtypedef/エイリアスを使って
template<typename T>
struct L
{
using Type = T&;
};
みたいなののT
に参照を入れると、コンパイル時には登場します。例えば、T
に右辺値参照(int&&
)を入れると、Type
は右辺値参照の左辺値参照(int&& &
)になりますよね。
このようなコンパイル時に発生する二重の参照を単独の参照に変換することが参照の圧縮です。
参照には右辺値と左辺値があるので、パターンは4通りありますが、以下のような規則で変換されます。
<1>/<2> | 左辺値参照& | 右辺値参照&& |
---|---|---|
左辺値参照& | 左辺値参照 | 左辺値参照 |
右辺値参照&& | 左辺値参照 | 右辺値参照 |
つまり例で言うと、int&& &&=int&&であり、その他は全てint&になるということです。
実際に確かめてみると、確かにそうなってるのがわかります。
こうしておくと、std::forward
で
T&&
のT
に非参照(int
)や右辺値参照(int&&
)を入れれば、右辺値参照が作れるint&
)を入れれば、左辺値参照を作れるようになります。
ということで、std::forwatd
は
template<typename T>
decltype(auto) forward(std::remove_reference_t<T>& param)
{
return static_cast<T&&>(param);
}
と実装できます。
どういう動きをするかというと、int x;
の場合、
forward(x)
と左辺値を渡した場合T
は参照(int&
)になるremove_reference
により参照が外されて、非参照(int
)になって、param
の型は参照(int&
)に戻る。T&&
はint& &&
と左辺値参照の右辺値参照になり、圧縮されて左辺値参照int&
になるforward(1)
あるいはstd::forward(std::move(x))
と右辺値を渡した場合T
は非参照(int
)になるremove_reference
は何もせず非参照(int
)のままで、param
の型は参照(int&
)になる。T&&
はint&&
と評価され右辺値参照になるという挙動を実現しています。
なお、参照の圧縮(つまり参照の参照)が起きる場合は実は4パターンしかありません。そのうち
は既に説明しました。
3番目は、実はテンプレートではなくauto&&
の型推論でも発生するということです。
int x;
auto&& y = x; // これは左辺値を代入するので、autoがint&になって、yはint& &&が圧縮されてint&になる
auto&& z = 1; // これは右辺値を代入するので、autoがintになって、yはint&&のまま
これも直接観測は難しいですが、TypeDisplayerで確かめると確かにそうなっています。
このように、テンプレートやauto
で型推論する場合には、参照の圧縮によって、&&と書いても右辺値ではなく左辺値参照になることがあります。
最初に「&&は右辺値参照とは限らない」と書いたとおりです。このような型推論を伴う&&で、実際には左辺値参照になるかもしれない参照は、ユニバーサル参照などと呼ばれています。
あと最後の4番目は、decltype
でも起きます。
decltype
は引数に変数名を与えると変数の型になり、式を与えるとその左辺値参照になります。なので、int x;
の時
decltype(x)
は、int
decltype((x))
は、int&
になります。ということは、
template<typename T>
void f(T v)
{
decltype((v)) a = v;
}
f<int&&>(1); // これは、Tがint&&なので、decltype((v))は、int&& &で、参照の圧縮がなされてaの型はint&となる
ということが起きます。
さて、先ほど以下の様な完全転送を実装しました。
template<typename T>
static void F(T&& v)
{
f(std::forward<T>(v));
}
でもこれは実はちょっと不完全です。なぜなら、引数の数が違う多重定義に対応していないからです。
extern void f(int x); //こっちはF経由で呼べるが
extern void f(int x, int y); //こっちはF経由では呼べない
template<typename T>
static void F(T&& v)
{
f(std::forward<T>(v));
}
引数の数も違う場合に対応するために、可変長にしましょう。
template<typename... Ts>
static void fwd(Ts&&... params)
{
f(std::forward<Ts>(params)...);
}
これでどんな場合でも完全転送できるようになりましたね!!めでたしめでたし!
と言いたいところですが、実はこれでも、f(???)
とfwd(???)
と???に同じものを書いた時に挙動が異なってしまうような完全転送できない場合が5パターン知られています
NULL
でも大丈夫です。一応ちゃんと対策もあります。1つずつ見てみましょう。
まず最初。ここまで見てきたように、完全転送には元の引数に対する参照をとる必要があります(参照がないと、コピーしなきゃいけなくなるので)。
右辺値参照によって、ほぼすべての参照がとれるようになりましたが、実は参照がとれないパターンが2つあります。
参照が取れないパターン2つのうち、簡単な方は、ビットフィールドです。
struct Foo
{
std::uint32_t a: 28,
b: 4;
};
これは、多くの場合、参照が取れません。
Foo f;
std::uint32_t& a = f.a; // 左辺値参照もとれない
std::uint32_t&& b = f.b; // 右辺値参照もとれない
なぜか?参照はハード的にはポインタと同じなのでアドレスが必要ですが、例えば「4bit後ろのアドレス」って取れないからです。とれるのは、アドレスがとれるのはビットフィールドを汎整数型に変換した先だけです。
なので、逆に言えば、汎整数型に変換した先のアドレスで事足りるような場合、つまり
である時は値を書き換えないので、参照をとっても問題ないとみなされます。
// const参照はOK
const std::uint32_t& a = f.a;
const std::uint32_t& b = f.b;
// 元の構造体が右辺値なら、非const参照も可
std::uint32_t&& c = std::move(f).a;
どちらも、変換された汎整数型の実体(アドレス)を指す参照になります。
ということで、もう少し厳密に言うと、元の構造体が左辺値の場合にビットフィールドに対する非const参照の場合に参照が取れないので、この場合は完全転送もできないです。
Foo foo;
f (foo.a); // OK
fwd(foo.a); // NG
これを回避するには、明示的にコピーを作ればよいだけです
Foo foo;
const uint32_t a = foo.a;
f (a);
fwd(a);
foo.a = a;
参照が取れないパターンのもう片方は、定義のない静的(コンパイル時)定数フィールドです。
struct Foo
{
static const std::size_t N = 10;
static const std::string S;
};
// const std::size_t Foo::N = 10; // 定義がなくてもOK
const std::string Foo::S = std::string("foo"); // こっちは非汎整数型なので、定義も必要
のように、汎整数型の場合、定義がなくてもFoo::N
を使うことができます。なぜなら、定数伝搬(const propagation)するので、定義(実体)がなくても、コンパイラがFoo::N
を10に置き換えなければならないからです。
なのでこの状態で参照を取ろうとすると、実体がない=メモリが割り当てられていないのでアドレスがとれず、つまり参照がとれない(コンパイルはできるけどリンクエラー)になります。
これ割とよくあって、私がよくやるのが、コンパイル時定数(constexpr)を完全転送しようとしてリンクエラーです。「コンパイル時定数なのに定義が要るとかなんでやねん!」ってなるので気をつけましょう(※一応、言われるがまま定義をすれば動きます)。
ただし、コンパイラによっては定義がなくても動いてくれるらしくて、それも規格上は許容されているらしいです(でも少なくともgcc/clangでは動かないです)。
以上のような参照が取れない場合以外、つまり参照がとれたとしても完全転送できない場合があります。
今までずーっと見てきたように完全転送はうまく型推論を駆使することで実現していますが、逆に言うと、型推論ができなかったり、推論結果が違うと、完全転送できません。そんなパターンが3つ知られています。
一番簡単なのはNULL
を使った場合です。
NULL
は実体は(だいたいは)0なので、可能な限りポインタではなく汎整数型(通常はint
)に推論されます。
なので、ポインタの代わりにNULL
を完全転送しようとすると、コンパイルエラーになります
static void f(int* adr);
fwd(NULL); // NG
f (NULL); // NG
解決策はnullptr
使えです。
fwd(nullptr);
「今時まだNULL
を使ってるなんておっさんみたい!」と現代っ子に言われないよう、NULLはもうやめましょう。
さて、次のパターンは、多重定義された関数・テンプレート関数です。
関数ポインタ型変数に関数を代入すると、関数への関数ポインタが取れるのは
void (*p)(const int) = f;
f(1);
p(1);
と、太古の昔から変わらない現象ですが、多重定義された関数を関数ポインタとして取る場合、受け取る側の型が合う方を代入してくれるのもご存知ですか?
static void f(const int val);
static void f(const char str[]);
void (*p)(const int val) = f; // これは上のf
void (*q)(const char str[]) = f; // これは下のf
なんかいつもの型推論みたいに、右辺から左辺を推論するのではなく、左辺から右辺を推論しているので若干気持ち悪いんですが、まぁこれのおかげで多重定義されていても、型さえしっかりしてあれば関数ポインタを区別して取れます。
これは関数の引数にした時も同じです
static void g(const int val);
static void g(const char str[]);
static void f(void (*p)(const int val));
f(g); // fが受け取るのはintを引数に取る関数しかないので、このgは上側のgになる
が、これを完全転送しようとする失敗します。
f(g); // OK
fwd(g); // NG
なぜなら、fwd
はどんな型でも受け取る関数なので、いつものように代入先からg
を推論することはできないからです。
回避策は、キャストするなどして代入元で型情報をつけてやればOKです。
fwd(g); // NG
fwd(static_cast<void(*)(const int)>(g)); // OK
なお、この問題はテンプレート関数でも同じです。
template<typename T>
static void g(const T val);
f(g);
fwd(g); // NG
fwd(static_cast<void(*)(const int)>(g)); // OK
fwd(g<int>); // OK
最後は波括弧初期化子です。これも、先の関数ポインタのように、代入先によって型を決定します。
std::vector<int> v = {0, 1, 2};
auto&& i = {4, 5, 6}; // initializer-list
ということで、完全転送しようとすると、関数ポインタと同じように、どの型になるべきか推論できないという問題が発生します。
f({0, 1, 2}); // initializer-list
fwd({4, 5, 6}); // NG
そして、この回避策も関数ポインタと同じで、ちゃんと明示的に型を指定すれば良いだけです。
auto i = {4, 5, 6}; // こうすればinitializer-list
fwd(i);
fwd(std::vector<int>({4, 5, 6}));
ということで、C++のムーブおよび転送についてざっと解説してみました。
実際使ってみないとややこしいだけに思えますが、特に高速かつ便利なライブラリを設計しようとしていると、この辺りを理解することはとても大事になります。
この辺りが一通り説明できたら、C++erレベル1は脱したと言ってもいいんじゃないかなと思います。
フィックスターズ広告
私は、実を言うと、ずいぶん前にstd::forwardを使えなくてあきらめたことがあったのですが、この発表の後、見返してみたら簡単に使えるようになってました。
フィックスターズは本物のC++プログラマーを募集しています
・・・というわけではないですが、高速化のお仕事が強いというだけあって、業務でもC++やC言語を使うことが多いですので、C++に強い関心がある方にもオススメです。
keisuke.kimura in Livox Mid-360をROS1/ROS2で動かしてみた
Sorry for the delay in replying. I have done SLAM (FAST_LIO) with Livox MID360, but for various reasons I have not be...
Miya in ウエハースケールエンジン向けSimulated Annealingを複数タイルによる並列化で実装しました
作成されたプロファイラがとても良さそうです :) ぜひ詳細を書いていただきたいです!...
Deivaprakash in Livox Mid-360をROS1/ROS2で動かしてみた
Hey guys myself deiva from India currently i am working in this Livox MID360 and eager to knwo whether you have done the...
岩崎システム設計 岩崎 満 in Alveo U50で10G Ethernetを試してみる
仕事の都合で、検索を行い、御社サイトにたどりつきました。 内容は大変参考になりま...
Prabuddhi Wariyapperuma in Livox Mid-360をROS1/ROS2で動かしてみた
This issue was sorted....