[!attention] 이 장의 목표
- 파생형 정의 — 파생형(derived type)으로 나만의 자료 구조를 만든다.
- 구조 조립 — 형 생성자와 중첩 구조로 복잡한 자료를 표현한다.
- 포인터 기초 — pointer·target과 연관을 기초 수준에서 다룬다.
- 연결 구조 — 포인터로 간단한 연결 구조를 만든다.
지금까지 다룬 변수는 정수 하나, 실수 하나처럼 단일 데이터이거나, 동일한 자료형의 값을 한데 모아둔 배열에 국한되었다. 그러나 실무에서 다루는 데이터는 다음과 같이 서로 다른 자료형의 값들이 하나의 구조 안에 유기적으로 묶여 있는 경우가 훨씬 많다.
물리계의 입자: 하나의 입자는 공간상에서의 위치 $(x, y)$와 물리적인 속도 $(v_x, v_y)$를 동시에 지닌다.
학생 정보: 학생 한 명의 데이터는 이름(문자형), 학번(정수형), 성적(실수형)이 한 덩어리로 결합되어 존재한다.
이런 데이터를 위치는 위치대로, 속도는 속도대로 서로 다른 배열에 분산하여 저장하게 되면 관리하기가 까다로워진다. 예컨대 "3번 입자의 속도"를 조회하거나 수정하려 할 때마다 각 배열의 인덱스를 일일이 수동으로 일치시켜야 하므로 코드가 복잡해지고 논리적 실수가 자주 발생한다.
이 장에서는 이와 같은 한계를 극복하기 위해 서로 다른 자료형의 변수들을 하나의 새로운 자료형으로 통합하는 파생형(derived type)을 학습한다. 아울러 실행 중에 데이터의 크기가 동적으로 변하는 유연한 자료 구조를 구축하도록 지원하는 포인터(pointer)의 개념과 활용법을 함께 배운다. 파생형과 포인터는 각각 독립적으로도 훌륭한 도구이지만, 이 둘을 유기적으로 결합하여 사용할 때 Fortran의 구조적이고 강력한 데이터 처리 능력이 온전히 발휘된다.
[!important] 13.1 파생형 정의와 구성 요소
파생형(derived type)은 기본 자료형(정수형, 실수형, 문자형 등)이나 이미 정의된 또 다른 파생형들을 하나의 단위로 묶어 사용자가 직접 설계하는 맞춤형 자료형이다. 이는 C 언어의 구조체(struct)나 파이썬의 데이터 클래스(dataclass)와 같은 개념이다.
파생형을 도입하면 밀접하게 연관된 값들이 하나의 변수 영역 안에 유기적으로 통합된다. 따라서 이 데이터들을 다른 프로시저로 전달할 때 여러 개의 인수를 넘길 필요 없이, 단 하나의 파생형 변수만으로 간결하게 주고받을 수 있다.
파생형 내부의 개별 구성 요소에 접근할 때는 아래와 같이 퍼센트 기호(%)를 사용한다. 파생형 변수 p가 2차원 평면의 좌표 성분(x,y)을 담고 있다면, 메모리와 코드 상에서 다음 구조로 매핑된다.
변수 p ──┬── p%x (실수형 데이터가 저장되는 공간)
└── p%y (실수형 데이터가 저장되는 공간)
파생형의 구조를 설계할 때는 아래와 같이 type과 end type 블록을 사용하며, 정의된 형식에 맞춰 변수를 선언하고 내부 요소에 접근한다.
type :: 형-이름
구성요소-자료형 :: 구성요소-이름 [= 초깃값}]
! ... 추가 구성 요소 선언
end type [형-이름]
%%writefile point_demo.f90
program point_demo
use, intrinsic :: iso_fortran_env, only: real64
implicit none
type :: point2d
real(real64) :: x
real(real64) :: y
end type point2d
type(point2d) :: p
real(real64) :: dist
p%x = 3.0_real64
p%y = 4.0_real64
dist = sqrt(p%x**2 + p%y**2)
print '(a, f0.2, a, f0.2, a)', "point = (", p%x, ", ", p%y, ")"
print '(a, f0.2)', "distance from origin = ", dist
end program point_demo
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall point_demo.f90 -o point_demo
!./point_demo
실행하면 평면 위의 좌표 정보와 원점으로부터의 거리가 다음과 같이 출력된다.
point = (3.00, 4.00)
distance from origin = 5.00
type :: point2d ... end type point2d 은 새로운 구조의 설계도를 알려주는 선언부이다. 이 단계에서는 메모리가 할당되지 않는다.
설계도에 따라 실제로 메모리에 변수 공간을 구축하는 시점은 type(point2d) :: p 문장을 만났을 때이다. 이후 코드 영역에서는 퍼센트(%) 연산자를 활용하여 p%x와 p%y로 개별 공간에 값을 나누어 보관하고, 원점과의 유클리드 거리를 계산하는 연산을 안전하게 수행하게 된다.
[!important] 13.2 형 생성자와 중첩 구조
파생형의 구성 요소에 값을 대입할 때, 멤버 변수를 하나씩 지정하여 대입하는 대신 형 생성자(type constructor)를 사용하면 단 한 줄로 모든 값을 동시에 초기화할 수 있다.
형 생성자의 이름은 사용자가 정의한 파생형의 이름과 완전히 동일하다. 값을 대입할 때는 정의된 구성 요소 순서대로 값을 나열하는 순서 지정 방식과, 구성 요소의 이름을 직접 명시하는 키워드 지정 방식을 모두 지원한다.
형 생성자 호출 문법은 아래와 같이 두가지로 나눌 수 있다.
type-name(value1, value2, ...)
=)를 사용하여 값을 매핑하는 방식이다. 이 방식을 사용하면 정의된 순서와 상관없이 값을 안전하게 대입할 수 있어 코드의 가독성이 높아진다. type-name(comp1=value1, comp2=value2, ...)
파생형을 정의하는 시점에 구성 요소 이름 옆에 = 초깃값 형태로 대입 식을 지정해 두면, 변수가 선언될 때 자동으로 이 값이 채워지는 기본 초기화(default initialization)가 이루어진다.
기본 초기화가 설정된 파생형을 생성자로 호출할 때는 해당 구성 요소를 생략할 수 있다. 단, 일부 구성 요소를 생략한 채 생성자를 호출할 때는 컴파일러가 누락된 위치를 혼동하지 않도록 반드시 키워드 지정 방식(이름=값)을 사용하여 호출해야 한다.
파생형의 구성 요소 내부의 데이터 형으로 또 다른 파생형을 선언하여 포함하는 중첩 파생 구조(nested derived types) 를 형성할 수 있다. 예를 들어, 기하학적 원(circle)은 중심 좌표를 나타내는 point2d 파생형과 실수형 반지름 필드를 내포하는 구조로 정의된다. 이때 중첩 구조 내부에 자리 잡은 가장 하위의 필드에 접근하려면 아래와 같이 퍼센트 기호(%)를 사슬처럼 연이어 연결하여 탐색해야 한다.
type :: circle
type(point2d) :: center
real :: radius
end type circle
type(circle) :: c
! 퍼센트 기호를 단계적으로 연결하여 중첩된 하위 구성 요소에 접근
c%center%x = 0.0
파생형 내부에 또 다른 파생형을 포함하는 중첩 구조를 형성하고, 각 필드에 기본 초기화 값을 할당하여 형 생성자를 유연하게 호출하는 예제이다.
%%writefile circle_demo.f90
program circle_demo
use, intrinsic :: iso_fortran_env, only: real64
implicit none
real(real64), parameter :: pi = 3.141592653589793_real64
type :: point2d ! 2차원 좌표(x,y)를 가진 파생형
real(real64) :: x = 0.0_real64
real(real64) :: y = 0.0_real64 ! 각 구성요소에 기본값을 설정
end type point2d
type :: circle ! circle은 center로 point2d를 포함하고 radius를 가지는 중첩구조
type(point2d) :: center
real(real64) :: radius = 1.0_real64
end type circle
type(circle) :: c1, c2
real(real64) :: area
c1 = circle(center = point2d(2.0_real64, 1.0_real64), radius = 3.0_real64)
! c1은 키워드 방식으로 명시적으로 초기화
c2 = circle() ! c2는 모든 구성요소가 기본값을 가짐
area = pi * c1%radius**2
print '(a, f0.2, a, f0.2, a, f0.2)', &
"c1: center=(", c1%center%x, ",", c1%center%y, "), r=", c1%radius
! % 연산자를 사용하여 원의 반지름에 접근
print '(a, f0.3)', "c1 area = ", area
print '(a, f0.2, a, f0.2, a, f0.2)', &
"c2: center=(", c2%center%x, ",", c2%center%y, "), r=", c2%radius
end program circle_demo
실행하면 중첩된 구성 요소의 도출 값과 기본 초기화가 적용된 결과가 다음과 같이 나타난다.
c1: center=(2.00,1.00), r=3.00
c1 area = 28.274
c2: center=(.00,.00), r=1.00
c1은 (2.0, 1.0) 좌표에 반지름이 3.0인 원으로 초기화되었으므로, 면적은 $\pi \times 3^2 \approx 28.27$이다. c2는 생성자에서 값을 전달하지 않았으므로, 정의된 기본값인 중심 (0.0, 0.0)에 반지름 1.0을 가진다.
파생형 구조 설계: point2d는 2차원 공간 좌표성분을 정의한다. circle은 이 point2d 형 변수를 center라는 내부 멤버로 수용하고 반지름 radius를 별도로 두어 중첩 구조를 형성한다. 선언 시 각 성분 우측에 = 0.0_real64, = 1.0_real64와 같이 대입 식을 지정하여 기본 초기화 상태를 결정한다.
형 생성자 호출을 통한 객체 초기화: 변수 c1은 주 프로그램 내에서 형 생성자를 호출할 때 키워드 지정 방식을 적용하여 명시적으로 값을 채웠다. 이때 내부의 point2d 구조 역시 생성자 내부에서 중첩 연동되어 초기화된다. 반면 변수 c2는 인수를 모두 생략한 채 빈 생성자 circle()을 호출하였으므로, 설계 단계에서 지정한 규격대로 중심점 $(0.0, 0.0)$과 반지름 $1.0$을 획득한다.
중첩 성분 데이터 접근: 파생형 내부에 매핑된 최종 데이터에 접근하기 위해 퍼센트(%) 연산자를 깊이에 맞게 체인 형태로 연결하여 사용한다. 원의 반지름은 c1%radius로 바로 가리키며, 중심점 내부의 세부 좌표 성분은 c1%center%x와 같이 가리킨다.
[!warning] [흔한 실수] 생성자 호출 시 기본값이 없는 구성 요소의 누락 형 생성자를 호출하여 변수를 초기화할 때, 파생형 정의 단계에서 기본값이 지정되지 않은 필드는 생성자의 인수 목록에서 단 하나라도 누락되면 안 된다. 예를 들어,
point2d구조를 정의할 때 기본값을 선언하지 않았다면, 임의로point2d(1.0_real64)와 같이 인수를 하나만 채워 호출할 경우 다음과 같이 초기화 인수가 부족하다는 에러가 발생한다.Error: No initializer for component 'y' given in the structure constructor at (1)따라서 파생형 설계 시 기본 초기화 식을 누락한 변수가 있다면 생성자 연산 시 반드시 순서대로 모든 구성 요소를 채우거나 키워드 지정 방식으로 명확하게 값을 할당해야 한다.
파생형 역시 기본 자료형과 동일하게 배열 형태로 선언하여 다룰 수 있다. Fortran의 파생형 배열이 가진 강력한 장점은 배열 전체를 대상으로 내부 구성 요소를 참조하면, 해당 성분들만 추출된 새로운 구조의 배열이 동적으로 반환된다는 점이다. 예를 들어, 본 예제의 class%score 구조는 학급 배열에 속한 모든 학생의 점수만을 모은 배열이 되므로, 반복문 없이 내장 함수 sum을 즉시 적용할 수 있다.
%%writefile students.f90
program students
use, intrinsic :: iso_fortran_env, only: real64
implicit none
type :: student
character(len=20) :: name
integer :: id
real(real64) :: score
end type student
type(student) :: class(3)
integer :: i
real(real64) :: avg
class(1) = student("Kim", 101, 88.5_real64)
class(2) = student("Lee", 102, 92.0_real64)
class(3) = student("Park", 103, 79.5_real64)
avg = sum(class%score) / size(class)
do i = 1, size(class)
print '(i3, 1x, a10, 1x, f5.1)', class(i)%id, class(i)%name, class(i)%score
end do
print '(a, f5.2)', "average = ", avg
end program students
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall students.f90 -o students
!./students
실행하면 개별 학생의 정보와 일괄 연산으로 도출된 전체 평균 점수가 다음과 같이 출력된다.
101 Kim 88.5
102 Lee 92.0
103 Park 79.5
average = 86.67
위 예제에서 사용한 방식은 다음 장점이 있다.
파생형 구조 설계 (student): 학생 개체를 고유하게 표현하기 위하여 이름(name), 학번(id), 점수(score)라는 서로 다른 자료형의 데이터 필드들을 student라는 하나의 파생형 단위로 통합한다.
파생형 기반 배열 선언: type(student) :: class(3) 명령을 통해 설계된 student 규격의 레코드를 3개 연속으로 저장할 수 있는 물리적인 배열 공간 class를 생성한다.
배열 원소 초기화: 파생형 이름과 동일한 형 생성자를 이용하여 각 학생 인스턴스를 생성하고, 이를 class 배열의 각 인덱스 위치에 순차적으로 대입한다.
구성 요소 일괄 연산 (class%score): 개별 원소의 인덱스를 지정하지 않고 배열 변수 명에 곧바로 퍼센트 연산자를 결합한 class%score 표현식은 배열 내부 전체 구조에서 score 값들만 골라낸 크기 3의 실수형 배열처럼 동작한다. 이를 통해 별도의 반복문을 구성하지 않고도 내장 함수 sum()과 size()만으로 학급 전체의 평균(avg)을 간결하게 산출할 수 있다.
반복 제어 및 서식 출력: do 루프를 순회하면서 각 배열 요소 내부에 보관된 하위 필드 데이터들을 지정된 포맷 서식에 맞추어 화면에 출력한다.
파생형 배열 구조의 주요 장점은 다음과 같다.
직관적인 데이터 구조화: 관리 대상이 되는 논리적 객체를 하나의 독립된 타입으로 묶어 다루므로 소스코드의 가독성과 제어 흐름의 이해도가 대폭 향상된다.
간결한 집계 연산: class%score와 같은 문법적 배열 슬라이싱 기법을 지원하므로, 불필요한 루프 오버헤드 없이 배열 내 특정 성분만을 추출해 냄으로써 통계 연산을 실시간으로 처리할 수 있다.
유연한 확장성: 관리할 데이터의 규모가 수백, 수천 명으로 증가하더라도 배열의 선언 크기만 조정하면 평균 및 통계를 계산하는 알고리즘 코드는 수정 없이 그대로 유지된다.
이런 파생형 배열 처리 메커니즘은 대규모 격자 구조 데이터를 다룰 때 코드의 복잡성을 낮추고 연산 효율을 극대화하는 방안이다.
[!important] 13.3 포인터 기초와 상태 관리
포인터(pointer)는 독립적인 메모리 공간에 직접 값을 보유하는 일반 변수와 달리, 메모리 상의 다른 대상이나 동적으로 할당된 공간을 가리키는 특수한 변수이다. 포인터는 스스로 값을 저장하지 않고 가리키는 대상의 '별명(alias)' 역할을 수행하며, 다음 두 가지 유형의 대상을 참조할 수 있다.
타겟 변수: 선언 시 target 속성이 명시되어 포인터의 접근을 허용한 일반 변수
익명 메모리: allocate 문을 통해 실행 시간(Runtime)에 직접 생성한 이름 없는 동적 메모리 공간
포인터 메커니즘을 정확하게 이해하기 위한 핵심은 연관(포인터 대입)과 값 대입(일반 대입)이라는 두 가지 형태의 대입 연산자를 명확하게 구분하여 사용하는 것이다.
연관 (Association): p => t 포인터 대입 연산자(=>)를 사용하여 포인터 p가 타겟 t를 가리키도록 설정한다. 이 단계를 거치면 p는 t를 참조하는 유효한 별명이 된다.
값 대입 (Assignment): p = v 일반 대입 연산자(=)를 사용하여 포인터 p가 현재 가리키고 있는 메모리 대상에 값 v를 기록한다.
p => t 구문을 통해 두 변수를 연관시킨 후 p = 5와 같이 값을 대입하면, 값 5는 포인터 p 자체에 저장되는 것이 아니라 p가 가리키고 있는 실제 타겟 t의 메모리 공간에 기록된다. 결과적으로 원본인 t의 값이 5로 변경된다. 이런 현상을 별명 효과(aliasing) 라고 부르며, 이는 포인터의 본질적인 기능인 동시에 논리적 혼동을 가장 자주 유발하는 원인이 되므로 주의해야 한다.
포인터 변수의 현재 상태는 항상 다음 세 가지 중 하나에 속하게 된다.
연관됨 (Associated): 메모리상의 유효한 대상 변수나 동적 할당 공간을 올바르게 가리키고 있어 안전하게 접근할 수 있는 상태
해제됨 (Disassociated): null()이나 nullify 문을 통해 명시적으로 연결을 끊어 아무것도 가리키지 않는 상태 (안전한 참조 차단 가능)
정의되지 않음 (Undefined): 선언만 이루어지고 초기화가 되지 않아 메모리 어디를 가리키고 있는지 알 수 없는 극히 위험한 상태
[!note] 안전한 포인터 사용을 위한 개발 수칙
선언 시 즉시 초기화: 포인터 변수를 선언할 때는 반드시
=> null()구문을 결합하여, 상태를 '정의되지 않음'이 아닌 명확한 '해제됨' 상태로 출발시켜야 한다. 초기화되지 않은 포인터는 시스템 내부의 임의의 메모리 주소를 가리키고 있을 수 있으므로 예기치 못한 런타임 다운(Segmentation Fault)을 유발하는 원인이 된다.접근 전 유효성 검사: 포인터를 통해 값을 읽거나 쓰기 전에는 반드시 내장 함수인
associated()를 조건문과 결합하여 현재 가리키고 있는 대상이 실재하는지 확인하는 방어적 코딩을 체득해야 한다.
포인터로 사용할 변수에는 pointer 속성을 지정해야 하며, 포인터가 가리킬 대상 변수에는 반드시 target 속성을 부여해야 컴파일 단계에서 참조 관계가 성립된다.
자료형, target :: 타겟 변수명자료형, pointer :: 포인터 변수명 => null()아래 표는 연관 제어 및 상태 검사 기능을 정리한 것이다.
| 제어 목적 | 문법 형식 | 세부 동작 설명 |
|---|---|---|
| 포인터 연관 (포인터 대입) | p => t |
포인터 p가 타겟 변수 t가 위치한 메모리 주소를 가리키도록 연결을 생성한다. |
| 연관 해제 (Nullify) | nullify(p) |
포인터 p의 기존 메모리 연결을 안전하게 끊고 상태를 '해제됨'으로 즉시 변경한다. |
| 연관 여부 확인 (내장 함수) | associated(p) |
포인터 p가 현재 임의의 유효한 대상을 가리키고 있다면 논리형인 .true.를 반환하고, 해제된 상태라면 .false.를 반환한다. |
| 특정 타겟 검증 | associated(p, t) |
포인터 p가 가리키고 있는 대상이 우측에 지정한 특정 타겟 변수 t와 정확히 일치하는지 검증하여 논리값을 반환한다. |
[!warning] [흔한 실수]포인터 사용 시 주의해야 할 흔한 실수
1. 대입 연산자 구조의 오용과 의도하지 않은 별명 버그
포인터 대입 연산자
=>와 일반 값 대입 연산자=를 혼동하면 심각한 데이터 오염이 발생한다. 포인터p를 타겟t와 별개의 독립적인 데이터 복사본으로 생성하려는 의도였으나 실수로p => t라고 기술하면, 두 변수는 메모리를 공유하는 연관 관계가 된다. 이 상태에서 나중에p내부의 값을 수정하게 되면 타겟인t의 원본 데이터까지 함께 변경되는 별명 버그가 발생한다.2. 초기화되지 않은 포인터에 대한 상태 검사 시도
선언만 수행하고 초기화(
=> null())를 빠뜨린 포인터는 '정의되지 않음' 상태가 된다. 이 상태에서는 위험을 감지하기 위해if (associated(p))와 같이 상태 검사를 수행하는 것조차 안전하지 않으며, 검사 구문 자체에서 런타임 오류가 발생할 수 있다. 선언과 동시에 명시적으로null()객체를 대입해 두어야만associated()함수가 정상적으로.false.를 반환하여 안전한 조건 제어가 가능해진다.
%%writefile pointer_demo.f90
program pointer_demo
use, intrinsic :: iso_fortran_env, only: real64
implicit none
real(real64), target :: a = 10.0_real64
real(real64), target :: b = 20.0_real64
real(real64), pointer :: p => null()
print '(a, l1)', "p associated? ", associated(p)
p => a ! associate p with a
print '(a, l1, a, f0.1)', "p associated? ", associated(p), ", p = ", p
p = 99.0_real64 ! writes through p into a
print '(a, f0.1)', "a = ", a
p => b ! re-point to b
print '(a, f0.1)', "p now points to b = ", p
print '(a, l1)', "p associated with a? ", associated(p, a)
print '(a, l1)', "p associated with b? ", associated(p, b)
nullify(p)
print '(a, l1)', "after nullify, associated? ", associated(p)
end program pointer_demo
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall pointer_demo.f90 -o pointer_demo
!./pointer_demo
실행하면 포인터가 대상을 가리키고 해제되는 흐름에 따라 다음 상태 변화가 출력된다.
p associated? F
p associated? T, p = 10.0
a = 99.0
p now points to b = 20.0
p associated with a? F
p associated with b? T
after nullify, associated? F
p = 99.0이 a를 99.0으로 바꾼 점에 주목하라. p는 a의 별명이었기 때문이다.
정적 선언 및 초기화: a와 b는 target 속성을 명시하여 포인터가 가리킬 수 있는 대상 메모리로 선언되었다. 포인터 변수 p는 pointer 속성을 부여받음과 동시에 => null() 구문으로 초기화되어 안전하게 '해제됨' 상태로 시작한다.
포인터 대입 연산 (p => a): 포인터 대입 연산자 =>를 통해 p가 변수 a를 가리키도록 설정한다. 이후 주 프로그램 내에서 p는 a와 동일한 메모리 공간을 가리키는 별칭으로 동작한다.
역참조 값 대입 (p = 99.0): 일반 값 대입 연산자 =를 사용하면 포인터 p 자체가 아니라, p가 역참조하고 있는 대상 변수인 a에 값 99.0이 직접 대입된다. 그 결과 a를 출력했을 때 값이 99.0으로 수정되었음을 확인할 수 있다.
재연관 제어 (p => b): p가 새로 변수 b를 가리키도록 연관 관계를 변경한다. 이때 기존 a와의 연결 사슬은 자동으로 끊어진다.
상수 및 관계 검증: associated(p, a)와 associated(p, b) 함수에 두 개의 인수를 넘겨 현재 p가 정확히 어떤 타겟과 연동되어 있는지 논리값으로 판정하고 제어할 수 있다.
명시적 연관 해제 (nullify(p)): nullify 문을 호출하여 p가 가지고 있는 메모리 주소 링크를 끊고, 다시 임의의 접근을 허용하지 않는 안전한 '해제됨' 상태로 되돌린다.
[!warning] [흔한 실수] 포인터가 가리키는 대상은 반드시
target속성이 명시되어 선언되었거나, 다른 활성 포인터 변수여야만 한다. 만약target속성이 부여되지 않은 일반 평범한 변수를 가리키도록 포인터 대입을 시도하면, 컴파일러는 정적 체크 단계에서 엄격하게 이를 감지하고 다음 에러 메시지를 발생시키며 빌드를 중단한다:fortran Error: Pointer assignment target is neither TARGET nor POINTER at (1)`[!note] 포인터 변수 명명 요령 Fortran은 변수명에 포인터 여부를 강제하는 접두사(prefix)나 접미사(suffix)를 요구하지 않는다. 하지만, 코드의 가독성을 높이고 프로그래머의 실수를 줄이기 위해 관습적인 명명 규칙(naming convention)을 사용하는 것이 좋다.
다음은 프로그래밍 현장에서 자주 쓰이는 포인터 구분 전략이다.
접미사(suffix) 활용 변수명 뒤에
_p또는_ptr과 같은 식별자를 붙이는 방식으로 명확하게 포인터임을 알 수 있다.
temp_p(포인터) vstemp(일반 변수)data_ptr(포인터) vsdata(일반 변수)접두사(prefix) 활용 변수명 앞에
p_를 붙이는 방식으로, C 언어 등 다른 언어에서도 흔히 사용한다.
p_temp(포인터) vstemp(일반 변수)상태 기반 명명 포인터가 가리키는 대상의 특성을 살려 이름을 짓는 방식이다.
target_val(일반 변수)val_ref(참조 역할을 하는 포인터)
포인터는 단일 변수뿐만 아니라 배열의 특정 구간(슬라이스)에도 연관될 수 있다. 이 특징을 활용하면 대규모 배열의 특정 영역만을 바라보는 일종의 창(window)을 개설할 수 있으며, 이 창을 통해 값을 수정하면 원본 배열의 해당 구간 데이터가 실시간으로 변경된다.
%%writefile pointer_array.f90
program pointer_array
use, intrinsic :: iso_fortran_env, only: real64
implicit none
real(real64), target :: grid(10)
real(real64), pointer :: window(:) => null()
integer :: i
do i = 1, 10
grid(i) = real(i, real64)
end do
window => grid(4:7) ! alias elements 4..7
print '(a, *(f5.1))', "window before: ", window
window = window * 10.0_real64 ! modifies grid(4:7)
print '(a, *(f5.1))', "grid after : ", grid
end program pointer_array
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall pointer_array.f90 -o pointer_array
!./pointer_array
실행하면 window 포인터를 통한 연산이 원본 grid 배열에 직접 반영된 결과가 다음과 같이 나타난다.
window before: 4.0 5.0 6.0 7.0
grid after : 1.0 2.0 3.0 40.0 50.0 60.0 70.0 8.0 9.0 10.0
배열 및 포인터 선언: 크기 10인 일반 배열 grid에 target 속성을 부여하여 포인터가 접근하도록 설정한다. 포인터 배열 window(:)는 1차원 지형태를 가리키는 배열 포인터로 선언되었으며, => null() 구문을 통해 초기화되었다.
배열 구간 연관 (window => grid(4:7)): 배열 슬라이싱 기법을 이용해 grid 배열의 일부 구간을 window 변수와 매핑한다. 이 시점부터 window는 크기 4인 독립된 배열처럼 기능하지만, 실제로는 원본 grid(4:7) 메모리 주소를 가리키는 별칭이다.
일괄 데이터 연산: window = window * 10.0_real64와 같이 루프 없이 일괄 연산을 수행하면 window가 참조하는 대상인 grid(4:7) 영역의 데이터들이 즉각 10배씩 증가한다.
복사 없는 접근(zero-copy): 배열의 특정 하위 도메인을 다루기 위해 별도의 임시 배열을 할당하고 데이터를 복사하는 과정이 발생하지 않는다. 원본 메모리 공간을 그대로 직접 참조하므로 메모리 오버헤드가 없으며 수치 연산 속도 면에서 극히 효율적이다.
가독성과 코드 관리 편의성: 복잡한 다차원 인덱스 표현식(grid(4:7))을 매번 복잡하게 기술할 필요 없이, 직관적인 포인터 식별자 이름으로 추상화하여 표현할 수 있어 소스코드의 가독성이 높아진다.
데이터 공유 영역 제어: 대규모 행렬 연산, 유체 역학 격자 모델링 등에서 전체 영역 중 특정 관심 격자 도메인(sub-domain)만을 떼어내어 독립적인 격자망처럼 집중 연산하고자 할 때 강력한 위력을 발휘한다.
[!important] 13.4 연결 리스트(Linked List)의 개념과 구현
연결 리스트(linked list)는 데이터를 연속적인 메모리 공간에 배치하는 배열과 달리, 데이터 노드들을 메모리 곳곳에 분산하여 저장하고 각 노드가 다음 노드의 메모리 주소를 가리키는 방식으로 연결한 동적 자료구조이다.
배열 (Array): 연속된 메모리를 점유하므로 인덱스를 통한 빠른 접근이 가능하다. 하지만 데이터를 중간에 삽입하거나 삭제하려면 이후의 모든 원소를 뒤로 밀거나 앞으로 당겨야 하므로 크기를 변경하기 어렵고 비효율적이다.
연결 리스트 (Linked List): 새로운 데이터를 삽입하거나 삭제할 때 포인터의 연결 고리(참조 주소)만 바꾸어 주면 된다. 메모리가 허용하는 한 노드를 물리적 한계 없이 계속 추가할 수 있어, 데이터의 개수가 유동적인 환경에서 강력한 위력을 발휘한다.
연결 리스트는 노드(Node)라 불리는 객체들의 사슬로 구성된다. 각 노드는 다음 두 가지 핵심 정보를 내포하는 파생형 구조를 가진다.
데이터 필드 (Value): 노드가 실제로 저장하고자 하는 수치나 문자 등의 원본 데이터 값
포인터 필드 (Next): 다음 노드가 메모리의 어느 위치에 존재하고 있는지 가리키는 주소 이정표
| 연산 유형 | 동작 메커니즘 |
|---|---|
| 추가 (Push) | 새로운 노드를 생성한 뒤, 기존 리스트의 시작(Head) 또는 끝(Tail) 부분에 포인터를 연결하여 사슬을 확장한다. |
| 순회 및 탐색 (Traversal / Search) | 맨 처음 노드(Head)를 가리키는 시작 포인터부터 출발하여, 각 노드의 next 포인터를 타고 마지막 노드(null)에 도달할 때까지 차례대로 이동하며 데이터를 처리하거나 원하는 값을 찾는다. |
| 삭제 (Delete) | 대상 노드의 이전 노드가 대상 노드의 '다음 노드'를 직접 가리키도록 포인터 방향을 우회시킨 후, 삭제 대상 노드의 메모리를 해제한다. |
[!important] 13.5 Fortran에서의 연결 리스트 정의
Fortran에서 연결 리스트를 구현하기 위해서는 파생형 구조와 포인터를 결합해야 한다. Fortran은 파생형 정의 내부에서 자기 자신과 동일한 타입의 포인터 구성 요소를 멤버로 선언하는 것을 완벽하게 지원한다.
type :: node
real :: value
type(node), pointer :: next => null()
end type node
재귀적 포인터 선언 (type(node), pointer :: next): 아직 정의가 완전히 끝나지 않은 node 파생형 내부에서 자기 자신을 참조하는 포인터를 선언할 수 있다. 이 포인터는 반드시 => null()로 초기화하여 연결의 끝을 안전하게 명시해야 한다.
동적 메모리 관리: 실행 시간 중에 allocate(node_pointer) 문을 사용하여 이름 없는 익명 노드 메모리를 힙(Heap) 영역에 필요한 만큼 동적으로 할당하고, 연산이 종료되면 deallocate 문을 통해 시스템에 메모리를 즉각 반환한다.
연결 리스트는 메모리 공간 곳곳에 흩어져 있는 데이터들을 포인터라는 유기적인 실로 꿰어 연결해 놓은 꾸러미와 같다. 대규모 수치 해석 모델에서 격자 요소가 동적으로 추가되거나, 원소의 삽입과 삭제가 빈번하게 발생하는 알고리즘을 설계할 때 필수적으로 활용되는 핵심적인 자료구조이다.
이 예제는 포인터로 연결된 자료구조가 어떻게 메모리를 동적으로 관리하며 리스트를 만들고, 출력하고, 최종적으로 해제하는 과정을 체계적으로 보여주는 가장 완벽한 기본 모델이다.
%%writefile linked_list.f90
module list_mod
implicit none
private
public :: node, push_front, print_list, free_list
type :: node
integer :: value
type(node), pointer :: next => null()
end type node
contains
subroutine push_front(head, v)
type(node), pointer, intent(inout) :: head
integer, intent(in) :: v
type(node), pointer :: new_node
allocate(new_node)
new_node%value = v
new_node%next => head ! new node points at old head
head => new_node ! head now points at new node
end subroutine push_front
subroutine print_list(head)
type(node), pointer, intent(in) :: head
type(node), pointer :: cur
cur => head
do while (associated(cur))
write(*, '(i0, 1x)', advance="no") cur%value
cur => cur%next
end do
write(*, '(a)') ""
end subroutine print_list
subroutine free_list(head)
type(node), pointer, intent(inout) :: head
type(node), pointer :: cur, tmp
cur => head
do while (associated(cur))
tmp => cur%next ! save next before freeing
deallocate(cur)
cur => tmp
end do
nullify(head)
end subroutine free_list
end module list_mod
program linked_list
use list_mod
implicit none
type(node), pointer :: head => null()
integer :: i
do i = 1, 5
call push_front(head, i * 10)
end do
call print_list(head)
call free_list(head)
print '(a, l1)', "list empty after free? ", .not. associated(head)
end program linked_list
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall pointer_array.f90 -o pointer_array
!./pointer_array
실행하면 전방 삽입에 따라 역순으로 정렬된 데이터가 출력되고, 메모리가 안전하게 해제되었음을 알 수 있다.
50 40 30 20 10
list empty after free? T
push_front를 1~5로 부르며 맨 앞에 끼워 넣었으므로, 순회하면 역순(50 40 30 20 10)으로 나온다. free_list의 tmp => cur%next가 삭제 전에 다음 노드를 미리 저장하는 부분이 핵심이다.
전방 삽입 메커니즘 (push_front): allocate(new_node)를 통해 힙 영역에 독립된 노드 공간을 개설한다. 그 후 새 노드의 next 포인터가 기존의 첫 번째 노드(head)를 가리키도록 연관(=>)시킨 뒤, 최종적으로 head 가 가리키는 주소를 new_node로 바꾸어 준다. 이 구조적 특징 때문에 먼저 입력된 데이터가 뒤로 밀려나 출력이 역순인 $[50, 40, 30, 20, 10]$ 기하 구조로 배치된다.
표준적 순회 패턴 (print_list): 임시 추적 포인터 cur에 head 주소를 대입하여 시작점을 잡는다. 이후 do while (associated(cur)) 구문을 매개로 하여 포인터 링크가 끊어지는 지점(null)을 만날 때까지 cur => cur%next를 수행하며 단방향 사슬을 차례대로 탐색한다. 이 연산 방식은 모든 포인터 기반 연결 구조를 탐색하는 교과서적인 표준 패턴이다.
메모리 역순 파괴와 주소 유실 방지 (free_list): 동적 할당된 메모리는 가비지 컬렉터(garbage collector)의 지원을 받지 못하므로, 수동으로 연동을 해제해 주어야만 메모리 누수(memory leak)가 생기지 않는다. 이때 현재 가리키는 노드를 deallocate(cur) 명령으로 파괴하기 직전에 반드시 tmp => cur%next를 수행하여 다음 노드의 행방을 미리 확보해 두어야 한다. 현재 노드가 메모리에서 소멸하면 그 내부에 적혀 있던 next 링크 주소 정보까지 한꺼번에 유실되어 리스트의 나머지 노드들이 미아(메모리에 떠도는 익명 공간)가 되기 때문이다.
[!note] 포인터 보다 동적 배열 동적 배열만 필요하다면 9장의
allocatable이 더 안전하다. 메모리 누수와 잘못된 참조를 컴파일러가 더 잘 막아 준다. 포인터는 연결 리스트나 트리처럼 노드끼리 서로를 가리켜야 하는 구조, 또는 별명이 꼭 필요한 경우에 사용한다.
위치와 속도 성분을 내포하는 particle 파생형을 정의하고, 동일한 초기 속력을 지닌 여러 입자를 다양한 각도로 공중으로 쏘아 올렸을 때 중력장 하에서 운동하는 물리계를 시뮬레이션하는 종합 예제이다.
이 프로그램은 각 시간 단계(Time step)마다 모든 입자의 좌표 정보를 particles.csv 파일로 기록한다.
%%writefile particles.f90
program particles
use, intrinsic :: iso_fortran_env, only: real64
implicit none
real(real64), parameter :: pi = 3.141592653589793_real64
real(real64), parameter :: gravity = 9.81_real64 ! m/s^2
real(real64), parameter :: speed = 20.0_real64 ! m/s
real(real64), parameter :: dt = 0.05_real64 ! s
integer, parameter :: n_part = 9
integer, parameter :: n_step = 80
type :: particle
real(real64) :: pos(2)
real(real64) :: vel(2)
end type particle
type(particle) :: cloud(n_part)
integer :: i, step, u
real(real64) :: angle
do i = 1, n_part
angle = (20.0_real64 + 60.0_real64 * real(i - 1, real64) &
/ real(n_part - 1, real64)) * pi / 180.0_real64
cloud(i)%pos = [0.0_real64, 0.0_real64]
cloud(i)%vel = [speed * cos(angle), speed * sin(angle)]
end do
open(newunit=u, file="particles.csv", status="replace", action="write")
write(u, '(a)') "step,id,x,y"
do step = 0, n_step
do i = 1, n_part
write(u, '(i0, ",", i0, ",", f0.4, ",", f0.4)') &
step, i, cloud(i)%pos(1), cloud(i)%pos(2)
end do
do i = 1, n_part
if (cloud(i)%pos(2) < 0.0_real64) cycle ! landed: stop updating
cloud(i)%pos = cloud(i)%pos + cloud(i)%vel * dt
cloud(i)%vel(2) = cloud(i)%vel(2) - gravity * dt
end do
end do
close(u)
print '(a)', "particles.csv written"
end program particles
컴파일·실행:
!gfortran -O2 -std=f2018 -Wall particles.f90 -o particles
!./particles
particles.csv written
!head particles.csv 명령으로 데이터 파일을 열어보면, 물리 엔진 연산이 수행되며 정상적으로 생성되었음을 확인할 수 있다.
step,id,x,y
0,1,.0000,.0000
0,2,.0000,.0000
0,3,.0000,.0000
0,4,.0000,.0000
0,5,.0000,.0000
0,6,.0000,.0000
0,7,.0000,.0000
0,8,.0000,.0000
0,9,.0000,.0000
구조적 추상화: 내부 멤버 변수로 크기 2인 실수형 배열 pos(2)와 vel(2)를 갖는 particle 파생형을 선언하고, 이를 다시 cloud(n_part)라는 배열로 구축하였다. 이로써 다수의 물리적 객체들이 복잡하게 뒤엉키지 않고 단 하나의 체계적인 자료 구조 안에서 직관적으로 제어된다.
배열 일괄 연산의 활용: 위치 벡터를 갱신하는 식인 cloud(i)%pos = cloud(i)%pos + cloud(i)%vel * dt는 Fortran의 고유한 특징인 배열 일괄 연산 메커니즘을 그대로 따른다. $x$ 좌표와 $y$ 좌표에 대하여 별도의 인덱스 제어문을 기술하지 않아도 내부에서 성분별 합산 연산이 동시에 안전하게 수행된다.
현실적인 제약 조건 적용: 낙하 연산 루프 내부에 if (cloud(i)%pos(2) < 0.0_real64) cycle 제어 구문을 삽입하여, 지면에 충돌한 입자가 지하 밑으로 무한히 뚫고 내려가지 않고 그 자리에 멈추도록 예외 처리를 유연하게 구현하였다.
출력된 particles.csv 파일의 데이터를 기반으로 각 시점별 입자의 좌표를 평면에 그려보자.
import csv
import matplotlib.pyplot as plt
ids, xs, ys = [], [], []
with open("particles.csv") as f:
for row in csv.DictReader(f):
ids.append(int(row["id"]))
xs.append(float(row["x"]))
ys.append(float(row["y"]))
plt.figure(figsize=(8, 4))
sc = plt.scatter(xs, ys, c=ids, cmap="viridis", s=10)
plt.colorbar(sc, label="particle id")
plt.xlabel("x (m)")
plt.ylabel("y (m)")
plt.title("Projectile fountain: Fortran simulation, Python plot")
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("particles.png", dpi=120)
plt.show()
공기 저항이 없는 중력장 내의 전형적인 등가속도 포물선 운동 궤적들이 아름다운 분수 형태의 군집으로 시각화된다. pos(2), vel(2)처럼 배열을 구성 요소로 갖는 파생형과, 그 파생형의 배열 cloud(n_part)가 결합해 입자계가 자료 구조 하나로 깔끔하게 표현된다. 위치 갱신 cloud(i)%pos = cloud(i)%pos + cloud(i)%vel * dt는 길이 2 배열의 요소별 연산이다(8장).
![[Pasted image 20260714143358.png]]
[!important] 오류 학습
동적 연결 리스트의 모든 노드를 파괴할 때 사용하는 free_list 서브루틴에서, 연결 정보를 임시 저장하지 않고 메모리를 먼저 해제했을 때 발생하는 치명적인 런타임 오류를 규명하는 예제이다. 단, 본 코드의 테스트를 위해 node 파생형 구조는 앞서 구현한 list_mod 모듈의 스펙을 그대로 활용한다고 가정한다.
%%writefile bad_free.f90
subroutine free_list(head)
type(node), pointer, intent(inout) :: head
type(node), pointer :: cur
cur => head
do while (associated(cur))
deallocate(cur) ! freed here ...
cur => cur%next ! ... then read freed memory
end do
nullify(head)
end subroutine free_list
아래와 같이 엄격한 메모리 검사 -fcheck=all 및 디버깅 -g 옵션을 활성화하고 컴파일한 후 실행한다.
!gfortran -O2 -std=f2018 -Wall -fcheck=all -g bad_free.f90 -o bad_free
!./bad_free
임의의 노드 객체들을 삽입한 뒤 해당 루틴으로 리스트를 해제하려고 하면, 정상적으로 종료되지 않고 프로세스를 강제로 차단하며 다음 예외 바이트 에러를 출력한다.
Program received signal SIGSEGV: Segmentation fault - invalid memory reference.
Backtrace for this error:
...
#3 ... in free_list
이 오류가 발생하는 원인은 동적 할당 메모리의 해제 메커니즘과 포인터 변수의 유효 범위를 오해했기 때문이다. deallocate(cur) 명령문이 수행되는 순간, 해당 노드가 차지하고 있던 물리 메모리 공간은 시스템에 즉각 반환되어 소유권을 잃게 된다. 당연히 그 공간 내부의 필드였던 데이터 값(value)과 다음 주소 이정표(next) 정보도 함께 소멸하거나 접근 불가능한 영역으로 격하된다.
그러나 바로 다음 문장에서 이미 소멸한 주소 공간의 컴포넌트인 cur%next를 참조하여 다음 노드로 이동(cur => cur%next)하려고 시도한다. 시스템에서 회수한 메모리 주소를 다시 읽으려고 역참조하는 행위는 운영체제의 보안 규격을 위반하는 행동이다. 이처럼 이미 해제되어 실체가 없는 대상을 계속해서 가리키고 있는 포인터를 허상 포인터(dangling pointer)라고 부르며, 이를 강제로 참조(역참조)하는 순간 예외 없이 세그멘테이션 오류(segmentation fault)를 유발하며 프로그램이 다운된다.
이 문제를 예방하려면 13.4절의 정석적인 설계 방식을 적용해야 한다. 현재 소유하고 있는 메모리를 안전하게 시스템에 반납하기 전에, 그 내부에 기록되어 있는 다음 목적지 링크 주소를 독립된 임시 포인터 변수에 사전 보관하는 방어적 로직이 선행되어야 한다.
do while (associated(cur))
tmp => cur%next ! save the link first
deallocate(cur) ! then it is safe to free cur
cur => tmp
end do
단순히 두 실행 문의 연산 순서만을 뒤바꾸었을 뿐이지만 결과는 완전히 상반된다. 포인터를 기반으로 연결 구조나 그래프 자료 구조를 직접 구축하고 관리할 때는, "메모리 공간을 물리적으로 해제하기 직전, 그 메모리가 들고 있던 다음 단계 연동 정보(링크 주소)를 완벽하게 대피시켰는가"를 검증하는 코딩 습관을 들이자.
[!important] 요약
type :: name … end type name, 구성 요소에 = 기본값 지정 가능.type(name) :: var, 접근: var%component(중첩은 var%a%b).name(...)로 한 번에 초기화한다(순서 지정 또는 comp=value 키워드 지정).arr%component는 그 구성 요소만 모은 배열을 준다(sum(arr%score) 등).target(또는 포인터) 속성이 필요하다.p => t는 연관(포인터 대입), p = v는 대상에 값 쓰기(보통 대입) — 구분이 핵심.=> null()로 초기화, 검사 associated(p), 해제 nullify(p).type(node), pointer :: next)와 allocate/deallocate로 만든다. 해제 시 다음 노드를 먼저 저장한다.allocatable을 우선한다.[!important] 연습 문제 1. 구성 요소
re,im(둘 다real64)을 가진 파생형complex_t를 정의하고,(3, 4)를 만들어 크기sqrt(re**2 + im**2)를 출력하라(내장complex형은 쓰지 말 것). 2. 13.2의student형을 그대로 써서 5명을 초기화하고, 최고 점수 학생의 이름과 점수를 출력하라. 3.point2d두 개a,b를 받아 두 점 사이 거리를 돌려주는 함수distance(a, b)를 작성하라(11장 인수 전달 활용). 4.real64변수temperature에target속성을 주고, 포인터p로 가리킨 뒤p를 통해 값을 36.5로 바꿔 원본이 바뀌었음을 확인하라. 5. 길이 8의target배열을 만들고, 포인터로 짝수 인덱스 구간arr(2:8:2)을 가리켜 그 구간에만 0을 써 넣어라. 출력으로 원본 변화를 보여라. 6. 13.4의 연결 리스트에 노드 개수를 세는 함수list_length(head)를 추가하고, 5개를 넣은 뒤 길이가 5임을 확인하라. 7.point2d의 배열poly(:)로 다각형 꼭짓점을 표현하고, 둘레 길이(이웃 꼭짓점 거리의 합, 마지막에서 처음으로 돌아오는 변 포함)를 계산하는 프로그램을 작성하라. 8.particle형을 사용해 입자 1개를 중력 아래 자유낙하시키되, 땅(y < 0)에 닿으면 수직 속도를 반전(vel(2) = -0.8 * vel(2))시켜 튕기게 하라. 위치를 csv로 출력하고 Python으로 높이-시간 그래프를 그려라. 9. 연결 리스트에 맨 뒤에 노드를 추가하는push_back(head, v)를 작성하라. 빈 리스트일 때와 아닐 때를 모두 처리해야 한다. 10.name(문자),mass(real64)를 가진body형의 배열로 천체 데이터를 담고, 질량이 평균보다 큰 천체만 골라 이름을 출력하라.