📺

minishellで学んだこと

に公開

まえがき

42に少しでも貢献したくminishellの振り返りも兼ねて記事を書きたいと思います。
はじめに断っておきますがこれはminishellを実装する上での説明書ではありません。
私がminishellを実装する上でどんなことを考えながら取り組んだのか姿勢についてが大枠を占めます。
結局、提出される課題は似たりよったりになります。
実装をするにあたってどんなことを試してどうやってその結果にたどり着いたかが大事だと思っています。
それを大切にすれば42がもっと良い場所になる気がします。
minishellの実装方法だけ知りたい!という方は
https://github.com/dattekoda/minishell.git
にソースコードが載っています。
また、事情がありペア課題のminishellをほとんど一人で取り組むことになりました。
その点、グループワークを練習する機会を失った点はもったいなかったと思っています。
そのあたりについても記載できればと思います。
拙い文章ですが最後までお付き合いいただけますと幸いです。
間違いも多々あるかと思います。
お手漉きでしたら指摘してくださると幸いです。

参考文献

  • 普通のLinuxプログラミング 第2版

    →Linuxのカーネルがどういった役割か、大まかな概要を掴むことができます。これ一冊では不十分だと思いますがひとまず入門として一読されることをおすすめします。特に12章で問題として提示されているサンプルコードのsh2.cは簡単なminishellが実装されていて実装の手順を確認できます。

  • 低レイヤを知りたい人のためのCコンパイラ作成入門/文法の記述方法と再帰下降構文解析
    →コンパイラの作成方法を丁寧に解説したサイトです。著者のRui UeyamaさんはGoogleでソフトウェアエンジニアとしての経験があり、一見難しい抽象構文木を簡単なソースコードを交えて丁寧に説明されています。図も豊富に使われていてとても為になりました。リンク先の構文木の節だけ読めば十分だと思います。

  • Bash reference manual
    Bashのリファレンスで使える機能の参照サイトです。Bashを開発したGNUによる説明であるため、実装の方針で迷子になったときに毎回確認を取っていました。特に、リダイレクトやビルトインコマンドの挙動、環境変数の役割が詳細に記載されています。説明は英語ですが英語が読めるようになると参考文献の幅も広がると思い英語でなるべく読むようにしました。

  • Linux プログラミングインターフェース
    →OSがシステムコールをどういった仕組みで機能を果たしているのか
    、の挙動を詳細に知りたいときのみ辞書的に使用していました。分厚くてぎょっとしますが文体はシンプルでとにかく詳しく説明されているのでふつうのLinuxプログラミングを読み終わった後、もやもやしたとき手に取ると良いと思います。

minishell一問一答

この課題に取り組む上で必要となる前提知識を一通りなぞっていきます。

そもそもShellって何?

Operating Systemとユーザーの橋渡しを行うユーザーインターフェースをテキストベースで提供するプログラムのことです。
OS、いわゆるカーネルにはコンピュータ内部の部品に直接指示を出す機能が備わっています。しかしその命令は、この文字列を画面に表示しろ!このメモリを参照してそのメモリに書き込まれている内容を参照しろ!程度の極めて単純なもののみ提供される形になっています。
これは使いづらいよねってことでその橋渡しとしての役割をシェルに担わせているわけです。
たとえばコンピュータから挨拶されたくなったとき...(みなさんも一度は思ったことあると思います。)


シェルがないと...

まずはC言語をコンパイル(機械語に翻訳)するプログラムを用意して、さらにC言語をコンパイルするプログラムがなかったらそれをアセンブリ言語で機械語により近いかたちで教えてあげる、あるいはさらにアセンブラを機械語に変換するすべがなかったらいよいよ0と1の数字の羅列である機械語を直接メモリに書き込む。。。みたいなことをする必要があります。


ちょっと大げさですがこんなめんどくさいこといちいちやってられません。そこで、シェルの出番です。

$ echo hello
hello

echo helloとシェルのプロンプトに入力して、キーボードのreturnを押すだけです。
実際には内部で前述した複雑な処理を一役で担って挨拶してくれています。

黒い窓口に色々指示を出すとコンピュータの部品たちがわかるような言葉に翻訳して指示を出す存在だと思えばOKです。CLI(コマンドラインインターフェイス)ベースでプログラムは動作します。

OSにはいろいろな系譜があります。中でも有名なものがWindowsMac, Linuxのことです。特に、MacLinuxUnixを源流に持っていて、そのへんの派生図は結構複雑ですが歴史がよくわかるので眺めると面白いかもしれません。

BashZshの違いとは?

校舎で使われているLinuxや、Mac OSでは、ZshというShellが使われています。では、サブジェクトで参照するように記載があるBashとはどんな違いがあるのでしょうか。

どちらも同じUnix系のシェルですがZshBashにはない高度な機能を有するなどBashの拡張版に位置づけられています。具体的にはC言語と似た文法特性を持つcshを上位互換に持つtcshや 対話モードで使用する際にコマンド行をWYSIWYG風の方法で編集できるkshなどの非常に有用な機能の一部を取り入れています。

となると今回実装の参考としてあるBashはもう使われていないかというとそんなことはありません。十分現役で使える実用的な機能を備えています。特に、過去に入力されたプロンプトを遡る機能や、タブ補完はBashの大きな特徴です。Bourne Shellと呼ばれるUNIXの最初期(1970年代)にAT&Tベル研究所のStephen R.Bourneによって開発されたShの後継としてBourne again SHellと銘打ってGNUによってフリーソフトウェアとして開発されたBashは`1989年、POSIX標準に準拠し、LinuxやmacOSではデフォルトのシェルとして広く使われていきました。

Bashminishellの違いとは?

ここでBashの完全な再現を作ると考えるのは早計です。まずminishellでは使えるコマンドが限られています。Bashで使われているコマンドのほんの一部のみの使用を許されています。また、Bashのソースコード(非公式のミラーリポジトリー)を見るとわかりますがファイルの数が膨大でグローバル変数等も多用されているため、かなり面倒だと思います。そこで、実装する範囲を予め、自身で定義することが大切です。

特に、構文エラーなどでクオートが正しく閉じていなかったときに標準入力を開くかどうかなど挙げていけばきりがありません。ただ、作ったShellの挙動をすべて把握しておく必要はあると思います。こういうパターンが来たらこうなると入力と出力がどうなるかを頭の中で把握しておかないとレビューされるときにボロが出ます。言ってしまえばこの課題は自分でこのように定義しましたと主張すればある程度許されます。その代わり、作ったShellを利用する人が不便しないように、もしBashでこういう利用をしたいのであればこういう使い方がありますと代替案を提示できるようにすることも必須だと思います。

ワードのおさらい

ここは読み飛ばしても構いません。
説明の練習になると思い設けました。

プロセスとは

プロセスはプログラムの実行中の姿です。LinuxではプロセスごとにプロセスID(PID)と呼ばれる番号が割り振られており、その識別と管理に使用されています。
これ以上気になる方はググってください。
また、参考文献にも挙げている普通のLinuxプログラミング 第2版もおすすめします。

シグナルとは

実行中のプロセスに割り込んで信号を送信することを指します。プロセスがシグナルを受け取るとそのプログラムで指定されたとおりにプロセスが挙動します。例えば、ctrl + CSIGINTというシグナルをプロセスに送信します。デフォルトだとこのシグナルを受け取ったプロセスは強制終了します。ほかにも用途に応じて様々なシグナルが用意されています。
これらの受け取った後の挙動を編集できるシステムコール関数が用意されています。それを用いてminishellでは期待通りの結果を出すように設計する必要があります。シグナルはありとあらゆるタイミングで飛んでくるので全体の設計で気を配る必要があります。
これ以上気になる方はググってください。
また、プロセスと同様普通のLinuxプログラミング 第2版もおすすめです。

シェルコマンドとは

シェルのプロンプトに入力されるコマンドのことです。
代表的なコマンドにファイルの中身を参照するコマンドのcat、現在いるディレクトリ内のファイルを参照するls、ディレクトリを移動するcdコマンドなどがあります。では、私達がコマンドを入力するときに、シェルはどんなことを行っているのでしょうか。
lsというコマンドは実はBashの内部には存在していないコマンドです。ではなぜlsと実行できているのか。それはlsという実行ファイルが保存されている場所を探索して、そのディレクトリにある実行ファイルを実行しているからです。

$ ls
0  2  3  4

は当然実行できます。

$ /bin/ls
0  2  3  4

ではこの場合はどうでしょうか。
lsと入力されたときと同じ結果が出力されていますね。つまり、lsというコマンドは/binというディレクトリに格納されている実行ファイルにほかならないわけです。/binというディレクトリは$PATHという環境変数に格納されています。この環境変数に記載されている候補のディレクトリを左から順々に探索していき、一番はじめに見つけたコマンドを実行しているわけですね。

$ echo $PATH
/home/khanadat/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin:/usr/share/clang/scan-build-12/bin

catコマンドも同様です。では、cdコマンドはどうでしょうか。これは少し仕組みが違ってシェルの内部で実装されているコマンドです。それが課題要件にも記載されているビルトインコマンドです。これは直訳すると組み込みともあるように、Bashの内部で実装されたコマンドのことです。なぜ、ビルトインと分けてコマンドを実装したかはプロセスに関係しています。

前者のビルトインコマンドでない通常コマンドは、子プロセスというプロセスを分岐した形で実行がされています。こうすることで、その実行ファイルが終了しても元のプロセスはそのまま動き続けるといったことができます。しかし、プロセスで実行されたシステムコールはそのプロセス自身にのみ反映されます。つまりシェルプロセスのディレクトリを移動したいとき、(cdはディレクトリを移動するコマンドです。)chdir()というシステムコール関数を使うのですが、これはそれを実行したプロセス自身にしか反映されません。そのためシェル自身に影響を与えるようなコマンドは(一部例外はありますが)ビルトインコマンドとして存在しています。

cdコマンドの実行例

# cursusに移動できた。
$ pwd
/home/khanadat
$ cd cursus/
$ pwd
/home/khanadat/cursus

※pwdは現在いるディレクトリを参照するビルトインコマンド
()を使うと子プロセスを立ち上げてコマンドを立ち上げることができます。

# cursusに移動できなかった。
$ pwd
/home/khanadat
$ (cd cursus/)
$ pwd
/home/khanadat

上記例のように子プロセス内の実行は本元の親プロセスには影響を及ぼさないことが確認できました。
これ以上気になる方はググってください。

パイプとは

$ cmd1 | cmd2

のように入力があったとき、cmd1の標準出力がcmd2の標準入力として受けわたされます。

$ echo hello | rev
olleh

これはecho helloが標準出力にhelloを書き出しそれを標準入力として受け取った文字列をリバースするシェルコマンドrevが受け取ってollehと出力されています。
パイプは並列でコマンドが処理されることに注意が必要です。
そのため、プロセスIDを指定してプロセスツリーを確認できるwatch -n 0.2 pstree -p (PID)で別のウィンドウからBashPIDを指定して見てみると、並列して処理されていることがわかります。

bash(1356538)-+-sleep(1369501)
              `-sleep(1369502)

そのため、以下のコマンド

$ sleep 5 | sleep 5

は10秒待機せずに5秒間待機します。なお、sleepは指定した秒数だけ待機するシェルコマンドです。
これ以上気になる方はググってください。

リダイレクトとは

入出力するファイルを選択できる機能です。

$ echo hello > file
$ cat file
hello

とあるように新たに現在いるディレクトリ内にファイルが作成されてそのファイルにecho helloの結果が反映されます。注意点として、構文が非常に自由な点とパイプと併用したときにリダイレクトが優先される点、一番最後のリダイレクトが優先される点です。(catはファイルの中身を参照するシェルコマンドです。)
構文が自由な点に関しては

$ > file echo hello
$ echo > file hello

このすべてで結果は同じになります。
これ以上気になる方はググってください。

終了ステータスとは

Bashはコマンドが実行された後にそのコマンドが正常終了したか、エラーを吐いて異常終了したかを知らせる仕組みを備えています。これを確認することで結果を大まかに確認するのに役立ちます。

$ ls
0  2  3  4
$ echo $?
0

前回のコマンドの終了ステータスを確認するのに$?を使います。$を使うのですが環境変数とは違い特殊パラメータと呼ばれ少し仕組みが異なります。
GNUのBash referenceを確認すると、こうあります。

For the shell’s purposes, a command which exits with a zero exit status has succeeded. So while an exit status of zero indicates success, a non-zero exit status indicates failure. This seemingly counter-intuitive scheme is used so there is one well-defined way to indicate success and a variety of ways to indicate various failure modes.

When a command terminates on a fatal signal whose number is N, Bash uses the value 128+N as the exit status.

If a command is not found, the child process created to execute it returns a status of 127. If a command is found but is not executable, the return status is 126.

3.7.5 Exit Status

成功すると0, 0以外は失敗と判断されます。リファレンスにもあるように127はコマンドを探したけど見つからなかったとき、130はコマンド実行中にctrl + cというシグナルを送信されて終了されたときなど様々な値が割り当てられています。
詳しくはreferenceをお読みください。

アンド、オアとは

&&の実行例

$ pwd
/home/khanadat
$ cd cursus/ && touch hello
$ pwd
/home/khanadat/cursus
$ ls
0  2  3  4  hello

アンド&&,オア||は前回のコマンドが完全に実行し終わってからその終了ステータスを確認したあとに次のコマンドを実行します。アンドは前回のコマンドが成功したら次のコマンドを実行して、オアは前回のコマンドが失敗したときに次のコマンドを実行します。
前回のコマンドの結果を参照した上で次に入力されたコマンドを実行するか決めるので、直列で実行することになります。先程のパイプのときと同様にwatch -n 0.2 pstree -p (PID)でプロセスツリーを確認すると、

bash(1356538)---sleep(1367542)

で5秒間待機した後に

bash(1356538)---sleep(1367651)

と出力されていて、確かに最初のコマンドが実行し終わるまで次のコマンドが実行されていないことが確認できました。
実行したコマンドは以下。

$ sleep 5 && sleep 5

このように入力された構文に応じて実行する順序を変える必要があります。そのときに役立つのが抽象構文木構造です。この説明に関しては参考文献にも記載のある低レイヤを知りたい人のためのCコンパイラ作成入門/文法の記述方法と再帰下降構文解析で記載されている簡単なインタプリタを実装してみるとこれがどういうものかが理解しやすいと思います。
これ以上気になる方はググってください。

考え方

Bashの挙動

まず、Bashがどういう挙動をするかについてひたすらプロンプトを投げつけて検証しました。パイプとアンドを組み合わせたとき、リダイレクトとパイプを組み合わせたとき、リダイレクト先が環境変数のときなど…様々なケースをプロンプトに投げつつリファレンスを参照して気になったテストケースを逐一メモに残しました。

コラム(1) -42を最大限活用するには

あまりにも目標設定を具体的にしすぎると42の課題をやる価値が減少する可能性に注意が必要です。例えばコードを自分で考えることを放棄して最初から全てAIにお任せにする、これは明らかに良くないですよね。考える力が身につきません。あるいは人のソースコードを殆ど丸コピして理解はあとからみたいな形でも一定、学習にはなると思います。ただ、今後エンジニアとして働いていく上で必ずしも先例があるとは限りません。先例があったとしてもそれがオープンソースでコードの隅から隅まで確認できる保証はどこにもないです。そういった状況でも考える力のあるエンジニアは重宝されるはずです。
一方で自分で全て考えるだけだと多くのソースコードで常識となっている書き方をせずに嫌に遠回りに書いたりして、あとでその癖を直すために苦労する可能性もあります。
そのバランスを大事にとってこれからも課題に取り組んでいこうと考えています。
42は捉え方次第で最高の場所にも最低の場所にもなり得ます。

要件から実装手順を考える。

実装する項目は大きく以下の10つです。

マンダトリー

  • プロンプト待機をして、ヒストリーの参照ができること。
  • 環境変数を独自で持ち、展開ができること。
  • Bashと同様にシグナルハンドリングを行うこと。
  • クオートをBashを参考に正しく展開すること。
  • リダイレクトの実装。
  • パイプの実装。
  • $?の展開
  • ビルトインコマンドの実装。

ボーナス

  • &&||の実装。
  • ワイルドカード*の展開。

使える関数を確認する。

続いて使えるコマンドを確認しましょう。
一覧はintraからsubject.pdfを見てください。
ここでは特に重要な関数を取り上げます。

基本的なシステムコール関数

read(), write(), open(), close(), malloc(), free()
このあたりは特に重要な関数です。もし、具体的な挙動がどうなるかよくわかっていない人は一旦引き返して復習するなどして基本に立ち返ることをおすすめします。

特に、malloc()する意味など深く考えずにしていた人は要注意です。
この関数が原因でローカルで宣言された変数との区別がつかないまま他の関数に受け渡したことでSegmentation faultを起こすことはあるあるです。そのときどきでなんとなくmalloc()したりしなかったりだと全体像がぼやけたまま進めることになるのでエラーが発生したときにそのエラーがどこからきたのかぱっと掴めずデバグに果てしない時間を要するため、とても危険です。
あるいは別にいらない箇所でmalloc()を必要以上に呼び出すことでパフォーマンスの低下にも繋がります。
そこは本当にその関数を使わないとならない箇所かどうか吟味し続ける姿勢が大事です。
また、変数も同様にどこの関数で使われているかどこで使われていないかを頭の中でトラックする習慣づけをすることでより安全な実装になります。


コラム(2) -グローバル変数はなぜだめなのか?

最終的にやめた方針として、もともとはプログラム名をstatic変数のスタック領域に書き込んである関数を呼び出すたびにどこからでも動いているプログラム名を取得できる実装をしていました。我ながら使いやすい関数ができたなと気に入っていたんですが、それを見た同期の一人にめちゃくちゃ詰められました。「グローバル変数が禁止されている意味をもっと深く考えるべきだ。42のルールでグローバル変数が禁止されているにも関わらずnormに違反しないからそういった実装をしてもいいという考えは卑怯だ。」とお叱りを受けました。そいつは人一倍42にかける思いがあって僕は彼のことがとても好きなんですがその時ばかりはせっかく実装した機能を貶されているように感じてストレスを感じました。
ただ、少し冷静になった後で「グローバル変数はなぜ使わないほうがよいのか」ググってAIに聞きました。

グローバル変数が忌避される理由は主に以下の2つです。

  1. 追跡の困難さ
    これが一番大きな理由です。もちろん、minishell程度の規模だったらその変数がどういう遷移をたどっているかは追うことは可能です。しかし組織で働きはじめそれが多くの人が参画するプロジェクトだったときその変数がどこでどういった処理が行われるか追うことは非常に困難です。ましてや、その変数を最初に作った人がもうプロジェクトから外れていたら? ますますその変数の役割が如何だったかわからなくなります。
  2. 意図しないバグの原因となる。
    グローバル変数は、プログラムのどの部分からでも読み書きが可能です。ある関数が良かれと思ってグローバル変数の値を変更した結果、その変数を参照していた他の全く別の関数が予期せぬ誤った動作を引き起こす可能性があります。

課題は提出して受かったらそれで終了です。プロジェクトはローンチした後も問題が発生したら保守点検することが必須です。その間ずっとそのプロジェクトに携わることができる可能性はごく僅かでしょう。いつか近い将来やってくるその時のために、他の人が見ても人目で何が起きているかわかるコードを書く必要があります。


プロセスに関わる関数

fork(), wait(), waitpid(), sigaction(), sigemptyset(), kill(), dup(), dup2(), pipe(), execve()
このあたりの関数を自由に使えるようになると、かなり実装の目処が見えてきます。おすすめは、それぞれの関数の挙動がよくわかるその関数しか使われていないコードをAIに書かせる、サイトに書いてあるかんたんなソースコードを模写するなどしてその関数を道具として使えるようになるまで頭の中で何が起きているかをトラフィックすることです。

例えば、乳幼児は言語を覚える過程で何度もその言葉を反芻してその言葉が意味する対象とを脳内で紐付け用とします。人形のおもちゃに服を着させて服の扱い方を学んだり、車のおもちゃを手で走らせて車輪が動く簡単な仕組みを理解します。子供に限らずプロ野球選手もひたすらボールを投げてそのボールを速く投げる術を身につけます。関数も同様です。何度もその関数をいろんな位置においてみることでその関数がどういった動きをしているか検証することで初めて道具として使えるようになります。
特に、その関数を使わなかったときにどういった挙動になるかを検証することでよりその関数に対する理解が深まるので何度も試行錯誤することが大事です。
私がfork()を理解するためにテストした関数を試しに載せます。
fork()は子プロセスを生成する関数です。

fork.c
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>

int	main(void)
{
	pid_t	pid_a;

	pid_a = fork();
	if (pid_a == 0)
		printf("%d	inside if\n", pid_a);
	printf("%d	outside if\n", pid_a);
}

結果は以下。

$ ./a.out  
800873	outside if
0	inside if
0	outside if

このコードだと、まず親プロセスがprintf()を呼んで800873 outside ifと出力しています。この800873の数字がわからなかったらmanページを読みます。

Upon successful completion, fork() shall return 0 to the child process and shall return the process ID of the  child  process  to  the  parent process.

流し読みすると成功したとき、子プロセスからみたfork()の返り値は'0'で親プロセスから見たfork()の返り値は子プロセスの返り値になっているとあります。
確かに実行結果は成功してそうですよね。
子プロセスはif分岐内とその下どちらもprintf()を呼び出せるので二回出力されています。
ここで本当に親プロセスから出力されている子プロセスのIDがそうなっているか気になるので、ソースコードの最後に無限ループを追加してプロセスが終了しないようにしたあとに、別ウィンドウで実行プロセスのPIDを参照できるpgrepを実行してみると、

$ ./a.out   
805136	here outside if
0	here inside if
0	here outside if

$ pgrep a.out
805135
805136

となり確かにそうなっていることが確かめられました。こんな具合に一つの関数でも色々遊べます。本当にそうなっているかなと好奇心をもって関数を使うことで段々その道具に馴れてきます。

コラム -どこまで深堀るか

ここまでいろいろな関数を見てきましたが、それぞれの関数についてどこまで理解するか自身が理解できている範囲をあえて制限することも必要だと感じます。というのも一つのことにこだわりすぎて実装が全く進まないリスクがあるからです。
例えば、今回minishellで使用が許可されているライブラリ関数のreadlineは内部的に領域を動的に確保します。更に言うと関数の内部でシグナルを制御しているようです。ただ、その内部については関数を使う側からは思うように制御することができません。厳密にはできるのですがそのために必要な関数が今回は制限されています。最初はこの使える関数をフルに利用して内部で確保されている領域をちゃんと開放するように実装させてほしいと感じました。
しかし、課題を進めていくうちに考え方が変わりました。というのも、実際にエンジニアとして働く上ではAPIや、社内で使われているライブラリ関数があるものと予想しています。(実際に働いたことはないので想像で恐縮ですが)そうなったときに、もちろんその関数がどういった挙動をするかについて理解することは大事ですがその道具の内部でどういったことが行われているか、さらにどういった本当に説明どおりに利用して不具合が発生しないかなどを確認するなど逐一疑っていると、実装までの時間が遠のいてしまいます。
課題に関しては、誰から急かされるわけではないので(もちろんBHはありますが)時間に余裕を持って取り組むことができます。ただ、実務だとクライアントや社内の要求に答えるために限定された時間内に成果物を出すことが求められます。その際に先のような姿勢で取り組むと締切に遅れる可能性が出てくるので自分が理解している範囲を絞ることは一定許容されるべきだと思います。
ただ、だからといって自分のソースコードで何が行われているかを反故にすることは許容されません。そこの責任を持つために現場ではメンバーごとに自分が理解する範囲をフォーカスすべきです。


実装の工夫

これだけ実装する項目があるため、いきなりコードを書き始めるのは得策ではないと判断しました。そこで考えた大枠の設計を以下に記載します。

大まかな流れはこのように説明できます。
このとき意識したことは終了するときにどう処理するべきかです。
これを意識して設計すると変数の寿命をどこまで持たせるかが明確になります。

法則通りに作業する。

42にはnorminetteと呼ばれるコーディング規則が存在します。一つの関数は25行以内や、変数の宣言と初期化を分割するなどコードをシンプルにする規則がこのルールで定められています。これだけでもコードを読みやすくなりますがそれ以上に全体の構造をもっと規則付けることで自分が迷わないように色々と工夫をしました。それらを紹介します。

ヘッダーの役割を明確にする。

また、このようにやるべきことを分割することで使われる関数の有効範囲を吟味する際に役立ちます。ヘッダーファイル内で全てインクルードをしてそれぞれのファイル内で一つだけインクルードしている人をよく見かけますが、それだとヘッダーを作成する意味がなくなります。関数がどこで使われているかがわからなくなるためです。
ヘッダーファイルを使われる有効範囲に分けてインクルードすることで構造がハッキリとして頭の中が整理されるため面倒でもそのファイル内で使われている関数で必要なヘッダーファイルだけをインクルードしました。

上記のように階層的にヘッダーをインクルードすることで、使われる関数の範囲が明確になり、その役割がより明確になります。
また、同じ名前の関数を宣言しても衝突が起きないメリットがあります。
ファイルの命名規則も厳密にしました。上記のように~部分のみで使われる関数が定義されているファイルはその部分でしか使われていない関数なので、~_utils.cのようにファイル名を決めて全体で一貫性をもたせました。

ゴールを分割する。

ここまで大きな課題だとゴールが途方もなく先でモチベーションを保つのに苦心します。そこで、部分ごとに目標を立て、それが達成されるまで、次のフェーズには移行しないようにしました。特に、構文解析を行う手前のレキサー(トークン化を行う部分)等のあとにも響くような箇所は入念にテストを行いました。

トークン化のテスト
$ echo gohan|cat >file && echo gohan
WORD: echo
WORD: gohan
OPERATOR: |
WORD: cat
OPERATOR: >
WORD: file
OPERATOR: &&
WORD: echo
WORD: gohan
EOF

OPERATOR: |, ||, &&, >, <, <<, >>

クォーテーション展開のテスト
$ abc'123'$USER"hello" #入力
val: abc'123'$USER"hello"
len: 3
val: 123'$USER"hello"
len: 3
val: khanadat
len: 8
val: hello"
len: 5

val: 文字列を指す。ドルマークが来たときのみその環境変数の中身を参照して適切なポインタを返す。
len: 切り取る部分の文字列の長さ。それらを結合して一つのトークンにつなげ直す。

テストを行うと、その後のコードを安心して書くことができます。
なぜならテストによって次渡ってくる変数がどういった形になっているかが想像できるからです。
テストをせずに続けるとエラーが発生したときにどこで発生しているか探しづらくなります。

さいごに

振り返ると、minishellの実装は本当に楽しかったです。
自分で要件を定義して、仮説を立てていき実装するために頭を捻る体験はとても貴重なものでした。
全体を設計できたのでその細部まで知識がつき、構造化する力が伸びて高く俯瞰して設計できるようになったと感じます。

ただ、一方で貴重なグループワークの機会を失った点、後悔しています。
gitの扱い方を学べる貴重な機会が失われたのは本当に良くなかったです。
最初の9月頭あたりは去年の10月入学生の方とペアを組み、わたしが構文解析、ペアが実行部といった具合に上手く分担できていたのですがペアの方が校舎に来る時間を割けず、なかなか話す機会を得られないということで、こちらから解消させていただきました。
(42は人それぞれその価値が異なるのでどうしても仕方ない点だと思います。)

そのため9月いっぱいはペアを組んでいたのですが実質的に一人でコードを書いていました。
その後10月に入り同じ4月生の方とペアを組めたのですが、その頃には大部分が出来上がっていて、そのまま提出という形になってしまいました。
(10月からペアを組んでくれた方はわからない点を逐一尋ねてもらえてコードの説明をすることで自分にとってとてもモチベーションになりました。ありがとうございます。)
世の中のソフトウェアのそのほとんどはチーム開発の賜物です。
これから、エンジニアとして活躍していくためには必ず必要なスキルのはずです。

振り返ってみると、役割分担が本当にただの分担になってしまっていた点が良くなかったと思います。
相手目線で無理なく達成できる目標を提示できていたらより良かったと思っています。
例えば、「とりあえず実行部分の設計を作成してもらう」ではなく「実行部分で使われる関数がどれかを吟味してもらう」→「その部分で求められる最終的なゴールを決めてもらう」→「必要な関数をリストアップしてもらう」→「とりあえず雛形を作ってもらう」等で段階的に目標達成しても良かったかなと今は思います。
私が要求していた目標が相手にとって高い要求だったかもと反省しこれからは相手の負担等も考慮して相手にとってそこまで高くないハードルの目標を設定していければと思います。

これからはチームでどう分担すれば効率的に開発ができるか、どうすればお互いのモチベーションの向上につながるかをよく考えてチームでの活動に向き合いたいと思います。
長々と駄文を連ねてしまいましたがお付き合いいただきありがとうございました。
これからも42楽しみます!

Discussion