[독서] 코딩을 지탱하는 기술
코딩을 지탱하는 기술
특정 언어에 국한된 지식이 아닌, 보다 보편적인 지식을 습득할 수 있도록 함
이를 위해 '비교를 통한 배움'과 '역사를 통한 배움'이라는 두 방법을 사용함
비교를 통한 배움
특정 언어로 프로그래밍을 배우는 것이 아닌, 다수의 언어를 비교하면서 학습하는 것
무엇이 언어에 따라 다르고 무엇이 공통적인지 배울 수 있음
역사를 통한 배움
언어가 어떻게 바뀌었고 바뀌기 전에는 어떤 의문점이 존재했는지 학습
언어가 가지고 있는 다양한 기능이 '왜' 탄생했는지 배울 수 있음
프로그래밍 언어가 생긴 이유
사람을 편하게 하기 위하여 생김
어떤 부분에서 편한지는(목적) 언어에 따라 다름
문법
언어 설계자가 정한 규칙
FORTH과 LISP
FORTH
- 가장 간단한 컴퓨터 언어; 괄호도 우선순위라는 규칙도 도입하지 않고 표현
- 스택 기반의 언어에서 사용(Stack Merchine형의 VM을 사용 - Java, Python, Ruby 1.9 등 / VM이 실행하는 명령열도 FORTH와 같은 구조로 되어 있음)
- 표기 : 1 2 + : 후위 표기법과 같다고 생각하면 됨
LISP
- 하나의 구역을 표현하기 위해 항상 괄호를 사용
- 표기 : (+ 1 2) : 괄호를 친 전위 표기법과 같다고 생각하면 됨
구문 트리
구문을 트리형태로 만들어 둔 것
표기법
전위 표기법(Polish; 폴란드 기법(폴란드인 Jan Lukasiewicz가 고안했기 때문에))
- + 1 2
- 연산자를 연산 대상 앞에 둠 (예 : LISP)
중위 표기법
- 1 + 2
- 연산자를 연산 대상 사이에 둠 (예 : FORTRAN)
후위 표기법(역폴란드 기법)
- 1 2 +
- 연산자를 연산 대상 뒤에 둠 (예 : FORTH)
FORTRAN(Formula Translating System; 수신 번역 시스템)
연산자 우선순위나 결합성 등 다양한 규칙을 도입해 문법을 만듦
프로그래머는 익숙한 방법으로 수식을 사용할 수 있음
구문 해석기(Parse; 파서)
소스 코드를 문자열로 읽어들여 해석하고, 구문 트리로 만드는 프로그램
FORTRAN에서 프로그램이 컴파일될 떄도 이 구문 해석기가 소스 코드를 문자열로 읽어 구문 트리로 변환하는 작업 진행
규칙간 마찰
과거의 규칙과의 마찰로 인해 부자연스러운 규칙이 생겨날 수 있음
처리 흐름 제어
while : 조건식으로 반복 제어
for : 횟수로 반복 제어
foreach : 처리 대상으로 반복 제어 - 어떤 대상의 요소 전부에 어떤 처리를 하기 위하여
재귀 호출
어떤 처리를 하고 있는 도중에 동일한 처리를 다른 대상(인수)에 대해 실행한다는 내포(nesting) 형태의 처리
함수 호출마다 개별적으로 (변수) 값을 기억하는 장소가 있다는 것과, 두 번째 호출된 함수 처리가 완료되었을 때, 첫 번째 호출된 함수의 처리는 아직 계속 진행되고 있는 것은 놓치기 쉬운 포인트
에러 처리
John Good enough의 주장 - 좋은 예외 처리 방법
프로그래머는 명령이 예외를 던질 가능성이 있다는 것을 잊어버리고 최적이 아닌 장소 또는 최적이 아닌 종류의 예외 처리를 사용하는 등의 실수를 할 가능성이 있음
이 가능성을 줄이고 프로그래머가 한 실수를 컴파일러가 경고하도록 하기 위해서는 2가지가 필요
1. 명령이 어떤 예외를 던질 가능성이 있는지를 명시적으로 선언
2. 자발적으로 실패할 것 같은 처리를 묶는 구문
> 묶는 구문이 현재 대부분의 언어가 채용하고 있는 '실패할 것 같은 처리를 묶은 후 에러 처리를 나중에 기술'하는 구문의 시초가 됨
> 어떤 예외를 던질지 명시적으로 선언한다는 설계 방침은 Java의 검사 예외로 계승
Finally
탄생 배경 : 프로그래머가 예측하지 못한 종료가 발생했을 시 메모리 블럭이나 파일 등의 리소스를 잘 닫을 수 있게 됨, 메모리 부족 등의 특정 문제에 대해서도 goto 구문이나 반환값 개념을 사용하지 않고 간단한 구조화 코드로 대응할 수 있음
= '짝이 되는 처리'를 실행할 것 (예, 메모리 열기-닫기)
Finally가 없는 경우 RAII(Resource Acquisition Is Initialization: 리소스 확보는 초기화 시)이라는 기술을 사용하거나, scope(exit)를 사용해 해결
예외를 던지는 시점
- 함수 호출시 인수가 부족한 경우
- 배열 범위 밖에 있는 것을 취득하려고 할 때
- 틀리면 바로 예외 던지기
- 예외의 이점 : '실패를 놓치지 않는 것'
Fail First(페일 퍼스트)
- 이상하면 처리를 정지하고 빨리 보고해야 한다는 설계 이념
- 소프트웨어의 목적에 따라 간단하게 정지하면 곤란한 경우도 있지만, 학습이나 개발 단계에서는 틀린 것을 바로 발견하는 것이 오히려 이점이 많음
예외의 전파
대부분의 예외 처리에서는(Java 포함) 예외가 호출처로 전파됨
어떤 함수에서도 처리가 되지 않으면 프로그램이 비정상 종료
단, 깜박하고 예외가 던져질 가능성을 놓치고 있다면, 프로그램이 비정상 종료가 될 수 있음
Java의 검사 예외
3가지로 나뉨
1. 예외 처리를 하지 않아도 되는 중요한 문제
2. 예외 처리를 해도 좋은 실행 시 예외
3. 예외 처리를 해도 좋은 기타 예외(검사 예외) - 메서드 밖으로 던지는 것이면 메서드 정의 시 선언해야 함(throw절)
이름 & 스코프
동적 스코프
스코프에 들어가서 나갈 때까지 시간 축 상에 존재하는 범위
어떤 처리를 하고 있는 중간에 일시적으로 변수 값을 바꾸고 나중에 되돌린다 - 동적 스코프를 만드록 있는 것과 같음
예외 처리도 동적 스코프와 닮음
- 어떤 함수 내에서 예외를 던질 때의 처리가 호출 처에 있는 try/catch로 영향
정적 스코프
스코프가 소스 코드 상에 한 묶음의 영역으로 동작
Java는 정적 스코프 언어
형
2진수
적은 수의 램프로 많은 수를 표시하기에 좋음
부동 소수점
고정 소수점이 소수점을 어디에 붙일지 미리 정하는 것과 다르게 부동 소수점 방식은 어디서부터 소수부인지의 정보 자체를 값에 포함시킴
작은 수 뿐만 아닌 큰 수도 표현할 수 있음(소수점 방향을 반대로)
규칙이 여러가지였으나, 요즘은 IEEE 754가 적용되어 있음
- 부호 : 좌측 끝 램프(최상위 비트)는 수의 부호를 표현, 0 + / 1 -
- 지수부 : 부호 옆 8자리, 소수점의 위치, -127 ~ 128의 범위를 표현할 수 있음
> -127, 128 - 제로/무한대 할당 & -126 ~ 127 - 소수점 위치 표현 (- 소수점 왼쪽 이동 / + 소수점 오른쪽 이동)
- 가수부 : 지수부 옆 23자리 / 유효 숫자의 소수점 이하 부분
> 가장 왼쪽이 1/2(2진수의 0.1), 그 다음이 1/4(2진수의 0.01)을 표현
문제점
- 대부분의 경우 별 문제가 없음
- 3 / 10을 하는 경우 2진수의 경우 무한 소수가 되어 버림
- 은행 등 돈을 다루는 붑ㄴ야에서는 이런 계산법이 용납되지 않기때문에 고정소수점수나 EXCESS-3과 같은 10진법 계산이 쓰임
암묵적 형 변환(승격)
같은 정수끼리, 부동 소수점끼리의 연산은 상관 없음
그런데 한 쪽은 정수고 한 쪽은 부동 소수점이라면?
1. 에러 반환 > 명시적으로 변환해야 함 / 초기 FORTRAN
2. 부동 소수점으로 암묵적 변환 > 부동 소수점 연산이라 예상치 못한 답이 나올 수도 있음 / C
3. 코드 작성법으로 구별 > 정수 타입으로 할지, 부동 소수점 타입으로 할지 선택(/ , //) / Python3.0
컨테이너(저장 공간)
사용 목적에 따라 알맞는 컨테이너(또는 자료구조)를 선택하는 것이 중요함
O 기법 - 계산 시간과 데이터량의 관계를 간단히 나타내는 것
배열에 삽입하는 경우와 같이 "데이터량 n이 두 배가 되면 계산 시간도 두 배가 된다"는 성질을 O(n)이라고 쓰고 'n 오더(order) / 오더 엔 / 오 엔' 이라고 부름
연결 리스트에 삽입하는 경우와 같이 '데이터량 n이 두 배가 되도 계산 시간은 바뀌지 않는다'는 성질을 O(1)이라고 쓰고 '정수 오더 / 오더 일 / 오 원'라고 부름
이 외 아래의 내용 등이 있음
- 데이터량이 2배, 3배가 되었을 때 계산 시간이 4배, 9배로 증가하는 O(n2)
- 데이터량이 2배에서 4배가 되었을 때 증가하는 계산 시간이 같아지는 O(log n)
큰 데이터를 가지고 for문으로 처리시 O(n) / 이중 루프를 쓰면 O(n2)
n이 커지면 다음 순으로 계산 속도가 빠름
O(1) < O(log n) < O(n) < O(n2)
O기법의 표기 방법은 함수의 증가 방식을 매우 간단히 표현하고 있기 때문에, n2+2n+1이든, 3n2이든 O(n2)가 됨(상세 생략)
배열과 연결 리스트
삽입/삭제 : 배열 O(n) / 연결리스트 O(1)
- 배열 : 요소를 복사해야 함
- 연결리스트 : 요소를 중간에 추가/삭제하면 됨
검색 : 배열 O(1) / 연결리스트 O(n)
- 배열 : 순서대로 나열이기 때문에 금방 n번째 요소를 알 수 있음
- 연결리스트 : 각 요소를 원하는 곳에 넣을 수 있기 때문에, 앞에서부터 n번째 요소를 직접 찾아야 함
사전 / 해쉬 / 연상 배열 / 맵 ... etc
해쉬 테이블(Hash table) : 문자열을 인수로 받아 정수를 반환하는 '해쉬 함수'를 이용하여 문자열과 값의 대응 관계를 표현
트리(Tree) : 왼쪽 자식, 오른쪽 자식으로 뻗어나가는 형태로 대응 관계를 표현
요소를 꺼내는 시간 비교
- 배열을 두 개 쓰는 경우 : 평균적으로 n/2회 체크가 필요 = O(n)
- Tree를 쓰는 경우 : 데이터량이 2배가 될 때마다 비교 횟수가 1회 증가(높이가 1만큼 증가하므로) = O(log n)
- Hash Table을 쓰는 경우 : 데이터량과 관계 없이 키 값을 해쉬함수로 변환 = O(1)
병행 처리
처리를 변경하는 방법 2가지
협력적 멀티태스크(multi-task, 병행처리) : 타이밍이 좋은 시점에서 자발적으로 처리 교대
- 처리중인 프로그램에 문제가 생기면 다른 프로그램도 사용 불가
선점적(preemptive; 타인의 행동을 막기 위한) 멀티 테스크 : 일정 시간에 교대
- 개별 프로그램과 입장이 다른 프로그램(태스크 스케쥴러)이 존재 > 일정 시간마다 지금 실행되고 있는 처리를 강제적으로 중단하여 다른 프로그램이 실행될 수 있게 함
경합 상태(Race condition) = 쓰레드에 안전하지 않음
3가지 조건을 모두 만족해야 함
1. 2가지 처리가 변수를 공유
> 메모리를 공유하지 않음 - 프로세스 (단, 추후 경량 프로세스- 쓰레드가 나옴)
> 메모리를 공유하는게 아닌 메시지를 보냄 - 액터(actor) 모델
2. 적어도 하나의 처리가 그 변수를 변경
> 변경하지 않음 - const, val, Immutable(Mark Grand가 제안한 디자인 패턴 - private 필드 + getter)
3. 하나의 처리가 완료되기 전, 다른 한 쪽의 처리가 끼어들 가능성이 있음
> 협력적 쓰레드 사용 - 파이버(fiber), 코루틴(coroutine), 그린 쓰레드(Green thread) 기법
> 끼어들면 곤란한 처리에 표식 - 락(lock), 뮤텍스(mutex), 세마포어(semaphore) 기법
락(lock)의 문제점
1. 교착상태(Deadlock; 데드락) 발생
- 현상 >> 처리 A : X락, Y락 / 처리 B : Y락, X락 / 동시실행 : A는 X락, B는 Y락 - 해결되지 않음!
- 방지하는 방법 : 프로그램 전체에 락이 일관된 순서로 동작하도록 해야 함, 무엇을 어떤 순으로 락을 걸어야 하는지 생각해야 함
2. 합성할 수 없음
- 락과 락 사이의 단계를 추가할 수 없음 > Thread Safe 라이브러리에서는 락 제어를 프로그래머가 챙기지 않아도 되도록, 내부에서 락을 사용해 '꺼내는 처리'나 '추가하는 처리'가 끼어들지 못하게 함
- 하지만 이렇다면 락으론 'X에서 빼내어 Y에 넣는다'같은 2가지 명령 사이에 끼어드는 것을 막진 못함
> 막으려면 새로운 락을 생성하여 두가지를 다 감싸야 함
>> 이것은 '락을 프로그래머가 신경쓰지 않도록 하자'의 목적을 달성할 순 없음
트랜잭션 메모리(Transactional memory) > 락(lock) 문제점의 해결책
DB의 트랜잭션 기법을 메모리에 적용한 것
실험적으로 해보고, 실패하면 처음부터 다시 고쳐서 하고, 성공하면 변경을 공유한다.
- 일시적으로 별도 버전을 만들어서 변경하고 하나의 묶음 처리가 끝나면 반영
단 쓰는 처리의 빈도가 높을 때는 "다시 고쳐서 하기" 작업이 다발적으로 발생하여 성능이 나빠짐
상속
상속에 관한 다양한 접근법
1. 일반화/특수화
- 부모 클래스로 일반적인 기능을 구현하고, 자식 클래스로 목적에 특화된 기능을 구현
- 즉, 자식 클래스는 부모 클래스를 특수화한다는 설계 방칙
- "클래스 = 분류"라는 접근과 일치
- 자식 클래스는 부모 클래스의 일종? Y
2. 공통 부분을 추출
- 자식 클래스들간의 공통적인 부분을 부모 클래스로 추출
- 자식 클래스는 부모 클래스의 일종? N
3. 차분 구현
- 상속 후 변경된 부분만 구현하면 효율이 좋다는 접근법
- 상속을 재사용을 위해 사용함으로써 구현이 편해질 수 있다는 발상
- 자식 클래스는 부모 클래스의 일종? N
유의점
상속을 많이 사용하면 코드가 복잡해짐
특히 차분을 구현하는 경우, 깊은 상속 트리를 만들어냄으로 코드가 복잡해지는 경향이 강함
깊은 상속 트리 - 영향 범위가 넓어짐으로써 해당 변경이 문제를 일으킬지 않을지를 알 수 없게 됨
상속을 사용해서 코드를 재활용하면 코딩량이 줄어서 편할 수 있지만, 반복하다보면 코드 영향 범위가 넓어져 이해하기가 어려워짐 > 이해가 쉬우려면 상속 트리의 깊이를 낮추는 것이 중요
리스코프의 치환 원칙
'T형의 객체 x에 관해 어떤 속성 q(x)가 항상 참. S가 T의 파생형이면, S형의 객체 y의 속성 q(y)가 항상 참이어야 함'
= 어떤 클래스 T의 객체에 대해 항상 성립하는 조건이 있다면, 그 조건은 자식 클래스 S의 객체에서도 항상 성립해야 함
= 상속은 is-a 관계가 아니면 안 된다.
다중 상속의 문제 해결법 1. 다중 상속 금지
Java는 클래스 다중 상속을 금지 - 문제는 발생하지 않지만, 편리함 또한 버리게 됨
대신 나온 개념 '위임(delegation)'
> 사용하고 싶은 코드를 가지고 있는 클래스 객체를 만들고, 필요에 따라 해당 클래스에 처리를 맡김
> 참조도 소스에 하드코딩하는 것이 아닌 실행 시 주입하는 것이 편함(의존성 주입(Dependency Injection))
단, 인터페이스는 다중 상속 가능(In java)
> 인터페이스는 코드(구현체)를 가지고 있지 않은 클래스이기 때문!
다중 상속의 문제 해결법 2. 메서드 해결 순서를 고민
앞에 쓰여있는 부모부터 순서대로 확인한다는 깊이 우선 탐색
- 부자연스러운 결과를 초래
- c3 선형화를 이용하면 문제를 피할 수 있음
c3 선형화(알고리즘 1996~) : 2가지 제약 조건을 만족하도록 클래스에 순서를 매겨 정렬
- 부모 클래스는 자식 클래스보다 먼저 탐색되지 않음
- 어떤 클래스가 복수의 부모 클래스를 상속할 경우, 먼저 만들어지는 것이 우선
다중 상속의 문제 해결법 3. 처리를 섞음
재사용하고 싶은 기능만을 모은 작은 클래스를 만들어서 해당 기능을 추가하고 싶은 클래스에 섞어 넣으면 됨
이런 설계 방침 / 섞어 넣는 것 / 섞기위한 작은 클래스를 모두 믹스-인(Mix-in)이라고 함
단, 이름 충돌을 모두 해결할 수 있는 것은 아님, 믹스-인 클래스들에 같은 이름의 메서드가 있으면 충돌
다중 상속의 문제 해결법 4. 트레이트(trait)
클래스의 역할
- 인스턴스를 만들기 위한 것
- 필요한 모든 것을 가지고 완결된 형태의 큰 클래스 - (역할) 재사용 단위 - 필요없는 기능을 가지고 있는 작은 클래스
트레이트 개념 = 재사용 단위라는 역할에 특화된 보다 작은 구조(트레이트 = 메서드 묶음)을 만드는 것