Devin.KR

파일 입출력과 서식 - 관측 자료 읽기

개발자KR 조회 0

이 장에서 배우는 것

앞 장까지는 프로그램 안에서 값을 직접 적어 넣어 계산했다. 실제 관측소의 자료는 파일에 들어 있고, 계산 결과도 화면에 흘려보내기보다 모양을 갖춘 표로 남겨야 한다. 이 장에서는 파일을 열고, 한 줄씩 읽고, 쓰고, 닫는 기본 흐름을 익힌다. 아울러 숫자를 원하는 자릿수와 간격으로 찍는 서식(format)과, 파일이 없거나 자료가 깨졌을 때 프로그램이 멈추지 않고 대처하는 방법을 다룬다.

  • open, read, write, close 네 문장으로 파일을 다루는 순서를 설명할 수 있다.
  • 서식 기술자 I, F, ES, A, X 로 출력 모양을 정할 수 있다.
  • iostat 와 iomsg 로 오류를 받아 처리하고, 파일 끝을 구분할 수 있다.
  • CSV 한 줄을 쉼표 위치로 나누어 정수와 실수로 바꿀 수 있다.

문제 상황

관측소 담당자가 시간별 기록을 obs.csv 라는 파일로 넘겨 주었다. 첫 줄은 열 이름이고, 그다음부터 한 줄에 시각, 기온(섭씨), 강수량(밀리미터)이 쉼표로 이어져 있다. 이 자료로 평균 기온과 강수 합계를 내려 한다.

사람이 손으로 옮겨 적은 자료라 모든 줄이 깨끗하지는 않다. 숫자 자리에 글자가 들어간 줄도 있고, 강수량 칸이 통째로 빠진 줄도 있다. 이런 줄을 만났다고 프로그램이 곧바로 멈추면 나머지 멀쩡한 자료까지 못 쓰게 된다. 우리가 원하는 동작은 다음과 같다.

  • 파일이 없으면 이유를 알리고 깔끔하게 끝낸다.
  • 읽을 수 있는 줄은 표로 찍고 합계에 더한다.
  • 읽을 수 없는 줄은 몇 번째 줄인지와 이유를 알리고 건너뛴다.
  • 파일 끝에 닿으면 반복을 끝내고 요약을 출력한다.

파일을 열고 닫는 기본 흐름

Fortran 에서 파일은 장치 번호(unit)라는 정수로 가리킨다. open 으로 파일과 장치 번호를 연결하고, 이후 read 와 write 에는 그 번호를 적는다. 다 쓰면 close 로 연결을 끊는다. 번호를 직접 정하면 다른 곳에서 쓰는 번호와 겹칠 수 있다. 그래서 newunit= 로 컴파일러가 쓰지 않는 번호를 골라 변수에 담아 주게 한다.

open (newunit=u, file='obs.csv', status='old', action='read', &
      iostat=ios, iomsg=msg)

각 지정자의 뜻은 다음과 같다.

  • file= 은 열 파일의 이름이다.
  • status= 는 파일의 존재 조건이다. 'old' 는 이미 있어야 하고, 'replace' 는 새로 만들며 있으면 내용을 버린다. 'new' 는 없을 때만 만들고 있으면 오류가 난다.
  • action= 은 용도다. 'read', 'write', 'readwrite' 중에서 고른다. 읽기만 할 파일을 'read' 로 열어 두면 실수로 쓰는 일을 막을 수 있다.
  • iostat= 와 iomsg= 는 뒤에서 따로 다룬다.

파일은 한 줄이 한 레코드(record)다. read (u, '(a)') line 은 한 레코드를 통째로 글자 변수 line 에 담는다. 변수보다 줄이 길면 뒤가 잘리고, 짧으면 뒤가 공백으로 채워진다. 쓸 때는 write (u, '(a)') '글자' 처럼 같은 장치 번호를 적는다. 화면에 찍는 print 는 장치 번호 대신 서식만 받는 짧은 형태다.

쓰기를 마친 파일을 곧바로 읽어야 한다면 먼저 close 로 닫고 다시 open 하는 편이 안전하다. 닫는 순간 아직 파일에 내려가지 않은 내용이 마무리되기 때문이다.

서식 기술자로 출력 모양 정하기

서식은 괄호로 묶은 문자열이며, 출력할 항목을 어떤 모양으로 찍을지 왼쪽부터 차례로 짝지어 준다. 숫자 하나가 차지할 칸 수를 폭(width)이라 하고, 그 칸 안에서 숫자는 오른쪽에 붙는다. 이 장에서 쓰는 기술자는 다섯 가지다.

이 장에서 쓰는 서식 기술자와 출력 예
기술자대상예(값 → 결과)설명
I4정수7 → 7폭 4칸, 오른쪽 정렬
I0정수7 → 7필요한 만큼만 차지
F8.2실수12.4 → 12.40전체 8칸, 소수점 아래 2자리
ES12.3실수5.6 → 5.600E+00과학 표기, 소수점 아래 3자리
A8글자'temp_c' → temp_c폭 8칸에 오른쪽 정렬
2X없음빈칸 2개항목 사이 간격

ES 는 소수점 앞에 항상 한 자리 숫자를 두고 10의 거듭제곱을 E+00 꼴로 붙인다. 아주 크거나 작은 값을 같은 자릿수로 비교할 때 쓴다. A 에 폭을 쓰지 않으면 글자 길이만큼 찍는다. 항목보다 기술자가 적으면 서식이 처음으로 돌아가며 새 줄에서 이어진다. 기술자가 남으면, 짝지을 항목이 없는 지점에서 출력이 멈춘다.

폭이 모자라면 Fortran 은 숫자를 줄이거나 잘라 보여 주지 않고 칸 전체를 * 로 채운다. 값이 크게 틀려 보이는 대신 잘못되었음을 분명히 알리는 방식이다. 폭을 정할 때는 부호와 소수점 자리까지 세어 넉넉히 잡는다.

iostat, iomsg 와 파일 끝

입출력 문장에 iostat= 가 없으면 오류가 나는 순간 프로그램이 멈춘다. 파일이 없는 경우도, 마지막 줄 다음을 읽은 경우도 마찬가지다. iostat=ios 를 달면 멈추는 대신 정수 변수 ios 에 결과가 담기고 프로그램은 계속 진행된다. 값의 의미는 다음과 같다.

iostat 값이 알려 주는 상태
ios 값뜻확인 방법
0성공ios == 0
음수파일 끝(또는 레코드 끝)is_iostat_end(ios)
양수그 밖의 오류ios > 0

음수나 양수의 구체적인 숫자는 컴파일러마다 다르다. 숫자를 외워 비교하지 말고 is_iostat_end 같은 내장 함수로 확인한다. iomsg=msg 를 함께 달면 오류가 났을 때 사람이 읽을 수 있는 설명이 글자 변수 msg 에 담긴다. 성공했을 때는 msg 를 쓰지 않는다.

읽기 반복의 뼈대는 한 줄 읽기 → ios 판정 → 처리 → 반복 이다. 판정을 읽은 직후에 두어야 한다. 읽기에 실패한 뒤에도 변수에는 이전 줄의 내용이 남아 있어서, 판정 없이 처리하면 같은 줄을 한 번 더 계산하게 된다.

읽기 반복은 한 줄 읽고 ios 를 판정해 정상이면 처리하고 끝이나 오류면 반복을 빠져나와 파일을 닫는다.

CSV 한 줄을 나누어 숫자로 바꾸기

CSV 는 쉼표로 칸을 나눈 글자 파일이다. 한 줄은 먼저 글자 변수에 통째로 들어온다. 이 글자를 숫자로 바꾸려면 쉼표 위치를 찾아 칸별로 잘라야 한다. 내장 함수 index(글자, 찾을글자) 는 처음 나타나는 위치를 돌려주고, 없으면 0 을 돌려준다. 글자의 일부는 line(3:5) 처럼 시작:끝 번호로 잘라 쓴다.

잘라 낸 글자는 내부 파일(internal file)로 읽는다. read 의 장치 자리에 파일 번호 대신 글자 변수를 적으면, 그 글자를 파일 한 줄처럼 읽어 숫자로 바꿔 준다. 형식이 맞지 않으면 파일에서 읽을 때와 똑같이 iostat 로 알려 준다.

c1 = index(line, ',')
c2 = c1 + index(line(c1+1:), ',')
read (line(1:c1-1),  *, iostat=ios) hour
read (line(c1+1:c2-1), *, iostat=ios) temp
read (line(c2+1:),   *, iostat=ios) rain

둘째 쉼표는 첫 쉼표 뒤쪽만 찾는다. index(line(c1+1:), ',') 는 잘라 낸 조각 안에서의 위치를 돌려주므로 c1 을 더해야 원래 줄의 위치가 된다. 읽기 문장의 * 는 서식을 정하지 않고 컴파일러가 알아서 해석하라는 뜻이다. 이 방식을 목록 지향 입력이라 부른다.

쉼표를 구분자로 인식하므로 read (line, *) hour, temp, rain 한 줄로도 읽힌다. 그러나 틀렸을 때 어느 칸이 문제인지 알 수 없고, 칸이 모자란 줄을 오류로 잡기도 어렵다. 칸별로 나누어 읽으면 "기온 칸이 잘못되었다"처럼 이유를 구체적으로 알릴 수 있다.

쉼표 위치 c1 과 c2 를 index 로 찾으면 줄이 시각, 기온, 강수 세 구간으로 나뉜다.

완성 코드

프로그램은 먼저 예제 obs.csv 를 직접 쓴다. 일부러 두 줄을 망가뜨렸다(기온 자리에 글자, 강수량 칸 누락). 그다음 없는 파일을 열어 오류 메시지를 확인하고, 실제 파일을 읽어 표와 요약을 출력한다.

main.f90

program main
  implicit none
  integer, parameter :: dp = kind(1.0d0)
  character(len=*), parameter :: fname = 'obs.csv'
  integer :: u, ios, lineno, nok, nbad, hour
  character(len=200) :: line, msg, why
  real(dp) :: temp, rain, tsum, rsum

  nok = 0
  nbad = 0
  lineno = 0
  tsum = 0.0_dp
  rsum = 0.0_dp

  call write_sample(fname)

  open (newunit=u, file='missing.csv', status='old', action='read', &
        iostat=ios, iomsg=msg)
  if (ios /= 0) print '(a,a)', 'open failed: ', trim(msg)

  open (newunit=u, file=fname, status='old', action='read', &
        iostat=ios, iomsg=msg)
  if (ios /= 0) then
    print '(a,a)', 'open failed: ', trim(msg)
    stop 1
  end if

  read (u, '(a)', iostat=ios, iomsg=msg) line
  lineno = lineno + 1
  if (ios /= 0) then
    print '(a,a)', 'header read failed: ', trim(msg)
    stop 1
  end if

  print '(a4,2x,a8,2x,a8)', 'hour', 'temp_c', 'rain_mm'

  do
    read (u, '(a)', iostat=ios, iomsg=msg) line
    if (is_iostat_end(ios)) exit
    if (ios /= 0) then
      print '(a,a)', 'read failed: ', trim(msg)
      exit
    end if
    lineno = lineno + 1

    call parse_line(line, hour, temp, rain, why)
    if (len_trim(why) > 0) then
      nbad = nbad + 1
      print '(a,i0,a,a,a,a)', 'line ', lineno, ': skipped (', trim(why), &
            '): ', trim(line)
    else
      nok = nok + 1
      tsum = tsum + temp
      rsum = rsum + rain
      print '(i4,2x,f8.2,2x,f8.2)', hour, temp, rain
    end if
  end do
  close (u)

  print '(a,i0,a,i0)', 'valid rows: ', nok, ', skipped rows: ', nbad
  if (nok > 0) then
    print '(a,f8.2)', 'mean temp (C): ', tsum / nok
    print '(a,f8.2)', 'total rain (mm): ', rsum
    print '(a,es12.3)', 'total rain (sci): ', rsum
  end if

contains

  subroutine write_sample(name)
    character(len=*), intent(in) :: name
    integer :: wu, wios
    character(len=200) :: wmsg

    open (newunit=wu, file=name, status='replace', action='write', &
          iostat=wios, iomsg=wmsg)
    if (wios /= 0) then
      print '(a,a)', 'cannot create file: ', trim(wmsg)
      stop 1
    end if
    write (wu, '(a)') 'hour,temp_c,rain_mm'
    write (wu, '(a)') '0,12.4,0.0'
    write (wu, '(a)') '1,11.8,0.2'
    write (wu, '(a)') '2,11.1,1.5'
    write (wu, '(a)') '3,abc,0.0'
    write (wu, '(a)') '4,10.9'
    write (wu, '(a)') '5,10.5,3.1'
    write (wu, '(a)') '6,10.2,0.8'
    close (wu)
  end subroutine write_sample

  subroutine parse_line(text, h, t, r, reason)
    character(len=*), intent(in) :: text
    integer, intent(out) :: h
    real(dp), intent(out) :: t, r
    character(len=*), intent(out) :: reason
    integer :: c1, c2, rios

    reason = ''
    h = 0
    t = 0.0_dp
    r = 0.0_dp

    c1 = index(text, ',')
    if (c1 == 0) then
      reason = 'no comma'
      return
    end if
    c2 = index(text(c1+1:), ',')
    if (c2 == 0) then
      reason = 'only two fields'
      return
    end if
    c2 = c1 + c2

    read (text(1:c1-1), *, iostat=rios) h
    if (rios /= 0) then
      reason = 'bad hour field'
      return
    end if
    read (text(c1+1:c2-1), *, iostat=rios) t
    if (rios /= 0) then
      reason = 'bad temp field'
      return
    end if
    read (text(c2+1:), *, iostat=rios) r
    if (rios /= 0) reason = 'bad rain field'
  end subroutine parse_line

end program main

줄별 해설

선언부. fname 은 이름 있는 상수라 파일 이름을 한 곳에서만 적는다. line, msg, why 는 200자짜리 글자 변수다. why 가 비어 있으면 성공, 내용이 있으면 실패 이유라는 약속을 쓴다. 합계 변수는 처음에 0 으로 만든다.

예제 파일 만들기. call write_sample(fname) 은 status='replace', action='write' 로 파일을 연다. 실행할 때마다 같은 내용으로 다시 만들어지므로 결과가 항상 같다. write (wu, '(a)') 는 글자를 가공 없이 한 줄로 적는다. close 를 마친 뒤에야 파일을 읽는다.

없는 파일 열기. missing.csv 를 status='old' 로 열면 실패하고 ios 가 0 이 아닌 값이 된다. 이때 프로그램은 멈추지 않고 msg 에 담긴 설명만 찍는다. 설명 문장의 틀은 컴파일러가 정하므로 이 책의 실행 결과는 gfortran 기준이다.

실제 파일 열기. 같은 변수 u 를 다시 쓴다. 이번에는 실패하면 계속할 수 없으므로 stop 1 로 끝낸다. 0 이 아닌 종료 코드는 셸에서 실패로 읽힌다.

머리글 한 줄 건너뛰기. 첫 줄은 열 이름이라 읽어서 버린다. 줄 번호 lineno 는 머리글도 한 줄로 세므로, 이후 알림의 번호가 편집기에서 보이는 줄 번호와 같다.

제목 줄 출력. '(a4,2x,a8,2x,a8)' 는 "hour" 를 4칸에, 빈칸 2개를 둔 뒤 "temp_c" 를 8칸 오른쪽 정렬로, 다시 빈칸 2개를 둔 뒤 "rain_mm" 을 8칸에 찍는다. 자료 줄의 숫자 폭(i4, f8.2)과 맞춰 두었으므로 열이 나란하다.

읽기 반복. do 에 조건이 없으므로 exit 가 실행될 때까지 계속 돈다. 읽은 직후 is_iostat_end(ios) 로 파일 끝을 먼저 확인하고, 그 밖의 0 이 아닌 값은 오류로 보아 메시지를 찍고 나온다. 끝을 먼저 거르는 것은 정상 종료이기 때문이다. 정상인 줄만 lineno 를 올리고 parse_line 에 넘긴다.

결과 분기. 실패 이유 why 가 있으면 건너뛴 줄 수를 올리고 줄 번호, 이유, 원문을 한 줄로 알린다. 서식 '(a,i0,a,a,a,a)' 는 글자 사이사이에 정수를 폭 없이 끼운다. 성공이면 기온과 강수를 합계에 더하고 표 한 줄을 찍는다.

요약. 읽기가 끝나면 close 로 닫는다. 평균 기온은 tsum / nok 로 구하는데, 정상 줄이 하나도 없으면 0 으로 나누게 되므로 nok > 0 일 때만 계산한다. 강수 합계는 F8.2 와 ES12.3 으로 두 번 찍어 모양을 견주어 볼 수 있다.

parse_line. reason, h, t, r 은 intent(out) 이므로 호출될 때마다 새로 정해야 한다. 그래서 맨 앞에서 모두 초기화한다. 쉼표가 없거나 하나뿐이면 곧바로 이유를 적고 return 으로 나간다. 둘째 쉼표 위치에는 c1 을 더해 원래 줄의 위치로 바꾼다. 이어서 세 칸을 차례로 읽고, 어느 칸에서 rios 가 0 이 아니면 해당 이유를 적는다. 이 서브루틴은 contains 아래에 있어서 상수 dp 를 바깥 프로그램에서 그대로 쓴다.

실행 결과

$ gfortran -std=f2018 -Wall main.f90 -o main
$ ./main
open failed: Cannot open file 'missing.csv': No such file or directory
hour    temp_c   rain_mm
   0     12.40      0.00
   1     11.80      0.20
   2     11.10      1.50
line 5: skipped (bad temp field): 3,abc,0.0
line 6: skipped (only two fields): 4,10.9
   5     10.50      3.10
   6     10.20      0.80
valid rows: 5, skipped rows: 2
mean temp (C):    11.20
total rain (mm):     5.60
total rain (sci):    5.600E+00

정상 줄 5개의 기온 합은 56.0, 평균은 11.2 이고 강수 합은 5.6 이다. 가운데 두 줄이 알림을 남기고 건너뛰어졌지만 뒤의 5, 6시 자료는 그대로 처리되었다. 같은 폴더에 obs.csv 가 새로 생기며 내용을 열어 확인할 수 있다.

실무에서 자주 틀리는 것

1. iostat 없이 반복해서 읽기

틀린 코드는 파일 끝을 판정할 수단이 없다.

do
  read (u, '(a)') line
  print '(a)', trim(line)
end do

마지막 줄까지 읽고 한 번 더 읽는 순간 파일 끝 오류로 프로그램이 멈춘다. 고친 코드는 iostat 를 달고 끝을 확인한다.

do
  read (u, '(a)', iostat=ios) line
  if (is_iostat_end(ios)) exit
  if (ios /= 0) exit
  print '(a)', trim(line)
end do

2. 폭이 모자란 서식

기온이 -12.34 인데 F5.2 로 찍으려는 경우다.

print '(f5.2)', -12.34_dp

부호, 숫자 두 자리, 소수점, 소수 두 자리를 합하면 6칸이 필요하므로 칸이 모두 ***** 로 채워진다. 필요한 칸을 세어 폭을 넓히거나, 폭을 모르면 여유를 두고 F8.2 로 쓴다.

print '(f8.2)', -12.34_dp

3. 둘째 쉼표 위치에 첫 쉼표 위치를 더하지 않기

c1 = index(line, ',')
c2 = index(line(c1+1:), ',')      ! 잘라 낸 조각 안의 위치
read (line(c1+1:c2-1), *) temp

3,abc,0.0 에서 c1 은 2, 조각 abc,0.0 안의 쉼표 위치는 4 다. 그런데 이 4 를 원래 줄의 위치처럼 쓰면 범위가 3:3 이 되어 기온 칸이 a 한 글자로 읽힌다. 항상 c1 을 더해 원래 줄 기준으로 맞춘다.

c2 = c1 + index(line(c1+1:), ',')

4. 쓰고 닫지 않은 채 같은 파일을 읽기

open (newunit=wu, file='obs.csv', status='replace', action='write')
write (wu, '(a)') '0,12.4,0.0'
open (newunit=ru, file='obs.csv', status='old', action='read')

쓰기용 연결이 아직 열려 있는 파일을 다시 열면, 내용이 파일에 다 내려갔다는 보장이 없고 같은 파일을 두 번 연결하는 것 자체가 오류가 될 수 있다. 쓰기를 끝내면 close 한 다음에 읽기로 연다.

close (wu)
open (newunit=ru, file='obs.csv', status='old', action='read')

한눈에 보기

파일 입출력 문장과 지정자 요약
항목쓰는 법핵심
열기open (newunit=u, file=..., status=..., action=...)장치 번호는 newunit 으로 받는다
읽기read (u, '(a)') line한 줄을 글자로 받는다
쓰기write (u, '(a)') 글자화면 출력은 print
닫기close (u)쓴 파일은 닫은 뒤 읽는다
오류 처리iostat=ios, iomsg=msg0 성공, 음수 끝, 양수 오류
파일 끝is_iostat_end(ios)읽은 직후 판정한다
칸 나누기index 와 부분 글자 line(a:b)잘라 낸 글자는 내부 파일로 읽는다
서식 기술자 한 줄 요약
기술자용도주의
Iw정수폭이 모자라면 * 로 채워진다
Fw.d고정 소수점 실수폭에 부호와 소수점도 센다
ESw.d과학 표기 실수지수 부분 5칸을 포함한다
A, Aw글자폭이 짧으면 앞부분만 찍는다
nX빈칸 n개열 간격 조정에 쓴다

연습 문제

  1. write (*, '(i4,2x,f8.2)') 7, -3.456_dp 가 찍는 한 줄을 공백 개수까지 정확히 적어라.
  2. 강수 합계 5.6 을 f10.3 으로 찍으면 어떤 글자가 나오는가? 앞의 공백 개수를 포함해 적어라.
  3. parse_line 이 기온이 -60 보다 낮거나 60 보다 높은 줄도 거부하도록 한 줄을 추가하라. 어디에 넣어야 하는가?
  4. 자료 파일 중간에 빈 줄이 있으면 이 프로그램은 어떻게 처리하는가? 빈 줄은 조용히 넘기도록 고쳐라.

정답과 해설

  1. 결과는 공백 3개, 7, 공백 5개, -3.46 이다. 즉 7 -3.46 이다. i4 는 3칸의 공백과 7 이고, 2x 는 공백 2칸이다. f8.2 는 -3.456 을 -3.46 으로 반올림해 5글자이므로 앞에 3칸이 더 붙어서, 공백이 모두 5개가 된다.
  2. f10.3 은 5.600 이 5글자이므로 앞에 공백 5개가 붙어 5.600 이 된다. 같은 값을 ES12.3 으로 찍을 때와 달리 지수 부분이 없다.
  3. 세 칸을 모두 읽은 뒤인 parse_line 의 맨 끝에 넣는다. 예를 들면 다음과 같다.
    if (len_trim(reason) == 0) then
      if (t < -60.0_dp .or. t > 60.0_dp) reason = 'temp out of range'
    end if
    읽기 실패 이유가 이미 있으면 덮어쓰지 않도록 비어 있을 때만 검사한다.
  4. 빈 줄은 쉼표가 없으므로 parse_line 이 no comma 를 돌려주고 건너뛴 줄로 센다. 조용히 넘기려면 읽기 직후, lineno 를 올린 뒤 parse_line 을 부르기 전에 if (len_trim(line) == 0) cycle 을 넣는다. cycle 은 반복의 나머지를 건너뛰고 다음 줄을 읽는다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.