배열 - 반복문 없이 통째로 계산하기
이 장에서 배우는 것
앞 장에서는 이름 붙은 do 루프로 같은 계산을 여러 번 되풀이하는 법을 배웠다. 이 장에서는 값 여러 개를 이름 하나로 묶어 담는 배열(array)을 다룬다. 배열을 쓰면 하루치 기온 24개를 변수 24개로 나눠 적지 않아도 된다. 합, 최댓값, 조건에 맞는 개수 같은 계산도 do 루프 없이 한 줄로 쓸 수 있다.
- 배열을 선언하고, 1부터 시작하는 첨자로 원소를 읽고 쓴다.
- 배열 생성자와 묵시적 do 로 배열에 값을 채운다.
- 배열 단위 연산과 구간(section), 간격(stride)으로 반복문 없이 계산한다.
sum,maxval,minloc,count로 배열을 하나의 값으로 요약한다.where와merge로 조건에 따라 칸마다 다른 값을 넣는다.
문제 상황
작은 기상 관측소가 하루 동안 한 시간마다 기온(도)과 강수량(mm)을 기록했다. 자료는 기온 24개, 강수량 24개다. 오늘 보고서에 넣을 항목은 다음과 같다. 하루 평균 기온, 최고·최저 기온과 그 시각, 낮 시간대 평균, 비가 온 시간 수와 총 강수량이다.
배열을 모르면 24개 값을 변수 24개에 따로 담거나, 앞 장의 do 루프로 하나씩 훑어야 한다. 평균과 최댓값만 구해도 코드는 이렇게 된다.
total = 0.0_dp
tmax = temp(1)
do i = 1, 24
total = total + temp(i)
if (temp(i) > tmax) tmax = temp(i)
end do
mean = total / 24.0_dp
이 코드는 틀리지 않았다. 다만 구할 값이 늘 때마다 누적 변수와 루프 안의 조건문이 늘어난다. 읽는 사람은 "최댓값을 구한다"는 의도를 루프 속에서 직접 찾아내야 한다. Fortran 은 배열 전체를 한 번에 다루는 문법을 언어에 넣어 두었다. 이 장에서는 그 문법으로 위 보고서를 만든다.
배열 선언, 첨자, 생성자
선언과 1부터 시작하는 첨자
배열은 같은 종류의 값을 일렬로 놓은 묶음이다. 묶음 안의 값 하나를 원소(element)라 하고, 원소의 자리를 가리키는 번호를 첨자(subscript)라 한다. 선언은 변수 이름 뒤 괄호에 원소 개수를 적는다.
integer, parameter :: dp = kind(1.0d0)
real(dp) :: temp(24) ! 원소 24개, 첨자는 1~24
real(dp), dimension(24) :: precip ! 같은 뜻을 다르게 쓴 형태
Fortran 의 첨자는 기본적으로 1에서 시작한다. temp(1) 이 첫 원소이고 temp(24) 가 마지막 원소다. 0시부터 기록한 자료라면 0시 값이 temp(1) 에, 13시 값이 temp(14) 에 들어간다. 시각과 첨자가 하나씩 어긋나는 점은 이 장 끝까지 따라다니는 주의점이다.
시각 그대로를 첨자로 쓰고 싶다면 첨자 범위를 직접 정할 수 있다. 콜론 앞이 시작 번호, 뒤가 끝 번호다.
real(dp) :: temp0(0:23) ! 첨자 0~23, temp0(13) 이 13시 값
이 장의 예제는 기본 규칙인 1부터 시작하는 형태를 그대로 쓴다. 배열의 원소 개수는 size(temp) 로, 첨자의 시작과 끝은 lbound(temp, 1) 과 ubound(temp, 1) 로 물을 수 있다. 숫자 24를 코드 곳곳에 적는 대신 size 를 쓰면 배열 크기를 바꿀 때 고칠 곳이 줄어든다.
배열 생성자
배열에 값을 한꺼번에 채울 때는 대괄호로 값을 나열한다. 이를 배열 생성자(array constructor)라 한다.
real(dp) :: w(3)
w = [1.5_dp, 2.5_dp, 4.0_dp]
생성자 안의 값은 모두 같은 종류여야 하고, 개수는 왼쪽 배열의 크기와 같아야 한다. 정수와 실수를 섞고 싶으면 맨 앞에 형(type)을 밝힌다. [real(dp) :: 1, 2.5_dp, 4] 처럼 쓰면 모든 값이 real(dp) 로 바뀐다.
값이 규칙적으로 늘어난다면 묵시적 do(implied do)를 쓴다. 괄호 안에 식과 반복 변수를 함께 적는다.
hour = [(i - 1, i = 1, 24)] ! 0, 1, 2, ..., 23
앞 장의 do 루프를 식 하나로 줄인 모양이다. 반복 변수 i 는 미리 선언해 두어야 한다. 모든 칸을 같은 값으로 채울 때는 생성자가 필요 없다. precip = 0.0_dp 처럼 스칼라(값 하나)를 배열에 대입하면 모든 칸에 같은 값이 들어간다.
배열 단위 연산과 구간
배열 단위 연산
배열 이름을 첨자 없이 쓰면 배열 전체를 뜻한다. 크기가 같은 두 배열을 더하거나 빼면 같은 자리의 원소끼리 계산해서 같은 크기의 배열이 나온다. 배열과 스칼라를 계산하면 스칼라가 모든 칸에 적용된다.
dev = temp - mean_all ! 24칸 모두에서 평균을 뺀다
tf = temp * 9.0_dp / 5.0_dp + 32.0_dp
이런 계산을 원소 단위(elemental) 연산이라 한다. 앞 장에서 쓴 sqrt, abs 같은 내장 함수도 배열을 받으면 칸마다 적용된다. abs(dev) 는 24개 절댓값의 배열이다.
두 가지를 기억해 둔다. 첫째, 연산에 참여하는 배열은 모양(shape)이 같아야 한다. 크기가 24인 배열과 23인 배열은 더할 수 없다. 둘째, 대입은 오른쪽을 모두 계산한 다음에 왼쪽에 넣는다. a(2:5) = a(1:4) 처럼 겹치는 범위를 대입해도 원래 값이 먼저 밀려 덮어써지지 않는다.
구간과 간격
배열의 일부만 고르려면 첨자 자리에 시작:끝:간격 을 쓴다. 이를 구간(section)이라 한다. 끝 번호도 포함되며, 간격을 생략하면 1이다. 시작이나 끝을 생략하면 배열의 처음과 끝으로 채워진다.
| 표기 | 선택되는 첨자 | 원소 수 | 설명 |
|---|---|---|---|
temp(7:19) | 7, 8, ..., 19 | 13 | 양 끝을 포함한다 |
temp(:5) | 1, 2, 3, 4, 5 | 5 | 시작을 생략하면 첫 원소 |
temp(20:) | 20, 21, 22, 23, 24 | 5 | 끝을 생략하면 마지막 원소 |
temp(1:24:6) | 1, 7, 13, 19 | 4 | 6칸씩 건너뛴다 |
temp(24:22:-1) | 24, 23, 22 | 3 | 간격이 음수면 거꾸로 센다 |
구간은 배열이므로 배열 연산과 요약 함수에 그대로 넣을 수 있다. sum(temp(7:19)) 는 7번째부터 19번째 원소의 합이다. 구간에 값을 대입할 수도 있다. precip(3:4) = [0.4_dp, 0.2_dp] 는 3번째와 4번째 칸만 바꾼다. 왼쪽 구간과 오른쪽 값의 개수는 같아야 한다.
위 그림은 기온 배열의 앞 13칸이다. 칸 위의 숫자가 첨자이고 칸 안의 숫자가 값이다. 구간은 메모리를 복사해 새로 만드는 것이 아니라 원래 배열의 일부를 가리키는 이름표로 생각하면 된다.
요약 함수와 조건 선택
배열을 값 하나로 줄이는 함수
배열 전체를 받아 값 하나로 줄이는 내장 함수가 있다. 이 장에서 쓰는 것은 다음과 같다.
| 함수 | 돌려주는 것 | 쓰는 예 | 이 장 자료의 결과 |
|---|---|---|---|
sum | 모든 원소의 합 | sum(precip) | 13.00 |
maxval, minval | 최댓값, 최솟값 | maxval(temp) | 21.40 |
maxloc, minloc | 최댓값, 최솟값이 있는 첨자 | minloc(temp, dim=1) | 5 |
count | 조건이 참인 원소 수 | count(precip > 0.0_dp) | 7 |
precip > 0.0_dp 처럼 배열을 비교하면 같은 크기의 논리 배열이 나온다. 칸마다 참 또는 거짓이 들어 있는 배열이다. count 는 이 논리 배열에서 참의 개수를 센다. 논리 배열은 any(하나라도 참인가)와 all(모두 참인가)에도 넣을 수 있다.
maxloc 과 minloc 은 값이 아니라 위치를 돌려준다. 1차원 배열에서 위치만 숫자 하나로 받으려면 dim=1 을 붙인다. 붙이지 않으면 원소가 하나인 배열이 나온다. 또 위치는 첨자이지 시각이 아니다. 5번째 칸이 최저라면 시각은 4시다.
합이나 개수를 일부 칸에 대해서만 구하고 싶을 때는 mask= 인수에 논리 배열을 준다. sum(temp, mask = temp > 20.0_dp) 는 20도를 넘은 칸만 더한다. 구간으로 범위를 자를 수도 있고 마스크로 조건을 걸 수도 있다. 시각처럼 위치로 정해지는 범위는 구간이 간단하고, 값의 조건으로 정해지는 범위는 마스크가 알맞다.
where 와 merge
칸마다 조건이 다르게 적용되는 계산에는 where 와 merge 를 쓴다. 먼저 where 는 앞 장의 if 와 비슷한 구조인데, 배열의 칸 단위로 판단한다.
where (temp > 20.5_dp)
warm = temp - 20.5_dp
elsewhere
warm = 0.0_dp
end where
조건이 참인 칸에는 첫 번째 대입이, 거짓인 칸에는 elsewhere 쪽 대입이 적용된다. 조건과 대입의 양쪽 배열은 모양이 같아야 한다. 대입이 한 줄이고 거짓인 칸을 그대로 둘 때는 where (조건) 대입 한 줄로 줄여 쓸 수 있다.
merge(참일 때 값, 거짓일 때 값, 조건) 는 식 안에서 쓰는 선택 함수다. 칸마다 조건이 참이면 첫 번째 값을, 거짓이면 두 번째 값을 고른다. merge 는 값을 돌려주는 식이므로 대입문의 오른쪽, 다른 함수의 인수, 출력 목록에 바로 넣을 수 있다. 두 번째 값 자리에 또 merge 를 넣으면 세 갈래도 나눌 수 있다. 이 장의 예제는 비의 세기를 이렇게 나눈다. 비가 없으면 0, 2mm 미만이면 1, 2mm 이상이면 2다. 한 가지 주의할 점이 있다. merge 의 두 값은 고르기 전에 모두 계산된다. 안 쓰일 쪽 값이 계산 오류를 일으키는 식은 넣지 않는다.
완성 코드
하루치 24개의 기온과 강수량으로 보고서 항목을 계산하는 프로그램이다. 파일 이름은 main.f90 이다. 자료는 코드 안의 배열 생성자로 직접 넣어서, 별도 입력 파일이 필요 없다.
program station_day
implicit none
integer, parameter :: dp = kind(1.0d0)
integer, parameter :: n = 24
real(dp) :: temp(n), precip(n), dev(n), warm(n)
integer :: hour(n), level(n)
integer :: i, imax, imin
real(dp) :: mean_all, mean_day, mean_mask
! 시각 0~23. 첨자 i 는 (i-1)시에 해당한다.
hour = [(i - 1, i = 1, n)]
temp = [ 11.2_dp, 10.8_dp, 10.5_dp, 10.1_dp, 9.8_dp, 9.9_dp, &
10.6_dp, 12.0_dp, 13.9_dp, 15.8_dp, 17.6_dp, 19.1_dp, &
20.3_dp, 21.0_dp, 21.4_dp, 21.1_dp, 20.0_dp, 18.5_dp, &
16.9_dp, 15.4_dp, 14.2_dp, 13.2_dp, 12.4_dp, 11.8_dp ]
! 비는 새벽 2~3시와 오후 15~19시에만 왔다.
precip = 0.0_dp
precip(3:4) = [0.4_dp, 0.2_dp]
precip(16:20) = [1.5_dp, 3.2_dp, 5.0_dp, 2.1_dp, 0.6_dp]
mean_all = sum(temp) / real(size(temp), dp)
imax = maxloc(temp, dim=1)
imin = minloc(temp, dim=1)
dev = temp - mean_all
! 낮(6~18시) 평균: 구간으로, 마스크로 각각 구한다.
mean_day = sum(temp(7:19)) / real(size(temp(7:19)), dp)
mean_mask = sum(temp, mask = (hour >= 6 .and. hour <= 18)) &
/ real(count(hour >= 6 .and. hour <= 18), dp)
! 20.5도를 넘은 만큼만 기록하고 나머지는 0
where (temp > 20.5_dp)
warm = temp - 20.5_dp
elsewhere
warm = 0.0_dp
end where
! 비의 세기 등급: 0 없음, 1 약함(2mm 미만), 2 강함(2mm 이상)
level = merge(2, merge(1, 0, precip > 0.0_dp), precip >= 2.0_dp)
print '(A,F8.2)', '평균 기온(도):', mean_all
print '(A,F8.2,A,I0,A)', '최고 기온(도):', maxval(temp), ' (', hour(imax), '시)'
print '(A,F8.2,A,I0,A)', '최저 기온(도):', minval(temp), ' (', hour(imin), '시)'
print '(A,F8.2)', '일교차(도):', maxval(temp) - minval(temp)
print '(A,F8.2)', '평균과의 최대 차이(도):', maxval(abs(dev))
print '(A,F8.2)', '낮 평균(구간, 6~18시):', mean_day
print '(A,F8.2)', '낮 평균(마스크, 6~18시):', mean_mask
print '(A,4F8.2)', '0,6,12,18시 기온:', temp(1:n:6)
print '(A,3F8.2)', '마지막 3시간(역순):', temp(n:n-2:-1)
print '(A,F8.2)', '총 강수량(mm):', sum(precip)
print '(A,I0)', '비 온 시간 수: ', count(precip > 0.0_dp)
print '(A,F8.2,A,I0,A)', '최대 시강수(mm):', maxval(precip), &
' (', hour(maxloc(precip, dim=1)), '시)'
print '(A,F8.2)', '20.5도 초과 누적(도):', sum(warm)
print '(A,6I2)', '강도 등급(14~19시):', level(15:20)
print '(A,I0)', '강한 비 시간 수: ', count(level == 2)
end program station_day
줄별 해설
integer, parameter :: dp = kind(1.0d0)는 배정밀도 실수의 종류 번호를 상수dp로 정한다. 모든 실수 선언과 상수에 이 종류를 쓴다.n = 24는 배열 크기를 한곳에서 정하는 상수다.real(dp) :: temp(n), ...는 크기n의 실수 배열 네 개를 선언하고,integer :: hour(n), level(n)은 정수 배열 두 개를 선언한다. 배열 크기에 상수 이름을 쓸 수 있다.hour = [(i - 1, i = 1, n)]는 묵시적 do 로 0부터 23까지를 만든다. 첨자 1인 칸이 0시, 첨자 24인 칸이 23시다.temp = [ ... ]는 24개의 값을 한 번에 넣는다. 줄 끝의&는 다음 줄로 이어진다는 표시다. 값 개수가 24가 아니면 컴파일 때 오류가 난다.precip = 0.0_dp는 모든 칸을 0으로 채운다. 이어서precip(3:4)와precip(16:20)구간에만 값을 넣는다. 3번째와 4번째 칸은 2시와 3시이고, 16번째부터 20번째 칸은 15시부터 19시다.sum(temp) / real(size(temp), dp)는 합을 원소 수로 나눈다.real(…, dp)로 정수 개수를 배정밀도 실수로 바꿔서 나눈다.maxloc(temp, dim=1)과minloc(temp, dim=1)은 최고와 최저 기온이 있는 첨자를 정수 하나로 돌려준다. 이 값은 시각이 아니므로, 출력에서는hour(imax)로 시각을 꺼낸다.dev = temp - mean_all은 24칸 전체에서 평균을 뺀 배열을 한 번에 만든다. 뒤에서abs(dev)의 최댓값으로 평균에서 가장 멀리 떨어진 정도를 구한다.sum(temp(7:19))는 6시부터 18시까지(첨자 7~19)의 합이고, 개수 13은size(temp(7:19))로 얻는다. 다음 줄은 같은 값을 마스크로 구한다.hour >= 6 .and. hour <= 18이 참인 칸만 더하고,count로 그 칸의 수를 센다. 두 방법의 결과는 같다.where ... elsewhere ... end where는 20.5도를 넘은 칸에만 초과분을 넣고 나머지 칸에는 0을 넣는다.level = merge(2, merge(1, 0, precip > 0.0_dp), precip >= 2.0_dp)에서는 안쪽merge가 비의 유무에 따라 1 또는 0을 고른다. 바깥쪽merge는 2mm 이상인 칸에서 2를 고르고, 아닌 칸은 안쪽 결과를 쓴다.- 출력의
F8.2는 전체 8칸, 소수점 아래 2자리로 실수를 쓴다.I0는 정수를 빈칸 없이 필요한 만큼만 쓴다.4F8.2는F8.2를 4번 되풀이하라는 뜻이라 배열 구간temp(1:n:6)의 4개 값이 한 줄에 나온다. temp(n:n-2:-1)은 24, 23, 22번째를 이 순서로 고르는 구간이다. 마지막 3시간의 기온이 역순으로 나온다.level(15:20)은 14시부터 19시의 등급이다.6I2로 폭 2칸씩 6개를 쓴다.
실행 결과
다음 명령으로 컴파일하고 실행한다. 경고는 나오지 않는다.
$ gfortran -std=f2018 -Wall -o station main.f90
$ ./station
평균 기온(도): 14.90
최고 기온(도): 21.40 (14시)
최저 기온(도): 9.80 (4시)
일교차(도): 11.60
평균과의 최대 차이(도): 6.50
낮 평균(구간, 6~18시): 17.55
낮 평균(마스크, 6~18시): 17.55
0,6,12,18시 기온: 11.20 10.60 20.30 16.90
마지막 3시간(역순): 11.80 12.40 13.20
총 강수량(mm): 13.00
비 온 시간 수: 7
최대 시강수(mm): 5.00 (17시)
20.5도 초과 누적(도): 2.00
강도 등급(14~19시): 0 1 2 2 2 1
강한 비 시간 수: 3
두 방식으로 구한 낮 평균이 같다. 14시에서 15시, 최고 기온의 첨자 15와 시각 14가 한 칸 어긋나는 점도 확인할 수 있다.
실무에서 자주 틀리는 것
첨자 범위를 벗어나 읽는다
배열 크기가 24인데 반복을 25까지 돌리거나, 첨자를 0부터 센다. 컴파일러가 경고를 낼 수도 있지만 그대로 실행되는 경우가 많다. 이때는 배열 바깥 메모리를 읽어서 엉뚱한 값이 합에 섞인다.
! 틀린 코드
total = 0.0_dp
do i = 0, 24
total = total + temp(i)
end do
! 고친 코드
total = 0.0_dp
do i = 1, size(temp)
total = total + temp(i)
end do
! 더 간단히: total = sum(temp)
의심스러울 때는 gfortran -std=f2018 -Wall -fcheck=bounds 로 컴파일한다. 범위를 벗어난 접근이 실행 중에 오류로 잡혀 프로그램이 멈춘다. 검사 코드가 들어가므로 실행이 느려져서, 개발 중에만 켜는 것이 좋다.
minloc 의 결과를 시각으로 착각한다
minloc 에 dim=1 을 빼먹으면 원소가 하나인 배열이 나온다. 정수 변수 하나에 대입하면 컴파일 오류다. 오류를 피해 값을 받아도 그 값은 첨자이지 시각이 아니다.
! 틀린 코드
integer :: imin
imin = minloc(temp) ! 컴파일 오류: 배열을 스칼라에 대입
print *, '최저 시각:', imin ! (오류를 피해도 5, 시각은 4)
! 고친 코드
imin = minloc(temp, dim=1)
print *, '최저 시각:', hour(imin) ! 4
배열 전체를 if 에 넣는다
if 의 조건은 참 또는 거짓 하나여야 한다. 배열을 비교하면 칸마다 참·거짓이 있는 논리 배열이라서 if 에 쓸 수 없다. 컴파일 오류가 난다.
! 틀린 코드
if (temp > 30.0_dp) print *, '폭염 시간이 있다'
! 고친 코드
if (any(temp > 30.0_dp)) print *, '폭염 시간이 있다'
! 칸마다 다르게 처리하려면 where 를 쓴다.
생성자의 값 종류를 섞는다
배열 생성자에서 정수와 실수를 섞으면 컴파일 오류가 난다. 값 개수가 왼쪽 배열 크기와 다른 경우도 마찬가지로 컴파일 오류다.
! 틀린 코드
real(dp) :: w(3)
w = [1, 2.5_dp, 4]
! 고친 코드
w = [1.0_dp, 2.5_dp, 4.0_dp]
! 또는 형을 밝히면 정수 값이 변환된다.
w = [real(dp) :: 1, 2.5_dp, 4]
한눈에 보기
| 하고 싶은 일 | 쓰는 법 | 예 | 주의 |
|---|---|---|---|
| 배열 선언 | 이름 뒤 괄호에 크기 | real(dp) :: temp(24) | 첨자는 1부터 |
| 값 채우기 | 생성자, 묵시적 do | [(i - 1, i = 1, n)] | 값의 종류와 개수가 맞아야 한다 |
| 전체 계산 | 첨자 없이 이름만 | dev = temp - mean_all | 모양이 같아야 한다 |
| 일부 고르기 | 시작:끝:간격 | temp(1:n:6) | 끝 번호도 포함된다 |
| 하나로 요약 | sum, maxval, count | count(precip > 0.0_dp) | minloc 은 첨자를 돌려준다 |
| 칸별 선택 | where, merge | merge(2, 1, precip >= 2.0_dp) | if 에 배열 조건을 쓰지 않는다 |
연습 문제
- 구간을 사용해 오전 0시부터 11시까지(12개)의 평균 기온을 구하는 식을 쓰고 결과를 계산하시오.
count를 사용해 기온이 15도 이상인 시간 수와 10도 미만인 시간 수를 구하는 식을 각각 쓰고 결과를 계산하시오.- 기온 배열
temp를 화씨로 바꾼 배열tf를 반복문 없이 만들고, 최고 기온의 화씨 값을F8.2로 출력하는 문장을 쓰시오. 섭씨를 화씨로 바꾸는 식은 섭씨 × 9/5 + 32 이다. temp(24:1:-5)가 고르는 첨자와 원소 수를 구하고, 각 첨자에 해당하는 시각을 쓰시오.
정답과 해설
sum(temp(1:12)) / real(size(temp(1:12)), dp)이다. 첨자 1~12의 합은 151.3 이고 12로 나누면 12.608... 이므로 12.61 이다. 구간은 끝 번호를 포함하므로 11이 아니라 12까지 쓴다.count(temp >= 15.0_dp)는 11이다. 첨자 10~20에 해당하는 값(15.8에서 15.4까지)이 조건을 만족한다.count(temp < 10.0_dp)는 2이다. 9.8 과 9.9 가 해당한다.tf = temp * 9.0_dp / 5.0_dp + 32.0_dp로 24칸을 한 번에 바꾼다. 출력은print '(F8.2)', maxval(tf)이고 결과는70.52이다. 21.4 × 1.8 + 32 = 70.52 이다. 상수에_dp를 붙이는 것을 잊지 않는다.- 첨자는 24, 19, 14, 9, 4 이고 원소는 5개다. 시각은 첨자에서 1을 뺀 23시, 18시, 13시, 8시, 3시다. 간격이 음수이면 시작 번호가 끝 번호보다 커야 한다. 그렇지 않으면 아무 원소도 선택되지 않는다.