golang 에 대해 알아보자.
Intro
- 겸사겸사 정리
- Tucker 의 Go 언어 프로그래밍 책을 기본으로 함.
정리
- 2007년에 개발을 시작해 2009.11.10에 공개된 오픈소스 언어이다.
- go 는 정적 컴파일 언어이기에 각 플랫폼에 맞는 실행 파일을 따로 만들어 줘야하고, 가비지컬렉터를 제공하고 있다.
- java 처럼 VM위에서 돌아가지 않는다 go 의 GC에 대해선 다시 정리하기.
- hello world 시작
- 폴더 생성
- go 는 패키지 단위로 작성됨, 같은 폴더에 위치한 .go 파일은 모두 같은 패키지에 포함됨. 패키지명으로 폴더명을 사용함.
- 폴더가 다르면 패키지도 달라짐.
- go 는 패키지 단위로 작성됨, 같은 폴더에 위치한 .go 파일은 모두 같은 패키지에 포함됨. 패키지명으로 폴더명을 사용함.
- .go 파일 작성
- Go 모듈 생성
- 빌드하기 전에 모듈을 생성해야함.
- 모듈 생성은 go mod init 명령으로 실행. go mod init 뒤에 모듈 이름을 적어주면 됨.
- 모듈을 생성하면 각종 정보가 들어있는 go.mod 파일이 생성됨.
- 빌드하기 전에 모듈을 생성해야함.
- 빌드
- go bulid 는 기계어로 변환하여 실행파일을 만듬.
- 사용중인 시스템에서 실행되는것을 만들기 위핸 그냥 go build 만 하면 됨.
- 다른 운영체제에서 실행되는 파일을 만들기 위해선 옵션 몇가지만 주면 됨.
- go tool dist list 명령으로 확인 가능.
- 다른 운영체제에서 실행되는 파일을 만들기 위해선 옵션 몇가지만 주면 됨.
- 샘플코드 분석
- 폴더 생성
//패키지 선언. main은 특별취금 main() 이 있어야만 main 패키지 사용가능.
package main
//특정 패키지를 불러오기.
import "fmt"
// go 는 main() 함수로부터 시작되고 main() 함수가 종료되면 프로그램이 종료됨.
func main() {
fmt.Println("hello world)
}
변수
- 아래의 예시를 보자
var a int = 1
//var 는 변수 선언 키워드, a 는 변수명, 그 이후에 변수 타입과 =뒤의 초깃값
- 숫자 타입
uint8 : 1바이트 부호없는 정수 : 0~255
uint16 : 2바이트 부호없는 정수 : 0~65535
...
byte : uint8의 별칭 : 0~255
rune : int32의 별칭 UTF-8 문자 하나를 타나낼때 사용.
- 그외 타입
- 블리언, 문자열, 배열, 슬라이스(가변 길이 배열), 구조체, 포인터, 함수타입(함수 포인터), 인터페이스(메서드 정의의 집합), 맵, 채널 이 있다.
- 변수 선언
var a int = 1 //기본 형태
var b int // 초깃값 생략. 기본값으로 세팅된다.
vac c = 2 //타입 생략. 우변 값으로 타입이 된다.
d := 3 // 선언 대입문 := 를 사용해 var 키워드 생각한다.
표준 입출력
- fmt 패키지
- Print() : 함수 입력값들을 출력함
- Println(): 함수 입력값들을 출력하고 개행함.
- Printf() : 서식(format)에 맞도록 출력함.
- 서식지정자
- %v : 데이터 타입에 맞춰서 기본 형태로 출력함.
- %d : 10진수 정숫값으로 출력
- %s : 문자열 출력
- %p : 메모리 주솟값을 출력함.
- 서식지정자
- 표준입력
- 키보드 입력과 Scan() 함수의 동작 원리
- 사용자가 표준입력장치(키보드 등)로 입력하면 입력 데이터는 컴퓨터 내부의 표준입력 스트림(standard input stream)이라는 메모리 공간에 임시 저장됨.
- Scan() 함수는 그 표준 입력스트림에서 값을 읽어서 입력값을 처리함.
- 키보드 입력과 Scan() 함수의 동작 원리
함수
- 아래와 같은 형태를 가짐.
- 함수명중 첫 글자가 대문자인 경우는 패키지 외부로 공개되는 함수임.
- 함수 코드 블록의 시작을 알리는 중괄호 { 는 함수를 정의하는 라인과 항상 같은 줄에 위치해야함.
- 함수를 호출하는 인수는 매내변수로 복사된다.
- 값 전달, 레퍼런스 전달 중 go 는 값 전달만 지원함. 변수 전달이 복사로만 이루어짐.
func Add(a int, b int) int {
return a + b
}
- 아래와 같이 별티 반환 함수도 가능함
c, success := Add(3, 5)
...
func Add(a int, b int) (int, bool) {
return a + b, true
}
- 아래와 같이 변수명을 지정해 반환도 가능함.
- return 만 호출하면 됨.
func Add(a, b int) (result int, success bool) {
result = a + b
success = false
return
}
상수
- go 에서 상수로 사용할 수 있늩 타입은 불리언, 룬, 정수, 실수, 복소수, 문자열이있음. 아래처럼 사용하면 됨.
- 상수명도 함수 외부에 선언되어있고, 첫 글자가 대문자인 상수는 패키지 외부로 공개되는 상수입.
const ConstValue int = 10
- 상수와 리터럴
- 리터럴이란 고정된 값, 값 자체로 쓰인 문구
- 아래에서 hello, 0 과 같이 고정된 값 자체로 쓰인 문구가 리터럴임
- go 에서는 상수는 리터널과 같이 취급됨.
- 상수의 메모리 주소값에 접근할 수 없는 이유는 컴파일 타임에 리터럴로 변환 되기 때문임.
- 리터럴이란 고정된 값, 값 자체로 쓰인 문구
var str string = "hello"
var i int = 0
if 문
- if 초기문; 조건문 도 가능하다 아래 예시를 보자
// filename, success := UploadFile() 이 초기문, success 이 조건문.
if filename, success := UploadFile(); success {
...
}
switch 문
- const 열거값에 따라 수행되는 로직을 변경할때 switch 문을 주로 사용한다. 아래 예시를 보자.
type ColorType int //별칭 ColorType 을 선언하고 const 열거값 정의
const (
Red ColorType = iota
Blue
)
func colorToString(color ColorType) string {
switch color {
case Red:
return "Red"
case Blue:
return "Blue"
default :
return "etc"
}
}
- 다른 언어는 break 문이 있어야 하지만, go 는 case 하나 실행 후 자동으로 switch 를 빠져나온다.
- 만약 다음 case 까지 실행시키고 싶으면, case 마지막에 fallthrough 키워드를 사용하면 된다.
for 문
- go 는 반복문으로 for 하나만 지원한다. 아래와 같은 여러 형태가 있다.
for 초기문; 조건문; 후처리 {
코드블록
}
// 초기문 생략
for ; 조건문; 후처리 {
코드블록
}
// 후처리 생략
for 초기문; 조건문; {
코드블록
}
// 조건문만 있는 경우
for ; 조건문; {
코드블록
}
// 무한 루프 : 조건문이 true 이다.
for true {
...
}
// true 도 생략 가능하다.
for {
...
}
- 레이블
- for 문을 사용할때 레이블을 정의하고 break 할때 정의한 레이블을 적어주면 그 레이블에서 가장 먼저 속한 for 문까지 모두 종료하게 된다.
OuterFor:
for {
for {
if a == b {
break OuterFor // 레이블에 가장 먼저 포함된 for 문까지 종료
}
}
}
배열
- 최대 요소 개수를 생성할 때 고정되어 중간에 늘리거나 줄일 수 없다.
- 배열은 값을 여러 개 저장하는 연속된 메모리 공간.
var 변수명 [요소 개수]타입
// int 타입 요소를 5개 갖는 배열 numbs 를 할당, 초깃값 지정안해서 0으로 세팅됨.
var nums [5] int
// string 타입 배열 3개 생성, 마지막은 "" 으로 초기화될듯.
days := [3]string{"monday", "tuesday"}
// int 타입 5개를 갖는 배열 s를 할당. 인덱스가 1인 요솟값을 10으로, 3인 요소값을 30으로 초기화, 나머지는 0으로 초기화됨.
var s = [5]int{1:10, 3:30}
//s[0] = 0, s[1] = 10, s[2] = 0, s[3]=30
// ...를 사용하면 배열 요소 개수를 생략할 수 있음.
x := [...]int{10, 20, 30}
- for 문에서 range 키워드를 사용하면 배열 요소 순회한다.
var t [5]float64 = [5]float64{1.0, 2.0, 3.0, 4.3, 5.3}
for i, v:= range t {
fmt.Println(i, v)
}
구조체
- 구조체는 다음과 같은 형식을 가짐
- 구조체의 타입명의 첫번째 글자가 대문자이면 패키지 외부로 공개되는 타입임.
- 구조체 안의 필드명이 대문자로 시작하는 경우는 패키지 외부로 공개되는 필드임.
type 타입명 struct { 필드명 타입 ... 필드명 타입 }
- 구조체 초깃값을 생략하면 모든 필드가 기본값으로 초기화됨.
- 구조체의 크기
type User struct {
Age int// 8바이트
Score float64 // 8바이트
}
// 아래 구조체의 메모리 크기는 16바이트가 됨.
var user User
- 구조체 복사
- go 내부에서는 필드 각각이 아닌 구조체 전체를 한번에 복사함. 대입 연산자가 우변 값을 좌변 메모리 공간에 복사할 때 복사되는 크기는 타입 크기와 같음.
type User struct {
Age int// 8바이트
Score float64 // 8바이트
}
...
var user User{3, 2.3}
user3 := user //구조체 복사.
- 필드 배치 순서에 따라 구조체 크기 변화
- go 에서도 메모리 정렬 이슈로 int 형과 float64 형을 가진 구조체의 크기는 16 바이트로 되어버림.
- 메모리 정렬을 위해 필드 사이의 공간을 띄우는 것을 메모리 패딩(memory padding)이라고 함.
- go 에서도 메모리 정렬 이슈로 int 형과 float64 형을 가진 구조체의 크기는 16 바이트로 되어버림.
포인터
- 포인터 변수 선언은 데이터 타입 앞에 * 를 붙여서 선언.
- 기본값은 nil 이다.
var p *int
var a int
p = &a // a의 메모리 주소를 포인터 변수 p에 대입
*p = 10 // p 가 가리키는 메모리 공간의 값을
- == 연산을 사용해 포인터가 같은 메모리 공간을 가리키는지 확인 가능하다.
var a int = 10
var b int = 30
var p1 *int = &a
var p2 *int = &a
var p4 *int = &b
if(p1 == p2){
...
}
- 변수 대입이나 함수 인수 전달은 항상 값을 복사하기 때문에 많은 메모리 공간을 사용하는 문제와 큰 메모리 공간을 복사할 때 발생하는 성능 이슈가 있음. 아래 예시를 보자.
type Data struct {
value int
data [200] int
}
func Change(arg Data) {
arg.value = 900
arg.data[100] = 999
}
func main() {
var data Data
Change(data)
//data 를 출력하면 0 만 출력되는것을 볼 수 있다. (value, data[100] 둘다. )
}
// 값이 복사되어 전달되기 때문에 각기 다른 메모리 공간에 존재한다.
// data 변숫값이 모두 복사되기에 구조체 크기만큼 복사된다. Data 구조체는 int 타입과 value 크기가 200인 int 배열로 구성되어있어 총 1608바이트이다.
// Change 이 호출될 때마다 1608 바이트가 복사된다.
// 아래처럼 바꾸는게 좋다.
func Change(arg *Data) {
arg.value = 900
arg.data[100] = 999
}
Change(&data)
// data를 출력하면 999로 모두 바뀌었다. 이제 data 변수값이 아니라 data 의 메모리 주소를 인수로 전달한다. 메모리 주소는 64bit 컴퓨터에서 8바이트 숫자값이기에 8바이트만 복사가 된다.
- Data 구조체를 생성해 포인터 변수 초기화 하기
- 구조체 변수를 별도로 생성하지 않고, 포인터 변수에 구조체를 생성해 주소를 초기값으로 대입하는 방법을 알아 보자.
// 기존 방식
var data Data
var p *Data = &data
//구조체를 생성해 초기화하는 방식
var p *Data = &Data{}// *Data 타입 구조체 변수 p 를 선언한다.
- 할당된 메모리 공간의 실체를 인스턴스라고 부른다.
- 쓸모없는 인스턴스를 메모리에서 해제하기 위해서 go 언어에서는 가비지 컬렉터라는 메모리 정리 기능을 제공한다.
- new() 내장 함수를 이용해 초기화 할 수도 있다.
- new() 내장 함수는 type 를 인수로 받는다.
p1 := &Data{} // & 를 사용하는 초기화 var p2 = new(Data) // new() 를 사용하는 초기화. 내부 필드값ㅇ르 원하는 값으로 초기화 할 수 없다.
- new() 내장 함수는 type 를 인수로 받는다.
- 대부분 프로그래밍 언어에서 메모리를 할당할 땐 스택 메모리 영역이나 힙 메모리 영역을 사용한다.
- 이론상 스택 메모리 영역이 힙 메모리 영역보다 효율적이지만, 스택 메모리는 함수 내부에서만 사용 가능한 영역이다.
- C/C++ 에서는 new, malloc 함수를 호출해 힙 메모리 공간을 할당, 자바에서는 클래스 타입을 힙에 기본 타입을 스택에 할당.
- go 언어는 탈출검사(escape analysis)를 해서 어느 메모리에 할당할지를 결정한다.
- go 의 스택 메모리는 계속 증가되는 동적 메모리 풀이다.
- 이론상 스택 메모리 영역이 힙 메모리 영역보다 효율적이지만, 스택 메모리는 함수 내부에서만 사용 가능한 영역이다.
- 함수 외부로 공개되는 인스턴스의 경우 함수가 종료되어도 사라지지 않는다.
type User struct {
Age int
}
func NewUser(age int) *User {
var u = User{age}
return &u // 탈출 분석으로 u 메모리가 사라지지 않음.
}
문자열
- go 는 UTF-8 문자코드를 사용한다. (유니코드의 일종으로 가변길이 문자 인코딩 방식이다. 한문자가 1~3바이트이다.)
- utf-16이 한문자에 2byte 를 고정사용하는 대신, 영문자, 숫자등은 1byte 그외 문자는 2~3byte 로 표현한다. ANSI와 1:1 대응이 된다.
- 문자 하나를 표현하는데 rune type 이 사용된다.
- rune 타입은 4바이트 정수 타입인 int32 타입의 별칭 type 이다.
- len() 내장함수는 문자열 크기를 알 수 있는데, 문자 수가 아니라 문자열이 차지하는 메모리 크기이다.
- []rune 타입 변환으로 글자수를 알 수 있다.
- string type, rune slice type인 []rune type 은 상호 타입 변환이 가능하다.
- string type 과 []byte 타입도 상호 타입 변환이 가능하다.
str := "hello 월드"
runes := []rune(str) // []rune 타입으로 타입 변환
len(runes) // 8
len(str) // 12
- string 타입은 연속된 바이트 메모리라면 []rune 타입은 글자들의 배열로 이루어저 있다.
- string 구조 알아보자.
- sting 내장타입으로 내부 구현은 감춰져 있지만 reflect 패키지안의 StringHeader 구조체를 통해 내부 구현을 엿볼수 있다. 내부 구조는 아래와 같다.
- 첫번째 필드인 Data 는 문자열 데티어가 있는 메모리 주소를 나타내는 일종의 포인터
- 두번째 필드인 Len은 문자열의 길이를 나타냄.
type StringHeader struct { Data uintptr Len int }
- 위와 같은 구조이기 때문에 string 끼리 대입을 하면 문자열을 하나하나 복사하는 것이 아니라
- 구조체 변수가 복사도리때 구조체 크기만큼 메모리가 복사된다.
- Data 포인터 값과 Len 값이 복사된다.
- 문자열은 불변이다.
- 즉 string 타입이 가리키는 문자열의 일부만 변경할 수 없다.
- []byte 슬라이스 타입으로 변환하면 일부만 변경 가능하다.
- go 에서는 슬라이스 타입변환을 할 때 문자열을 복사해서 새로운 메모리 공간을 만들어 슬라이스가 가리키도록 한다. (문자열 불변 원칙이 지켜진다.)
- []byte 슬라이스 타입으로 변환하면 일부만 변경 가능하다.
- 문자열을 합칠때에도 기존 문자열 메모리 공간을 건들이지 않고, 새로운 메모리 공간을 만들어 두 문자열을 합친다.
- 문자열 합치는 연산이후의 string 주소값은 바뀌게 된다. (문자열 불변 원칙이 지켜진다.)
- string 합 연산이 빈번하게 일어나면 메모리 낭비가 된다.
- 이럴경우는 string 패키지안의 Builder 를 이용해 메모리 낭비를 줄일 수 있다.
- strings.Builder 는 내부에 슬라이스를 가지고 있기에 문자를 더할 때 매번 메모리를 새로 생성하지 않고, 기존 메모리 공간에 빈 자리가 있으면 그냥 더하게 된다.
- 즉 string 타입이 가리키는 문자열의 일부만 변경할 수 없다.
패키지
- go 언어에서 코드를 묶는 가장 큰 단위
- main 패키지 하나와 여러 외부 패키지의 조합
- 프로그램 시작점을 포함한 패키지.
- 패키지 찾기 좋은 곳 (awesome go) 에서 필요한 것들을 찾아보자.
- https://github.com/avelino/awesome-go
- 여러가지 패키지 사용법 예시
//소괄호로 패키지를 묶어 임포드 시킬수 있다.
import (
"fmt"
"os"
"math/rand" //패키지 명은 math 이다.
"text/template" // 같은 이름이라면.
htmltmeplate "html/template" // 별칭을 붙여서 해결할수 있다.
_ "github.com/something" // 직접 사용하지 않는 패키지는 _ 을 붙이면 된다.
)
...
rand.Int() // 경로가 있는 패키지에 접근할땐 마지막 폴더명인 rand 만 사용한다.
htmltmeplate.New()
- 패키지 설치하기.
- import 로 패키지를 포함하면 go build 를 통해 빌드할때 해당 패키지를 찾아 포함한 다음 실행파일을 생성한다.
- go 는 import 된 패키지를 어떻게 찾을까?
-
- 기본 제공하는 패키지는 go 설치 경로에서 찾는다.(go 설치시 같이 설치된다.)
-
- 깃허브와 같은 외부 저장소의 저장된 패키지는 외부 저장소에서 다운 받아 GOPATH\pkg 폴더에 설치한다. (go 모듈에 정의된 패키지 버전에 맞게 다운로드 한다.)
-
- 현재 모듈 아래 위치한 패키지인지 검사한다. 현재 모듈 아래 위치한 패키지는 현재 폴더 아래 있는 패키지를 찾는다.
-
- go 모듈
- go 모듈은 go 패키지를 모아놓은 go 프로젝트 단위. 모든 go 코드는 go 모듈 아래 있어야 함.
- go build 를 하려면 반드시 go 모듈 루트 폴더에 go.mod 파일이 있어야 함.
- go.mod 파일은 모듈 이름과 go 버전, 필요한 외부 패키지 등이 명시
- go 는 go build 를 통해 실행파일을 만들때 go.mod 와 외부 저장소 패키지 버전정보를 담고있는 go.sum 파일을 통해 외부 패키지와 모듈 내 패키지를 합처 실행 파일을 만들게 됨.
- go 모듈은 go mod init 명령어를 통해 만들 수 있음.
- 이 명령어를 실행하면 go.mod 파일이 생성됨.
go mod init [패키지명]
- go mod tidy
- go 모듈에 필요한 캐피지를 찾아서 다운로드 해주고 필요한 패키지 정보를 go.mod 파일과 go.sum 파일에 적어주게 된다.
- 실행해보기
- goproject/usepkg 폴더를 만들고 그 폴더 안에서 go mod init goproject\usepkg 를 실행하자.
- 패키지명과 패키지 외부 공개
- 대문자로 선언된 첫 글자로 시작하는 모든 변수, 상수, 타입, 함수, 메서드는 패키지 외부로 공개된다.
- 패키지 초기화
- 패키지를 임포트 하면.
-
- 컴파일러는 패키지 내 전역 변수를 초기화 함.
-
- 그런 다음 패키지에 init() 함수가 있다면 호출해 패키지를 초기화 함.
- init() 함수는 반드시 입력 매개변수가 없고, 반환값도 없는 함수여야 한다.
- 만약 어떤 패키지의 초기화 함수인 init() 함수 기능만 사용하기를 원할 경우 밑줄 _ 을 이용해서 패캐지를 임포트 하자.
- 그런 다음 패키지에 init() 함수가 있다면 호출해 패키지를 초기화 함.
-
- 패키지를 임포트 하면.
슬라이스
- 일반적인 배열은 처음 배열을 만들때 정한 길이에서 더이상 늘어나지 않는 문제가 있다. 슬라이스는 배열과 비슷 하지만, [ ] 안에 배열의 개수를 적지 않고 선언한다.
- 슬라이스를 초기호 하지 않으면 길이가 0인 슬라이스가 만들어 진다.
var slice []int
- 슬라이스 초기화 방법
-
- 배열처럼 { } 를 사용해 요솟값을 지정하자.
-
- 내장함수인 make() 를 이용한 초기화, 첫번째 인수로 타입을, 두번째 인수로 길이를 적어준다.
-
var slice1 = []int{1,2,3} // 1 2 3
var slice2 = []int{1, 5:2, 10:3} // 1 0 0 0 0 2 0 0 0 0 3
//아래 2개는 서로 다른 타입을 만든다.
var array = [...]int{1,2,3}//배열 선언
var slice = []int{1,2,3}//슬라이스 선언
var slice = make([]int, 3)
- 슬라이스 요소 추가 - append()
- 첫 번째 인수로 추가하고자 하는 슬라이스를
- 두 번째 인수로 요소를 적어주면 슬라이스 맨 뒤에 요소를 추가해 만든 새로운 슬라이스를 결과로 반환한다.
- 여러 값도 추가 가능하다.
- slice = append(slice, 1,2,3,4) 처럼 여러개의 값을 적으면 된다.
- 슬라이스 동작 원리
- 슬라이스는 내장 타입으로 내부 구현은 감춰져있지만 reflect 패키지의 SliceHeader 구조체를 사용해 내부 구현을 살펴볼 수 있다.
type SliceHeader struct {
Data uintptr // 실제 배열을 가리키는 포인터
Len int // 요소 개수
Cap int // 실제 배열의 길이
}
- make() 함수를 사용해 슬라이스를 만들때 인수를 2개 혹은 3개를 넣는데 어떻게 다를까?
- make( [ ] int, 3) 처럼 하면 slice의 len도 3이고 cap도 3이다.
- make( [ ] int, 3, 5) 처럼 하면 slice의 len은 3이고 cap는 5이다.
- 슬라이스와 배열의 동작 차이
- go 에서 모든 값의 대입은 복사로 이루어진다. 함수에 인수로 전달될 때나 다른 변수에 대입을 할때나 값의 이동은 복사이다.
- 복사는 타입의 값이 복사가 된다.
- 포인터는 포인터의 값인 메모리 주소가 복사
- 구조체가 복사될때는 구조체의 모든 필드가 복사된다. 배열은 배열의 모든 값이 복사된다.
- 배열과 슬라이스를 함수의 인자로 넘겨 특정 값을 변경을 하면 이런한 이유로 차이가 발생한다.
- 슬라이스의 경우 포인터와 len, cap값이 복사되는 것이다. 슬라이스에 실제 데이터가 저장되어있는 배열은 복사되지 않는 영역이다.
- 슬라이싱 (배열의 일부를 집어내는 기능)
- 슬라이싱 기능을 사용하면 그 결과로 슬라이스를 반환한다.(슬라이싱은 동사, 슬라이스는 그 결과인 명사로 보자.)
- 배열의 일부를 집어낼때는 대상이 되는 배열을 쓰고 대괄호 사이에 집어내고자 하는 시작 인덱스 : 끝 인덱스 를 쓴다.
- 이러면 시작인덱스 부터 끝인덱스 -1 까지의 배열 일부를 나타내는 슬라이스가 반환된다.
- 배열뿐 아니라 슬라이스 일부를 집어낼 때에도 사용 가능하다.
array[startIndex:endIndex]
//
slice1 := []int{1,2,3,4,5}
slice2 := slice1[0:3] // slice2 는 [1,2,3]
// 만약 첫번째 부터 슬라이싱을 하면 시작인덱스를 생략할 수 있다. 아래 2개는 같다.
slice2 := slice1[0:3]
slice2 := slice1[:3]
slice3 := slice[:] // 전체 슬라이스이다 끝 인덱스도 생략하였다.
- 슬라이싱을 하면 그 결과로 배열 일부를 가리키는 슬라이스를 반환한다.
- 새로운 배열이 만들어 지느 ㄴ것이 아니라, 배열의 일부를 포인터로 가리키는 슬라이스를 만들어 낼 뿐이다.
- 슬라이싱 기능 활용
2개의 슬라이스가 서로 같은 배열을 가리켜 문제가 발생할수 있다. 항상 다른 배열을 가리키게 하려면 어떻게 하면 될까?
//1. 하나의 슬라이스를 순회해서 하나씩 복사하는 방법
//2. slice2 := append([]int{}, slice1...) 처럼 append() 함수를 사용해 slice1 의 모든 값을 복제한 새로운 슬라이스를 만들어 slice2 에 대입하기.
// 배열이나 슬라이스 뒤에 ... 을 하면 모든 요솟값을 넣어준 것과 같게 된다.
//3. 내장함수 copy() 를 사용하는 방법
func copy(dst, src[]Type) int
특정 요소 삭제하기
// 특정 요소를 삭제 한 다음에
// append 를 이용해 슬라이스를 시프트 시키자.
특정 요소 추가하기
//슬라이스 중간에 요소 하나를 추가하려면.
//슬라이스 맨뒤에 요소 하나를 추가후. 원하는 위치부터 하나씩 시프트 시키는 방식을 사용한다.
//slice = append(slice[:idx], append([]int{100}, slice[idx:]...)...)
// 불필요한 메모리 사용이 없도록 코드를 개선하면 아래처럼 된다.
slice = append(slice, 0)// 맨뒤에 요소 추가
copy(slice[idx+1:], slice[idx:]) // 값 복사
slice[idx] = 100 // 값 변경.
슬라이스 정렬
- int 형 슬라이스 정렬
- sort 패키지의 Ints() 함수를 사용하면 된다.
- Float64s() 를 사용하면 float64 슬라이스를 정렬할 수 있다.
- 구조체 슬라이스 정렬
- Sort() 함수를 사용하기 위해선 Len(), Less(), Swap() 세 메서드가 필요하다. 이것들만 구현하면 우리가 정의한 구조체도 정렬할 수 있다.
type Student struct{
Name string
Age int
}
...
type Sutdents []Student
func (s Students) Len() int {return len(s)}
func (s Students) Less(i,j int) bool {return s[i].Age < s[j].Age}
func (s Students) Swap(i,j int) {s[i], s[j] = s[j], s[i]}
...
s := []Student{ {"1", 1}, {"2", 2}, {"3", 3}}
sort.Sort(Students(s)) // Age 로 정렬.
메서드
- 메서드는 함수의 일종. go 에는 클래스가 없다. 그래서 구조체 밖에 메서드를 지정한다.
- 구조체 밖에 메서드가 있으므로 어떤 구조체에 속하는지 표시할 방법이 필요한데, 이때 리시버가 사용된다.
- 메서드 선언
- 메서드를 선언하려면 리시버를 func 키워드와 함수 이름 사이에 소괄호로 명시해야 한다.
// (r Rabbit) 가 리시버이고
// info() 가 메서드명 이다.
// 이제 info() 는 Rabbit 타입에 속한다고 알게 되었다.
func (r Rabbit) info() int {
return r.w * r.h
}
// 일반 함수 표현
func info(r Rabbit) int {
return r.w * r.h
}
// 메서드 표현
func (r Rabbit) info() int {
return r.w * r.h
}
- go 는 클래스와 상속을 지원하지 않고 메서드와 인터페이스만 지원한다.
- 포인터 메서드 vs 값 타입 메서드
- 리시버를 값 타입과 포인터로 정의할수 있다. 이 둘의 차이는
type account struct {
balance int
}
// 포인터 메서드
func(a1 *account) withdrawPointer(amount int){
a1.balance -= amount
}
// 값 타입 메서드
func(a2 account) withdrawValue(amount int){
a2.balance -= amount
}
// 값 타입 메서드
func(a3 account) withdrawReturnValue(amount int) account{
a2.balance -= amount
return a3
}
var test *account = &account{100}
test.withdrawPointer(30) // 70 이다
test.withdrawValue(20) // 여전히 70 이다.
test.withdrawReturnValue(20) // 50 이다.
- 포인터 메서드를 호출하면 포인터가 가리키고 있는 메모리의 주솟값이 복사됨.
- 값 타입 메서드를 호출하면 리시버 타입의 모든 값이 복사됨. 리시버 타입이 구조체이면 구조체의 모든 데이터가 복사됨.
- 값 타입 메서드와 포인터 메서드는 어떤 경우에 사용하면 좋을까?
- 포인터 메서드는 메서드 내부에서 리시버의 값을 변경시킬 수 있다.
- 값 타입 메서드는 호출하는 쪽과 메서드 내부의 값은 별도 인스턴스로 독립이 되기에 메서드 내부에서 리시버의 값을 변경시킬 수 없다.
인터페이스
- 구현을 포함하지 않는 메서드 집합
- 인터페이스 선언
- type 을 쓴뒤 인터페이스 명을 쓰고 interface 키워드를 쓴다. 그 뒤 중괄호 { } 안에 인터페이스에 포함된 메서드 집합을 써 준다.
- 인터페이스도 구조체처럼 타입중 하나이기에 type 을 써줘야 한다
type DuckInterface interface {
// 메서드는 반드시 메서드 명이 있어야 한다.
// 매개변수와 반환이 다르더라도 이름이 같은 메서드는 있을 수 없다.
// 인터페이스는 메서드 구현을 포함하지 않는다.
Fly()
Walk(distance int) int
}
// 실제 예제
//Stringer 인터페이스 선언
type Stringer interface {
String() string
}
type Student struct {
Name string
}
// Student 의 String 메서드
func (s Student) String() string {
return "a"
}
func main() {
student := Student{"name"}//Student 타입
var stringer Stringer //Stringer 타입
stringer = student //stringer 값으로 student 대입
stringer.String() //stringer 의 String() 메서드 호출
}
- go 에서는 ~er 를 붙여 인터페이스명을 만드는 것을 권장함.
- go 언어에서는 어떤 타입이 인터페이스를 포함하고 있는지 여부를 결정할 때 덕 타이핑 방식을 사용
- 타입 선언시 인터페이스 구현 여부를 명시적으로 나타낼 필요없이 인터페이스에 정의한 메서드 포함 여부만으로 결정하는 방식.
- 덕타이핑이란
- 특정 타입의 인터페이스 구현 여부를 타입 선언시 미리 명시하지않아도 그 타입이 인터페이스에서 정의한 메서드들을 모두 포함하고 있다면, 그 인터페이스로 간주하는 방식
- 인터페이스 기능 더 알기
- 포함된 인터페이스
- 빈 인터페이스
- 인터페이스 기본값 : nil 이다.
// 인터페이스도 다른 인터페이스를 포함 할 수 있다.
// Reader와 Write 인터페이스를 선언하고 이둘을 포함한 ReadWrite 인터페이스를 선언하는 예시를 보자.
type Reader interface {
Read() (n int, err error)
Close() error
}
type Writer interface {
Write()(n int, err error)
Close() error
}
type ReadWriter interface {
Reader //Reader 의 메서드를 포함
Writer //Writer 의 메서드를 포함
}
// interface{} 는 메서드를 가지고 있지 않는 빈 인터페이스.
// 모든 타입이 빈 인터페이스로 쓰일 수 있음.
// 빈 인터페이스는 어떤 값이든 받을 수 있는 함수, 메서드, 변숫값을 만들때 사용함. 아래 예시를 보자.
func PrintVal(v interface{}) {
//v 의 타입에 따라 다른 로직을 수행한다.
switch t := v.(type) {
case int:
case float64:
case string:
default :
}
}
// 인터페이스를 사용할 때 항상 인터페이스 값이 nil이 아닌지 확인해야 하낟.
type A interface {
b()
}
func main() {
var a A
a.b() //a가 nil이기에 런 타임 에러 발생.
}
- 인터페이스 변환하기. 인터페이스 변수를 타입 변환을 통해 구체화된 다른 타입이나 다른 인터페이스로 변환할수있다.
- 구체화된 다른 타입으로 타입 변환하기
- 인터페이스를 본래의 구체화된 타입으로 복원할때 주로 사용함. 사용법은 인터페이스 변수 뒤에 점을 찍고 소괄호 안에 변경하려는 타입을 써주면 됨.
- 인터페이스 변수를 구체화된 타입으로 변환하려면 해당 타입이 인터페이스 메서드 집합을 포함하고 있어야 함. 아니면 컴파일 타임 에러 발생.
- 다른 인터페이스로 타입 변환하기.
- 인터페이스 변환을 통해 구체화된 타입뿐 아니라 다른 인터페이스로 타입 변환 할 수 있음.
- 이 때는 구체화된 타입으로 변환할 때와 달리 변경되는 인터페이스가 변경 전 인터페이스를 포함하지 않아도 됨. 인터페이스가 가리키고 있는 실제 인스턴스가 변환하고자 하는 다른 인터페이스를 포함해야 함.
- 구체화된 다른 타입으로 타입 변환하기
// a 인터페이스 변수를 ConcreteType 타입으로 변환해 변수 ConcreteType t를 생성하고 대입
var a Interface
t := a.(ConcreteType)
// 구체화된 다른 타입으로 타입 변환하기 변환 샘플
// 인터페이스
type Stringer interface {
String() sting
}
// 구조체
type Student struct {
Age int
}
//Student 타입의 String() 메서드
func (s *Student) String() string{
return "a"
}
//
func Print(stringer Stringer) {
s := stringer.(*Student) // *Student 타입으로 타입 변환
s.Age
}
func main() {
s := &Student{2} //*Student 타입 변수 s 선언 및 초기화
Print(s) // s 를 인터페이스 인수로 Print() 함수 호출
}
// 다른 인터페이스로 타입 변환하기. 샘플
// ConcreteType 이 AInterface 와 BInterface 모두 포함하고 있는 경우 변환 가능함.
var a AInterface = ConcreteType{}
b := a.(BInterface)
- 타입 변환 성공 여부 반환
- 타입 변환 반환값을 2개의 변수로 받으면 타입 변환 가능 여부를 두 번째 반환값 으로 알려줌. 이때 타입 변환이 불가능하더라도 두 번째 반환값이 false로 반환될뿐 런타임 에러는 없다.
var a Interface
t, ok := a.(ConcreteType) //t 는 타입 변환한 결과, ok 는 변환 성공 여부임.
함수 고급
- 가변 인수 함수
- … 키워드를 사용하면 가변 인수 처리가 된다.
//가변 인수를 받는 함수.
func sum(nums ...int) int {
// 내부에서 nums 는 int 슬라이스 타입 [] int 로 처리됨.
for _, v := range nums {
...
}
}
- 여러 타입을 인수로 한번에 섞어쓰는 함수같은 Print 도 있는데 이는 빈 인터페이스 에 있다
func Print(args ... interface{}) string{
for _, arg := range args {
switch f : = arg.(type)
case bool:
case int:
...
}
}
- defer 지연 실행
- 바로 실행이 아닌 해당 함수가 종료되기 직전에 실행되도록 하기 위해서 사용되는 명령어이다.
- defer 는 역순으로 호출 된다.
func main() {
defer fmt.Println("반드시 호출됩1")
defer fmt.Println("반드시 호출됩2")
defer fmt.Println("반드시 호출됩3")
}
// 실행 결과는 아래와 같다.
// 반드시 호출됩3
// 반드시 호출됩2
// 반드시 호출됩1
- 함수 타입 변수
- 함수 포인터 라고 생각하면 되는 변수.
- 함수 타입은 함수명과 함수 코드 블록을 제외한 함수 정의로 표시된다.
func add(a, b int) int {
return a+b
}
//함수 포인터는 함수명인 add 와 코드 블록인 {...} 사이를 제외하고 다음과 같이 표현한다.
func (int, int) int
// 예제를 보자.
func add(a, b int) int {
return a+b
}
func mul(a, b int) int {
return a*b
}
//op에 따른 함수 타입 반환
func getOperator(op string) func(int, int) int {
if op == "+"{
return add
}
return mul
}
func main() {
var operaotr func (int, int) int
operator = getOperator("*")
// 함수 타입 변수를 사용해서 함수 호출
var result = operator(3, 4)
}
//별칭으로 아래처럼 줄여쓸수도 있다.
type opFunc func (int, int) int
func getOper(op string) opFunc
- 함수 리터럴
- 이름없는 함수로 함수 타입 변숫값으로 대입되는 함숫값을 의미
- 익명 함수, 또는 람다 라고 불림.
에러 핸들링
- fmt 패키지의 Errorf() 함수를 이용하면 원하는 에러 메시지를 만들 수 있음. 또는 errors 패키지의 New() 함수를 이용해서 error 를 생성할수 있음
func New(text string) error
//그래서 아래처럼 사용가능
errors.New("에러 메시지")
- error 는 인터페이스로 문자열을 반환하는 Error() 메서드로 구성됨.
- 즉, 어떤 타입이든 문자열을 반환하는 Error() 메서드를 포함하고 있으면 에러로 사용할 수 있음.
type error interface {
Error() string
}
//아래와 같은 예제를 만들수 있음.
type AError struct{ // 에러 구조체 선언
Len int
RequireLen int
}
func (err AError) Error() string {//
return "a가 이상해요."
}
func Bmetohd(name string) error {
if(len(name) < 3 ){
return AErrorP{len(name), 3} // error 반환.
}
return nil
}
func main(){
err := Bmetohd("input_name")
if err != nil {
if errInfo, ok := err.(AError); ok { //인터페이스 변환.
...
}
}
}
- 에러 랩핑
- 에러를 감싸서 새로운 에러를 만들어야 할 수도 있다.
- 예를들어 파일에서 텍스트를 읽어서 특정 타입의 데이터로 변환하는 경우, 파일 읽기에서 발생하는 에러도 필요하지만, 텍스트의 몇번째 줄의 몇번째 칸에서 에러가 발생했는지 알면 더 유용할 것이다.
- 패닉
- 프로그램을 정상 진행시키기 어려운 상황을 만들었을때 프로그램 흐름을 중지시키는 기능.
- 잘못된 메모리에 접근하거나 그러면 프로그램 실행이 불가능 할 수 있다. 이럴땐 프로그램을 강제 종료하는편이 좋다.
- panic() 함수를 사용해 패닉을 발생시키거나 go 내부에서 패닉이 발생하기도 한다.
- 콜스택을 알 수 있다.
- 패닉 발생후 바로 종료해도 되지만, 복구할수있는 것이라면 복구를 시도하는게 좋다.
- panic 은 호출 순서를 거슬러 올라가 전파된다.
- 만약 main() -> f() -> g() 였고, g 에서 패닉이 발생하면 호출 순서를 거꾸로 올라가며 함수로 전달된다.
- main함수까지 복구되지 않으면 프로그램은 그때 강제 종료가 된다.
- 어느 단계든 패닉은 복구된 시점부터 프로그램이 계손된다.
- recover() 함수를 호출해 패닉 복구를 할 수 있다.
- recover 함수가 호출되는 시점에 패닉이 전파중이면 panic 객체를 반환하고, 그렇지 않으면 nil 반환한다.
- recover 는 제한적으로 사용하는 것이 좋다.
- panic 은 호출 순서를 거슬러 올라가 전파된다.
// 패닉의 함수 선언.
func panic(interface{})
//아래와 같은 경우 다 가능하다.
panic(34)
panic("error")
panic(SomeType{someData})
//recover 함수 선언
func recover() interface{}
//recover 도 interface{} 타입을 반환한다. recover 로 반환한 타입을 사용하려면 다음과 같이 타입 검사를 하자.
if r, ok := recover().(net.Error); ok {
...
}
고륀과 동시성 프로그래밍
- 프로그램 시작점임 main() 함수도 고루틴에 의해서 실행된다.
- thread 가 전환될 때마다 thread context 를 저장하고 복원하기에 전환 비용이 든다.
- go 에서는 cpu 코어마다 os 스레드를 하나만 할당해서 사용하기에 컨텍스트 스위치 비용이 발생하지 않는다.
- 고루틴을 생성하는 구문은 아래처럼 이다.
- sync 패키지의 WaitGroup 객체를 사용하면 고루틴이 종료될때 까지 대기할 수 있다.
go 함수_호출
func A() {
...
}
func main() {
go A() //새로운 고루틴 생성
go A() //새로운 고루틴 생성
}
//
var wg sync.WaitGroup
wg.Add(3) //작업 개수 설정
wg.Done() //작업이 완료될 때마다 호출
wg.Wait() //모든 작업이 완료될 때까지 대기.
- 고루틴의 동작 방법
- 고루틴과 스레드 간의 관계를 알아보기 위해 2개의 코어를 가진 컴퓨터에서 고루틴이 어떻게 동작하는지 알아보자.
- 고루틴이 하나일때
- 코어 1 - os thread 1 - 고루틴 1
- 고루틴이 2개일때
- 코어 1 - os thread 1 - 고루틴 1
- 코어 2 - os thread 2 - 고루틴 2
- 고루틴이 3개일때
- 이러면 3번째 고루틴은 남는 코어가 생길때까지 실행되지 않고 멈춰있다.
- 코어 1 - os thread 1 - 고루틴 1
- 코어 2 - os thread 2 - 고루틴 2
- 고루틴 3
- 시스템 콜 호출시
- 시스템 콜을 호출하면 os 에서 해당 서비스가 완료될 때가지 대기한다.
- 기존에 대기하고 있던 고루틴이 동작하게 된다.
- 고루틴이 하나일때
- 결국 컨텍스트 스위치 비용이 발생하지 않는다.
- 코어가 스레드를 변경하지 않고 오직 고루틴만 옮겨 다니기 때문.
- 고루틴과 스레드 간의 관계를 알아보기 위해 2개의 코어를 가진 컴퓨터에서 고루틴이 어떻게 동작하는지 알아보자.
- 뮤텍스
- 한 고루틴이 값을 변경할 때 다른 고루틴이 건들지 못하게 하는방법중 하나가 뮤텍스를 이용 하는 것이다.
- 뮤텍스의 Lock() 메서드를 호출해 뮤텍스를 획득할 수 있다. Unlock() 메서드로 반납한다.
- 이미 Lock() 메서드를 호출해서 다른 고루틴이 뮤텍스를 획득했다면 나중에 호출한 고루틴은 앞의 뮤텍스가 반납할때까지 대기하게 된다.
채널과 컨텍스트
- 채널이란 고루틴끼리 메시지를 전달할 수 있는 메시지 큐이다. 메시지 큐에 메시지들은 차례대로 쌓이게 되고 메시지를 읽을 때는 맨 처음 온 메시지부터 차례대로 읽게 된다.
- 채널을 사용하기 위해선 먼저 채널 인스턴스를 만들어야 한다.
- 채널은 슬라이스, 맵 등과 같이 make() 함수로 만들수 있다. 채널 타입은 채널을 의미하는 chan과 메시지 타입을 합쳐서 표현한다.
- 채널에 데이터 넣기
- 채널에 데이터를 넣을때 <- 연산자를 이용한다.
- 채널에서 데이터 빼기
- 채널에서 데이터를 뺄때도 <- 연산자를 이용한다.
- 일반적으로 채널을 생성하면 크기가 0인 채널이 만들어짐.
- 채널이 더이상 필요없으면 close를 호출해서 닫아준다.
var messages chan string = make(chan string)
// 채널 인스턴스 변수 : messages
// 채널 타입 : chan string
// 채널 키워드 : chan
// 메시지 타입 : string
messages <- "this is a message"
// 채널 인스턴스 : messages
// 연산자 : <-
// 넣을 데이터 : "this is a message"
var msg string = <- messages
// 빼낸 데이터를 담을 변수 : var msg string
// 연산자 : <-
// 채널 인스턴스 : messages
//아래는 채널 사용하는 얘시이다.
func main() {
var wg sync.WaitGroup
ch := make(chan int) // 채널 생성
wg.Add(1)
go square(&wg, ch) // 고루틴 생성
ch <- 9 // 채널에 데이너 넣음
wg.Wait() // 작업이 완료되길 기다림.
}
func square(wg *sync.WaitGroup, ch chan int) {
n := <- ch // 채널에서 데이터 빼옴, 현재 채널에는 아무런 데이터가 없기 때문에 데이터가 들어올때까지 대기하게 됨.
time.Sleep(time.Second) //1초 대기
wg.Done()
}
// 크기가 0인 채널 생성 예시.
func main() {
ch : make(chan int) // 크기가 0인 채널 생성
ch <- 9 // main 함수가 여기서 멈춤, 채널에 데이터를 넣었지만, 보관할 곳이 없어 다른 고루틴에서 데이터를 빼가기를 기다림.
fmt.Print("never print")
}
// 버퍼를 가진 채널 생성
var chan string messages = make(chan string, 2) // 버퍼 크기 2로 만들기.
//버퍼가 다 차면 버퍼가 없을때 와 마찬가지로 빈자리가 생길때까지 대기하게 됨. 고루틴 멈춤.
// 아래와 같은 사용법도 가능하다.
select {
case n1 := <- ch1:
... //ch1 채널에서 데이터를 빼낼 수 있을 때 실행
case n2 := <- ch2:
... //ch2 채널에서 데이터를 빼낼 수 있을 때 실행
}