gccでのループ処理のアセンブリを見てみる
cの実装
簡単なループ処理です。
int main(void) { int r = 0; for (int i = 0; i < 10; i++) { r++; } return r; }
gccでコンパイル
↓のようにコンパイルします。
gcc -S -masm=intel test.c
-S
オプションをつけるとアセンブリソースファイルを作成してくれます。
アセンブルは行いません。
-masmオプションでアセンブリの方言を指定することができます。
ここではintel記法を指定しています。
筆者の環境ではデフォルトだとAT&T記法になるようでした。
出力結果
.file "test.c" .intel_syntax noprefix .text .globl main .type main, @function main: .LFB0: .cfi_startproc push rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 mov rbp, rsp .cfi_def_cfa_register 6 mov DWORD PTR [rbp-4], 0 mov DWORD PTR [rbp-8], 0 jmp .L2 .L3: add DWORD PTR [rbp-4], 1 add DWORD PTR [rbp-8], 1 .L2: cmp DWORD PTR [rbp-8], 9 jle .L3 mov eax, DWORD PTR [rbp-4] pop rbp .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE0: .size main, .-main .ident "GCC: (GNU) 7.3.1 20180712 (Red Hat 7.3.1-9)" .section .note.GNU-stack,"",@progbits
ローカル変数
- 変数
r
DWORD PTR [rbp-4]
- 変数
i
DWORD PTR [rbp-8]
ローカル変数はスタック領域にスタックされている。
ラベル
全て接頭辞.L
が付けられています。
.L
があるとラベルがファイルスコープになります。
for文の制御構造がどのラベルに対応しているか
- 初期化式
.LFB0
の末尾
- 条件式
.L2
の頭
- forブロック内のプログラム
.L3
の頭
- 再設定式
.L3
の末尾
初期化式
for (int i = 0; i < 10; i++)
のint i = 0
の部分。
mov DWORD PTR [rbp-8], 0 jmp .L2
変数i
に0を入れています。
初期化が終わってすぐに.L2
にジャンプします。
条件式
for (int i = 0; i < 10; i++)
のi < 10
の部分。
.L2: cmp DWORD PTR [rbp-8], 9 jle .L3
変数i
を9と比較して9以下であれば.L3
にジャンプするという命令です。
比較命令cmpは引数の二つの値を比較します。
比較命令の結果はフラグレジスタにセットされます。
jle
はフラグレジスタの値を評価して比較の結果が等しいかそれより小さい場合に指定した位置にジャンプするという命令です。
ブロック内の処理と再設定式
{r++;}
とfor (int i = 0; i < 10; i++)
のi++
の部分。
.L3: add DWORD PTR [rbp-4], 1 add DWORD PTR [rbp-8], 1
変数r
に1を足してます。
変数i
に1を足してます。
順番はブロック内の処理->再設定式です。
自作コンパイラ開発メモ(2020/11/20)
低レイヤを知りたい人のための C コンパイラ作成入門を読んでコンパイラ自作してる時の作業記録です。
対象箇所
for 文に対応するアセンブリを出力できるようにしました。
実装
メモ
for 文を c で表現すると下記のようになる。
再設定式がブロックを抜ける直前に実行されていることがわかった。
// A:初期化式 // B:条件式 // C:再設定式 // D:真文 A; begin: if(B==0){ goto end; } D; C; goto begin; end:
コーディングの品質について
はじめに
これは僕が品質の高いコードを書くために意識していることをまとめたものです。
プログラムを書くための原則はいろいろあると思います。(SOLID, KISS, DRY, Rob Pike's 5 Rules, etc.)
そういった先人達の知恵から影響を受けながら少ない経験を元に自分の言葉で書いてみたいと思います。
品質の高いコードって
品質の高いコードってどういう物でしょうか?
僕は下記のような利点をもたらしてくれるものが品質の高いコードだと思っています。
- 再利用しやすい
- 読みやすい
- 変更しやすい
- バグが発生しにくい
品質の高いコードのためにできること
品質の高いコードを書くために下記のようなことに意識してます。
- プログラムを透明にする
- 使う人のことを意識する
- 仕様書として読みやすいようにする
プログラムを透明にする
プログラムに対する「透明」という表現はClean Codeで使われていて気に入ったので自分の中のキーワードになっています。
透明性が高いプログラムとはパッとみてそれが何をしているのかすぐにわかるようなコードの集まりです。 小さくて簡潔で一つのことだけを行うプログラムであれば透明性が高いプログラムであると言えると思います。
透明なコードの良さ
前述のように透明なプログラムは小さなプログラムとほとんど同義ですが、その利点は下記のようなものです。
- 見通しが良く、理解が簡単
- バグが目立つ
1 は割と自明かなと思います。
2 については実践しているとわかりますが、透明なコードにはバグが入り込む余地が少なくなります。
注意して見るべき場所が少ないためおかしなところが目立ちやすくなるのです。
逆に複雑で複数のことを同時に行うようなプログラムではバグが隠れやすくなってしまいます。
それを読んでいるときに意識が複数の箇所に分散してしまうからです。
馬鹿みたいに簡単にする
そのプログラムの仕様について最も詳しいのはそれを実装している人です。
ただその人も実装し終わればその仕様について忘れ始めます。
プログラムについてわかっているつもりでも後から見るとよくわからなくなることはよくあることだと思います。
それが他人が書いたものであれば尚更です。
だからこそ実装しているときは可能な限り簡単に見えるようにコードを書くべきだと思います。
アルゴリズムではなくデータ構造に労力をかける
複雑な処理をするために複雑なアルゴリズムを使うことはなるべく避けるべきだと思います。
これまでも書いているように複雑な実装はバグを生みやすく管理もしにくいです。
複雑なことをしたいときはそれに特化したデータ構造を定義することに努力します。
それが成功すればアルゴリズムはシンプルなものにできる可能性が高いです。
小さいプログラムを書くには頭を使う
大きくて複雑なプログラムを書くのは小さくて単純なコードを書くのに比べて簡単です。
大きなメソッドを書くときは頭を使う必要がありません。
そこから小さくメソッドを切り出していくのには設計が必要になります。
設計をするには頭を使います。
プログラムが動く・動かないのような具体的な基準ではなく抽象的な思考が求められるからです。
易きに流れるのが人間だと思うので放っておけば(急いでいる時とかは特に)大きなプログラムを書きがちになってしまうと思います。
使う人のことを意識する
これは再利用しやすいプログラムを作成するために必要な心構えだと思います。
インターフェイスをシンプルに
インターフェイスは可能な限りシンプルであるべきです。
例えばメソッドの引数です。2つよりは1つの方が良いし、1つよりも0の方が良いです。
引数を少なくするためにはそのメソッドが配置されるクラスのプロパティを正しく設計する必要があります。
また、引数の要素はメソッド名から容易に想像できるまたは納得できるものでなければいけません。
これらの理由は簡単でその方が使うのが簡単だからです。
使う人がいかに簡単にそれを使えるようにするかを考えると良いと思います。
あるべき場所にあるようにする
これも理由は簡単でその方が使いやすいからです。
整理された道具箱に道具を置いておくようなイメージです。
然るべきクラスに然るべきメソッドが置いてあれば他の人が探しやすく、再利用されやすくなります。
仕様書として読みやすいようにする
コードは仕様を最も詳細なレベルで記述したものでもあります。
これはつまりは可読性の高いコードを書くということです。
ビジネスロジックとデータの扱うレイヤーが行き来する流れを意識するべきだと思います。
そこにはグラデーションがあるので抽象のレベルを統一してそのレイヤーにマッチするメソッド名をつけるのが良いと思います。
名前を簡潔に
わかりにくいよりは長い方が良いがわかりやすさを保持できるなら短い方が良いです。 また、例えばメソッド名を考えるときはメソッド内でやっていることのみを考慮して考えるべきです。それがどんな文脈で必要とされるかはわからないからです。
高品質なコードを目指すことの落とし穴
上記のようないろいろを意識してプログラミングを行うわけですが、ここにも落とし穴があるように思っています。
それは最初から綺麗なコードを意識しすぎるあまりコーディングやその設計に時間をかけ過ぎてしまうということです。
良いコーディングは良い設計とある程度同じだと思います。
闇雲に品質の高いコードを目指すと簡単な機能や改修でさえ繰り返し手戻りが発生して終わりが一向に進捗しないような状態になってしまうことがあります。
こういった現象を避けるために下記のような工夫ができると思います。
- 大枠から考える
- テストコードを書く
目指したい在り方
やはり理想として目指したいのは早く綺麗なコードを生産できるプログラマです。
僕の場合はまだまだその域には到達できていません。
そのためには訓練が必要なのではないかと思います。
訓練をつめば良いコードの作り方や対象をパターンで捉えられるようになるのではないかと思います。そういったパターンマッチングの能力を訓練できればより早くより正確にただしいコーディングの在り方へ到達できるようになるはずと思ってその域を目指したいなと思います。
終わりに
この記事に書いたことは本で学んだ考え方が多いので紹介します。 また、こちらも本の紹介となっています。
これらの本で勉強しました
自作コンパイラ開発メモ(2020/11/08)
低レイヤを知りたい人のための C コンパイラ作成入門を読んでコンパイラ自作してる時の作業記録です。
対象箇所
if 文に続く else をパースし対応するアセンブリを出力できるようにしました。
実装
メモ
トークナイズ
if (strncmp(p, "else", 4) == 0 && !is_alnum(p[4]) && cur->kind != TK_IDENT) { cur = new_token(TK_ELSE, cur, p, 4); p += 4; continue; }
上記のようなトークン化処理をキーワードごとに書くのは間違いの元になりそうなので下のようにキーワードをトークナイズする処理を抽象化する関数を作ってみたけどなぜか無限ループに入ってしまってた。printf()してみてわかった。
gdb でデバッグしてみたがよくわからなかったのでいったん保留にした。
Token *tokenize_keyword(char *p, Token *cur, int kind) { char *keyword; switch (kind) { case TK_RETURN: keyword = "return"; break; case TK_IF: keyword = "if"; break; case TK_ELSE: keyword = "else"; break; case TK_WHILE: keyword = "while"; break; case TK_FOR: keyword = "for"; break; default: return NULL; } int len = strlen(keyword); if (strncmp(p, keyword, len) != 0 || is_alnum(p[len]) || cur->kind == TK_IDENT) { return NULL; } printf("%d\n", kind); Token *tok = new_token(kind, cur, p, len); p += len; return tok; }
else
の文はnode->ethen
に入れることにした。
アセンブリ出力
if 文のみだった実装から else に対応できるように改修した。
node が ethen を持っていたら else に対応するアセンブリを出力するようにする。
条件式が true の場合は else のブロックに入らないようにLendXXX
ラベルまで処理を飛ばす。
case ND_IF: gen(node->cond); printf(" pop rax\n"); printf(" cmp rax, 0\n"); if (node->ethen) { printf(" je .Lels%d\n", label_num); gen(node->then); printf(" jmp .Lend%d\n", label_num); printf(".Lels%d:\n", label_num); gen(node->ethen); printf(".Lend%d:\n", label_num++); } else { printf(" je .Lend%d\n", label_num); gen(node->then); printf(".Lend%d:\n", label_num++); }
jmp
命令で指定のアドレスまで処理をジャンプすることができる。
ラベルはアドレスに付与できる識別子。
.L
で始まるラベルは自動的にファイルスコープになる。
ハマったところ
tokenize_keyword()が意図せず繰り返し実行される。
自作コンパイラ開発メモ(2020/11/07)
低レイヤを知りたい人のための C コンパイラ作成入門を読んでコンパイラ自作してる時の作業記録です。
対象箇所
if 文をパースし対応するアセンブリを出力できるようにしました。
実装
if
というキーワードを解析しトークン化できるようにした。if
のトークンを検知して構文解析し構文木に加えられるようにした。if
のノードを検知してアセンブリとして出力できるようにした。while
とfor
の構文解析も含まれている
メモ
構文解析
if 文は else がない場合if(条件式)文
のような構文になる。
if もノードとして扱う。
Node 構造体のlhs
,rhs
メンバはそれぞれノードの左辺と右辺を意味する。
if はキーワードであって演算子ではない。
なので条件式やブロック内の文をlhs
やrhs
に入れることはできない。
条件式やブロック内の文を if ノードのメンバとして保存することにする。
(consume_if()) { node = new_node(ND_IF, node, NULL); expect("("); node->cond = expr(); expect(")"); node->then = stmt(); }
node->cond
が条件式でnode->then
が true の際に実行される文。
このようにして式や文をノードの入れ子として表現することができた。
ノードはツリー構造の連結リストだけど入れ子にもなっている。
if ノードは文として完結するはずなので左辺にはNULL
を入れてある。
アロー演算子を使うと左辺が構造体のポインタである場合に右辺のメンバの値にアクセスできる。
アセンブリ出力
case ND_IF: gen(node->cond); printf(" pop rax\n"); printf(" cmp rax, 0\n"); printf(" je .Lend%d\n", label_num); gen(node->then); printf(".Lend%d:\n", label_num++);
if ノードを検出したらまず条件式を実行する。
スタックのトップに式実行後の値(1 or 0)が入っているはずなのでそれをcmp
で 0 と比較する。
je
命令では比較演算の結果が true の場合に指定のアドレス(ラベル)まで処理をジャンプさせる。
je
は"je jump if equal"を意味するらしい。(参考)
このようにすることで条件文が false(0)の場合にnode->then
の文を実行させないようにすることができる。
ラベルはプログラム内でユニークにする必要があるので通し番号を付けてある。
ハマったところ
ラベルは宣言箇所では:が必要であることに気づかず、ラベルが認識されなかった。
C の変数
変数の中身はどこにあるか
変数の中身(データ)はメモリに収められている。
「変数」って何
変数は中身が収められているメモリ領域のアドレスに付けられた名前である。
変数を構成するもの
変数には下の三つの要素が必要。
- 識別子
- アドレス
- データ型
識別子
変数名のようにプログラムで付けられた任意の名前を識別子(identifier)と呼ぶ。
関数名も識別子の一つ。
アドレス
変数のアドレスはそのデータが収めれらたメモリ領域の開始位置を指す。
データ型
データには長さがある。
データのメモリ上の終了位置を判断するにはアドレス(開始位置)からのデータ領域の相対的な距離がわかっていないといけない。
データ型を明示することでデータの長さの情報を変数に付与することができる。
宣言
変数に必要な三つの要素(識別子・アドレス・データ型)を同時に用意するために変数の宣言を行う。
変数の宣言は下記のように行う。
int a;
int
はデータ型を表現している。
a
は識別子を表現している。
これによってメモリ上に変数 a のための領域が用意される。
アドレスは処理系によって自動的に割り当てられる。
代入
変数への値の代入は下のように行う。
a = 8;
=
によって変数のアドレスに値を入れている。
=
の左側にはメモリのアドレスを指定する式以外を置くことはできない。
即時関数
概要
即時実行関数とも呼ばれる。
呼び名の通り定義と同時に実行される関数である。
JSにおいて関数はスコープを限定するために使用されることがよくある。
スコープの限定のために使用し一度しか呼ばれない関数であることがわかっている場合には即時関数として定義する。
構造をみる
下記のように定義する。
(function () { //処理 })();
まず、functionの前の()
についてはJavaScriptエンジンが解析時にfunctionを見てそれを関数定義と間違えないようにするためのものである。
↓のように()を外して考える。
function () { //処理 }();
function () {}
部分は無名関数である。
無名関数部分をひとかたまりとしてみると全体で無名関数();
の形となる。
関数呼び出しの形になった。
当然()
には引数をいれることも可能。
アロー関数に置き換え
(function () { //処理 })();
↑の無名関数部分をアロー関数に置き換える。
function () {}
が()=>{}
となる。
なので全体としては↓のようになる。
(()=>{ //処理 })();
結構違和感ある形になる。