13장: 특이한 데이터형

13장: 특이한 데이터형

  • 일부 언어는 12장의 기본 데이터형 외에 색다른 종류의 데이터를 지원함
  • 이 장은 클래스 대신 구조체를 쓰는 경우, 포인터 사용법, 전역 데이터 문제를 피하는 방법을 다룸
  • 현대 객체 지향 프로그래밍 책에서 흔히 다루지 않는 형이라 “특이한(Unusual)” 데이터형이라 부름

13.1 구조체

구조체의 정의

이 책에서 구조체는 다음과 같이 정의함

  • “서로 다른 형의 집합으로 구성된 데이터”

The term “structure” refers to data that’s built up from other types.

각 언어에서 구조체의 역할을 하는 예는 다음과 같음

  • C/C++의 struct
  • Visual Basic의 `Structure
  • Java나 C++에서는 public 데이터 멤버만 있고 public 루틴이 없는 클래스`

You’ll generally want to create classes rather than structures so that you can take advantage of the privacy and functionality offered by classes in addition to the public data supported by structures. But sometimes directly manipulating blocks of data can be useful, so here are some reasons for using structures:

저자는 다음 전제를 깔아둠

  • 일반적으로 구조체보다 클래스
    • 구조체에 비해서 클래스는 다음 이점이 있음
      • 데이터 은닉
      • 기능

구조체가 유용한 이유 4가지를 앞으로 소개

이유 1 - 데이터 관계를 명확하게 하기 위해 구조체를 사용하라

Use structures to clarify data relationships

관련 항목을 묶으면 어떤 데이터가 서로 관련되는지 드러남

  • 프로그램을 파악할 때 가장 어려운 부분이 데이터 간 관계 파악인 경우가 많음

이유 2 - 데이터 블록 연산을 단순화 하기 위해 구조체를 사용하라

Use structures to simplify operations on blocks of data

요소별로 복사하는 대신 구조체 단위로 연산하면 다음 이점이 있음

  • 코드 수정이 쉬워짐
    • 데이터가 흩어진 상황에서 새로운 필드가 추가되면…
    • swap 예제
  • 코드 가독성 향상

이유 3 - 매개변수 목록을 단순화하기 위해 구조체를 사용하라

Use structures to simplify parameter lists

데이터를 사용하는 함수가 많을 때 구조체만 고치면 되니 유지보수에 유리

  • 하지만 너무 극단적인 상황은 경계 “big, juicy variable”

정보 은닉의 한 측면(“aspect of information hiding”)

  • “need-to-know” 기준으로 어떤 정보는 루틴(함수)안에 숨겨지고, 어떤 정보는 루틴으로 부터 숨겨짐
  • “함수가 config 객체를 통째로 받아놓고 필드 하나만 쓰는 코드” 같은 것들은 경계하고 “need-to-know”에 따라서 작성하기
    • 이렇게 짜면 테스트할 때 mock 만들기도 번거로움
    • 5.3 절 “결합도 낮추기”와 연결되는 내용

      이유 4- 유지보수를 줄이기 위해 구조체를 사용하라

Use structures to reduce maintenance

같은 맥락

구조체는 변경이 닿는 반경을 그 데이터를 진짜로 아는 코드 로 좁혀줌

13.2 포인터

포인터 이해를 위한 패러다임

포인터는 개념적으로 두 부분으로 구성

  • 메모리상의 위치(주소)
  • 그 위치의 내용을 해석하는 방법
    • “base type” (포인터가 가리키는 대상의 타입)을 기반으로 해석
    • 메모리 자체에는 고유한 해석이 없음

포인터 사용 일반 팁

포인터 오류의 특별한 점

  • 포인터로 잘못 값을 쓰면 엉뚱한 메모리가 손상됨
    • A.K.A “memory corruption”
    • 오류 원인 위치를 찾기 어려움

책에서는 포인터 오류를 방지하기 위해 크게 2가지 전략을 사용

  1. 애초에 만들지 마라 [예방]
  2. 가능한 빨리 발견하라 [탐지]

  1. 포인터 연산을 루틴이나 클래스로 격리 [예방]
    • 연결 리스트를 곳곳에서 직접 순회하지 않고 NextLink(), InsertLink() 같은 접근 루틴을 만듦
    • 포인터를 만지는 곳을 최대한 줄이는 전략
  2. 포인터 선언과 정의를 동시에 해라 [예방]
    • OOP에서 생성자를 사용하는 이유
      • 개체는 생성 시점 유효한 값을 가짐
  3. 같은 스코프 수준에서 대칭으로 할당/해제 [예방]

  4. 포인터 사용하기 전 검사 [탐지]
    • 사용하기 전 주소값이 유효한 범위인지 확인
  5. 포인터가 참조하는 변수를 사용 전에 확인 [탐지]
    • 포인터 접근 루틴(함수)로 자동화하기
  6. “dog-tag”를 사용해 “memory corruption”을 방지하라 [탐지]
    • 오류 검사만을 위한 태그 필드를 두고, 해제 시 태그가 기대값(정상값)이면 무효값(비정상값)으로 바꾼 뒤 해제
    • “dog-tag”의 위치에 따라 용도가 다름
      • 할당한 메모리 블록의 시작 위치에 “dog-tag”를 위치하는 경우 뒤에 나오는 목록(아래 16번의 별도의 자료구조)없이 “이중 해제”(redundant attempts to deallocate the memory block) 탐지
      • “dot-tag”가 마지막 위치에 나오는 경우 허용한 주소 범위를 넘어서 덮어쓰는 것 방지
        • 끝에 나오는 “dot-tag”를 덮어써 손상되면, 허용한 주소 범위를 넘어섰다는 것을 알 수 있음
      • 두 방법을 같이 사용해도 됨
  7. 명시적 중복(“explicit redundancies”) 추가 [탐지]
    • “dog-tag”의 대안
    • 특정 필드를 두번 저장해서 두 값이 다르면 손상으로 판단
  8. 포인터 변수를 아끼지 마라 [예방]
    • currentNode->next->previous 대신 startNode, newMiddleNode, followingNode 세 변수로 셋 다 이름을 주라는 것
  9. 복잡한 포인터 식을 단순화 해라 [예방]

  10. 글 보다는 그림 [예방]

  11. 링크드 리스트의 첫 포인터를 먼저 해제하면 다음 노드로 못가니 다음 요소의 포인터를 확보하고 해제 [예방]

  12. 낙하산 메모리 할당 [예방]
    • 프로그램 시작 시 안전한 종료(“exit gracefully”)를 위해 종료할 때 필요한 만큼 미리 메모리를 할당
      • 파일 저장 버퍼, 로그 문자열 포매팅 등 모두 메모리가 필요함
  13. 해제 직전 메모리를 쓰레기 값으로 덮어쓰기 (“Shred your garbage”) [탐지]
    • “dangling pointer” 사용이 항상 확실하게 터지게 만들기
  14. 해제 후 포인터 변수에 null 대입 [탐지]
    • 해제한 포인터를 사용할 때 오류가 바로 드러나게 하기
  15. 이미 해제된 포인터를 확인하기 [탐지]
    • 14번과 결합해 assert (pointer != null) 검사
  16. 할당한 포인터 목록(자체 자료구조)를 유지하과 해제 전에 목록을 참고 [탐지]
    • 위의 6번 “dot-tag”방법을 사용하면 목록을 사용하지 않아도됨
  17. 커버 루틴 작성 [예방]
    • 검사들을 자동화하기
    • C++의 SAFE_NEW, SAFE_DELETE
  18. 포인터가 아닌 다른 기술을 사용하라 [예방]

Pointers are harder than average to understand, they’re error-prone, and they tend to require machine-dependent, unportable code. If you can think of an alternative to using a pointer that works reasonably, save yourself a few headaches and use it instead.

C++ 포인터

  • 포인터(*)와 참조(&)의 차이를 이해
    • 참조는 항상 객체를 참조해야 하고, 한 번 초기화되면 참조 대상을 바꿀 수 없음
  • “참조에 의한 전달”에는 포인터를, “값에 의한 전달” 의미에는 const 참조를 사용
    • 큰 객체 복사 비용을 피하면서, 수정 가능한 객체는 object->member, 불가능한 객체는 object.member로 구문상 구분됨
  • auto_ptr을 사용함. 스코프를 벗어나면 자동으로 메모리를 해제해 누수 문제를 줄임
    • 현대 C++ 기준: auto_ptr은 폐기됨, unique_ptr/shared_ptr 사용
  • 스마트 포인터를 배워라

C 포인터

  • 기본 형 대신 명시적 포인터 형을 사용
    • 여기서 기본형은 char* / void*
    • 구체적 형(“base type”을 써야 컴파일러가 형 불일치를 경고할 수 있음
      • 형 변환이 필요하면 명시적 캐스팅으로 드러냄
  • 형 변환 자체를 피함
    • 캐스팅은 컴파일러의 형 검사 능력을 꺼서 컴퍼일러라는 보호막을 꺼버림
    • 캐스팅이 많이 필요한 프로그램은 아키텍처에 결함이 있을 가능성이 큼
  • 매개변수 전달 할 때 별표(*) 빼먹는거 주의
  • 메모리 할당 크기는 sizeof()로 결정하기
    • 직접 만든 구조체에도 동작
    • 컴파일 시점 계산이라 성능 비용이 없음
    • 이식성도 좋음

13.3 전역 데이터

전역 변수란

전역 변수는 프로그램 어디서나 접근 가능한 변수

  • “전역”이라는 용어가 느슨하게 쓰이는 경우 주의
    • 클래스 안 어디서나 접근 가능한 클래스 변수를 전역이라 부르기도 하지만, 클래스 하나 안에서 접근 가능하다고 전역인 것은 아님

경험 많은 프로그래머들의 두 가지 결론 (서로 긴장 관계)

  • 전역 데이터는 지역 데이터보다 위험함
  • 여러 루틴에서 데이터에 접근하는 것 자체는 꽤 유용함

Even if global variables don’t always produce errors, however, they’re hardly ever the best way to program.

  • 전역 변수가 항상 오류를 내는 건 아니지만, 최선의 방법인 경우는 거의 없음

전역 데이터의 일반적인 문제 6가지

전역 변수를 마구 쓰고 싶거나 못 쓰는 게 답답하다면, 아직 ==정보 은닉과 모듈화의 가치==를 체득하지 못한 것이라고 저자가 꾸짖음

  1. 전역 데이터의 부주의한 변경 (“side effect”)

Inadvertent changes to global data

  • 한 곳에서 값을 바꿔놓고 다른 곳에서는 안 바뀌었다고 착각
  • 책 예제: GetOtherAnswer() 호출이 전역 변수 theAnswer를 몰래 바꿔서 평균 계산이 틀어짐
  1. 전역 데이터의 기괴한 앨리어싱(aliasing) 문제

Bizarre and exciting aliasing problems with global data

  • 앨리어싱: 같은 변수를 두 개 이상의 다른 이름으로 부르는 것
  • 전역 변수를 루틴에 매개변수로 넘기고, 루틴 안에서 전역 변수와 매개변수 둘 다로 사용할 때 발생
  • 책 예제: WriteGlobal( globalVar )inputVarglobalVar가 사실 같은 변수라 기대와 다른 결과 출력 (비쥬얼 베이직)
  1. 전역 데이터의 재진입(re-entrant) 코드 문제

Re-entrant code problems with global data

  • 멀티스레드 환경에서는 전역 데이터가 루틴 사이뿐 아니라 프로그램 복사본들 사이에서도 공유됨
  • 여러 복사본이 동시에 돌아도 전역 데이터의 의미가 유지되도록 보장해야 함
  1. 전역 데이터로 인한 코드 재사용 방해

Code reuse hindered by global data

  • 재사용하려는 클래스가 전역 데이터를 읽거나 쓰면 그냥 뽑아서 다른 프로그램에 꽂을 수 없음
  • 나쁜 해결책: 새 프로그램에 그 전역 데이터를 만들어 줌 → 바이러스처럼 전염됨
  • 좋은 해결책: 옛 클래스에서 전역 데이터 의존을 제거 → 다음부터는 그냥 재사용 가능
  1. 전역 데이터의 불확실한 초기화 순서

Uncertain initialization-order issues with global data

  • 서로 다른 번역 단위(파일) 간 초기화 순서가 언어에 따라 미정의 (특히 C++)
  • 다른 파일에서 초기화되는 전역 변수에 의존하면 값이 보장되지 않음
  • 해결책은 있지만(Effective C++ Item 47), 그 까다로움 자체가 전역 데이터가 더하는 복잡성을 보여줌
  1. 전역 데이터로 인한 모듈성과 지적 관리 가능성 훼손

Modularity and intellectual manageability damaged by global data

  • 큰 프로그램을 지적(intellectual)으로 관리하는 유일한 방법은 쪼개서 한 번에 한 부분만 생각하는 것
  • 전역 데이터를 쓰면 루틴 하나에만 집중할 수 없음 — 같은 전역 데이터를 쓰는 다른 모든 루틴도 같이 생각해야 함
  • 모듈성을 완전히 파괴하는 건 아니지만 약화시키며, 그것만으로도 더 나은 해법을 찾을 이유가 됨

전역 데이터를 사용하는 이유

데이터 순수주의자는 절대 쓰지 말라고 하지만, 넓게 보면 대부분의 프로그램이 “전역 데이터”를 씀

  • DB의 데이터, 설정 파일(윈도우 레지스트리)도 전역 데이터
  • 명명된 상수(named constant)도 전역 데이터임 (전역 변수가 아닐 뿐)
    • named constant는 12.7 절 참고

전역 데이터를 절제해서 사용하면 유용한 상황들

  1. 전역 값의 보존
    • 프로그램 전체에 개념적으로 적용되는 데이터
      • 예: 대화형 vs 커맨드라인 모드 같은 프로그램 상태를 구분하는 변수
  2. 명명된 상수의 흉내
    • 언어가 명명된 상수를 지원하지 않을 때 대체재로 사용
      • 당시 Python, Perl, Awk, 셸 스크립트
    • 언어 안에서(in) 프로그래밍이 아니라 언어 속으로(into) 프로그래밍하는 대표 사례 (34.4절 “Program into Your Language, Not in It”)
      • This disciplined use of global data is a prime example of the distinction between programming *in* vs. programming *into* a language
      • in: 언어가 직접 지원하는 구문만으로 생각을 제한하며 프로그래밍하는 방식
      • into: 표현하고 싶은 생각을 먼저 정하고, 그 다음 언어가 주는 도구로 그 생각을 표현할 방법을 찾는 방식
      • 언어에 상수 문법이 없어도 “전역 변수 + 값 변경 금지 관례”로 명명된 상수라는 개념을 구현하는 것이 into의 예 — 언어가 강제해주지 않는 것을 규율로 해내는 것
  3. 열거형의 흉내
    • 열거형이 없는 언어에서 대체재로 사용
  4. 극도로 자주 쓰이는 데이터의 사용 능률화
    • 모든 루틴의 매개변수 목록에 등장할 정도라면 전역으로 만들 수 있음
    • 단, “어디서나 접근되는 것 같은” 변수는 실제로는 제한된 루틴 집합에서만 접근되는 경우가 대부분 → 클래스로 묶을 수 있음에 주의
  5. 떠돌이 데이터(tramp data) 제거
    • 다른 루틴에 전달하기 위해서만 거쳐 지나가는 데이터를 제거
    • 책 예제: 모든 루틴에서 쓰이는 오류 처리 객체 — 호출 사슬 중간의 루틴은 그 객체를 쓰지 않고 그저 다음 루틴에 넘기기 위해 받기만 함 → 이런 데이터가 “tramp data”

Java로 표현하면 다음과 같은 상황

// tramp data: processOrder()는 errorReporter를 쓰지 않는데
// validate()에 넘겨주기 위해서만 매개변수로 받음
void handleRequest(Request request, ErrorReporter errorReporter) {
    processOrder(request.getOrder(), errorReporter);
}

void processOrder(Order order, ErrorReporter errorReporter) { // 여기서는 안 씀
    validate(order, errorReporter);
}

void validate(Order order, ErrorReporter errorReporter) {
    if (order.isEmpty()) {
        errorReporter.report("empty order"); // 실제 사용처는 여기뿐
    }
}

전역(정적) 접근으로 바꾸면 중간 루틴의 매개변수에서 사라짐

void processOrder(Order order) { // 시그니처에서 errorReporter가 사라짐
    validate(order);
}

void validate(Order order) {
    if (order.isEmpty()) {
        ErrorReporter.getInstance().report("empty order");
    }
}
  • 현대 Java에서는 이 문제를 보통 다른 방식으로 해결함
    • 로깅: Logger 같은 정적/전역 인스턴스 — 사실상 이 항목 그대로
    • DI(스프링 등): 필요한 객체를 호출 사슬로 나르지 않고 생성자 주입으로 각 클래스가 직접 받음 — tramp data를 없애면서도 전역 상태를 피하는 절충

전역 데이터는 최후의 수단으로만 사용하라

  1. 모든 변수를 지역으로 시작하고, 필요할 때만 범위를 넓혀라
    • 지역 → private/protected 클래스 변수 → (정말 확실할 때만) 전역
    • 전역으로 시작하면 지역으로 되돌리기 어려워짐
    • 지역으로 시작하면 전역으로 만들 일이 없을 수도 있음
  2. 전역 변수와 클래스 변수를 구분하라
    • 정말 프로그램 전체에서 접근되는 변수 vs 특정 루틴 집합에서만 많이 쓰이는 클래스 변수
    • 클래스 밖에서 값이 필요하면 접근 루틴으로 제공하고, 전역 변수처럼 직접 접근하지 말 것
  3. 접근 루틴(access routine)을 사용하라
    • 전역 데이터 문제를 우회하는 주력(workhorse) 접근법

전역 데이터 대신 접근 루틴 사용하기

Anything you can do with global data, you can do better with access routines.

전역 데이터로 할 수 있는 건 뭐든 접근 루틴으로 더 잘할 수 있음

접근 루틴의 이점 4가지

  1. 데이터에 대한 중앙화된 통제
    • 나중에 더 나은 구현을 발견해도 참조하는 모든 곳을 고칠 필요 없음 — 변경이 접근 루틴 안에 머묾
  2. 모든 참조가 방벽(barricade) 처리됨을 보장
    • stack.array[ stack.top ] = newElement 직접 조작은 오버플로 검사를 잊기 쉬움
    • PushStack( newElement ) 안에 검사를 넣으면 호출할 때마다 자동으로 수행됨 (8.5절 방벽과 연결)
      • OOP에서 멤버 변수를 직접 read/write 하는 것보다 getter/setter을 권하는 개념과 유사
      • 루틴(함수)의 장점
  3. 정보 은닉의 일반적 이점을 자동으로 얻음
    • 접근 루틴 내부를 바꿔도 나머지 프로그램은 그대로 — “집 내부를 고쳐도 외관은 그대로”
      • 함수 분리의 이점
  4. 추상 데이터형으로 전환하기 쉬움
    • if lineCount > MAX_LINES 대신 if PageFull() — 의도가 코드에 문서화됨
      • 함수 이름 잘 지으면 의도가 코드에 잘 들어나는 개념

접근 루틴 사용법

  • 데이터를 클래스 안에 숨기고, static으로 단일 인스턴스를 보장하고, 조회/변경 루틴을 만들고, 외부 코드는 반드시 그 루틴을 거치게 함
    • 싱글턴
  • 예: 전역 g_globalStatus → 클래스 안에 숨기고 globalStatus.Get() / globalStatus.Set() 제공
  • 언어가 클래스를 지원하지 않으면 코딩 표준으로 강제 (이것도 위에서 본 “into a language” 프로그래밍)

언어 차원 지원이 없을 때의 세부 지침

  1. 모든 코드가 접근 루틴을 거치도록 요구하라
    • 관례: 전역 데이터는 g_ 접두어로 시작하고, g_ 변수는 자신의 접근 루틴 외에는 접근 금지
  2. 전역 데이터를 한 통에 다 던져 넣지 마라
    • 전역 데이터 전부에 접근 루틴만 씌우면 전역 데이터 문제는 없어지지만 정보 은닉·ADT의 이점은 놓침
    • 각 전역 변수가 어느 클래스에 속하는지 생각하고, 그 데이터와 접근 루틴을 관련 데이터·루틴과 함께 패키징
  3. 잠금(locking)으로 전역 변수 접근을 통제하라
    • DB의 동시성 제어처럼 사용 전 “check out”, 사용 후 “check in” — 사용 중에 다른 곳이 접근하면 오류 메시지나 assertion
    • 단순한 잠금 스킴은 개발 단계용 안전장치 — 프로덕션에서는 더 안전하고 우아한 동작(예: 로그 기록)으로 교체
    • 접근 루틴을 쓰면 구현하기 쉽고, 전역 데이터를 직접 쓰면 구현하기 어려운 안전장치의 예
  4. 접근 루틴에 추상화 수준을 넣어라
    • 구현 세부사항이 아니라 문제 도메인 수준으로 작성
    • node = node.next 대신 account = NextAccount( account )
    • 직접 쓰면 자료구조가 하는 일(다음 링크 이동)과 도메인에서 벌어지는 일(다음 계정 얻기)을 동시에 보여주는 셈
  5. 데이터에 대한 모든 접근을 같은 추상화 수준으로 유지하라
    • 한 연산을 접근 루틴으로 했으면 나머지도 전부 접근 루틴으로
    • PushStack()으로 넣고 value = array[ stack.top ]으로 꺼내면 비일관적 — PopStack()을 만들 것
    • 비일관성은 다른 사람의 코드 이해를 어렵게 함

전역 데이터 사용의 위험을 줄이는 방법

대부분의 “전역 데이터”는 사실 제대로 설계되지 않은 클래스의 클래스 데이터

  • 정말 전역이어야 하는 극소수의 경우 아래 지침을 따를 것
  • 저자의 비유: 외국 여행 전 예방주사 — 아프긴 해도 건강할 확률을 높여줌
  1. 전역 변수임이 분명히 드러나는 명명 규칙을 개발하라
    • 전역 변수를 여러 용도(변수 + 상수 대체)로 쓴다면 용도별로 구분되는 규칙 사용
      • 11.4절 참고
  2. 모든 전역 변수의 주석 달린 목록을 만들어라
    • 전역 변수 목록은 그 프로그램을 다루는 사람에게 가장 유용한 도구 중 하나
  3. 중간 결과를 담는 데 전역 변수를 쓰지 마라
    • 계산 중간값을 전역 변수에 담지 말고, 계산이 끝난 최종 값만 대입
  4. 거대 객체(monster object)에 다 넣어 돌리면서 전역 데이터 안 쓰는 척하지 마라
    • 형식적으로는 전역 변수를 피했지만 순수한 오버헤드일 뿐, 진짜 캡슐화의 이점은 하나도 없음
    • 전역 데이터를 쓴다면 공개적으로(openly) 쓸 것 — 비만 객체(obese object)로 위장하지 말 것
    • 13.1의 “big, juicy variable” 경계와 같은 맥락

참고 자료에 체크 리스트 확인

results matching ""

    No results matching ""