EDOCODEでエンジニアをしているYutakaです。
-
こちらは社内勉強会で発表した資料を元にしています。
-
関数型言語の知識がほとんどないエンジニアがなっとく!関数型プログラミングで学んだ用語を一部まとめました。原著はGrokking Functional Programmingです。本書はScalaとJavaで説明がされていますが、できる限り社内で使われている言語(Go, JavaScript, TypeScript)でサンプルコードを記載しました。
-
書籍のソースコードはこちらに全て公開されています。
そもそも関数型プログラミングとは?
プログラミングのパラダイムには大きく①命令型プログラミング②宣言型プログラミングがあります。
①命令型プログラミングとは
どのよう(HOW)に計算するかに焦点を合わせ、段階的なアルゴリズムを詳細に定義します。これは実際のハードウェアの計算処理の流れに沿っています。
②宣言型プログラミングとは
どのように計算をするかではなく何(WHAT)を行うかに焦点を当てています。
また①命令型プログラミングの中には
- 手続型プログラミング
- 構造化プログラミング
- オブジェクト指向プログラミング
②宣言型プログラミングの中には
- 関数型プログラミング
- 論理プログラミング
などがあります。
具体的な違いを知るため、下記はscala
という文字が入っている本のタイトルを取り出す処理を①命令型と②宣言型で書いたものです。
// [JavaScript] ①命令型 と ②宣言型 の比較
class Book {
constructor(title, authors) {
this.title = title;
this.authors = authors;
}
}
const books = [
new Book("Functional Programming in Scala", ["Paul Chiusano", "Runar Bjarnason"]),
new Book("Grokking Machine Learning", ["Luis G. Serrano"]),
new Book("Rust Servers, Services, and Apps", ["Prabhu Eshwarla"])
];
// ①命令型
let imperativeScalaBooks = [];
for (let i = 0; i < books.length; i++) {
if (books[i].title.toLowerCase().includes('scala')) {
imperativeScalaBooks.push(books[i].title);
}
}
console.log(imperativeScalaBooks); // ["Functional Programming in Scala"]
// ②宣言型
const declarativeScalaBooks = books
.filter(book => book.title.toLowerCase().includes('scala'))
.map(book => book.title);
console.log(declarativeScalaBooks); // ["Functional Programming in Scala"]
UIの①命令型と②宣言型の比較はReact公式が出している宣言型 UI と命令型 UI の比較がわかりやすいです。
関数型言語は数学の関数の考え方を使い、関数型プログラミングを行える言語です。社内のプロダクト開発で使われている言語(Perl, Go, JavaScript, TypeScriptなど)はマルチパラダイム(①命令型と②宣言型)をサポートしている言語です。一方でSQLは②宣言型に該当します。
関数型プログラミングを知るメリット
以下のものがあると感じました。
-
命令型と宣言型(関数型プログラミング)の二つの違う視点でコーディングを行うことが出来るようになる
-
普段使っているフレームワークにも宣言型(関数型)の考えが根底になっているものが多くあり、学習や理解が早くなる
EX: フロントフレームワーク(React, Vue), バックエンドフレームワーク(echoのmiddleware), モバイルのUIフレームワーク(SwiftUI, Jetpack Compose)
具体的に関数型プログラミングとは?
書籍には次を満たす小さな関数を組み合わせて行うプログラミングのことだと説明がありました。
A. シグニチャが "嘘" をつかない
B. 関数本体が宣言的
それぞれ詳しく見ていきます。
A. シグニチャ が "嘘" をつかない とは?
関数のシグニチャは関数名、引数、戻り値を含めたものでTypeScriptやGoだと
// TypeScript
function increment(num: number): number
// Go
func increment(num int) int
の部分がシグニチャに該当します。
シグニチャが"嘘"をつく3つのケース
"嘘" とは具体的に下記のケースの場合です。
① 関数が既存の値を変更する(サイドエフェクトがある)
② null(やnil)を返す
③ エラーをthrowする
これらに関してそれぞれ見ていきます。
①関数が既存の値を変更する(サイドエフェクトがある)
関数が既存の値を変更することをサイドエフェクトがあると言います。具体的には API呼び出し、global変数の変更、DB操作などがあります。
サイドエフェクトの例を下記の2つのサンプルコードで説明します。
- サイドエフェクトの例①
// [JavaScript] サイドエフェクトの例①
function replaceFirstElement(list, newItem) {
list[0] = newItem; // <- originalListを変更している
return list;
}
let originalList = [1,2,3,4] // mutable
newList = replaceFirstElement(originalList, "A")
console.log(newList) // ['A', 2, 3, 4]
console.log(originalList) // ['A', 2, 3, 4]
問題点: 関数の引数(変数originalList
)を変更している
- サイドエフェクトの例②
// [JavaScript] サイドエフェクトの例②
let count = 0; // mutableなグローバル変数
function incrementCounter() {
count += 1; // <- グローバル変数(count)を変更している
console.log(`Count is now: ${count}`);
}
問題点: 引数にはないグローバル変数count
を変更している
このような問題に対して関数型言語では基本的にimmutable(変更不可)なデータ型を使います。上記のサイドエフェクトの例① をScalaで実装すると下記のようになります。ScalaのList
はimmutableのためoriginalList
の値は変わりません。
// [Scala] List(immutable)の例
def replaceFirstElement[A](list: List[A], newItem: A): List[A] = {
list match {
case Nil =>
List(
newItem
)
case _ :: tail =>
newItem :: tail
}
}
val originalList = List(1, 2, 3, 4) // immutable list
val newList = replaceFirstElement(originalList, "A")
println(newList) // List(A, 2, 3, 4)
println(originalList) // List(1, 2, 3, 4)
②null(やnil)を返す
戻り値からnilを返すかわからない場合、ヌルポインタが返ってくる可能性があります。そのため、正しく処理をしないとプログラムが実行時にエラーを起こしランタイムエラーが起こる可能性があります。
該当のユーザーがないときにnilが返ってくる関数の例(Go)は下記です(playground)。サンプルコードをわかりやすくするため、②null(やnil)を返すと③エラーをthrowするでは同じ処理を違う言語や書き方で記載しています。
// [Go] ランタイムエラーが起こる例(User取得)
package main
import (
"fmt"
)
type User struct {
Name string
}
func getUser(found bool) *User {
if !found {
// userが見つからなかったらnilポインタを返す
return nil
}
// userが見つかったら新しいユーザーへのポインタを返す
return &User{Name: "John Doe"}
}
func main() {
user := getUser(false) // nilを返すためにfalseを渡す
fmt.Println(user.Name)
// -> panic: runtime error: invalid memory address or nil pointer dereference
}
問題点: 実行時にpanicが起こる
この問題に対して関数型言語ではOption型を使います。
上記のGoの例をScalaのOption型を使って書いたのが以下になります。Scalaの場合、Option[A]
はSome[A]
もしくはNone
です。
これによりシグニチャを見るだけでNoneが返ってくる可能性があることを知ることが出来ます。
// [Scala] Option型の例(User取得)
case class User(name: String)
// Option[User]が戻り値のためSome[User], Noneが返ってくることがわかる
def getUser(found: Boolean): Option[User] = {
if (!found) {
// userが見つからなかったらNoneを返す
None
} else {
// userが見つかったらSome[User]をを返す
Some(User("John Doe"))
}
}
val userOption = getUser(false) // Noneを返すためにfalseを渡す
// Goの例と同じようにNoneのnameフィールドにアクセスを試みる
println(userOption.map(u => u.name)) // Output: None
// エラーは起こらない
③エラーをthrowする
例外をthrowする言語(EX: TypeScript, JavaScript, Javaなど)の場合、例外処理のためtry...catch
で括る必要があります。
try...catch
で括る場合の問題点は以下のようなものがあります。
- そもそもどの関数がthrowするかシグニチャからわからない
- ビジネスロジック以外のコードが入り、読みにくくなる
- エラー後の処理が煩雑になる
これに関数型言語ではResult型でerrorを値として扱います。具体的にはScalaの場合、②null(やnil)を返すで紹介したOption型またはEither型を使います。Either[A, B]
はLeft[A]
かRight[B]
です。慣習的にLeftは異常時、Rightは正常時の値を入れます。throw
する代わりにOption型やResult型を使うことでシグニチャから利用者は関数がエラーを返すかを知ることができます。
下記は②null(やnil)を返すのUser取得の例をScalaのEitherを使った例です。
// [Scala] Either型の例(User取得)
case class User(name: String)
// Either[String, User]が戻り値のため失敗時はLeft[String],成功時はRight[User]が返ってくることがわかる
def getUser(found: Boolean): Either[String, User] = {
if (!found) {
// userが見つからなかったらLeft[String]を返す
Left("No user found")
} else {
// userが見つかったらRight[User]を返す
Right(User("John Doe"))
}
}
val userEither = getUser(false) // Left(失敗)を返すためにfalseを渡す
// LeftとRightを処理するためmatchを利用
userEither match {
case Left(error) => println(s"Error: $error")
case Right(user) => println(s"User found: ${user.name}")
}
// Output: Error: No user found
Goでは関数型のようにerrorを値として扱います(Errors are values.)。Either型やOption型は用意されていないため、errorsパッケージのNew
を使ってerrorの値を作ることが一般的です。
下記はerrorを値として返しているGoのコードです。 (go playground)
// [Go] Errorを値として返す(User取得)
package main
import (
"errors"
"fmt"
)
type User struct {
Name string
}
// errorを返す値を戻り値に追加
func getUser(found bool) (*User, error) {
if !found {
// userが見つからなかったらエラーを返す
return nil, errors.New("user not found")
}
// userが見つかったら新しいユーザーへのポインタとnilエラーを返す
return &User{Name: "John Doe"}, nil
}
func main() {
user, err := getUser(false) // getUser now also returns an error value
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println(user.Name)
}
シグニチャが嘘をつかない = 純粋関数
つまりシグニチャが嘘をつかない ということは 純粋関数(pure function)と言えます。
定義は下記のようになっています
In computer programming, a pure function is a function that has the following properties:
- the function return values are identical for identical arguments (no variation with local static variables, non-local variables, mutable reference arguments or input streams, i.e., referential transparency), and
- the function has no side effects (no mutation of local static variables, non-local variables, mutable reference arguments or input/output streams).
("Pure function", Wikipedia, https://en.wikipedia.org/wiki/Pure_function, accessed 2024/3/26).
日本語にすると下記のようになります。
コンピュータープログラミングにおいて、純粋関数とは、以下の特性を持つ関数のことです:
- 関数の戻り値は同じ引数に対しては常に同一です (ローカルの静的変数、非ローカル変数、可変参照引数や入力ストリームによる変動がなく、つまり参照透過性がある)
- 関数は副作用がありません (ローカルの静的変数、非ローカル変数、可変参照引数や入出力ストリームを変更しません)
参照透過性とは?
参照透過性があるとは簡単に言うと関数を同じ引数を用いた場合、同じ戻り値が必ず返ってくるということです。
下記は参照透過性のある関数の例をTypeScriptで記載したものです。add(2,3)
の結果は必ず5
になります。
// [TypeScript] 参照透過性の例
function add(a: number, b: number): number {
return a + b;
}
console.log(add(2, 3)); // 5
純粋関数のルールに沿ってプログラミングをするメリット
下記があると思います。
-
リーダブルなコードになる
サイドエフェクトがないため、コードを読むときに推論しやすく読みやすくなる。 -
ユニットテストが書きやすくなる
サイドエフェクトがないためモック等不要になります。また参照透過性がある(同じ引数の値の時は戻り値は必ず決まっている)ためテストが書きやすい。
B. 本体が宣言的 とは?
もう一つの条件である本体が宣言的
について説明していきます。改めて、宣言的プログラミングはWhatに命令型はHowに焦点を合わせています。
そのため、本体が宣言的な場合、式を多く使っていると言えます。
文と式
文は値を返さない 一方 式は値を返す ものです。
文と式の例をJavaScriptを使って見ていきます。
// [JavaScript] 文と式
// 下記は文
let a = 5;
if (a > 0) {
console.log('Positive');
} else {
console.log('Non-positive');
}
for(let i = 0; i < a; i++) {
console.log(i);
}
let x = 3;
// 下記は式
let y = x * 2;
JavaScriptのifは文のため下記のようには書くことができません。
// [JavaScript] ifは式として使えない
let max = if (a > b) { a } else { b }
// -> Uncaught SyntaxError: Unexpected token 'if'
一方でScalaのforやifは式です。そのため下記のように書くことができます。
// [Scala] ifとforを式として使う
val max =
if (a > b)
a
else
b
val nums = List(1, 2, 3, 4)
val evenNumbers = for (
n <- nums
if n % 2 == 0
) yield n
多くの言語(Go, JavaScript, TypeScript, Perl, Scalaなど)で関数は値のため、関数の引数や戻り値として関数を使うことができます。
※このようなプログラミング言語の性質のことを第一級関数(first-class function)と言います。
高階関数
高階関数は引数や戻り値に関数を使うことが出来る関数のことです。
次は高階関数の例です。
// [JavaScript] 戻り値が関数 と 引数が関数
// largerThan: 戻り値が関数
function largerThan(a) {
return function(i) {
return i > a;
};
}
// filter: 引数が関数
let t = [1,2,3,4,5,6,7,8]
t.filter(largerThan(3)) // [4,5,6,7,8]
私がJavaScriptでよく使う高階関数は次のようなものがあります。
次にGoで高階関数の例を見ていきます。業務で使っているフレームワークEchoのmiddlewareは高階関数です。これは引数と戻り値の両方が関数です。
下記は公式ドキュメントのMiddlewareのサンプルコードです。
// [Go] 高階関数(引数と戻り値が関数)の例
// ソース
// https://echo.labstack.com/docs/cookbook/middleware#:~:text=//%20Process%20is%20the,nil%0A%09%7D%0A%7D
// Process is the middleware function.
func (s *Stats) Process(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if err := next(c); err != nil {
c.Error(err)
}
s.mutex.Lock()
defer s.mutex.Unlock()
s.RequestCount++
status := strconv.Itoa(c.Response().Status)
s.Statuses[status]++
return nil
}
}
引数と戻り値で使われているecho.HandlerFunc
は下記でContextを引数errorを戻り値にする関数です。
// [Go] echo.HandlerFunc
// ソース
// https://github.com/labstack/echo/blob/011acb4732fe4bdca1cb6d8ea9c29735e0b941f7/echo.go#L129
// HandlerFunc defines a function to serve HTTP requests.
type HandlerFunc func(c Context) error
カリー化
最後に関連した用語としてカリー化を紹介します。カリー化は①複数の引数を持つ関数 を ②引数が「もとの関数の最初の引数」で戻り値が「もとの関数の残りの引数を取り結果を返す関数」の関数に変換することを言います。文字ではイメージがつきにくいと思うので同様にコードで説明します。
下記は同じログ処理をカリー化の前(logging
)と後(curriedLogging
)をJavaScriptとGoで書いたものになります。 カリー化をすることでlogInfoNow
という関数を作ることができ、重複した引数(level
,timestamp
)を毎回渡す必要がなくなります。
// [JavaScript] カリー化前後(ログ処理)
// (カリー化前)logging: 3つの引数を持つ関数
function logging(level, timestamp, message) {
console.log(`[${level}] - ${timestamp}: ${message}`);
}
logging('INFO', new Date().toISOString(), 'This is a message'); // [INFO] - 2024-04-07T08:16:53.942Z: This is a message
// (カリー化後)curriedLogging
function curriedLogging(level) {
return function(timestamp) {
return function(message) {
console.log(`[${level}] - ${timestamp}: ${message}`);
};
};
}
const logInfo = curriedLogging('INFO');
const logInfoNow = logInfo(new Date().toISOString());
logInfoNow('This is a message'); // [INFO] - 2024-04-07T08:13:55.301Z: This is a message
curriedLogging('ERROR')(new Date().toISOString())('An error occurred'); // [ERROR] - 2024-04-07T08:18:09.289Z: An error occurred
// [Go] カリー化前後(ログ処理)
package main
import (
"fmt"
"time"
)
// (カリー化前)logging: 3つの引数を持つ関数
func logging(level string, timestamp string, message string) {
fmt.Printf("[%s] - %s: %s\n", level, timestamp, message)
}
// (カリー化後)curriedLogging
func curriedLogging(level string) func(string) func(string) {
return func(timestamp string) func(string) {
return func(message string) {
fmt.Printf("[%s] - %s: %s\n", level, timestamp, message)
}
}
}
func main() {
logging("INFO", time.Now().Format(time.RFC3339), "This is a message")
logInfo := curriedLogging("INFO")
logInfoNow := logInfo(time.Now().Format(time.RFC3339))
logInfoNow("This is a message")
curriedLogging("ERROR")(time.Now().Format(time.RFC3339))("An error occurred")
}
最後に
今回は書籍の前半(第6章エラー処理)までの用語をまとめました。後半ではサイドエフェクト(DB接続やAPIコール)がある場合や並行プログラミングをどうやって関数型で記述するかの説明があります。
書籍には練習問題やサンプルコードが豊富にあるため、学習しやすかったです。今までとは少し違う視点でプログラミングを見ることが出来るようになりました。