go함수의 기본적인 부분은 c, python 과 같이 일급 함수(first class function
)을 가진 다른 언어로 프로그래밍을 해보았다면 쉽게 파악할 수 있다.
func div(numerator int, denominator int) int {
if denominator == 0 {
return 0
}
return numerator / denominator
}
위의 코드를 확인하면 함수는 4가지 부분으로 나눌 수 있다.
func
키워드입력 파라미터는 괄호 내에 컴마로 구분되며 이름과 타입을 순서대로 나열한다. go는 정적 타입언어기 때문에 반드시 타입을 명시해야한다. 반환 타입은 파라미터의 닫는 괄호 다음 함수 body의 시작 중괄호 사이에 작성한다.
go에서의 함수도 return
을 지원한다. 반환 타입과 같아야하며 반환 타입이 딱히 없다면 return
만 써주어도 된다.
만약 입력 파라미터에서 타입이 같은 여러 파라미터를 나열한다면 마지막에 타입 하나로 묶어낼 수 있다.
func div(numerator, denominator int) int {
if denominator == 0 {
return 0
}
return numerator / denominator
}
위와 같이 numerator
, denominator
를 하나의 int
타입으로 묶어낼 수 있다.
go는 '이름이 지정된 파라미터' 가령 파이썬의 키워드 파라미터를 지원하지않는다. 거기다 일부는 생략해도 되는 '선택적 파라미터'를 지원하지않는다. 따라서 파라미터가 있다면 모두 다 할당해주어야 한다.
임의의 개수를 입력 파라미터로 가지는 함수도 있다. 다른 언어처럼 go도 가변 파라미터(variadic parameters)를 지원한다. 가변 파라미터는 반드시 입력 파라미터 리스트에 마지막 파라미터로 있어야 한다.
가변 파라미터를 만드는 방법은 타입 앞에 3개의 점 ...
을 붙이면 된다. 함수 내에서 생성된 변수는 지정된 타입의 슬라이스이다. 기본 슬라이스와 동일하게 사용이 가능하다.
func addTo(base int, vals ...int) []int {
out := make([]int, 0, len(vals))
for _, v := range vals {
out = append(out, base+v)
}
return out
}
그리고 해당 함수를 여러가지 방법으로 호출이 가능하다.
func main() {
fmt.Println(addTo(3))
fmt.Println(addTo(3, 2))
fmt.Println(addTo(3, 2, 4, 5, 6, 7))
a := []int{4, 3}
fmt.Println(addTo(3, a...))
}
가변 인자로 원하는 만큼 함수로 전달하거나 어떠한 값도 전달하지 않아도 된다. 가변 파라미터는 슬라이스로 변환되기 때문에 입력을 슬라이스로 제공할 수도 있다. 하지만 변수나 슬라이스 리터럴 뒤에 3개의 점(...
)을 붙여주어야 한다. 그렇지 않으면 컴파일 오류가 난다. javascript의 spread문법과 같다고 생각하면 된다.
결과는 다음과 같다.
[]
[5]
[5 7 8 9 10]
[7 6]
go는 return
하나에 여러 개의 값을 반환할 수 있다.
func divAndRemainder(numerator int, denominator int) (int, int, error) {
if denominator == 0 {
return 0, 0, errors.New("cannot divide by zero")
}
return numerator / denominator, numerator % denominator, nil
}
다중 반환은 반환값들 타입들을 괄호 내에 쉼표로 구분하여 나열하면 된다. 또한, 함수 body내에서 return
을 할 때 괄호로 묶어서 반환해서는 안된다. 이러면 컴파일 에러가 발생한다.
위 함수를 받는 부분은 다음과 같다.
func main() {
result, remainder, err := divAndRemainder(5, 2)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println(result, remainder) // 2 1
}
go는 python에서 처럼 다중 반환값을 하나의 튜플로 묶었다가 분해하는 방법으로 동작하지 않는다. python은 실제적으로 반환값이 하나이지만 튜플을 분해하여 변수에 할당하는 반면, go는 그냥 반환값 자체가 여러 개인 것이다. 그래서 여러개의 반환값이 있다면 python처럼 하나로 묶어낼 수가 없다.
함수의 반환값이 있다고해서, 모두 받을 필요는 없다. 다음과 같이 필요한 부분만 받아낼 수도 있다.
_, _, err := divAndRemainder(5, 2)
받아도 쓸만하지 않은 변수들은 _
으로 처리하는 것이다.
또한 암묵적으로 함수에서 반환되는 값들을 모두 무시할 수 있다.
divAndRemainder(5, 2)
위와 같이 암묵적으로 반환값을 무시할 수 있다. fmt.Println
함수도 2개의 반환값이 있찌만 관용적으로 해당 값들을 무시한다.
go에서는 반환값에 대해 이름을 지정할 수 있다.
func divAndRemainder(numerator int, denominator int) (result int, remainder int, err error) {
if denominator == 0 {
return 0, 0, errors.New("cannot divide by zero")
}
return numerator / denominator, numerator % denominator, nil
}
반환값에 대한 이름을 제공하게 되면, 함수 내에서 반환값을 담기 위해 사용되는 변수를 미리 선언하는 것이다. 매개변수 쓰듯이 반환값 이름, 타입로 하나씩 써주고 이들을 괄호 구분해주면 된다. 단, 이름을 지정할 때에는 여러 개를 반환하지 않더라도 반환값 괄호 ()
로 묶어내야 한다.
참고해야할 사항은 이름이 지정된 반환값은 해당 함수 내에서만 접근 가능하다. 함수 외부에서 해당 이름의 변수가 적용되지 않는다. 다른 이름의 변수에 반환값을 할당하는 것은 당연히 가능하다.
x, y, z := divAndRemainder(5, 2)
이름이 지정된 반환값이 어떤 부분에 대해서 코드를 명확하게 해주는 반면 잠재적인 코너 케이스를 가진다. 바로 쉐도잉 문제이다. 다른 모든 변수와 마찬가지로 이름이 지정된 반환값이 쉐도잉 될 수 있다. 반환값을 할당하면서 해당 변수가 쉐도잉 되었는 지 주의해야한다.
이름이 지정된 반환값의 다른 문제점은 해당 변수들을 반드시 반환할 필요가 없다는 것이다.
func divAndRemainder(numerator int, denominator int) (result int, remainder int, err error) {
result, remainder = 20, 30
if denominator == 0 {
return 0, 0, errors.New("cannot divide by zero")
}
return numerator / denominator, numerator % denominator, nil
}
위의 예제와 같이 반환값 변수들에 값을 넣고, 다른 값들을 반환한다면 어떤 결과가 나올까? 즉, 반환값으로 먼저 선언한 변수들이 나올지, 아니면 return
문안에 넣은 값들이 나올 지이다.
2 1
결과는 반환값으로 선언한 변수들이 아닌 return
문이 반환된다. 이를 통해 이름이 지정된 반환값을 선언하더라도 반드시 해당 변수를 반환하지 않는다는 것을 알 수 있다.
사실 이름있는 반환값들은 반환이 된 것이 맞다. 단지 go
컴파일러가 반환되는 모든 항목을 반혼 파라미터에 할당하는 코드를 추가했기 때문이다.
func divAndRemainder(numerator int, denominator int) (result int, remainder int, err error) {
result, remainder = 20, 30
if denominator == 0 {
result = 0
remainder = 0
err = errors.New("cannot divide by zero")
return result, remainder, err
}
result = numerator / denominator
remainder = numerator % denominator
err = nil
return result, remainder, err
}
다음과 같이 말이다.
이를 통해 이름이 지정된 반환 파라미터는 반환 값을 담기위한 변수를 사용하는 것처럼 선언하는 방법을 제공하는 것이지, 굳이 그것을 사용하여 return
할 필요는 없다.
이름이 지정된 반환값을 사용했다면 go에서 하나의 심각한 오류인 빈 반환(blank return)을 조심하자, 이름이 지정된 반환값이 있다면 반환될 값을 명시하지 않고, return
만 사용하자 해당 반환은 이름이 지정된 변수에 마지막으로 할당된 값으로 처리된다. divAndRemainer
함수를 다시 작성하는데, 이번에는 빈 반환을 사용해보도록 하자.
func divAndRemainder(numerator int, denominator int) (result int, remainder int, err error) {
if denominator == 0 {
err = errors.New("cannot divide by zero")
return
}
result, remainder = numerator/denominator, numerator%denominator
return
}
return
문을 보면 반환이 비어있다. 자동으로 반환값으로 할당된 변수가 반환되는 것이다. 이것이 '빈 반환'이다. 그러나 아무리 이름이 있는 반환값을 사용하여 빈 반환을 사용한다고 하여도 return
문은 꼭 넣어주어야 한다. 위 코드에서 마지막에 return
문이 생략되어 있다면 컴파일 오류가 발생한다.
사실 이름이 있는 반환 값과 빈 반환은 그닥 좋은 아이디어가 아니다. 실제로 코드 가독성도 많이 떨어지고 유지보수도 어렵다는 단점이 있어서 커뮤니티 사이에서는 잘 안쓰이는 문법이다.
go는 함수를 1급 함수('first class function')으로 평가하기 때문에, 함수는 값이다. 값이면 타입이 있어야 하므로, 함수의 타입 구성은 다음과 같다.
func
키워드이러한 조합을 함수 시그니처(function signature)라고 한다. 정확히 같은 파라미터의 수와 타입을 가지는 함수는 함수 타입 시그니처를 만족한다.
함수를 값으로 사용하면 맵에도 넣을 수 있다는 것이다.
var onMap = map[string]func(int, int) int{
"+": add,
"-": sub,
"*": mul,
"/": div,
}
func add(i int, j int) int { return i + j }
func sub(i int, j int) int { return i - j }
func mul(i int, j int) int { return i * j }
func div(i int, j int) int { return i / j }
위와 같이 맵의 값으로 함수 시그니처를 적고, 이를 만족시키는 함수를 값으로 넣어주면 된다.
구조체를 정의하기 위해 type
키워드를 사용한 것과 같이 함수 타입을 정의하는데도 사용할 수 있다.
type opFuncType func(int, int) int
위와같이 함수 타입을 선언해놓으면 함수 시그니처를 사용할 때 편하게 사용할 수 있다.
var opMap = map[string]opFuncType {
}
위와 같이 바꿀 수 있는 것이다.
함수 자체에는 전혀 손댈 필요가 없다. 두 개의 정수 입력 파라미터를 가지고 정수 단일 반환값을 가지는 모든 함수는 타입을 만족시키며 맵에서 값으로 할당될 수 있다.
함수를 변수에 할당할 뿐만 아니라, 함수 내에 새로운 함수를 정의하여 변수에 할당할 수 있다. 이런 이름이 없는 내부 함수를 익명 함수(anonymous function)이라 한다. 또한 해당 함수를 변수에 할당할 필요도 없다. 함수를 인라인으로 작성하고 바로 호출할 수 있다.
func main() {
for i := 0; i < 5; i++ {
func(j int) {
fmt.Println("Printing", j, "from inside of an anonymous function")
}(i)
}
}
익명 함수는 func
키워드 바로 뒤에 입력 파라미터, 반환값을 넣고 여는 중괄호를 사용하여 선언할 수 있다. func
키워드와 입력 파라미터 사이에 함수 이름을 넣으려 한다면 컴파일 오류가 발생한다.
위 함수의 결과는 다음과 같다.
Printing 0 from inside of an anonymous function
Printing 1 from inside of an anonymous function
Printing 2 from inside of an anonymous function
Printing 3 from inside of an anonymous function
Printing 4 from inside of an anonymous function
익명 함수는 굳이 왜 사용할까? 보통의 경우는 그냥 익명 함수의 내용을 함수 밖으로 꺼내고 익명함수를 지우는 것이 더 좋다. 그런데 익명 함수가 아주 유용하게 쓰일 때가 있는데, 그것이 바로 defer
문과 고루틴을 사용하는 경우이다.
함수 내부에 선언된 함수를 클로저(closure)라고 부른다. 클로저는 컴퓨터 과학에서 사용하는 단어이며, 함수 내부에 선언된 함수가 외부 함수에서 선언한 변수를 접근하고 수정할 수 있는 것을 의미한다.
클로저는 함수의 범위를 제한한다 함수가 다른 하나의 함수에서만 호출되는데, 여러 번 호출되는 경우 내부 함수를 사용하여 호출된 함수를 숨길 수 있다. 이는 패키지 레벨 선언 수를 줄여 사용되지 않는 이름을 쉽게 찾을 수 있도록 만든다.
클로저는 다른 함수로 전달되거나 함수에서 반환될 때 가장 효율적이다.
함수는 값이고 파라미터와 반환값을 사용하여 함수의 타입을 지정할 수 있기 때문에 파라미터로 함수를 다른 함수로 넘길 수 있다. 지역 변수를 참조하는 클로저를 생성하고, 해당 클로저를 파라미터로 다른 함수에 전달하는 경우를 보도록 하자.
sort
패키지에 sort.Slice
함수는 슬라이스를 받아 정렬을 해주는데, 두 번째 파라미터로 사용자 정렬 함수를 인수로 받는다.
type Person struct {
FirstName string
LastName string
Age int
}
func main() {
people := []Person{
{"Pat", "Patterson", 37},
{"Tracy", "Bobbert", 23},
{"Fred", "Fredson", 18},
}
fmt.Println(people)
}
people
슬라이스를 LastName
기준으로 정렬해보도록 하자.
type Person struct {
FirstName string
LastName string
Age int
}
func main() {
people := []Person{
{"Pat", "Patterson", 37},
{"Tracy", "Bobbert", 23},
{"Fred", "Fredson", 18},
}
sort.Slice(people, func(i, j int) bool {
return people[i].LastName < people[j].LastName
})
fmt.Println(people) // [{Tracy Bobbert 23} {Fred Fredson 18} {Pat Patterson 37}]
}
sort.Slice
로 넘기는 클로저는 두 개의 파라미터 i,j밖에 없지만 클로저 내에서는 LastName
항목으로 정렬하기 위해 people
을 참조한다. 이는 people
이 클로저에 의해 capture되었다고 한다.
이렇게 클로저는 클로저 외부에 있는 지역변수를 캡처하여 사용할 수 있다.
클로저를 사용하여 다른 함수로 어떤 함수의 상태를 넘겨줄 뿐만 아니라, 함수에서 클로저를 반환할 수도 있다. 다음은 곱셈연산을 하는 클로저를 반환하는 모습이다.
package main
import (
"fmt"
)
func makeMult(base int) func(int) int {
return func(factor int) int {
return base * factor
}
}
func main() {
twoBase := makeMult(2)
threeBase := makeMult(3)
for i := 0; i < 3; i++ {
fmt.Println(twoBase(i), threeBase(i))
}
}
결과는 다음과 같다.
0 0
2 3
4 6
makeMult
함수는 함수를 반환하는데, 이는 클로저로 makeMult
의 파라미터인 base
를 capture하고 있다. 클로저로 함수가 반환되더라도 클로저 외부에 있던 지역변수가 사라지지 않고, 클로저에 의해 capture되면 사용할 수 있는 것이다.
클로저는 go에서 매우 자주 사용되는데, defer
키워드를 통해 사용할 때 자주사용된다.
프로그램이 구도오디면서 네트워크 연결, 파일과 같은 임시 자원들을 만들게 된다. 이러한 자원들은 향후 정리될 필요가 있다. 이런 정리는 반드시 이루어져야 하는데, go는 defer
키워드를 통해 이를 쉽고 신뢰성있게 만들어준다.
defer
를 사용하여 자원을 해제하는 방법을 확인해보자.
func main() {
if len(os.Args) < 2 {
log.Fatal("no file specified")
}
f, err := os.Open(os.Args[1])
if err != nil {
log.Fatal(err)
}
defer f.Close()
data := make([]byte, 2048)
for {
count, err := f.Read(data)
os.Stdout.Write(data[:count])
if err != nil {
if err != io.EOF {
log.Fatal(err)
}
break
}
}
}
해당 코드의 구동에 관한 자세한 성명은 나중에 하도록 하자. 먼저 os
패키지에 실행된 프로그램의 이름과 넘겨진 인자들을 슬라이스 os.Args
로 file
path를 가져온다. Open
함수로 파일 핸들을 얻어오고 이를 f
라는 변수에 할당해준 것이다. 그 아래는 f
file의 데이터를 읽어 data
buffer에 써주는 코드이다.
f
와 같은 파일 핸들을 얻고나면 해당 파일을 사용하고나서 이를 사용하는 함수가 어떻게 되든간에 파일 핸들을 닫아주어야 한다. 이를 위해서 defer
키워드에 함수나 메서드 호출을 바로 사용한다. 가령 위의 경우 f
의 Close
메서드를 바로 사용하여 파일을 닫도록 하였다. 함수 호출은 즉시 실행되지만, defer
은 호출하는 함수르 둘러싼 함수(상위 함수)가 종료될 때까지 수행을 연기한다.
defer
에 관해 몇 가지 더 알아야할 것들이 있는데, defer
는 go함수에서 하나만 사용할 수 있는 것이 아니라, 여러번 사용이 가능하다. 즉, 여러 클로저를 지연시킬 수 있다. 이는 후입선출(Last in first out)순서이다. 즉, 마지막 defer
로 등록된 것이 가장 먼저 실행된다.
defer
클로저 내의 코드는 defer
가 있는 go함수에서 return
문이 실행된 후에 실행된다. defer
에 입력 파라미터가 있는 함수를 제공할 수도 있는데, defer
가 즉시 실행되지 않은 것처럼, 지연된 클로저 내로 전달된 모든 변수는 클로저가 실행되기 전에는 사용되지 못한다.
재밌는 것은 defer
는 함수가 종료된 후에야 실행된다는 것인데, defer
를 둘러싼 함수의 반환값을 클로저로 감싼 뒤 defer
로 지연시켜버리면 최종 반환값이 무엇인지 검사할 수 있다. 이를 위해 이름이 지정된 반환값을 사용하는 것이 좋다. 이는 오류 처리를 위한 수행을 처리하도록 허용하는 것이다.
오류 처리 방법은 이후에 더 자세히 알아보고 defer
와 이름이 지정된 반환 값을 사용하여 데이터베이스 트랜잭션 정리 처리 방법을 알아보자.
func DoSomeInserts(ctx context.Context, db *sql.DB, value1, value2 string) (err error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func () {
if err == nil {
err = tx.Commit()
}
if err != nil {
tx.Rollback()
}
}()
_, err = tx.ExecContext(ctx, "INSERT INTO FOO (val) values $1", value1)
if err != nil {
return err
}
return nil
}
database/sql
패키지는 데이터베이스를 위한 확장 자원을 포함한다. 지금 당장 이게 어떤 동작을 하는 지는 중요하지 않다. defer
와 클로저를 사용하는 부분을 보도록하자.
예제 함수에서는 데이터베이스 삽입에 대한 일련의 수행을 위한 트랜잭션을 생성했다. 어떤 것이든 실패하면 다시 되돌리고, 성공하면 commit하도록 하는 것이다. 이를위해 defer
와 함께 클로저를 사용하여 err
에 할당된 값을 확인한다. err
에 어떠한 값도 없다면 tx.Commit
을 실행하고 err
를 받는다. 만약 err
의 값이 있다면 tx.Rollback()
을 호출하여 트랜잭션을 수행한다.
go
에서 자원을 할당하고 자원을 정리하는 클로저를 반환하는 함수를 작성하는 일반적인 패턴이 있다. 다음은 파일을 열고, 파일을 닫는 클로저를 반환한다.
func getFile(name string) (*os.File, func(), error) {
file, err := os.Open(name)
if err != nil {
return nil, nil, err
}
return file, func() {
file.Close()
}, err
}
반환 값의 두번째는 헬퍼 함수로 불린다. 이제 해당 헬퍼함수를 사용하는 부분을 보도록 하자.
func main() {
f, closer, err := getFile(os.Args[1])
if err != nil {
log.Fatal(err)
}
defer closer()
}
go 컴파일러는 변수를 할당해놓고 사용하지 않으면 에러를 낸다. 그렇기 때문에 두 번째 인자인 클로저를 받으면 이를 사용해야하므로 defer
를 사용해야한다는 것을 상기시킨다.
go는 값에 의한 호출을 사용하는 언어이다. 함수에 파라미터로 넘겨지는 변수가 있다면 go는 항상 해당 변수의 복사본을 만들어 넘긴다. 다음의 예제를 보자.
type person struct {
age int
name string
}
func modifyFails(i int, s string, p person) {
i = i * 2
s = "GoodBye"
p.name = "Bob"
}
파라미터를 받아 이를 수정하는 함수 modifyFails
를 실행시켜보자. 과연 구조체와 파라미터의 변수들이 해당 함수를 호출한 곳에서도 수정되었는지 확인해보자.
func main() {
p := person{}
i := 2
s := "Hello"
modifyFails(i, s, p)
fmt.Println(i, s, p) // 2 Hello {0 }
}
결과를 보면 알 수 있듯이 함수로 전달된 파라미터의 값들은 변경되지 않았다.
자바, 자바스크립트, 루비는 함수로 객체를 파라미터로 넘기면 객체의 항목을 수정할 수 있다. 그러나 go는 구조체를 객체로 생각할 수 있는데 언급한 언어들과 달리 수정되지않았다.
그런데 이러한 행동은 map과 slice와는 조금 다르다. 함수 내에 map이나 slice를 수정해보도록 하자.
func modMap(m map[int]string) {
m[2] = "hello"
m[3] = "goodbye"
delete(m, 1)
}
func modSlice(s []int) {
for k, v := range s {
s[k] = v * 2
}
s = append(s, 10)
}
위의 코드는 map
을 함수의 파라미터로 받아 수정하는 modMap
함수와 slice
를 파라미터로 받아 수정하는 modSlice
로 이루어져 있다.
이를 실행시켜보자.
func main() {
m := map[int]string{
1: "first",
2: "second",
}
modMap(m)
fmt.Println(m) // map[2:hello 3:goodbye]
s := []int{1, 2, 3}
modSlice(s)
fmt.Println(s) // [2 4 6]
}
파라미터에 대한 함수 안에서의 수정이 외부에서도 반영된 것을 확인할 수 있다. 이는 map
과 slice
가 포인터이기 때문이다. 즉, 다른 기본 타입들과 구조체와 달리 이들은 기본적으로 포인터로 구현되어 있기 때문에 이러한 일이 발생한 것이다.
단,slice
의 경우 append
부분이 반영되지 않았는데, append
의 같이 슬라이스의 길이를 늘리는 것은 안된다.
go의 모든 타입은 값 타입이다. 때로는 값이 포인터일 뿐이다.
값에 의한 호출을 통해서 변수들은 함수로 전달될 때 값으로 전달되기 때문에 map
, slice
를 제외한 경우는 상수라고 생각하여 수정이 안된다는 것을 확신할 수 있다.