[Go] 한 번에 정리하는 Golang 기본 개념 - 3편

2022. 11. 8. 11:06[ DevOps ]/[ Golang ]

배열

import (
	"fmt"
	"time"
)

func main() {
	var arr [5]string
	arr[0] = "el1"
	arr[1] = "el2"
	fmt.Println(arr[0]) // el1
	fmt.Println(arr)    // [el1 el2   ]

	var dates [3]time.Time
	dates[0] = time.Unix(1257894000, 0)
	fmt.Println(dates[0])

	nums := [3]int{3, 9}
	fmt.Println(nums)      // [3 9 0]
	fmt.Println(len(nums)) // 3
}

 

- 고정 길이를 가지기 때문에 선언 시 크기를 할당해야 함

- 변수와 마찬가지로 := 키워드를 통해 선언 및 초기화를 하고 초기화값은 {}로 묶는다. (자바와 비슷)

- 배열 길이를 구할 땐 len() 함수를 사용한다. 배열을 static 성질을 가지기 때문에 배열 길이가 변하지 않는다.

- 초기화를 하지 않은 인덱스에는 각 타입별 기본값이 들어간다.

 

1) int -> 0

2) bool -> false

3) string -> "" (nil이 아닌 빈 문자열이다)

 

 

배열 순회: for ~ range

func main() {
	nums := [3]int{3, 9}

	for index, num := range nums {
		fmt.Println(index, num)
	}
}

- range 키워드를 통해 배열의 인덱스와 해당 값을 순차적으로 순회할 수 있다.

- 컬렉션 자료구조를 다룰 때에는 위와 같이 range 키워드를 사용하는 게 가장 안정적이다.

- index와 num 변수를 모두 사용하지 않을 경우엔 컴파일 에러가 발생하니 사용하지 않을 변수는  식별자 _를 통해 무시할 수 있다.

 

func main() {
	nums := [3]int{3, 9}

	for _, num := range nums {
		fmt.Println(num)
	}
}

 

 


 

슬라이스(slice)

func main() {
	var nums1 []int = make([]int, 7)
	nums1[4] = 5

	nums2 := make([]int, 7) // 타입 추론
	nums2[1] = 2

	for _, num := range nums2 {
		fmt.Print(num, " ") // 0 2 0 0 0 0 0
	}

	fmt.Print("\nlen of nums2: ", len(nums2)) // len of nums2: 7

	nums3 := []int{1, 3, 5}
	fmt.Print("\nlen of nums3: ", len(nums3)) // len of nums3: 3
}

 

- 배열과 다르게 확장 가능하다.

- 크기 지정 여부에 따라 배열과 슬라이스로 나뉜다.

- 배열과 다르게 make 함수를 통해 명시적으로 생성해줘야 한다.

- 값의 할당과 접근 방식을 배열과 동일하다.

 

 

슬라이스 연산

func main() {
	// 배열의 슬라이스 연산을 통해 슬라이스 생성
	arr1 := [5]int{5, 8, 9}
	slice1 := arr1[2:4] // 슬라이스 연산
	fmt.Println(slice1) // [9 0]

	i, j := 2, 4
	slice2 := arr1[i:j]
	fmt.Println(slice2) // [9 0]

	slice3 := arr1[:4]
	fmt.Println(slice3) // [5 8 9 0]

	slice4 := arr1[0:4]
	fmt.Println(slice4) // [5 8 9 0]

	slice5 := arr1[2:]
	fmt.Println(slice5) // [9 0 0]
}

 

- 슬라이스는 내부적으로 배열 기반으로 구성된다. 따라서 배열에 슬라이스 연산자(:)를 적용하여 슬라이스를 생성할 수 있다. 정확히 말하면 슬라이스는 자체적으로 데이터를 갖지 않으며 배열의 원소에 대한 뷰일 뿐이다.

 

 

슬라이스는 배열을 참조한다

func main() {
	arr1 := [5]int{5, 8, 9, 2}
	slice1 := arr1[2:4]

	arr1[2] = -1
	slice1[1] = -1

	fmt.Print(arr1, slice1)	// [5 8 -1 -1 0] [-1 -1]
}

 

- 위와 같이 배열의 값을 바꾸면 슬라이스에서도 변경된다. 반대로 슬라이스의 값을 변경해도 배열의 값 또한 변경된다.

- 슬라이스는 단순히 배열에 대한 뷰 형태이기 때문이다. 즉, 슬라이스는 자체적으로 데이터를 갖지 않는다.

- 일반적으로 이러한 문제를 방지하기 위해 배열의 슬라이스 연산을 통해 슬라이스를 만들지 않고 make 함수를 통해 슬라이스를 만드는 것이 일반적이다.

 

 

append

func main() {
	slice1 := make([]int, 5)
	fmt.Println(slice1) // [0 0 0 0 0]

	slice1 = append(slice1, 1)
	fmt.Println(slice1)               // [0 0 0 0 0 1]
	fmt.Println("len: ", len(slice1)) // len:  6

	slice1 = append(slice1, 2, 3)
	fmt.Println(slice1)               // [0 0 0 0 0 1 2 3]
	fmt.Println("len: ", len(slice1)) // len:  8
}

 

- 배열과 슬라이스의 가장 큰 차이점 중 하나는 append이다. append를 통해 슬라이스의 가장 마지막에 데이터를 추가할 수 있다.

- "슬라이스는 배열을 기반으로 하기 때문에 내부 배열의 capacity가 초과될 경우 append 함수를 호출하면 배열의 deep copy가 발생하게 되기 때문에 주의하여 사용해야 한다." (java의 ArrayList와 비슷한 개념)

 

 

cf. 슬라이스의 nil 처리

func main() {
	var slice1 []int
	fmt.Printf("slice1: %#v \n", slice1)           // slice1: []int(nil)
	fmt.Println("len of nil slice: ", len(slice1)) // len of nil slice:  0

	slice1 = append(slice1, 5)
	fmt.Println("len: ", len(slice1))	// len:  1
	fmt.Println(slice1) // [5]
}

- 슬라이스를 초기화하지 않으면 nil이다.

- nil이지만 len 함수를 호출하면 에러가 발생하지 않고 0이 응답된다.

- append의 경우에도 nil을 인자로 받으면 슬라이스를 자체적으로 하나 생성하고 값을 append 한 슬라이스를 응답한다.

 

* 즉, 슬라이스를 다룰 때 슬라이스 변수가 빈 슬라이스인지 nil인지는 신경 쓸 필요가 없다.

 

 

cf. 실행 argument 받기

[main.go]

func main() {
	fmt.Println(os.Args) // ./main 1 2 3
	fmt.Println(os.Args[1:])	// [1 2 3]
}

 

- 위 파일을 빌드하고 커맨드 "./main 1 2 3"로 실행하여 인자를 줄 경우 os.Args를  통해 입력된 인자를 가져올 수 있다.

- os.Args는 슬라이스 타입이다. 

 

 


 

맵(Map)

1. 다양한 맵 초기화 방법

func main() {
	var map1 map[string]int = make(map[string]int)
	map2 := make(map[string]int)

	fmt.Println(map1) // map[]
	fmt.Println(map2) // map[]

	map1["a"] = 1
	fmt.Println(map1["a"]) // 1

	map3 := map[string]float64{
		"a": 1.1,
		"b": 1.2,
	}

	fmt.Println(map3["a"]) // 1.1

	map4 := map[string]float64{}
	fmt.Println(map4)	// map[]
}

 

 

2. 할당되지 않은 맵의 value값

func main() {
	map1 := map[string]float64{
		"a": 1.1,
		"b": 1.2,
	}

	fmt.Println(map1["c"]) // 0

	map2 := map[string]string{
		"a": "1.1",
		"b": "1.2",
	}

	fmt.Println(map2["c"])	// ""
}

 - 맵의 ket-value가 할당되지 않았더라도 default 값을 반환한다. 따라서 할당되지 않은 경우에도 맵을 안전하게 다룰 수 있다. Java에서 처럼 containsKey를 통해 검사할 필요가 없다.

- 맵 자체가 선언만 되고 할당되지 않을 경우엔 nil로 초기화된다.

 

 

2-1) 할당된 값과 할당되지 않은 경우를 구분하는 법

func main() {
	map1 := map[string]float64{
		"a": 1.1,
		"b": 1.2,
	}

	var value float64
	var ok bool

	value, ok = map1["c"]
	fmt.Println(value, ok) // 0 false

	value, ok = map1["a"]
	fmt.Println(value, ok) // 1.1 true

	_, ok = map1["b"]
	fmt.Println("contains \"b\" in map1?", ok)	// contains "b" in map1? true
}

- 맵의 값을 조회할 때 2번째 리턴 값으로 bool 타입의 값이 할당되는데, 해당 값이 true일 경우에만 맵에 값이 존재한느 경우로 판단할 수 있다.

 

 

3. key-value 삭제

func main() {
	map1 := map[string]float64{
		"a": 1.1,
		"b": 1.2,
	}

	fmt.Println(map1)	// map[a:1.1 b:1.2]

	delete(map1, "a")

	fmt.Println(map1)	// map[b:1.2]
}

 

 

4. 맵 전체 순회

func main() {
	map1 := make(map[string]float64)
	map1["a"] = 1.1
	map1["b"] = 1.2
	map1["c"] = 1.3

	for curkey, curVal := range map1 {
		fmt.Printf("key: %s, value: %.2f\n", curkey, curVal)
	}

	// key: a, value: 1.10
	// key: b, value: 1.20
	// key: c, value: 1.30

	for curkey := range map1 {
		fmt.Printf("key: %s\n", curkey)
	}

	// key: a
	// key: b
	// key: c

	for _, curVal := range map1 {
		fmt.Printf("value: %.2f\n", curVal)
	}

	// value: 1.10
	// value: 1.20
	// value: 1.30
}

- 배열과 슬라이스와 마찬가지로 for ~ range 구문을 적용할 수 있다.

- 배열과 슬라이스와 다르게 ket, value가 응답된다.

- key만 필요한 경우 value 부분을 생략할 수 있다. 단, value만 필요한 경우엔 key 변수를 _ 처리해야 한다.

- map은 순서가 없기 때문에 저장된 순회되는 순서는 알 수 없다. 만약 정렬이 필요한 경우라면 sort 패키지를 사용할 수 있다.

 


 

가변 인자 함수

func variadic(value ...int) {
	fmt.Println(value)
}

func main() {
	variadic(1, 2, 3, 4) // [1 2 3 4]
	variadic(1, 2, 3)    // [1 2 3]
	variadic(1, 2)       // [1 2]

	slice1 := []int{1, 1, 1}
	variadic(slice1...)	// [1 1 1]
}

 

- 함수 인자 타입 앞에 "..." 키워드를 사용하여 함수의 인자 개수를 동적으로 변경할 수 있다. (Java에도 있는 개념)

- 가변 인자는 함수 인자 중 가장 마지막에만 위치할 수 있다.

- 슬라이스 처럼 동작하며 for ~ range 키워드를 적용할 수 있다.

- 슬라이스를 가변 인자 함수에 전달할 때에는 슬라이스 변수명 뒤에 "..."을 붙인다.

- 예시: Println, append 함수

 

 


 

File I/O

1. read

import (
	"bufio"
	"fmt"
	"log"
	"os"
)

func main() {
	file, err := os.Open("data.txt")	// 열린 파일에 대한 포인터 반환
	if err != nil {
		log.Fatal(err)
	}

	scanner := bufio.NewScanner(file)	// 열린 파일에 대한 scanner생성
	for scanner.Scan() { // 한 줄을 읽음.
		fmt.Println(scanner.Text())	// 1, 2, 3, 4 출력
	}

	err = file.Close()
	if err != nil {
		log.Fatal(err)
	}
	if scanner.Err() != nil {
		log.Fatal(scanner.Err())
	}
}

 

data.txt

1
2
3
4

 

 

 


 

 

구조체: Custom Data Type

var struct1 struct {
	field1 string
	field2 int
}

func main() {
	var struct2 struct {
		field1 string
		field2 int
	}

	fmt.Println(struct1)	// { 0}
	fmt.Println(struct2)	// { 0}
}

 

- 구조체는 함수를 가지지 않는다.

- Go는 객체지향 프로그래밍(OOP)을 고유의 방식으로 지원하기 때문에 클래스, 객체, 상속 개념이 없다.

- Java와 같은 전통적인 OOP 클래스가 필드와 메소드를 함께 가지는 것과 달리 Go 언어의 구조체는 필드만을 가지며 메소드는 별도로 분리하여 정의된다.

 

 

필드 접근

func main() {
	var struct1 struct {
		field1 string
		field2 int
	}

	struct1.field1 = "str1"
	struct1.field2 = -1

	fmt.Println(struct1) // {str1 -1}
}

 

 

구조체 기반의 사용자 정의 타입

type MyType1 struct {
	field1 int
	field2 string
}

var val1 MyType1
var val2 MyType1

func main() {
	val1.field1 = 1
	val1.field2 = "tmp1"

	val2.field1 = 2
	val2.field2 = "tmp2"

	val3 := MyType1{field1: 3, field2: "tmp3"}

	fmt.Println(val1) // {1 tmp1}
	fmt.Println(val2) // {2 tmp2}
	fmt.Println(val3) // {3 tmp3}
}

 

- type 키워드를 통해 구조체를 선언하면 type으로 사용가능하다.

- 구조체 기반으로 사용자 정의 타입을 정의하는 것은 보통 함수 외부의 패키지 수준에 선언하는 것이 일반적이다.

- 사용자 정의 타입의 경우 대부분 외부로 노출하는 경우가 많기 때문에 대문자로 작성한다. 반면, 변수 자체는 외부로 노출하지 않는 것이 대부분이기 때문에 소문자로 작성한다.

- 위와 같이 구조체 이름만 대문자로하고 구조체 내부 필드는 소문자로 할 경우, 필드는 외부 패키지로 노출되지 않는다.

- 사용자 정의 타입 이름 자체가 변수로 사용될 경우 사용자 정의 타입이 가려질 수 있기 때문에 주의해야 한다.

- 구조체 필드를 선언과 동시에 초기화할 경우 생략된 필드들에 대해선 각 타입별 default 값이 할당된다.

 

 

구조체 역시 call-by-value

func setValue1(tmp MyType1) {
	tmp.field1 = -1
	tmp.field2 = "tmp-1"
}

func setValue2(tmp *MyType1) {
	tmp.field1 = -1
	tmp.field2 = "tmp-1"
}

func main() {
	var val1 MyType1
	val1.field1 = 1
	val1.field2 = "tmp1"

	setValue1(val1)
	fmt.Println(val1) // {1 tmp1}

	setValue2(&val1)
	fmt.Println(val1) // {-1 tmp-1}
}

 

- 구조체 역시 call-by-value이다. (포인터를 제외하면 모두 call-by-value임)

- 따라서 구조체 타입 자체를 함수 인자로 받으면 구조체 자체가 복사되어 넘어간다. 즉, 구조체가 클 경우 성능 저하의 원인이 될 수 있다.

 

type MyType1 struct {
	field1 int
	field2 string
}

func createMyType(f1 int, f2 string) *MyType1 {
	var new MyType1
	new.field1 = f1
	new.field2 = f2
	return &new
}

func chageValue(tmp *MyType1) {
	tmp.field1 = -1
	tmp.field2 = "tmp-1"
}

func printMyType(tmp *MyType1) {
	fmt.Println(tmp.field1, tmp.field2)	// -1 tmp-1
}

func main() {
	myTypePtr := createMyType(1, "tmp1") // myTypePtr의 타입: *MyType1
	chageValue(myTypePtr)
	printMyType(myTypePtr)
}

 

- myTypePtr 변수의 타입은 구조체가 아니라 구조체의 포인터이다.

 

 

cf. 이 외, 구조체 관련 제공되는 기능

1) 익명 구조체 필드

- 특정 구조체 안에 또 다른 구조체의 타입의 필드를 가질 경우, 해당 필드를 타입만 선언하고 필드명을 생략함.

- 익명 구조체 필드는 구조체 정의에서 필드명을 생략할 수 있는 것 외에도 다양한 기능들이 제공됨

- 그 중 하나가 구조체 임베딩으로 익명 구조체 필드로 선언된 구조체의 필드들을 익명 구조체를 선언한 구조체의 필드처럼 사용하도록 하는 것(임베딩된 구조체의 필드를 외부 구조체로 승격)

 

 


 

 

구조체 외 사용자 정의 타입

type TmpType1 float64
type TmpType2 float64

func main() {
	val1 := TmpType1(10.0)
	val2 := TmpType2(10.0)

	fmt.Println(val1, val2) // 10 10
}

 

- 구조체 뿐만 아니라 int, string, bool 등 모든 타입을 기본타입으로 사용할 수 있다.

- int를 기반으로 하는 타입은 +, -, * 등의 연산이 int 타입의 리터럴(변수화되지 않은 실제 숫자)과 가능하다. 하지만 이러한 연산은 하지 않는 것이 좋다.

 

 


 

메소드

- 보통 다른 언어에서는 함수와 메소드를 크게 구분하지 않지만 Go에서는 구분되는 개념이다.

- Java와 같은 전통적인 OOP 클래스가 필드와 메소드를 함께 가지는 것과 달리 Go 언어의 구조체는 필드만을 가지며 메소드는 별도로 분리하여 정의한다.

- Go의 메소드란 특별한 형태의 func 함수이다.

- 메소드는 함수 정의에서 func 키워드와 함수명 사이에 "그 함수가 어떤 struct를 위한 메소드인지"를 명시하며, 이 부분을 receiver parameter라고 불린다.

- receiver는 메소드가 속한 struct 타입과 struct 변수명을 지정한다.

- struct 변수명은 함수 내에서 입력 파라미터처럼 사용된다.

 

type TmpType1 string

func (m TmpType1) method1() {
	fmt.Println("called", m)
}

func main() {
	val1 := TmpType1("hello")
	val1.method1() // called hello
}

 

- 위 코드에서 TmpType1을 receiver parameter type, m을 receiver라고 한다.

- 메소드의 receiver 역시 함수의 매개변수와 마찬가지로 동일한 방식으로 접근하고 사용할 수 있다.

- receiver는 Java에서 볼 때 this와 비슷한 개념이다. receiver 역시 this와 동일한 역할을 수행한다.

- receiver parameter 이름은 보통 소문자로 작성한다. (관례)

- 메소드는 타입이 정의된 같은 패키지에서만 정의할 수 있다. 만약 타입이 정의된 다른 패키지에서 메소드를 만들고 싶다면 익명 필드로 갖는 구조체 타입을 만들어 사용할 수 있다.

- int, string과 같은 내장 타입에 대해서는 메소드를 정의할 수 없으며 구조체 기반의 사용자 정의 타입이나 그 외 사용자 정의 타입에 대해서만 메소드를 정의할 수 있다.

- 기존 함수나 변수처럼 메소드 명을 대문자로 할 경우, 패키지 외부로 노출시킬 수 있다.

 

 

 

Pointer Receiver Parameter

type TmpType1 int

func (m TmpType1) Double1() {
	fmt.Println("called", m*2)
}

func (m *TmpType1) Double2() {
	*m = (*m) * 2
	fmt.Println("called", *m)
}

func main() {
	val1 := TmpType1(2) // cast

	val1.Double1()    // called 4
	fmt.Println(val1) // 2

	val1.Double2()    // called 4
	fmt.Println(val1) // 4
}

 

- receiver parameter 역시 일반 함수의 매개변수와 다를 게 없다. 즉, receiver parameter 타입을 포인터 타입으로 표현할 수 있다.

- 위 코드에서 주목할 점은 메소드 호출 부분은 변경하지 않아도 된다는 부분이다. pointer receiver 메소드를 호출할 때 Go는 자동으로 receiver를 포인터로 변환한다.

- 보통 Double1과 같은 메소드를 value receiver 메소드라고 하며 Double2와 같은 메소드를 pointer receiver 메소드라고 한다. 일반적으로 관습상 둘 중 하나만 사용하는 것이 원칙이다.

 

 


 

 

go.mod 및 go.sum

- go.mod 파일은 모듈(패키지의 묶음)로 한 개의 모듈은 다수의 패키지를 포함한다.

- go.mod 내 명시된 모듈을 통해 Go언어는 패키지들의 종속성(Dependency)을 관리할 수 있다.(Gradle 같은?)

- 모듈은 패키지를 관리를 위해 사용된다. import 된 패키지들을 관리한다.

- 모듈은 패키지들을 Tree 형태로 관리하고 Go 프로젝트의 루트 디렉토리에 go.mod 파일을 위치시킨다.

- go.sum 파일은 go.mod로 받은 패키지의 무결성(중복 방지)을 위해 자동 생성된다.

- go.sum으로 Go 프로젝트를 재실행하면 기존에 사용했던 패키지들을 그대로 활용한다. 즉, 기존에 다운로드한 패키지들을 다시 다운로드하지 않고 활용한다.