제 4 부 — 프로시저와 모듈



11장 · 인수 전달과 인터페이스


The Contract Between Caller and Callee



[!attention] 이 장의 목표


10장에서 함수와 서브루틴의 기본적인 정의와 호출 방법을 익혔다. 프로시저가 실제 프로그램에서 제 역할을 수행하려면, 호출하는 측과 호출되는 프로시저 사이에서 데이터 전달을 제어할 수 있어야 한다. 본 장에서는 인수 전달의 세부 규칙과 이를 컴파일러가 검증하도록 만드는 장치인 인터페이스를 다룬다. 핵심 개념은 다음과 같이 요약된다.


[!important] 11.1 인수의 방향: intent(in/out/inout)

프로시저로 들어온 가인수가 읽기 전용인지, 결과를 내보내는 통로인지, 아니면 들어온 값을 갱신하여 돌려보내는지를 명시하는 속성이 intent이다. 이 속성은 세 가지 값을 가진다.

intent를 지정하는 이유는 컴파일러가 프로그래머의 의도와 다르게 코드가 작성된 부분을 정확히 잡아내어 버그를 미리 막아 주고, 컴파일 최적화에 필요한 정보를 제공하기 때문이다. 본 교재의 모든 가인수에는 intent 속성을 명시하여 선언하도록 한다.

프로시저의 인수(dummy-arg)는 intent 속성을 사용하여 데이터의 흐름과 보호 수준을 명시해야 한다. 기본적인 선언 형식은 다음과 같다.

⟨type⟩, intent(⟨spec⟩) :: ⟨dummy-arg⟩

여러 속성을 조합하여 다음과 같이 선언할 수 있다.

! 실수형 변수를 읽기 전용으로 전달
real(real64), intent(in) :: x

! 정수형 변수를 입출력용으로 전달
integer, intent(inout) :: count

! 파생형 포인터를 입출력용으로 전달
type(node), pointer, intent(inout) :: head

이런 문법 규칙은 프로시저 간의 원치 않는 데이터 간섭을 방지하고, 프로그램 코드의 구조적 안정성을 높이는 역할을 한다.

[예제] 입력은 보호하고 다중 결과 내보내기

원의 반지름을 입력받아 넓이와 둘레를 동시에 계산하는 서브루틴을 구현한 예제이다. 입력으로 사용되는 반지름은 intent(in) 속성을 지정하여 안전하게 보호하고, 계산된 두 가지 결과물은 각각 intent(out) 속성을 지정하여 호출 측으로 전달한다.

%%writefile circle.f90
program circle_props
   use iso_fortran_env, only: real64
   implicit none
   real(real64), parameter :: pi = 3.141592653589793_real64
   real(real64) :: r, area, perimeter

   r = 2.5_real64
   call circle(r, area, perimeter)
   print '(a, f8.4)', "radius    = ", r
   print '(a, f8.4)', "area      = ", area
   print '(a, f8.4)', "perimeter = ", perimeter

contains

   subroutine circle(radius, a, p)
      real(real64), intent(in)  :: radius
      real(real64), intent(out) :: a, p
      a = pi * radius**2
      p = 2.0_real64 * pi * radius
   end subroutine circle

end program circle_props

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall circle.f90 -o circle
!./circle

실행 결과:

radius    =   2.5000
area      =  19.6350
perimeter =  15.7080

가인수 radiusintent(in) 속성이 부여되어 있으므로, 서브루틴 내부에서 프로그래머의 실수로 반지름 값을 변경하려는 코드를 작성하더라도 컴파일 단계에서 차단된다. 이를 통해 호출하는는 서브루틴이 자신이 넘겨준 원본 변수 r의 값을 절대 변경하지 못하게 보호한다.

만약 intent(in)으로 선언된 가인수 radius에 대해 서브루틴 내부에서 radius = radius + 1.0_real64와 같이 값을 수정하려 시도하면, 컴파일 단계에서 다음 에러 메시지를 출력한다.

Error: Dummy argument 'radius' with INTENT(IN) in variable definition
context (assignment) at (1)

이처럼 intent 속성은 의도하지 않은 잘못된 변수 대입 연산을 컴파일 단계에서 확실하게 걸러내는 안전장치 역할을 한다.

[!warning] [흔한 실수] 실인수와 가인수의 종류(kind) 불일치 오류 호출할 때 넘기는 실인수와 프로시저에서 받는 가인수의 종류(kind)가 다르면 치명적인 오류가 발생할 수 있다. 예를 들어, 단정밀도(4바이트) 실수를 배정밀도(real(real64), 8바이트) 가인수에 그대로 넘기는 경우이다.

따라서 프로그램 전체의 데이터 정밀도는 iso_fortran_env 모듈의 real64로 명확하게 통일하여 사용하는 것이 안전하다. 소스 코드 내에서 사용하는 실인수 형태의 상수 역시 2.0_real64와 같이 가인수의 정의와 완벽히 일치하는 종류의 리터럴 접미사를 붙여 작성해야 한다.

[예제] intent(inout)을 활용한 데이터 누적

호출 측에서 전달한 기존 변수 값에 새로운 계산 결과를 누적하여 갱신하는 예제로서, 1부터 5까지의 정수를 각각 제곱한 뒤 그 합산 결과를 차례대로 누산하는 과정을 보여준다. 서브루틴 내부에서 가인수 acc는 매번 이전까지 계산된 합산 값을 읽어 들인 후 새로운 값을 갱신하므로 intent(inout) 속성을 지정해야 한다.

%%writefile accumulate_demo.f90
program accumulate_demo
   use iso_fortran_env, only: real64
   implicit none
   real(real64) :: total
   integer :: i

   total = 0.0_real64
   do i = 1, 5
      call add_square(total, real(i, real64))
   end do
   print '(a, f8.1)', "sum of squares 1..5 = ", total

contains

   subroutine add_square(acc, x)
      real(real64), intent(inout) :: acc
      real(real64), intent(in)    :: x
      acc = acc + x**2
   end subroutine add_square

end program accumulate_demo

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall accumulate_demo.f90 -o accumulate_demo
!./accumulate_demo

실행 결과:

sum of squares 1..5 =     55.0

이 예제의 핵심은 가인수 accintent(out)가 아닌 intent(inout) 속성을 사용했다는 점이다. 만약 intent(out)을 쓰면, 서브루틴이 시작되자마자 기존에 total이 가지고 있던 원래 값을 컴퓨터가 깨끗이 지워버린다. 빈 공간으로 만들고 시작하는 셈이다.

그렇게 되면 이전에 더해 두었던 누적 값이 모두 사라지기 때문에, 원래 값에 새로운 값을 계속 더해나가는 누적 계산이 불가능해진다. 반면 intent(inout)를 쓰면 원래 들어있던 값을 그대로 가져와서 거기에 계속 더해나갈 수 있다.

[!warning] [흔한 실수] intent(out)으로 누적을 시도하면 값이 사라진다 intent(out)으로 선언한 상태에서 acc = acc + x2와 같은 연산을 수행하면, 더하려는 기준인 acc에 어떤 값이 들어있는지 알 수 없으므로(쓰레기 값이 들어있을 수 있음) 그 계산 결과를 전혀 신뢰할 수 없다. 누적 계산처럼 이전의 값을 그대로 이어받아 그 위에 새로운 값을 더해가며 갱신해야 할 때는, 반드시 기존 값을 유지해 주는 intent(inout) 속성을 사용해야 한다.


[!important] 11.2 선택적 인수와 키워드 인수

지금까지는 프로시저를 호출할 때 가인수의 순서에 맞춰 실인수를 빠짐없이 나열했다. Fortran은 여기에 두 가지 유연한 기능이 추가되어 있다.

이 두 기능을 사용하려면 호출하는 시점에 프로시저의 명시적 인터페이스가 반드시 확보되어 있어야 한다. 내부 프로시저와 모듈 프로시저는 이 조건을 자동으로 만족하므로, contains 문 내부에 둔 프로시저라면 별도의 작업 없이 바로 사용할 수 있다.

선택적 인수를 사용하면 호출부에서 필요에 따라 인수를 생략할 수 있어 프로시저의 활용성이 크게 증대된다. 구현 및 호출 규칙은 다음과 같이 정리할 수 있다.

1. 선언부 규칙 (프로시저 내부)

인수를 선언할 때 다른 속성과 함께 optional 속성을 추가로 지정한다.

⟨type⟩, intent(in), optional :: ⟨dummy-arg⟩

2. 호출부 규칙 (키워드 인수 사용)

호출부에서 인수를 생략하거나 원래 정의된 순서를 바꾸어 전달할 때는 가인수의 이름을 명시하는 키워드 인수 방식을 사용한다.

call ⟨name⟩(⟨keyword⟩=⟨value⟩ [, ⟨keyword⟩=⟨value⟩]...)

3. 존재 확인 규칙 (프로시저 내부)

프로시저 내부에서 선택적 인수가 실제로 전달되었는지 안전하게 검사하기 위해 present 함수를 사용한다. 이 함수는 인수가 전달되었으면 참(.true.), 생략되었으면 거짓(.false.)의 논리형 값을 반환한다.

if (present(⟨dummy-arg⟩)) then
   ! 인수가 전달되었을 때의 동작
end if

이런 규칙은 매개변수가 많고 복잡한 프로시저를 안전하고 직관적으로 설계할 때 매우 유용하게 활용된다.

[예제] 평균과 폭을 선택할 수 있는 가우스 함수

가우스 함수 $e^{-0.5 \left(\frac{x - \mu}{\sigma}\right)^2}$를 계산하되, 중심 $\mu$와 폭 $\sigma$를 선택적으로 지정하도록 구현한 예제이다. 인수를 생략하면 각각 0과 1을 기본값으로 사용한다.

%%write gaussian.f90
program gaussian_demo
   use iso_fortran_env, only: real64
   implicit none
   real(real64) :: y1, y2, y3

   y1 = gaussian(1.0_real64)
   y2 = gaussian(1.0_real64, mu=0.5_real64)
   y3 = gaussian(1.0_real64, mu=0.5_real64, sigma=2.0_real64)

   print '(a, f10.6)', "default mu=0, sigma=1 : ", y1
   print '(a, f10.6)', "mu=0.5               : ", y2
   print '(a, f10.6)', "mu=0.5, sigma=2.0    : ", y3

contains

   function gaussian(x, mu, sigma) result(y)
      real(real64), intent(in)           :: x
      real(real64), intent(in), optional :: mu, sigma
      real(real64) :: y
      real(real64) :: center, width

      center = 0.0_real64
      width  = 1.0_real64
      if (present(mu))    center = mu
      if (present(sigma)) width  = sigma

      y = exp(-0.5_real64 * ((x - center) / width)**2)
   end function gaussian

end program gaussian_demo

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall gaussian_demo.f90 -o gaussian_demo
!./gaussian_demo

실행 결과:

default mu=0, sigma=1 :   0.606531
mu=0.5               :   0.882497
mu=0.5, sigma=2.0    :   0.969233

주 프로그램에서의 세 가지 호출 방식에 주목한다. 첫 번째 호출은 선택적 인수를 모두 생략하여 기본값으로 계산했고, 두 번째 호출은 mu만 지정했으며, 세 번째 호출은 musigma를 모두 키워드 인수로 지정했다.

gaussian 함수 내부에서는 present 함수로 각 인수의 전달 여부를 확인한 뒤, 전달된 값 또는 기본값을 적절히 분기하여 연산에 활용한다. 선택적 인수를 present 확인 없이 곧장 연산에 사용하면 심각한 오류가 발생할 수 있다.

[!warning] [흔한 실수] 선택적 인수를 present 확인 없이 사용하는 경우 optional 속성이 붙은 인수는 호출할 때 언제든지 생략될 수 있다. 만약 호출 측에서 생략한 인수를 함수 내부에서 present로 검사하지 않고 곧바로 읽거나 연산에 대입하면 Fortran 표준 위반으로, 컴파일 단계에서 발견되지 않더라도, 프로그램 실행 중에 실제 존재하지 않는 메모리 주소에 접근하게 되므로 세그멘테이션 오류(segmentation fault)로 이어진다. 따라서 선택적 인수를 다룰 때는 반드시 if (present(arg)) 조건문으로 인수의 존재를 안전하게 확인한 후에만 참조하도록 코드를 설계해야 한다.

[예제] 키워드를 이용한 인수 순서 변경 호출

키워드 인수를 사용하면 가인수 인수를 적는 순서를 마음대로 바꿀 수 있다. 원래 정의된 위치나 순서를 신경 쓰지 않아도 되므로 편리하다. 다음은 거듭제곱 함수를 호출할 때 두 인수의 순서를 서로 다르게 뒤바꿔서 전달해도 똑같은 계산 결과가 나오는 것을 보여주는 예제이다.

%%writefile keyword_order.f90
program keyword_order
   use iso_fortran_env, only: real64
   implicit none
   print '(f12.4)', power(base=2.0_real64, exponent=10.0_real64)
   print '(f12.4)', power(exponent=10.0_real64, base=2.0_real64)
contains
   function power(base, exponent) result(y)
      real(real64), intent(in) :: base, exponent
      real(real64) :: y
      y = base ** exponent
   end function power
end program keyword_order

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall keyword_order.f90 -o keyword_order
!./keyword_order

실행 결과:

   1024.0000
   1024.0000

두 호출문은 인수를 기술한 순서만 다를 뿐 정확히 같은 값을 계산한다.

키워드 인수를 조합하여 사용할 때는 위치 인수가 키워드 인수보다 항상 먼저 와야 한다. 즉, 호출문 내에서 이름=값 형태의 키워드 인수를 한 번이라도 사용하기 시작하면 그 뒤에 오는 모든 인수는 반드시 키워드 형태를 유지해야 한다.

[!warning] [흔한 실수] 키워드 인수를 위치 인수보다 먼저 작성 함수를 정의할 때 가인수의 순서는 상관없다. function power(exponent, base)로 순서를 바꾸어 정의해도 문법적으로 옳다. 하지만, 함수를 사용할 때(호출할 때) 는 호출문에서 키워드 인수를 위치 인수보다 앞쪽에 배치하면 안 된다. 예를 들어, 아래 코드는 가능하다. 값만 적는 위치 인수를 앞에 두고, 이름=값 형태의 키워드 인수를 뒤에 두었기 때문이다.

fortran ! 10.0_real64은 순서대로 base에 들어가고, exponent는 키워드로 명시함 print '(f12.4)', power(10.0_real64, exponent=2.0_real64)

하지만, 아래 코드는 컴파일 오류가 발생한다. 일단 base= 하고 키워드를 쓰기 시작했다면, 그 뒤에 오는 2.0_real64도 반드시 exponent=2.0_real64 처럼 키워드를 붙여 주어야 한다.

Fortran ! 에러! 키워드 인수를 먼저 써버리면 그 뒤에 위치 인수가 올 수 없음 print '(f12.4)', power(base=10.0_real64, 2.0_real64) 즉, 위치 인수(값만 적기)키워드 인수(이름=값)를 한 호출문 안에서 섞어 쓸 때는, 값만 적는 것을 무조건 앞에 몰아서 써야 한다.


[!important] 11.3 배열 인수 전달

프로시저에 배열을 인수로 넘기는 방식은 가인수를 어떻게 선언하느냐에 따라 크게 두 가지로 나뉜다.

가정형상 배열은 코드가 간결해지고 크기를 나타내는 인수를 따로 넘길 필요가 없어 현대 Fortran에서 강력히 권장되는 방식이다. 다만 이 기능을 사용하려면 호출하는 시점에 프로시저의 명시적 인터페이스가 반드시 보여야 한다. 내부 프로시저와 모듈 프로시저에서는 이 조건이 자동으로 보장되므로 안심하고 사용할 수 있다.

선언 형식은 다음과 같다. 배열의 크기를 고정하지 않고 콜론(:)을 사용하여 형상 정보를 호출 측으로부터 전달받는다.

type, intent(spec) :: dummy-arg(:)

가정형상 배열을 사용하면 프로시저 안에서 배열 크기를 임의로 단정할 수 없으므로, 다음 내장 함수들을 사용하여 실행 중에 배열 정보를 확인해야 한다.

주요 장점과 주의할 점은 아래와 같다.

[예제] 배열의 평균과 표준편차 계산

호출 측에서 넘겨주는 배열의 크기를 프로시저가 미리 알 필요 없이, 전달된 실제 배열의 크기에 맞춰 통계치를 계산하는 예제이다. 서브루틴은 가인수를 v(:)와 같이 가정형상 배열로 선언하여 받고, 내부에서 내장 함수인 size(v)를 사용하여 전체 원소 수를 동적으로 얻는다.

%%writefile array_stats.f90
program array_stats
   use iso_fortran_env, only: real64
   implicit none
   real(real64) :: sample(5) = [4.0_real64, 8.0_real64, 15.0_real64, &
                                16.0_real64, 23.0_real64]
   real(real64) :: avg, sd

   call stats(sample, avg, sd)
   print '(a, i0)',   "n        = ", size(sample)
   print '(a, f9.4)', "mean     = ", avg
   print '(a, f9.4)', "std dev  = ", sd

contains

   subroutine stats(v, mean, stddev)
      real(real64), intent(in)  :: v(:)
      real(real64), intent(out) :: mean, stddev
      integer :: n

      n = size(v)
      mean = sum(v) / real(n, real64)
      stddev = sqrt(sum((v - mean)**2) / real(n, real64))
   end subroutine stats

end program array_stats

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall array_stats.f90 -o array_stats
!./array_stats

실행 결과:

n        = 5
mean     =   13.2000
std dev  =    6.6151

이 구조에서 stats 서브루틴은 자신에게 전달되는 배열의 길이가 5인지 혹은 500인지 상관하지 않는다. 내장 함수 size(v)가 호출되는 시점의 실제 배열 길이를 정확하게 조사하여 반환하므로, 동일한 서브루틴을 임의의 길이를 가진 모든 배열에 그대로 재사용할 수 있다.

또한, 앞서 학습한 전체 배열 연산 식인 v - mean과 배열 내장 함수인 sum이 가정형상 배열 문맥에서도 아무런 제약 없이 자연스럽게 결합되어 결합도를 낮추는 점에 주목하자.


[!important] 11.4 인터페이스 블럭과 명시적 인터페이스

프로시저를 호출할 때, 컴파일러가 해당 프로시저의 인수 개수, 데이터 종류(kind), 속성 등의 구조를 호출 지점에서 정확하게 파악하고 있다면, 그 인터페이스는 명시적(explicit)이다. 반면 컴파일러가 이를 알지 못하는 상태라면 암시적(implicit) 인터페이스이다. 컴파일러가 인수의 유효성을 엄격하게 검사하고 선택적 인수, 키워드 인수, 가정형상 배열 등의 기능들을 정상적으로 처리하기 위해서는 반드시 명시적 인터페이스가 확보되어야 한다.

명시적 인터페이스는 다음 두 가지 경우에 자동으로 제공된다.

어떤 프로그램 단위에도 속하지 않은 채 소스 코드 파일 수준에 독자적으로 존재하는(external procedure)는 기본적으로 암시적 인터페이스를 가진다. 따라서 외부 프로시저를 안전하게 호출하려면, 호출하는 프로그램 측에서 인터페이스 블록(interface block)을 직접 작성하여 컴파일러에게 해당 프로시저의 형태를 명확히 알려주어야만 명시적 인터페이스로 전환된다.

인터페이스 블록은 호출하려는 프로시저의 외형적 구조를 선언하는 역할을 한다. 기본적인 구성 규격은 다음과 같다.

interface
   ⟨procedure-header⟩
      ⟨declarations of dummy arguments⟩
   ⟨end-statement⟩
end interface

주요 특징은 다음과 같다.

[예제] 외부 서브루틴과 인터페이스 블록

벡터의 모든 요소 합이 1이 되도록 정규화(normalization)하는 서브루틴을 외부 프로시저로 별도 작성하고, 메인 프로그램에서 인터페이스 블록을 통해 해당 구조를 컴파일러에게 알리는 예제이다. 가인수로 크기가 고정되지 않은 가정형상 배열을 사용하므로 명시적 인터페이스를 제공하는 인터페이스 블록 작성이 필수적이다.

아래는 주 프로그램 외부에 독립적으로 존재하는 외부 서브루틴 소스코드이다.

%%writefile normalize.f90
subroutine normalize(v)
   use iso_fortran_env, only: real64
   implicit none
   real(real64), intent(inout) :: v(:)
   real(real64) :: total

   total = sum(v)
   if (total /= 0.0_real64) then
      v = v / total
   end if
end subroutine normalize

다음은 위 외부 서브루틴을 호출하는 메인 프로그램 소스코드이다. interfaceend interface 구문 내부에 외부 서브루틴의 헤더와 가인수 선언부 정보를 그대로 작성하여 포함한다.

%%writefile use_normalize.f90
program use_normalize
   use iso_fortran_env, only: real64
   implicit none

   interface
      subroutine normalize(v)
         use iso_fortran_env, only: real64
         implicit none
         real(real64), intent(inout) :: v(:)
      end subroutine normalize
   end interface

   real(real64) :: weights(4) = [2.0_real64, 3.0_real64, &
                                 4.0_real64, 1.0_real64]

   call normalize(weights)
   print '(a)', "normalized weights:"
   print '(4f8.4)', weights
   print '(a, f8.4)', "sum = ", sum(weights)

end program use_normalize

위 두 소스코드 파일을 함께 지정하여 컴파일한 뒤 실행한다.

!gfortran -O2 -std=f2018 -Wall normalize.f90 use_normalize.f90 -o use_normalize
!./use_normalize

실행하면 다음과 같이 정상적으로 정규화된 배열 요소와 합산 결과가 출력된다.

normalized weights:
  0.2000  0.3000  0.4000  0.1000
sum =   1.0000

메인 프로그램 내부에 선언된 인터페이스 블록 덕분에, 컴파일러는 normalize 서브루틴이 배정밀도 실수형 가정형상 배열 하나를 intent(inout) 속성으로 전달받는다는 사실을 호출 지점에서 정확히 인지한다. 이를 바탕으로 실행 시점에 배열의 실제 크기와 형상 정보를 외부 프로시저로 올바르게 넘겨주어 정상적인 연산이 수행되도록 돕는다.

[예제] 매개변수 변화에 따른 가우스 곡선군 시각화

가우스 함수의 폭을 결정하는 매개변수인 $\sigma$의 값만 다르게 지정하여 연속적으로 호출하면, 하나의 함수로부터 파생되는 다양한 곡선군(family of curves)을 얻을 수 있다.

본 예제에서는 세 가지 서로 다른 $\sigma$ 값에 대한 계산 결과를 하나의 csv 파일로 내보낸 뒤, 이를 시각화하여 선택적 인수와 키워드 인수가 데이터 분석에서 어떻게 유용하게 활용되는지 확인한다. 독립 변수 $x$의 범위를 세분화하여 각 지점마다 $\sigma = 0.5$, $\sigma = 1.0$, $\sigma = 2.0$을 적용한 가우스 함수 값을 한 행에 동시에 기록한다.

%%writefile gauss_family.f90
program gauss_family
   use iso_fortran_env, only: real64
   implicit none
   integer, parameter :: n = 200
   real(real64), parameter :: x_min = -6.0_real64, x_max = 6.0_real64
   real(real64) :: sigmas(3) = [0.5_real64, 1.0_real64, 2.0_real64]
   integer :: i, j, u
   real(real64) :: x

   open(newunit=u, file="gauss.csv", status="replace", action="write")
   write(u, '(a)') "x,s0p5,s1p0,s2p0"
   do i = 0, n
      x = x_min + (x_max - x_min) * real(i, real64) / real(n, real64)
      write(u, '(f9.4)', advance="no") x
      do j = 1, size(sigmas)
         write(u, '(",", f9.6)', advance="no") gaussian(x, sigma=sigmas(j))
      end do
      write(u, '(a)') ""
   end do
   close(u)
   print '(a)', "gauss.csv written"

contains

   function gaussian(x, mu, sigma) result(y)
      real(real64), intent(in)           :: x
      real(real64), intent(in), optional :: mu, sigma
      real(real64) :: y
      real(real64) :: center, width

      center = 0.0_real64
      width  = 1.0_real64
      if (present(mu))    center = mu
      if (present(sigma)) width  = sigma

      y = exp(-0.5_real64 * ((x - center) / width)**2)
   end function gaussian

end program gauss_family

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall gauss_family.f90 -o gauss_family
!./gauss_family

실행 후 생성된 gauss.csv의 앞부분은 다음과 같다.

x,s0p5,s1p0,s2p0
  -6.0000, 0.000000, 0.000000, 0.011109
  -5.9400, 0.000000, 0.000000, 0.012150

저장된 csv 데이터를 바탕으로 세 개의 열을 하나의 그래프에 겹쳐 그리는 데이터 시각화해 보자.

import csv
import matplotlib.pyplot as plt

x = []
cols = {"s0p5": [], "s1p0": [], "s2p0": []}
with open("gauss.csv") as f:
    reader = csv.DictReader(f)
    for row in reader:
        x.append(float(row["x"]))
        for key in cols:
            cols[key].append(float(row[key]))

plt.figure(figsize=(8, 4))
labels = {"s0p5": "sigma = 0.5", "s1p0": "sigma = 1.0", "s2p0": "sigma = 2.0"}
for key in cols:
    plt.plot(x, cols[key], label=labels[key])

plt.xlabel("x")
plt.ylabel("f(x)")
plt.title("Gaussian family (varied sigma)")
plt.legend()
plt.grid(True)
plt.savefig("gauss.png", dpi=120, bbox_inches="tight")
plt.show()

$\sigma$ 값이 작을수록 중심이 좁고 뾰족하게 솟아오르는 종형 곡선이 나타나며, 값이 클수록 좌우로 넓고 완만한 형태를 그리는 종형 곡선이 하나의 평면 위에 겹쳐서 표현된다. 이처럼 매개변수의 지정 값만 바꾸어 동일한 프로시저를 반복 호출하는 연산 방식은 앞서 살펴본 선택적 인수와 키워드 인수의 설계 목적과 정확히 부합한다.

![[Pasted image 20260727192451.png]]


[!important] 오류 학습

[예제] 명시적 인터페이스를 통한 인수 유효성 검사

명시적 인터페이스가 가지는 가장 큰 가치는 컴파일 단계에서 인수의 유효성을 엄격하게 검사한다는 점이다. 사각형의 넓이를 구하는 외부 함수 rect_area(width, height)를 메인 프로그램에 인터페이스 블록과 함께 선언해 두고, 호출 시 인수를 하나 누락하는 코드를 작성해 보자.

real(real64) :: a
a = rect_area(3.0_real64)   ! height is missing

이 경우 명시적 인터페이스가 확보되어 있으므로 컴파일러는 빌드 단계에서 즉시 다음 에러 메시지를 출력하며 실행 파일 생성을 거부한다.

Error: Missing actual argument for argument 'height' at (1)

만약 인터페이스 블록을 제공하지 않은 채 외부 함수를 호출했다면, 컴파일러는 호출 지점에서 인수의 개수가 맞는지조차 검사하지 못하고 그대로 통과시킨다. 이는 결국 프로그램 실행 중에 메모리 참조 오류나 비정상 종료를 일으키는 원인이 된다.

따라서 명시적 인터페이스를 자동으로 확보하도록 모든 프로시저는 주 프로그램의 내부 프로시저나 모듈 프로시저로 설계하는 것을 원칙으로 삼는다. 부득이하게 외부 프로시저를 사용해야 할 때는 호출 측에 반드시 인터페이스 블록을 명시적으로 작성하여 제공해야 한다.


[예제] 명시적 인터페이스가 없는 외부 프로시저의 오작동 사례

배열의 모든 원소에 특정 상수를 곱해 크기를 조절하는 서브루틴을 구현한 예제이다. 가인수로 가정형상 배열을 받도록 설계하여 별도의 크기 인수를 넘길 필요가 없도록 구성했다. 이 서브루틴을 외부 프로시저로 분리하고, 메인 프로그램에서는 별도의 인터페이스 선언 없이 그대로 호출하도록 코드를 구성해 본다.

%%writefile ext_proc.f90
subroutine scale_vec(v, factor)
   use iso_fortran_env, only: real64
   implicit none
   real(real64), intent(inout) :: v(:)
   real(real64), intent(in)    :: factor
   v = v * factor
end subroutine scale_vec

다음은 위 외부 서브루틴을 명시적 인터페이스 없이 암시적으로 호출하는 메인 프로그램이다.

%%writefile use_noiface.f90
program use_noiface
   use iso_fortran_env, only: real64
   implicit none
   real(real64) :: a(3) = [1.0_real64, 2.0_real64, 3.0_real64]
   call scale_vec(a, 2.0_real64)
   print '(3f6.1)', a
end program use_noiface

터미널에서 두 소스코드를 함께 컴파일한다. 컴파일 단계에서는 아무런 경고나 오류 메시지 없이 정상적으로 빌드가 완료된다.

!gfortran -O2 -std=f2018 -Wall ext_proc.f90 use_noiface.f90 -o use_noiface

그러나 생성된 실행 파일을 실행하면, 정상적인 연산 결과를 출력하지 못하고 다음과 같이 세그멘테이션 결함(segmentation fault)을 일으키며 프로그램이 비정상 종료된다.

Program received signal SIGSEGV: Segmentation fault - invalid memory reference.

컴파일이 정상적으로 완료되었음에도 실행 단계에서 메모리 오류가 발생하는 이유는 Fortran의 배열 전달 방식 메커니즘 때문이다. scale_vec 서브루틴의 가인수 v(:)는 크기가 고정되지 않은 가정형상 배열이다. 가정형상 배열은 단순히 메모리의 시작 주소만 전달되는 것이 아니라, 배열의 크기(형상)와 메모리 내 간격(보폭, stride) 정보까지 한데 묶은 '배열 서술자(array descriptor)' 형태로 전달된다.

호출하는 메인 프로그램 측에서 이 복합적인 배열 서술자를 올바르게 생성하여 던져주려면, 호출하려는 목적지 프로시저의 구조를 컴파일 단계에서 완벽히 파악하고 있어야 한다. 즉, 명시적 인터페이스가 필수적으로 요구된다.

그러나 외부 프로시저 형태로 작성된 scale_vec은 메인 프로그램 시점에서 암시적 인터페이스 상태로만 인식된다. 이로 인해 컴파일러는 배열 서술자 대신 단순한 평범한 메모리 시작 주소만을 서브루틴으로 넘겨준다. 반면 인수를 받는 서브루틴 측에서는 넘어온 일반 주소 값을 배열 서술자 구조체로 오해하여 해석하므로, 전혀 엉뚱한 메모리 영역을 참조하게 되어 결국 메모리 접근 오류를 일으키고 멈추게 된다.

이 문제를 안전하게 해결하는 방법은 크게 두 가지가 존재한다.

1. 인터페이스 블록 작성하기

첫 번째 방법은 호출하는 메인 프로그램 내부에 interface 블록을 명시하여 컴파일러가 배열 서술자를 정상적으로 생성하도록 명시적 인터페이스를 제공하는 것이다. 이 코드를 use_iface.f90 파일로 저장한다.

%%writefile use_iface.f90
program use_iface
   use iso_fortran_env, only: real64
   implicit none

   interface
      subroutine scale_vec(v, factor)
         use iso_fortran_env, only: real64
         implicit none
         real(real64), intent(inout) :: v(:)
         real(real64), intent(in)    :: factor
      end subroutine scale_vec
   end interface

   real(real64) :: a(3) = [1.0_real64, 2.0_real64, 3.0_real64]
   call scale_vec(a, 2.0_real64)
   print '(3f6.1)', a
end program use_iface

컴파일·실행:

!gfortran -O2 -std=f2018 -Wall ext_proc.f90 use_iface.f90 -o use_iface
!./use_iface

이제 정상으로 동작한다.

   2.0   4.0   6.0

2. 내부 프로시저 또는 모듈 프로시저로 설계하기

훨씬 더 간결하고 권장하는 방법은, 해당 프로시저를 독립된 외부 프로시저로 만들지 않는 것이다. 메인 프로그램의 contains 구문 아래에 내부 프로시저로 배치하거나, 추후 다룰 모듈(module) 내부에 구조화하면 명시적 인터페이스가 자동으로 생성되므로 번거로운 인터페이스 블록을 수작업으로 작성할 필요가 없다.

가정형상 배열이나 선택적 인수, 키워드 인수와 같은 현대적인 Fortran 기능들은 모두 명시적 인터페이스가 확보된 상태를 전제로 작동하며, 구조화되지 않은 외부 프로시저는 이런 전제 조건을 스스로 만족할 수 없으므로 주의하여 설계해야 한다.


[!important] 요약


[!important] 연습 문제

  1. 섭씨 온도 하나를 intent(in)으로 받아 화씨로 변환한 값을 intent(out)으로 돌려주는 서브루틴 to_fahrenheit를 작성하라. 변환식은 F = C * 9/5 + 32이며, 정수 나눗셈 함정에 주의해 실수 연산으로 계산하라.

  2. 두 실수 변수의 값을 맞바꾸는 서브루틴 swap을 작성하라. 두 가인수의 intent를 무엇으로 선언해야 하는지 근거와 함께 밝혀라.

  3. intent(in)으로 선언한 가인수에 값을 대입하는 코드를 일부러 작성해 컴파일하고, gfortran이 출력하는 오류 메시지를 그대로 옮겨 적어라.

  4. base와 지수 exponent를 받아 거듭제곱을 돌려주는 함수를 만들고, 키워드 인수를 이용해 두 가지 다른 순서로 호출해 같은 결과가 나옴을 확인하라.

  5. 가정형상 배열 하나를 받아 그 최댓값과 최솟값을 각각 intent(out)으로 돌려주는 서브루틴을 작성하라. 내장 함수 maxval, minval을 사용하라.

  6. 가정형상 정수 배열을 받아 size로 원소 수를 출력하는 서브루틴을 작성하고, 길이가 다른 두 배열로 각각 호출해 같은 서브루틴이 양쪽 모두에 동작함을 보여라.

  7. 선택적 인수 tol(허용 오차)을 받는 함수 is_close(a, b, tol)을 작성하라. tol이 생략되면 기본값 1.0e-9_real64를 쓰고, 두 실수의 차의 절댓값이 tol 이하이면 참을 돌려준다. 부동소수점 동치 비교를 직접 하는 대신 이 함수를 쓰는 이유를 한두 문장으로 설명하라.

  8. 11.3의 stats 서브루틴을 확장하여, 선택적 인수 use_sample(논리형)이 참이면 표본 표준편차(분모 n-1), 생략되거나 거짓이면 모표준편차(분모 n)를 계산하도록 만들어라.

  9. 외부 함수 dot(u, v)(두 가정형상 실수 배열의 내적)를 별도 파일에 작성하고, 메인 프로그램에서 인터페이스 블록을 통해 호출하라.

  10. 가정형상 배열을 받아 모든 원소를 정해진 구간으로 자르는(clamp) 서브루틴을 작성하되, 하한 lo와 상한 hi를 선택적 인수로 두어 생략 시 해당 방향으로는 자르지 않도록 하라.