コンピューターサイエンス 学び直し 関数の詳細編
今月からコンピューターサイエンスを学び直しているので、そのアウトプットをしようと思います。
そもそも、コンピュータサイエンスとは、「コンピュータを利用して様々な課題を解決する方法を研究する学問」と言えます。
コンピュータサイエンスが扱う分野は、プログラミング言語やフレームワークだけでなく、ハードウェアや設計パターン、アルゴリズムなど多岐にわたります。
これらを組み合わせ、最適な選択をし、社会的学術的な課題を解決していくことが、コンピュータサイエンスの本質です。
今回は、プログラムの構成要素となる「関数の詳細」について解説していきます。
関数を記述する際のテクニック
まず、関数を記述する際のテクニックについて解説してきます。
抽象化
ソフトウェア開発は、レゴブロックと似ています。
たくさんのパーツを組み合わせて完成させますが、パーツが複雑すぎると組み立てるのが困難になり、誤った組み合わせ(バグやエラー)を生む可能性も高まります。
なので、レゴのパーツを見るときのように、各部分が何をするものか簡単に理解できるように、プログラムも分かりやすく作ることが大切です。
コンピュータサイエンスでは、この「分かりやすくする」ことを抽象化と呼びます。
抽象化とは、プログラムの中で詳細を隠し、必要な情報だけを強調する考え方です。
これはコードを整理し、理解しやすくするための重要な手法です。
実際の例があった方が分かりやすいと思うので、コード付きで詳しく説明してきます。
例えば、配列内のすべての数字を足し合わせるような関数を考えてみましょう。
この関数を特定の配列に対してのみ動作するように作成すると、そのコードは以下のようになります。
let array1 = [1, 2, 3, 4, 5];
let sum = 0;
for (let i = 0; i < array1.length; i++) {
sum += array1[i];
}
console.log(sum); // 15
しかし、別の配列で同じ操作を行いたい場合はどうでしょうか? 同じループを再度記述する代わりに、関数を抽象化してその操作を一般化することができます。
function sumArray(array) {
let sum = 0;
for (let i = 0; i < array.length; i++) {
sum += array[i];
}
return sum;
}
let array1 = [1, 2, 3, 4, 5];
let array2 = [6, 7, 8, 9, 10];
console.log(sumArray(array1)); // 15
console.log(sumArray(array2)); // 40
これにより、sumArray 関数は任意の数値配列に対して動作し、その総和を計算します。
同じロジックを繰り返し記述する代わりに、この関数を再利用することができます。
また、この関数が何をするのかを一目で理解でき、必要ならば単独でテストすることも可能です。
抽象化は、このようにコードを再利用可能にし、それを理解しやすくし、バグを特定しやすくするための重要な概念です。
関数の合成
関数の合成は、一つの関数の出力を別の関数の入力として使うことです。
これにより、新たな処理を作ることができます。
関数の合成を使用すると、一連のチェックや操作を組み合わせて新しい複雑な関数を作ることができます。
そして、それぞれの部品となる関数を単体でテストすることで、全体のコードの品質を確保することが可能です。
具体的なコードを書くと次のような感じです。
const double = x => x * 2;
const increment = x => x + 1;
// 関数合成
const doubleAndIncrement = x => increment(double(x));
// 使用例
console.log(doubleAndIncrement(2)); // 5 (2を倍にして4にし、その後1を足して5にします)
この例では、double 関数と increment 関数を合成して doubleAndIncrement 関数を作成しました。
doubleAndIncrement 関数は入力値を2倍にし、その後1を足すという処理を行います。
このような関数合成は、より複雑な操作を行う関数を作成する際に非常に有用です。
このように関数を分解し、それぞれを小さい問題に対応するブラックボックスとして扱うことで、全体のプログラムを分かりやすく構築することができます。
これにより、コードの可読性が向上し、全体の複雑さを軽減することができます。
またその際は、関数1つにつき、1つのタスクを意識することで、より分かりやすいコードを書くことができます。
関数の分解
関数の分解とは、一つの大きなタスクをいくつかの小さなタスクに分けて、それぞれを独立した関数として定義することです。
この方法を使うと、1 つの関数に多くの処理を詰め込むのを避け、各関数が特定のタスクを実行するようにします。
関数の分解では次のようなメリットが得られます。
- 可読性: 小さい関数は理解しやすく、コードは読みやすくなります。
- 再利用性: 一度定義した関数は他の場所でも再利用できます。これによりコードの重複を避けることができます。
- テスト容易性: 小さい関数は個別にテストしやすく、バグを見つけ出しやすくなります。
- メンテナンス性: 関数が単一の責任を持つことで、機能の変更やバグ修正が容易になります。
ただし無闇に関数を細分化すると、逆に追跡が困難になったり、コードの全体像が見えにくくなることもあるため、適度な粒度で分割することが大切です。
再帰
ある関数がその内部処理において、自身を関数として呼び出すという処理を再帰といいます。
再帰関数では実際の値がわからずとも前後関係を自身を使って定義することによって、値を計算することができます。
再帰的計算をプログラムで行う前に、気をつけなければいけないことがあります。
再帰関数において、関数に戻り値を保証し、ループを終了させるステートメントをベースケースといいます。
もし、再帰関数がベースケースを持っていない場合、関数の呼び出しが処理をひたすら繰り返させ、無限ループに陥ってしまいます。
無限ループが発生した場合、外部からプログラムを止める必要があるので、無限ループが起こらないように十分に注意する必要があります。
再帰を実際のコードを書くと、次のようになります。
// 1 から n までの総和を計算する関数s
function summation(n){
if (n <= 0) {
return 0;
}
return summation(n-1) + n;
}
// 1 から 5 までの総和を計算
console.log(summation(5));
コールスタック
プログラムで関数が呼び出されると、オペレーティングシステムはコールスタックと呼ばれる領域に、パラメータや実行終了後に戻るべきコード内の場所など、関数に関する情報を格納します。
コールスタックは、プログラム内で行われた関数呼び出しを追跡するために使用される特別なデータ構造で、プログラムの実行方法において重要な役割を担っています。
このコールスタックは、スタックと呼ばれる特殊な構造によって管理されています。
スタックは要素が入ってきた順に一列に並べ、後に入れた要素から順に取り出すという規則に従った構造で、2 つの基本操作 push、pop を持ちます。
たとえば以下のようなプログラムを考えてみましょう。
function foo() {
bar();
}
function bar() {
baz();
}
function baz() {
console.log('Hello from baz');
}
foo();
このプログラムが実行されると、次のようなステップでコールスタックが形成されます。
- 最初に foo() が呼び出され、コールスタックにプッシュされます。
- 次に foo() から bar() が呼び出され、コールスタックにプッシュされます。
- 次に bar() から baz() が呼び出され、コールスタックにプッシュされます。
- baz() が終了すると、それはコールスタックからポップされ、制御は bar() に戻ります。
- bar() が終了すると、それはコールスタックからポップされ、制御は foo() に戻ります。
- 最後に foo() が終了すると、それはコールスタックからポップされ、すべての関数の実行が終了します。
以上がコールスタックの基本的な動作です。
非同期処理など、もう少し複雑な状況になると、コールスタックの挙動も複雑になりますが、その基本的な原理は変わりません。
計算量
ある問題を解くのにどれくらい手間を要したかを数値で表したものを計算量といいます。
計算量は主に2つの要素、時間計算量と空間計算量から成ります。
- 時間的にどれほど手間を要したか
- 空間的にどれほど手間を要したか
コンピュータが特定の手順に従って与えられた問題を解く際に必要とする手順の回数のことを時間計算量と言います。
また、必要とする記憶領域の容量のことを空間計算量といいます。
時間計算量が少ないほどより短い手数で、空間計算量が少ないほどより少ないメモリ容量で問題を解くことができるので良しとされます。
この計算量にはO記法というものを使用します。
例えば、以下のとおりです。
時間計算量
- 線形時間計算量 (O(n)): 一般的な例は、配列の要素を一つ一つ確認するアルゴリズムです。例えば、配列に特定の値が存在するかを確認するために全ての要素を一度ずつ調べる必要がある場合、このアルゴリズムの時間計算量はO(n)となります。なぜなら、n個の要素を持つ配列を走査するのにn回の操作(ステップ)が必要だからです。
- 二次時間計算量 (O(n^2)): このタイプのアルゴリズムは、入力の全てのペアに対して何かをする場合によく見られます。具体的な例としては、バブルソートがあります。n個の要素を持つ配列をソートするには、最悪の場合、約n^2回の操作が必要になります。
空間計算量
- 定数空間計算量 (O(1)): このタイプのアルゴリズムは、入力のサイズに関係なく一定量のメモリしか使用しません。例えば、配列の最初の要素を返す関数は、返す要素を保存するための一定のメモリ(変数)だけを必要とします。このようなアルゴリズムの空間計算量はO(1)となります。
- 線形空間計算量 (O(n)): このタイプのアルゴリズムは、入力のサイズに比例してメモリを使用します。例えば、入力リストをそのままコピーするアルゴリズムは、n個の要素を持つリストをコピーするためにn個のメモリを必要とします。したがって、このようなアルゴリズムの空間計算量はO(n)となります。
スコープ
スコープとは、コード内で使われる変数名や関数名の有効範囲のことを指します。
複数の行や変数がまとまっていると、それらのスコープが作られます。
変数や関数が宣言される時に、その名前がスコープに紐付けられます。
何も行われていない最初の段階では、グローバルスコープと呼ばれるプログラム全体のスコープが一つだけ存在します。
その後、関数、名前空間、および特定の制御フロー内に個別のスコープが作成されます。
このような個別のスコープはローカルスコープと呼ばれます。
同一のスコープに対する変数の紐づけは一度しかできないというルールになっていますが、別のスコープであれば同じ名前をもった変数の宣言が可能です。
すなわち別のスコープにさえ含まれていれば、同一の名前を持っていても衝突を起こすことなく扱えるのです。
スコープ内の挙動
スコープ階層内で名前を探す時には、まず現在のスコープを検索します。
仮にそのスコープで名前が見つからない場合は、その親スコープを検索します。
どの親スコープにもその名前が含まれていない場合は、「名前が存在しません」というようなエラーメッセージが表示されます。
関数の呼び出しの場合、作られたスタックはローカルスコープと言えるでしょう。
つまり、関数の呼び出しの際に作られたローカルスコープの中にある変数や仮引数は一時的なメモリに保存されますが、関数の処理が終了すると、自動的に破棄されます。
一方、グローバルスコープでは紐付けされた名前は常にメモリ上に残ります。
したがって、グローバルスコープで宣言されたものはプログラムが終了するまでメモリを占有し、ローカルスコープはサブルーチンが終了するまで瞬間的にメモリを占有します。
変数がグローバルスコープに紐付けされる場合、それらをグローバル変数と呼びます。
グローバル変数には、プログラムのどこからでもアクセスすることができます。
一見便利に聞こえますが、その意味合いを考慮しないと、バグやメモリの無駄遣い、後述する副作用を発生させる可能性があります。
関数のその他の要素
関数に関数その他の要素をもう少し解説していきます。
副作用
デバッグや論理エラーを処理する際に重要な前提として、副作用と呼ばれるものがあります。
コンピュータサイエンスの分野において、副作用という言葉は少し特殊な意味を持っており、端的に言うと、「どこかにある何かを、知らず知らずのうちに変容させてしまっている」ことを意味します。
副作用はプログラミングにおいて避けて通ることはできません。
変数を変更する時、ファイルやデータベースに書き込む時時などには、副作用は付き物です。
副作用が適切に管理されていない場合、副作用が論理エラーを引き起こし、マイナス影響をもたらすケースがあります。
なのでソフトウェアを開発する際は以下の2点を意識すると良いです。
- 副作用をなるべく引き起こさないこと
- 必要最低限に留めること
値渡しと参照渡し
プログラミングで、関数を呼び出すときには、データがどのように渡されるかにルールがあります。
そして、関数の仮引数にデータを渡す方法には、次の2つがあります。
値渡し
値渡しとは、実引数の値のコピーが仮引数に渡されることを指します。
変数はコンピュータの中でデータはメモリ上のセルに格納され、保持されています。
値渡しではそのセルに格納されているデータ自体がコピーされ、他のセルへと渡されます。
最近の全ての言語ではこの値渡しと呼ばれる方法で仮引数にデータを渡しています。
参照渡し
値渡しではあくまでも値のコピーが渡されたのに対し、参照渡しでは実引数のメモリアドレスが仮引数に渡されます。
つまり、2つの引数が同じメモリ上の場所を指すようになります。
1つの引数に対して変更を加えると、もう1つの引数にも変更が反映されます。
値渡しと参照渡しの大きな違いは、値渡しでは呼び出し先が呼び出し元の変数データを上書きできないのに対して、参照渡しではそれが可能であるということです。
このように、この仕様はバグの原因になり得るので、ほとんどの最新言語ではデフォルトで値渡しになっています。
メモリ割り当て
メモリ割り当てとは、変数、データ構造、クラスインスタンスなどの値を格納するために、メモリ上の特定の領域を確保するプロセスのことです。
これにより、プログラムは必要に応じてこれらの値にアクセスし、操作することができるようになり、プログラムを実行する上で重要な役割を担っています。
一般的にメモリ割り当てには、以下の 3 つの方法があります。
静的メモリ割り当て
静的メモリ割り当ては、プログラムが実行される前であるコンパイル時に行われます。
割り当てられるサイズは固定されており、静的に割り当てされた変数はプログラムの開始から終了まで存続します。
例えば、グローバル変数や静的変数は静的メモリ割り当てによって確保されます。
スタックメモリ割り当て
スタックメモリ割り当ては、コンピュータが関数を呼び出すたびに新しいメモリスペースを確保する方法です。
このメモリスペースは、先ほど解説したコールスタックと呼ばれる場所に一時的に保存されます。
このメモリスペースには、関数で使用する変数のデータが格納されます。
関数が呼び出されると、その関数に対応するスコープがスタックに追加され、変数はこのスコープに保存されます。
スコープが終了すると、変数とそのデータは削除され、スタック領域は再利用されます。
つまり、変数の有効期間はスコープに制限されるため、自動メモリ割り当てとも呼ばれます。
ヒープメモリ割り当て
スタックメモリが自動的に割り当てられるメモリ領域であるのに対し、ヒープメモリはプログラマーが明示的に割り当てることのできるメモリ領域です。
ヒープ割り当ては動的に実行されるので、動的メモリ割り当てとも呼ばれます。
メモリを割り当てるためには new や malloc などのキーワードや関数が使用され、そのサイズもプログラマーによって決められます。
ヒープメモリの大きな問題は、メモリの割り当てをユーザが管理する責任があることです。
ユーザーがヒープ割り当てを削除し忘れて、そのメモリが二度と使われない状態をメモリリークと呼びます。
メモリリークは、メモリの性能低下の原因となるため注意が必要です。
そして、使用しなくなったメモリをヒープメモリから自動的に削除する処理は、ガベージコレクタ呼ばれています。
制御フロー
ステートメントや関数、式がどのような順序で評価されるかには一定のルールが存在します。
これらの一連のルールは制御フローと呼ばれ、言語によって異なります。
なのでログラムを記述する際には、言語が用意するキーワードや記号をしっかり把握し、適切な制御フローを構築しなければいけません。
制御フロー理解することは、プログラムをより効率的に作成することにも繋がり、チームを使った大規模なソフトウェア開発も行うことができるようになります。
if文
if文は前の記事で説明したので詳しい説明は省きますが、if文も制御フローの1つです。
switch 文
switch文は、値を受け取り、ケースと比較し、ケースが入力された値に等しい場合に、そのブロックのステートメントを実行する条件付き制御フローです。
switch文には、値が一致しなかった際に実行されるデフォルトのケースと、制御フローをエスケープする break キーワードが使われます。
反復処理
反復とは、内部にある一連のステートメントを設定された回数だけ繰り返して計算することを指します。
for文
最も一般的な処理方法はfor文を使うやり方です。
forループは、以下の2つから構成されています。
- ループを制御するステートメント
- 実行されるステートメント
コードで書くと次のようになります。
for(初期化; 条件; ループの最後に評価される式){
ステートメント
}
また、入れ子構造の for ループは大きな計算量を生み出すので注意が必要です。
while文
for文とは別に while 文という処理を使って反復を表すこともできます。
while ループは、条件文が true の条件下で、複数のステートメントを複数繰り返す反復制御フローです。
while 文では、述語が真の場合、反復処理を行います。
つまり、ある時点で述語が false となるようにプログラムの状態を変更しないと、while 文が無限ループに陥ってしまいます。
break/continue
反復ループを使うとき、強制的に反復から離脱させるbreakキーワードというものがあります。
制御フロー内でbreakキーワードが処理されると、反復ループから抜け出すことができます。
このbreakキーワードはループ内で条件が満たされた場合に使用されるため、コンピュータが不要な計算をしなくて済むようになります。
もう1つの便利な制御フローキーワードはcontinueキーワードで、このキーワードを実行するとループはすぐに次のループに進みます。
continue キーワードを使うと、特定の条件で次のループに進むことができるので、ループの本体内に大きなステートメントのグループがあり、全てのステートメントを実行したくない場合に便利です。
ラムダ関数
ここでは、関数を他の関数に入出力として渡すことができるデータとして扱う概念について説明します。
ラムダ関数を使うことで、複雑で柔軟な処理を設計し、変化する要件に容易に対応することができます。
ラムダ関数は、匿名関数とも呼ばれ、関数リテラルとしてその場で作成される関数です。
ラムダ関数が通常の関数と異なるのは、その場で作成され、名前が固定されていない点です。このため、柔軟性が高く、永続的な名前を付けずに関数を定義してすぐに使用する必要がある特定のシナリオで有用です。
無名関数は、メモリ上でオブジェクトとして扱われます。
それ自体がオブジェクトなので、他のデータと同じように扱うことができます。
例えば、関数に渡したり、関数から返したり、変数に格納したりすることが可能です。
高階関数
ラムダ関数の特徴を利用すると、関数を入力として受け取り関数を出力として返す関数である高階関数を作成できます。
また、ある関数が他の関数を入力として受け取り、片方の関数内のどこかでもう1つの関数を呼び出す場合、呼び出される関数はコールバック関と呼ばれます
高階関数を使うと、責務を分離した複数の関数を組み合わせることができます。
これにより、関数を柔軟に使用してプログラムを組むことができ、処理をより抽象化したり、再利用性の高いプログラムを構築することができます。
高階関数は、コードの可読性やモジュール性を向上させ、再利用可能で簡潔、かつ保守性の高いコードを作成するためによく使用されます。
カリー化
カリー化とは、複数の引数を取る関数を、それぞれ単一の引数を取る一連の関数に変換する手法のことです。
これは、より特化した関数や再利用可能な関数を作成するのに便利で、関数型プログラミングでは一般的な手法です。
function curry(f) {
return function(a) {
return function(b) {
return f(a, b);
};
};
}
// 使用例:
let multiply = (x, y) => x * y;
let curriedMultiply = curry(multiply);
console.log(curriedMultiply(2)(3)); // 6
部分適用
一方、部分適用とは、複数の引数を取る関数のうち、一部の引数をデフォルト値に固定し、より少ない引数を取る新しい関数を作成することです。
これにより、関数が定義された時点で引数の一部を指定し、関数が呼び出された時点で、返された関数を使用して残りの引数を指定することができます。
function partialApply(f, ...fixedArgs) {
return function(...remainingArgs) {
return f.apply(this, fixedArgs.concat(remainingArgs));
};
}
// 使用例:
let multiply = (x, y) => x * y;
let double = partialApply(multiply, 2);
console.log(double(3)); // 6
ラムダクロージャ
関数にはステートレスとステートフルという 2 つの概念があります。
ステートレス関数とは、同じ入力が与えられたときに常に同じ結果を返す関数のことを指します。
逆にステートフル関数とは、関数が以前の状態を保持して、その状態に基づいて出力を変更できる関数のことを言います。
つまり、同じ入力が与えられても異なる結果を返すことがあります
クロージャは、プログラミングにおける特殊な関数の一種です。
特殊なのは、その関数が生成されたスコープを記憶しているからです。
通常の関数と違い、クロージャはそれが定義されたスコープにある変数を覚えており、そのスコープが消えた後でもそれら変数にアクセスすることができます。この性質により、データのプライバシーや状態を維持したままで関数を操作することが可能になります。
この特性は、グローバルスコープを汚染せずに、変数の値を維持したり、プライベート変数を実装したりする場合などに非常に便利です。
また、一つの関数の状態を保持しながらも、新たにクラスを作成する必要がなくなるため、コードの簡潔さと効率性も向上します。
function outerFunction(outerVariable) {
return function innerFunction(innerVariable) {
console.log('outerVariable:', outerVariable);
console.log('innerVariable:', innerVariable);
}
}
const newFunction = outerFunction('outside');
newFunction('inside'); // logs "outerVariable: outside" and "innerVariable: inside"
まとめ
今回はプログラムの基本となる「関数の詳細」について解説しました。
今回学んだ知識を使うことで、プログラムをよりわかりやすく、効率的に記述することができます。
引き続き学んだ内容をアウトプットしていこうと思うので、参考にしてください。
宣伝
0からエンジニアになるためのノウハウをブログで発信しています。
また、YouTubeでの動画解説も始めました。
インスタの発信も細々とやっています。
興味がある方は、ぜひリンクをクリックして確認してみてください!
Discussion