こんにちは。田原です。
C++を含む幾つかのプログラミング言語は左辺値や右辺値という概念を持ちます。
これらはC++の型システムを使って安全性の高い(できるだけコンパイラにバグを検出させる)プログラムを書く時にたいへん有用です。
簡単なようで混乱し勝ちな概念ですが、演算子によって左辺値・右辺値は決まってます。そこを理解すれば簡単ですので演算子との関係に着眼して解説します。
プログラミング言語の多くは、y=5*x+100
と書いた場合、「5*x+100
を計算してその結果をyへ代入しろ」という命令になります。C++も例外ではありません。
つまり、左辺(この場合y)には変数を書かないといけません。右辺(この場合5*x+100
)には色々なものが書けます。定数(例えば100)や変数(例えばx)、式(例えば2.8*x+3.14
)などを書けます。
この左辺に書けるもののことを「左辺値」(英語ではlvalue)、右辺に書けるもののことを「右辺値」(同rvalue)と呼びます。
古き良き?時代:
2011年前までは左辺値と右辺値の定義は単純でした。そして、2011年に規定されたC++11標準規格にて拡張され、高速なプログラムをよりスマートに記述できるようになりました。しかし、理解するための難易度も相応に高いです。
そこで、まだ単純だった時代の左辺値・右辺値を解説します。今後、学習が進むに連れてC++11の拡張の解説も進めます。
左辺値は代入先のことですのでデータの容器です。そして、右辺値はその中身のデータそのものです。例えば、int y=100;とint型変数yが定義されていた場合、変数yが容器でありメモリが割り当てられています。そして、その中身の100は変数yの中に記録さているデータです。
では、右辺が100ではなく5*x+100
のような式だった場合、データはどうなるかといいますと、式5*x+100
を計算した結果です。
そして、5*x+100=y
のような書き方はできません。右辺値を左辺に書くことは許されていません。間違って書くとコンパイル・エラーになります。「値」に何か代入するって意味をなしません。
逆に、y=x
のように左辺値を右辺に書くことはできます。これは、「xの中身を取り出してyへコピーしろ」と言う命令になります。左辺値には必ず中身のデータが入っています。その中身を取り出せば右辺値です。ですので左辺値は右辺値へ常に変換できるのです。
更に、下記のような特徴があります。
- 容器(変数)の場所を移動することはできません。
容器(変数)を確保した時点でアドレスが決まり、使用するメモリも確定します。そのメモリに別のアドレスを割り当てることはできません。(*1)
つまり、一度確保した容器(変数)を中身ごと移動することはできません。
「移動」に近いことをする時は、別の場所に容器(変数)を確保して中身をコピーし、元の容器(変数)を解放する(他の用途に使って良いと許可する)ことになります。 -
中身(値)はコピーできます。
ある容器(変数)の中身(値)を取り出して別の容器(変数)へコピーすることが可能です。
現実世界では中身を取り出して他の容器へ「移動」することができますね。逆にコンピュータの世界では「移動」することはできません。コピーすることができるだけです。
「移動」みたいなことをしたい時は、コピーした後、元の容器の中身を0や何か無効な値に書き換えます。
(*1)メモリに別のアドレスを割り当てることはできません
ごめんなさい。実は不可能ではありません。PCにはMMUと言うハードウェアが搭載されており、これを操作することで可能です。しかし、それはOSの役割です。OSに近い部分を開発している時以外はそのような場面に出会うことはありませんし、自由に移動できるわけでもありません。更にC++の機能でもありません。ですので、C++を学習する際には「メモリに別のアドレスを割り当てることはできない」ものとして考えた方が適切ですし、学習も容易になります。
左辺値は容器(変数)です。右辺値はその中身(値)です。簡単にみえます。
でも、油断してはいけません。実は意外に難しいです。
例えば、1+1
の結果は2
です。見ての通り2は変数(容器)ではなく値(中身)ですから、右辺値です。このように多くの場合、計算した結果は数値であり、値(中身)です。容器(変数)にはなりえません。
しかし、C++には左辺値(容器)を返却する式があります。計算の結果、容器を返してくるものがあるのです。以下、そのあたりの混乱しがちな部分を含めて解説します。ここを乗り越えることに成功すれば、あなたは一皮むけます。頑張って下さい。
普通に演算子と言えば、足し算や掛け算の記号の+
や*
等のことをイメージすると思います。
C++では更に演算子の概念が拡張されています。例えば =
は代入しますが「代入演算子」と呼ばれる演算子です。
さて、そのような演算子は、オペランドと演算結果の型と左辺値・右辺値に着目して分類することができます。その分類にそって解説します。(この分類は当ブログ独自のものではありません。C++のリファレンスが纏められているC++ referenceの分類です。なお、リンク先は機械翻訳です。元の英語版はここにあります。)
また、C++は演算子オーバーロードという仕組みで演算子の機能を拡張できますが、ここではC++のコア言語に組み込まれた演算子について解説します。オーバーロードは複雑ですので後日適切なタイミングで解説します。
ところで、当講座で使う用語を2つ定義させて下さい。
「オペランド」は、演算対象のことです。例えば、a+b
の時、+
は演算子(オペレータ)でa
とb
がオペランドです。
「数値型」は、整数型、浮動小数点型、非scoped enum型のことを指します。(非scoped enum型については後日解説します。)
オペランド、および、演算結果は数値型の「右辺値」です。
それぞれ多数の型があります。厳密にいつどの型になるのか決められていますが、かなりややこしいので、ここではざっくり説明します。
- int型より精度が低いオペランドはint型へ拡張されます。
- (1.の拡張適用後)、より精度の高い方の型に合わせて計算されます。
名前 | 書き方 | 説明 | 演算結果 |
---|---|---|---|
単項マイナス | -a | -aを返します | 右辺値 |
単項プラス | +a | aを返します(何もしません) | 右辺値 |
加算 | a + b | a + bを返します | 右辺値 |
減算 | a – b | a – bを返します | 右辺値 |
乗算 | a * b | a * bを返します | 右辺値 |
除算 | a / b | a / bを返します | 右辺値 |
名前 | 書き方 | 説明 | 演算結果 |
---|---|---|---|
剰余 | a % b | aをbで割った余りになります | 右辺値 |
ビット否定 | ~a | ビット毎に0と1を反転した値になります | 右辺値 |
ビット積 | a & b | ビット毎に積になります | 右辺値 |
ビット和のOR | a | b |
ビット毎の和になります | 右辺値 |
ビット排他的論理和 | a ^ b | ビット毎の排他的論理和を取った値になります | 右辺値 |
右シフト | a << b | aをbビット左シフトした値になります | 右辺値 |
左シフト | a >> b | aをbビット右シフトした値になります | 右辺値 |
オペランド、および、演算結果はbool型の「右辺値」です。
名前 | 書き方 | 説明 | 演算結果 |
---|---|---|---|
論理否定 | !a |
「aでない」になります | 右辺値 |
論理積 | a && b |
「aかつb」になります | 右辺値 |
論理和 | a || b |
「aまたはb」になります | 右辺値 |
オペランドは数値型、enum型、ポインタ型、メンバ・ポインタ型の「右辺値」です。
演算結果はbool型の「右辺値」です。
enum型とポインタについては後日解説しますが、ポインタについてはここで少し解説してますので参考にして下さい。
名前 | 書き方 | 説明 | 演算結果 |
---|---|---|---|
等価 | a == b | aとbが等しい時true | 右辺値 |
非等価 | a != b | aとbが等しくない時true | 右辺値 |
小なり | a < b | aがbより小さい時true | 右辺値 |
大なり | a > b | aがbより大きい時true | 右辺値 |
小なりイコール | a <= b | aがb以下の時true | 右辺値 |
大なりイコール | a >= b | aがb以上の時true | 右辺値 |
なお、標準規格上は、オペランドがポインタ型の場合、aとbは同じ配列の要素とその配列の最後+1へのポインタの時のみ結果は定義されています。より添字が小さい要素を指すポインタの方が小さいものとして比較されます。
しかし、実装はポインタに入っているアドレスを単純に比較すれば足りますので、単純にアドレスの値を比較していると考えて良いです。そして、例えば異なる配列や構造体等について、通常はどちらがアドレスの小さい方に割り当てられるのか決まっていないことを理解しておいて下さい。
メンバ・ポインタ型については後日解説します。メンバ・ポインタ型の比較演算子は==
と!=
のみ定義されています。
代入演算子は左辺と右辺があります。代表的な例はa=b
です。
左辺側オペランドは任意の型の「左辺値」です。
右辺側オペランドは左辺側オペランドの型へ変換可能な「右辺値」です。
そして、結果は左辺側オペランドそのもので、これは「左辺値」です。
例えば、a, b, cがint型だった時、a=b=c;
、a=(b=c);
、(a=b)=c;
などと書くことができます。
代入演算子は右から左へ処理されるとC++の標準規格で決められています。
ですので、a=b=c;
はb=c
→a=b
の順序で処理されます。
a=(b=c);
も同じ順序で処理されます。
さて、(a=b)=c;
は、まず括弧の中が先に処理されますのでa=b
が実行されます。そしてa=b
は「左辺値」としてのaを返却します。ですから、次にa=c
が実行されることになります。
さて、問題です。下記プログラムを実行したら結果はどうなるでしょうか?
#include <iostream> int main() { int a=10; int b=20; int c=30; a=b=c; std::cout << "a=" << a << " b=" << b << " c=" << c << "\n"; a=10; b=20; c=30; a=(b=c); std::cout << "a=" << a << " b=" << b << " c=" << c << "\n"; a=10; b=20; c=30; (a=b)=c; std::cout << "a=" << a << " b=" << b << " c=" << c << "\n"; return 0; }
繰り返しになりますが、(b+c)=a;
はできません。b+c
は数値型の「右辺値」を返却します。右辺値は中身(値)であり、容器(変数)ではありませんので代入できません。しかし、(a=b)=c
はできます。何故なら、a=b
が返却するのもは左辺値であり、容器(変数)だからです。
=
は「2-1.算術演算子とビット演算子」で述べた演算子と複合し、複合演算子になります。
例えば、+=
は=
と+
の複合です。’a += b’はa = a + b
と概ね同等です。
ただし、微妙に異なります。`a`が単なる変数ではなく「左辺値」を返す式だった時、その式を処理する回数が異なります。前者は1回、後者は2回処理されます。例えば、後述の配列添え字演算子[]は左辺値を返します。`a[i] += 1`と`a[i] = a[i]+1`の場合、前者の方が添字計算をする回数が少ない分高速です。
名前 | 書き方 | 特記事項 | 演算結果 |
---|---|---|---|
単純代入 | a = b | 左辺値 | |
ムーブ代入 | a = rvalue | 2011年に出現しました。後日解説します | 左辺値 |
加算代入 | a += b | 左辺値 | |
減算代入 | a -= b | 左辺値 | |
乗算代入 | a *= b | 左辺値 | |
除算代入 | a /= b | 左辺値 | |
剰余代入 | a %= b | 左辺値 | |
ビット積代入 | a &= b | 左辺値 | |
ビット和代入 | a |= b |
左辺値 | |
ビット排他的論理和代入 | a ^= b | 左辺値 | |
左シフト代入 | a <<= b | 左辺値 | |
右シフト代入 | a >>= b | 左辺値 |
前置型と後置型がある単項演算子です。
- 前置型
オペランドは数値型、ポインタ型の「左辺値」です。
演算結果はオペランドの「左辺値」です。 -
後置型
オペランドは数値型、ポインタ型の「左辺値」です。
演算結果はオペランドと同じ型の「右辺値」です。
名前 | 書き方 | 説明 | 演算結果 |
---|---|---|---|
前置インクリメント | ++a |
aをインクリメントしてaを返却 | 左辺値 |
前置デクリメント | --a |
aをデクリメントしてaを返却 | 左辺値 |
後置インクリメント | a++ |
aをインクリメントします。 インクリメント前のaの値を返却 |
右辺値 |
後置デクリメント | a-- |
aをデクリメントします。 デクリメント前のaの値を返却 |
右辺値 |
他にも様々な演算子があります。それらについては、ここで詳しく解説すると混乱するだけですので極簡単な説明だけします。
原則として、演算結果は「左辺値」です。
オペランドは複雑ですし、あまり重要ではないので割愛します。
例外が1つあります。アドレス演算子です。これは「変数(容器)」のアドレスを返却しますのでオペランドは「左辺値」です。演算結果は「右辺値」です。しかもこの「右辺値」は特殊です。間接演算子*
で「左辺値」へ変換できてしまいます。
クラス、構造体、共用体を纏めてレコード型と呼びます。レコード型については後日解説します。
名前 | 書き方 | 説明 | 演算結果 |
---|---|---|---|
配列添え字 | a[b] | 配列aの添字bの要素を返却 | 左辺値 |
間接演算子 | *a | ポインタaの指すオブジェクトを返却 | 左辺値 |
アドレス | &a | オブジェクトaへのポインタの値を返却 | 右辺値 |
間接メンバ | a->b | レコード型のオブジェクトへのポインタaの メンバbをを返却 |
左辺値 |
直接メンバ | a.b | レコード型のオブジェクトaのメンバbを返却 | 左辺値 |
間接メンバ・ポインタ | a->*b | レコード型のオブジェクトへのポインタaの メンバ・ボインタbが指すメンバを返却 |
左辺値 |
直接メンバ・ポインタ | a.*b | レコード型のオブジェクトaの メンバ・ボインタbの指すメンバを返却 |
左辺値 |
以上の分類に属さない演算子です。説明は割愛します。必要に応じて後日解説します。
書き方 | 説明 |
---|---|
a(…) | 関数呼び出し |
a, b | 右辺値の並び |
? : | 条件演算子 |
(type) a | C言語スタイルの型変換 |
static_cast<type>(a) |
C++スタイルの型変換 |
dynamic_cast<type>(a) |
C++スタイルの型変換 |
const_cast<type>(a) |
C++スタイルの型変換 |
reinterpret_cast<type>(a) |
C++スタイルの型変換 |
new type | type型の領域確保と初期化 |
new type[b] | type型の配列の領域確保と各要素の初期化 |
delete a | aの終了処理と領域解放 |
delete[] a | 配列型aの各要素の終了処理と領域解放 |
sizeof(a) | aの型のバイト数 |
sizeof… | パラメータ・パックの要素数 |
typeid(a) | aの型の動的型情報 |
noexcept(a) | aは式で、これが例外を投げると宣言されている時true |
alignof(a) | aの型のアライメント値 |
各演算子には、優先順位があります。
そして、同じ優先順位の者同士については、左から先に処理するのか、右から先に処理するのか決まっています。
同じ優先順位の演算子が隣り合っている時、左右どちらから処理するかの決め事は「結合規則」(Associativity)と呼ばれています。
これは、例えば、*
と%
は同じ優先順位で結合規則が左から右です。なので、a * b % c
は、a * b
を計算した後、その結果 % c
を計算します。つまり、2 * 3 % 2
は2ではなく0になります。
更にもう一つ。2つのオペランドのどちらを先に処理るのか決められていません。例えば、(a+b)*(c+d)
の時、a+b
とc+d
のどちらから先に計算するのか決められていないのです。処理系が自由に決めます。結合規則とは無関係ですので注意して下さい。
そして、n + ++n
のような式を書いた場合、n
と++n
のどちらが先に処理されるか決まらないため、左側のn
がインクリメント前の値なのかインクリメント後の値なのか決まりがありません。このような記述は「未定義動作」を引き起こします。
「未定義動作」に該当するケースは何が起こっても、それは全てプログラマの責任と決められています。処理系の責任ではありません。
具体的な演算子の優先順位と結合規則については特に解説することはありませんので、WikipediaやC++ reference(日本語)、C++ reference(英語)を参照下さい。
今回は、ちょっと早まったかも知れないとドキドキしつつ、左辺値と右辺値の解説をしました。
左辺値と右辺値自体はメモリと変数の関係を理解できれば難しくはない概念ですが、「左辺値を返す計算式」は理解しづらい概念と思います。
C言語であれば、左辺値を返却したいような場合にはポインタを使います。ポインタにはnullptrも設定できますし、受け取った後、異なる場所を指すこともできます。自由度が高い分、やっちゃいけないことをやらないようプログラマの注意力をより多く消費してしまいます。
C++の場合、左辺値がnullptrのようにどこも指していない状態を作ることは容易にはできない仕組みも導入されています。
使い方はちょっと難しいですが、うまく使いこなすことができれば、自分が作ってしまったバグをコンパイラが指摘してくれます。細かいバグはコンパイラにまかせて自分はもっと高度な設計に注力できるちょっと幸せな世界が待っているのです。ぜひ、マスターして下さい。
次回はループや分岐などの基本的な制御構造について解説します。お楽しみに。
2-1-2.オペランドと演算結果が整数型(浮動小数点を含まない)の演算子
の表の中の右シフト、左シフトの説明と演算子の関係が逆になっています。
ikedaさん
コメントありがとうございます。
本当ですね!! 修正します。 ご指摘ありがとうございます。
すごく分かりやすい解説ありがとうございます。
++aには代入できるのに、a++に代入できない謎が解けました。
++aとa++の演算結果がそれぞれ左辺値、右辺値だったからなんですね。
morinoさん
コメントありがとうございます。分かりやすいと言って頂けて嬉しいです!!
C++は学習難易度は高いですが、マスターすればするほどプログラミングの力がどんどん増えます。
今後とも頑張って下さい。