파생 타입 - 관측값을 한 묶음으로
이 장에서 배우는 것
앞 장에서는 관측 자료를 파일에서 읽어 서식에 맞춰 출력하는 방법을 다뤘다. 그 과정에서 시각, 기온, 강수량이 서로 다른 배열에 따로 담겼다. 값이 서너 개일 때는 이것으로 충분하지만, 항목이 늘어나면 "같은 시각에 속한 값"이라는 관계를 프로그래머가 머릿속으로 계속 기억해야 한다. 이 장에서는 관련된 값을 이름 붙은 한 덩어리로 묶는 파생 타입(derived type)을 배운다. 타입에 그 값을 다루는 함수까지 붙이는 방법도 함께 다룬다.
type으로 시각·기온·강수량을 묶은 타입을 정의하고%로 구성 요소(component)를 읽고 쓴다.- 파생 타입 배열을 만들고,
obs(i)%temp_c와obs%temp_c의 차이를 구별한다. contains와procedure로 타입에 속한 프로시저(타입 바운드 프로시저)를 선언하고obs%show()형태로 호출한다.- 값을 검증하면서 객체를 만드는 생성 함수의 관례를 익힌다.
문제 상황
관측소에서 오전 6시부터 오후 1시까지 여덟 시간의 기온과 강수량을 받았다고 하자. 지금까지 방식대로라면 hours(8), temps(8), rains(8) 세 배열을 만든다. 이 방식은 곧 문제를 일으킨다.
첫째, 서브루틴에 자료를 넘길 때마다 인수가 세 개씩 따라간다. 풍속이나 기압을 추가하면 모든 호출부를 고쳐야 한다. 둘째, 자료를 정렬하거나 일부만 골라낼 때 세 배열을 똑같이 재배열해야 하는데, 하나라도 빠뜨리면 14시의 기온에 13시의 강수량이 붙는 오류가 생긴다. 컴파일러는 이 오류를 알려 주지 않는다. 셋째, "비가 왔는가"를 판단하는 기준(예: 0.1 mm 이상)이 프로그램 곳곳에 흩어져 서로 어긋나기 쉽다.
이런 문제는 시각, 기온, 강수량을 하나의 관측값으로 묶고, 관측값에 대한 질문("비가 왔는가", "화씨로는 몇 도인가")을 그 타입 가까이에 두면 줄어든다. 이 장의 프로그램은 여덟 개의 관측값을 만들어 표로 출력하고 평균 기온, 총강수량, 비 온 시간 수, 최고 기온 시각을 계산한다. 자료는 파일에서 읽지 않고 코드에 직접 적는다. 파일 읽기는 앞 장의 방법으로 바꿔 끼울 수 있다.
파생 타입의 정의와 사용
type 으로 구성 요소 묶기
파생 타입은 프로그래머가 정의하는 자료형이다. integer, real 같은 내장 타입을 재료로 삼아 새 타입을 만든다. 이 장의 타입은 다음과 같다.
type :: observation
integer :: hour = 0
real(wp) :: temp_c = 0.0_wp
real(wp) :: rain_mm = 0.0_wp
end type observation
hour, temp_c, rain_mm 을 구성 요소라 한다. 구성 요소마다 = 0 처럼 기본값을 줄 수 있다. 이 타입의 변수를 선언하면 따로 대입하지 않아도 기본값이 들어 있으므로, 초기화하지 않은 쓰레기 값을 읽는 실수를 막아 준다. 여기서 wp 는 두 장 앞에서 배운 대로 정밀도를 고르는 종류 값(kind)이며, 이 장에서는 8바이트 실수인 real64 로 정했다.
타입을 정의한 것만으로는 메모리에 아무것도 생기지 않는다. 타입은 설계도이고 type(observation) :: latest 처럼 변수를 선언해야 실제 값이 생긴다. 변수의 구성 요소는 % 기호로 가리킨다.
latest%temp_c = 21.4_wp
print *, latest%hour
다른 언어에서 점(.)을 쓰던 자리에 Fortran 은 % 를 쓴다. 점은 .and. 같은 연산자 표기와 겹치기 때문이다.
값을 한꺼번에 채우는 구조 생성자(structure constructor)도 있다. 타입 이름을 함수처럼 쓰고 구성 요소를 선언 순서대로 적는다. 예를 들면 observation(12, 22.0_wp, 2.6_wp) 이다. 같은 타입의 변수끼리는 b = a 로 통째로 복사된다. 구성 요소마다 대입문을 쓸 필요가 없다.
파생 타입 배열
파생 타입도 배열의 원소 타입이 될 수 있다. type(observation) :: obs(8) 은 관측값 여덟 개를 담는다. 이때 구성 요소를 가리키는 방법은 두 가지다. 어느 쪽을 쓰느냐에 따라 결과의 모양이 달라진다.
| 표현 | 가리키는 것 | 결과의 모양 |
|---|---|---|
obs(3) | 세 번째 관측값 전체 | observation 하나 |
obs(3)%temp_c | 세 번째 관측값의 기온 | 실수 하나 |
obs%temp_c | 모든 관측값의 기온 | 실수 배열(길이 8) |
obs(2:4)%rain_mm | 2~4번째 관측값의 강수량 | 실수 배열(길이 3) |
세 번째 줄이 중요하다. obs%temp_c 는 배열이므로 앞서 배운 배열 연산을 그대로 쓸 수 있다. sum(obs%temp_c) 는 기온의 합이고 maxloc(obs%temp_c, dim=1) 은 기온이 가장 높은 원소의 번호다. 반복문을 쓰지 않고 한 항목만 골라 계산할 수 있다.
그림은 논리적인 구조를 보여 준다. 각 행이 관측값 하나이고, 강조한 열이 obs%temp_c 가 가리키는 값들이다. 이 값들이 메모리에서 어떻게 놓이는지는 컴파일러가 정한다. 프로그램은 그 배치에 기대지 않고 % 표기로만 접근한다.
타입에 붙는 프로시저
contains 와 procedure
"비가 왔는가"를 판단하는 함수는 관측값에만 쓰인다. 그렇다면 이 함수를 타입의 일부로 선언하는 편이 자연스럽다. 타입 정의 안에 contains 를 쓰고 그 아래에 procedure 문을 나열한다.
type :: observation
integer :: hour = 0
real(wp) :: temp_c = 0.0_wp
real(wp) :: rain_mm = 0.0_wp
contains
procedure :: is_rainy
procedure :: temp_f
procedure :: show
end type observation
procedure :: is_rainy 는 "이 타입에는 is_rainy 라는 프로시저가 속한다"는 선언이다. 실제 본문은 모듈의 contains 아래에 따로 쓴다. 이 두 contains 는 서로 다르다. 타입 안의 것은 타입에 속한 프로시저의 목록을 열고, 모듈 안의 것은 모듈 프로시저의 본문을 연다.
본문에서 첫 번째 인수는 호출한 객체 자신을 받는다. 이 인수를 class(observation) 로 선언하며, 이 책에서는 이름을 self 로 쓴다. 이름은 규칙이 아니라 관례다.
pure function is_rainy(self) result(rainy)
class(observation), intent(in) :: self
logical :: rainy
rainy = self%rain_mm >= rain_threshold
end function is_rainy
class 는 이 타입과 이 타입에서 확장된 타입을 모두 받겠다는 뜻이다. 이 책에서는 확장 타입을 다루지 않으므로 "타입 바운드 프로시저의 첫 인수는 type 이 아니라 class 로 쓴다" 정도로 기억하면 된다. 호출은 다음과 같이 한다.
if (obs(3)%is_rainy()) print *, 'rain'
call obs(3)%show()
점 앞의 obs(3) 이 자동으로 첫 인수 self 가 된다. 괄호 안에는 나머지 인수만 적는다. 인수가 없으면 빈 괄호를 쓴다. 함수 호출이라는 사실을 드러내려면 빈 괄호가 필요하다.
모듈이 필요한 이유
타입 바운드 프로시저로 연결할 수 있는 것은 모듈 프로시저, 또는 인터페이스가 명시된 외부 프로시저다. 프로그램 안의 contains 에 쓴 내부 프로시저는 연결할 수 없다. 그래서 타입과 그 프로시저는 모듈에 함께 둔다. 앞서 모듈을 파일로 나누는 방법을 익혔으므로 이 장은 obs_mod.f90 와 main.f90 두 파일로 구성한다. 모듈에는 private 를 선언해 내부 상수 rain_threshold 를 숨기고, 밖에서 쓸 이름만 public 으로 연다.
생성 함수 관례
구조 생성자는 편리하지만 값을 검사하지 않는다. 시각이 25이거나 강수량이 음수인 관측값도 그대로 만들어진다. 이런 값은 처음부터 막아야 이후 계산이 틀어지지 않는다. 관례는 객체를 만들어 돌려주는 생성 함수(constructor function)를 따로 두는 것이다. 이 책은 make_ 접두어를 붙인다.
function make_observation(hour, temp_c, rain_mm) result(obs)
integer, intent(in) :: hour
real(wp), intent(in) :: temp_c, rain_mm
type(observation) :: obs
if (hour < 0 .or. hour > 23) error stop 'make_observation: hour must be 0-23'
if (rain_mm < 0.0_wp) error stop 'make_observation: rain_mm must not be negative'
obs = observation(hour, temp_c, rain_mm)
end function make_observation
잘못된 값이 들어오면 error stop 으로 프로그램을 즉시 멈춘다. 마지막 줄에서 구조 생성자로 실제 값을 채운다. 규모가 큰 프로그램에서는 함수 이름을 타입 이름과 같게 하여 구조 생성자를 대신하는 방법도 쓴다. 그러려면 제네릭 인터페이스를 선언해야 하는데, 이 장에서는 이름이 분명한 make_ 방식을 택한다.
완성 코드
모듈 파일을 먼저 컴파일하고 main.f90 을 마지막에 컴파일한다. 컴파일러가 모듈을 처리하면서 만드는 .mod 파일을 main.f90 이 읽기 때문이다.
obs_mod.f90
module obs_mod
use, intrinsic :: iso_fortran_env, only: real64
implicit none
private
public :: wp, observation, make_observation
integer, parameter :: wp = real64
real(wp), parameter :: rain_threshold = 0.1_wp
type :: observation
integer :: hour = 0
real(wp) :: temp_c = 0.0_wp
real(wp) :: rain_mm = 0.0_wp
contains
procedure :: is_rainy
procedure :: temp_f
procedure :: show
end type observation
contains
function make_observation(hour, temp_c, rain_mm) result(obs)
integer, intent(in) :: hour
real(wp), intent(in) :: temp_c, rain_mm
type(observation) :: obs
if (hour < 0 .or. hour > 23) error stop 'make_observation: hour must be 0-23'
if (rain_mm < 0.0_wp) error stop 'make_observation: rain_mm must not be negative'
obs = observation(hour, temp_c, rain_mm)
end function make_observation
pure function is_rainy(self) result(rainy)
class(observation), intent(in) :: self
logical :: rainy
rainy = self%rain_mm >= rain_threshold
end function is_rainy
pure function temp_f(self) result(f)
class(observation), intent(in) :: self
real(wp) :: f
f = self%temp_c * 9.0_wp / 5.0_wp + 32.0_wp
end function temp_f
subroutine show(self)
class(observation), intent(in) :: self
character(len=4) :: state
state = merge('rain', 'dry ', self%is_rainy())
write (*, '(i4, ":00", 3f8.1, 3x, a)') self%hour, self%temp_c, self%temp_f(), &
self%rain_mm, trim(state)
end subroutine show
end module obs_mod
main.f90
program station_summary
use obs_mod, only: wp, observation, make_observation
implicit none
integer, parameter :: n = 8
type(observation) :: obs(n)
type(observation) :: latest
integer :: i, rainy_hours, peak
real(wp) :: mean_temp, total_rain
obs = [ make_observation(6, 14.2_wp, 0.0_wp), &
make_observation(7, 15.0_wp, 0.0_wp), &
make_observation(8, 16.8_wp, 0.4_wp), &
make_observation(9, 18.1_wp, 1.2_wp), &
make_observation(10, 19.5_wp, 0.0_wp), &
make_observation(11, 21.3_wp, 0.0_wp), &
make_observation(12, 22.0_wp, 2.6_wp), &
make_observation(13, 20.4_wp, 0.8_wp) ]
write (*, '(a)') ' hour temp_C temp_F rain_mm state'
do i = 1, n
call obs(i)%show()
end do
mean_temp = sum(obs%temp_c) / n
total_rain = sum(obs%rain_mm)
peak = maxloc(obs%temp_c, dim=1)
rainy_hours = 0
do i = 1, n
if (obs(i)%is_rainy()) rainy_hours = rainy_hours + 1
end do
write (*, '(a, f8.2)') 'mean temp (C):', mean_temp
write (*, '(a, f8.1)') 'total rain (mm):', total_rain
write (*, '(a, i3)') 'rainy hours:', rainy_hours
write (*, '(a, i3, a, f5.1)') 'peak:', obs(peak)%hour, ':00,', obs(peak)%temp_c
latest = obs(n)
latest%temp_c = latest%temp_c + 1.0_wp
write (*, '(a, f5.1, a, f5.1)') 'copy:', latest%temp_c, ' original:', obs(n)%temp_c
end program station_summary
줄별 해설
obs_mod.f90
- 2행: 표준 모듈
iso_fortran_env에서 8바이트 실수 종류 값real64만 가져온다. - 4~5행:
private로 모듈의 이름을 기본적으로 숨기고, 밖에서 쓸wp,observation,make_observation만 연다.rain_threshold와is_rainy등의 본문 이름은 숨겨져 있어도 타입을 통한 호출은 가능하다. - 7~8행: 종류 값
wp와 비 온 것으로 치는 기준 0.1 mm 를 상수로 선언한다. 기준을 바꿀 때 이 한 줄만 고치면 된다. - 10~13행: 구성 요소 세 개와 기본값이 있는 타입 정의다.
- 14~17행:
contains아래에서 타입에 속한 프로시저 세 개를 선언한다. 본문은 모듈의contains아래에 쓴다. - 22~30행: 생성 함수다. 27~28행에서 범위를 벗어난 시각과 음수 강수량을 거르고, 29행에서 구조 생성자로 값을 채운다. 결과 변수 이름
obs는 이 함수 안에서만 쓰인다. - 32~37행:
pure는 부작용이 없는 함수라는 표시다.self는intent(in)이라 읽기만 한다. - 39~44행: 섭씨를 화씨로 바꾼다.
9.0_wp / 5.0_wp처럼 실수로 써서 정수 나눗셈이 되지 않게 했다. - 50행:
merge는 조건이 참이면 첫 값을, 거짓이면 둘째 값을 고른다. 두 문자열의 길이를 4로 맞춰야 하므로'dry '뒤에 공백을 하나 넣었다. - 51~52행: 서식
3f8.1은 실수 세 개를 폭 8, 소수 한 자리로 쓴다. 줄 끝의&는 다음 줄로 이어진다는 표시이고,trim(state)가 끝의 공백을 잘라 낸다.
main.f90
- 2행: 모듈에서 쓸 이름만 가져온다. 빠뜨린 이름을 쓰면 컴파일 오류가 되어 어디서 왔는지 분명하다.
- 6~7행: 관측값 8개짜리 배열과 하나짜리 변수를 선언한다. 기본값 덕분에 대입 전에도 안전한 값이 들어 있다.
- 11~18행: 생성 함수를 여덟 번 불러 만든 배열을 대괄호 배열 생성자로 묶어
obs에 한 번에 대입한다. - 21~23행: 원소를 하나씩 꺼내
show를 호출한다. 호출한obs(i)가show의self가 된다. - 25~27행: 반복문 없이
obs%temp_c와obs%rain_mm배열에 합계 함수를 적용한다.maxloc은 최댓값의 위치(1부터 센 번호)를 돌려주므로 시각 12시의 번호는 7이다. - 29~32행: 비 온 시간을 센다. 비 판정 기준은 모듈 안에만 있고 여기서는
is_rainy만 부른다. - 37행: 번호
peak로 최고 기온 관측값의 시각과 기온을 꺼낸다. - 39~41행:
latest = obs(n)은 값을 복사한다. 복사본의 기온을 1도 올려도 원본은 그대로임을 출력으로 확인한다.
실행 결과
$ gfortran -std=f2018 -Wall obs_mod.f90 main.f90 -o station
$ ./station
hour temp_C temp_F rain_mm state
6:00 14.2 57.6 0.0 dry
7:00 15.0 59.0 0.0 dry
8:00 16.8 62.2 0.4 rain
9:00 18.1 64.6 1.2 rain
10:00 19.5 67.1 0.0 dry
11:00 21.3 70.3 0.0 dry
12:00 22.0 71.6 2.6 rain
13:00 20.4 68.7 0.8 rain
mean temp (C): 18.41
total rain (mm): 5.0
rainy hours: 4
peak: 12:00, 22.0
copy: 21.4 original: 20.4
평균 기온은 합 147.3 을 8로 나눈 18.4125 를 소수 둘째 자리로 쓴 값이다. 비 온 시간은 8, 9, 12, 13시의 네 시간이다.
실무에서 자주 틀리는 것
생성자 인수의 순서를 바꿔 쓴다
기온과 강수량은 둘 다 실수이므로 순서를 바꿔 써도 컴파일러가 잡지 못한다.
obs(3) = make_observation(8, 0.4_wp, 16.8_wp) ! 기온 0.4, 강수 16.8 이 된다
인수 이름을 붙여 호출하면 순서가 틀려도 뜻이 변하지 않는다.
obs(3) = make_observation(hour=8, temp_c=16.8_wp, rain_mm=0.4_wp)
첫 인수를 type 으로 선언한다
타입 바운드 프로시저의 객체 인수는 class 여야 한다. type 으로 쓰면 컴파일 오류가 난다.
function temp_f(self) result(f)
type(observation), intent(in) :: self ! 오류
function temp_f(self) result(f)
class(observation), intent(in) :: self
main.f90 을 먼저 컴파일한다
main.f90 이 use obs_mod 를 하는 시점에 obs_mod.mod 가 없으면 컴파일러가 모듈을 찾지 못해 오류를 낸다.
$ gfortran -std=f2018 -Wall main.f90 obs_mod.f90 -o station ! 모듈이 뒤에 있어 실패
$ gfortran -std=f2018 -Wall obs_mod.f90 main.f90 -o station
복사본을 고쳐 놓고 원본이 바뀌길 기대한다
파생 타입의 대입은 값을 복사한다. 복사본을 고쳐도 배열 원소는 바뀌지 않는다.
latest = obs(n)
latest%temp_c = 25.0_wp ! obs(n) 은 그대로
원본을 고치려면 원소를 직접 지정하거나 associate 로 별명을 붙인다.
associate (o => obs(n))
o%temp_c = 25.0_wp ! obs(n)%temp_c 가 바뀐다
end associate
한눈에 보기
| 주제 | 문법 | 예 | 주의 |
|---|---|---|---|
| 타입 정의 | type :: 이름 … end type | type :: observation | 정의만으로는 변수가 생기지 않는다 |
| 구성 요소 접근 | 변수%요소 | latest%temp_c | 점이 아니라 % 를 쓴다 |
| 배열의 한 항목 | 배열%요소 | sum(obs%temp_c) | 결과가 배열이다 |
| 타입 바운드 선언 | contains + procedure :: | procedure :: show | 모듈 프로시저만 연결한다 |
| 타입 바운드 호출 | 객체%이름(인수) | obs(i)%show() | 객체가 첫 인수로 자동 전달된다 |
| 객체 인수 | class(타입) | class(observation) :: self | type 으로 쓰면 오류 |
| 생성 함수 | make_이름 함수 | make_observation(…) | 범위를 검사한다 |
| 비교 항목 | 배열 세 개 | 파생 타입 배열 |
|---|---|---|
| 서브루틴에 넘길 인수 | 배열마다 하나씩 | 배열 하나 |
| 항목을 추가할 때 | 모든 호출부 수정 | 타입 정의만 수정 |
| 재배열할 때 어긋날 위험 | 있다 | 없다 |
| 판정 기준의 위치 | 여러 곳에 분산 | 타입의 프로시저 한 곳 |
연습 문제
- 관측값에 풍속
wind_ms(m/s, 기본값 0.0)를 추가하려 한다. 타입 정의를 어떻게 고쳐야 기존의observation(hour, temp, rain)형태 호출이 계속 동작하는지 설명하라. - 기온이 기준값 이상인지 알려 주는 타입 바운드 함수
is_hot(self, limit)를 작성하라. 이 장의 자료에서 기준값을 21.0 으로 하면 몇 시간이 해당하는가? - 비가 온 시간들만의 시간당 평균 강수량을 배열 구성 요소와
mask를 써서 한 줄로 계산하는 식을 쓰고, 이 장의 자료에서의 값을 구하라. - 타입 바운드 프로시저의 본문을
main.f90의contains아래에 두면 안 되는 이유를 설명하라.
정답과 해설
- 새 구성 요소를 기존 구성 요소 뒤에 추가한다. 구조 생성자는 선언 순서대로 값을 받고, 기본값이 있는 요소는 생략할 수 있다.
중간에 넣으면 기존 호출의 인수가 한 칸씩 밀려 뜻이 달라지므로 주의한다. 생성 함수에 풍속을 받으려면real(wp) :: wind_ms = 0.0_wp ! rain_mm 다음 줄에 추가optional인수로 추가하면 기존 호출도 유지된다. - 모듈에
procedure :: is_hot을 추가하고 본문을 쓴다.
호출은pure function is_hot(self, limit) result(hot) class(observation), intent(in) :: self real(wp), intent(in) :: limit logical :: hot hot = self%temp_c >= limit end function is_hotobs(7)%is_hot(21.0_wp)처럼 한다. 21.0 이상인 값은 21.3(11시)과 22.0(12시)이므로 2시간이다. 20.4 는 해당하지 않는다.
비 온 시간의 강수량 합은 0.4 + 1.2 + 2.6 + 0.8 = 5.0 이고 시간 수는 4이므로 값은 1.25 mm 다.mean_rain = sum(obs%rain_mm, mask = obs%rain_mm >= 0.1_wp) / count(obs%rain_mm >= 0.1_wp)count는 조건이 참인 원소의 개수를 정수로 돌려준다.- 타입 바운드 프로시저로 연결할 수 있는 것은 모듈 프로시저나 인터페이스가 명시된 외부 프로시저다. 프로그램의
contains아래에 쓴 내부 프로시저는 연결 대상이 아니다. 타입 정의가 모듈에 있으므로 프로시저 본문도 같은 모듈에 두는 것이 가장 자연스럽다.