프로그래밍 언어의 목적

프로그래밍 언어는 인간을 편하게 하기 위해 만들어졌다. 예전에는 케이블의 연결을 바꾸고(ENIAC), 테이프에 구멍을 뚫어서 데이터를 표현하는(EDSAC) 것을 통해 프로그램을 변경할 수 있었다. 이것은 사람이 프로그램을 읽거나 쓰기에는 어려웠다. 그 이후에 현재 우리가 사용하고 있는 것과 비슷한 프로그래밍 언어 FORTRAN이 고안되었다. 이렇게 프로그래밍 언어의 목적은 프로그래밍을 편리하게 하는 것이다. 하지만 이 편리함의 의미는 사람에 따라 다르기 때문에 현재에 수많은 언어가 존재하고 있는 것이다.

예를 들어, C++는 빠른 실행 속도를 중시하고 있지만, 결과적으로 언어 사양이 복잡해졌다. Scheme는 언어 사양을 쉽게 파악할 수 있는데 중점을 두고 있다. 따라서 Scheme는 사양서 전체가 50 페이지 밖에 되지않지만, 구문이 수많은 괄호로 이루어져있어 싫어하는 사람도 많다. Python은 다른 사람이 쓴 코드를 쉽게 해석할 수 있는 데 중점을 두고 있지만 속도가 느리며 사양도 단순하지 않다.

이렇게 프로그래밍 언어는 사람을 편하게 하기 위해 만들어졌다. 그러나 무엇이 편한지는 언어에 따라 다르다. 따라서, 언어를 선택할 때 자신이 어느 정도 성과를 낼 수 있는지를 고려해서 결정하는 것이 좋다.

문법의 탄생

문법이란 프로그래밍 언어 설계자가 정한 규칙이다. FORTH와 LISP라는 언어를 소개하며 현재 수많은 규칙들이 탄생한 이유를 설명한다.

현재 우리가 사용하고 있는 언어에서는 1 + 2 * 3 이 있으면 대부분은 7 이라는 결과가 나온다. 이는 프로그래밍 설계자가 ‘+보다 *가 우선순위가 높다’라는 규칙을 정했기 때문이다. ‘그렇게 해야지 사칙연산과 같아서 이해하기 쉽다’는 생각이다. 하지만 FORTH와 LISP는 이런 규칙이 적은 것이 이해하기 간단하고 편리하다고 주장했다.

  • FORTH

먼저 위의 식은 2 3 * 1 + 로 표현이 가능하다. FORTH 에서는 스택 머신이라는 개념을 도입한다. 이 표현을 설명하면 23를 스택에 순서대로 담아두고 * 를 만나게 되면 두 개의 값을 꺼내서 그 결과를 다시 스택에 담는다. 이후에 1 을 만나게 되면 스택에 담아 61 을 만나게 되고, + 을 만나 7 이라는 결과를 얻을 수 있다. 이렇게 연산자 별로 우선 순위라는 규칙이 따로 존재하지않는다.

  • LISP

LISP는 ‘하나의 구역을 표현하기 위해 항상 괄호를 사용하자’는 선택을 했다. 따라서 위의 식을 (+ 1 (* 2 3)) 으로 표현할 수 있다. 이렇게 연산자 별 우선 순위라는 규칙이 따로 존재하지 않고, 괄호로 우선 순위를 표현한다.

하지만 시장이 요구한 것은 규칙이 적거나 간다한 것이 아니였다. FORTRAN은 ‘*가 +보다 우선 순위가 높다’ 등의 정해진 규칙을 대량으로 도입해서 ‘다가가기 쉬운 작성법’을 중시했고 이는 시장에서 성공했다. 현재 대부분의 프로그래밍 언어는 이렇게 ‘다가가기 쉬운 작성법’을 중시하지만 모순 없이 해석할 수 있는 문법을 만들어내는 것은 어려운 작업이다.(아래는 예시) 따라서 현실의 언어에서는 이해하기 어려운 작성법이 여전히 존재하는 것이다.

  • C++
// OK
vector<vector<int> > x;
// NG
vector<vector<int>> y;

C++에서는 닫는 괄호를 이중으로 하면 쉬프트 연산자로 해석되어버릴 수 있는 문제가 있다.

처리 흐름 제어

우리가 프로그래밍 할 때 자주 사용하는 if, while, for 또한 문법 중 하나이다. if, if-else, while 은 어셈블리어를 보게되면 점프(jmp, jge, …) 연산을 통해 충분히 구현 가능하다. 즉 없어도 구현가능한 규칙이다. 또한 forwhile 만 있으면 충분히 구현 가능한 문법이다. 아래는 그 예다.

for (i = 0; i < N; i++) {
	printf("%d\n", i);
}

i = 0;
while(i < N) {
	printf("%d\n", i);
	i++;
}

하지만 이런 문법이 존재하는 이유는 단순하다. 알기 쉬운 코드를 구성할 수 있기 때문이다. 점프 연산을 남용하면 코드가 너무 복잡해지고, for 문을 사용하면 루프의 시작, 증가, 종료를 한 곳에 정리가 되기 때문에 의도를 쉽게 이해할 수 있다. 심지어 최근의 프로그래밍 언어에는 배열의 각 요소에 대한 처리를 하기위해 foreach 라는 문법도 등장했다.

for (int item: items) {
	System.out.println(item);
}

함수

함수 또한 사용하지 않고 프로그램을 짤 수 있다. 하지만 역시 함수를 사용하는 게 보다 편한 경우가 많다.

  • 이해

프로그래밍을 할 때, 코드의 행수가 많아지면 전체를 파악하기 어렵게 된다. 몇 개의 행을 하나의 그룹으로 묶어서 거기에 이름을 붙이는 것이 함수다.

  • 재사용

같은 처리를 여러번 해야되는 경우, 한 군데에 정리하게 되면 코드가 짧아질 뿐만 아니라 코드를 읽는 사람이 몇 번이고 같은 내용의 소스 코드를 읽을 필요가 없게된다. 따라서 함수를 통해 프로그램을 보다 쉽게 이해할 수 있게 된다.

함수를 사용하지 않는다면, 조건문과 같이 점프 연산을 해야되고, 어디에서 점프해 왔는지 기억해두고 나중에 다시 점프로 돌아가야된다. 이런 모든 과정을 함수를 사용하여 이해하기 쉬운 코드로 표현할 수 있다.

함수가 만들어지면서 재귀 호출이라는 프로그래밍 기법이 생겼고, HTML과 같은 내포 구조로 된 데이터를 다루기에 적합한 기법이다.

에러 처리

예외 처리는 Java, C++, Python, Ruby 등 많은 현대 언어들이 지원하고 있는 기능이다. 예외 처리는

  • 반환값으로 알린다
if(silpae()) {
	/* 에러 처리 */
}
  • 실패하면 점프한다
int main() {
	if(!silpae("A")) goto ERROR;
	if(!silpae("B")) goto ERROR;
	if(!silpae("C")) goto ERROR;
	return;
ERROR:
	/* 에러 처리 */
}

라는 두 가지 방법이 있다.

‘반환값으로 알린다’ 전략에는 반환값을 확인하는 것을 잊어버려서 실패를 놓칠 수 있는 문제가 있다. 따라서 ‘실패하면 점프한다’는 전략이 많이 사용되고 있다. 하지만 이 예외 처리에는 2가지 문제점이 있다.

  • 프로그래머가 ‘명령’이 예외를 던질 가능성이 있다는 것을 잊어버릴 수 있다.

이렇게 되면 실수로 최적이 아닌 장소 또는 최적이 아닌 종류의 예외 처리를 사용할 수 있게된다. 이를 해결하기 위해 2가지가 필요하다. 첫 번째는 프로그래머가 자발적으로 ‘실패할 것 같은 처리’를 묶는 것이다. 이로 인해 나온 것이 try-catch 문이다. 두 번째로 명령이 어떤 예외를 던질 가능성이 있는지를 명시적으로 선언하는 것이다. 대표적인 예가 ‘Java의 검사 예외’이다. (관련글) 하지만 이 개념은 어떤 메서드에 검사 예외를 추가하면 그 메서드를 호출하고 있는 모든 메서드를 수정해야된다는 문제가 있다.

  • ‘짝이 이루는 처리’를 하기 어렵다.

파일을 열었다가 닫는 처리, 락을 걸었다 푸는 처리 등 프로그래밍에는 많은 ‘짝이 되는 처리’가 있다. 하지만 예외 처리를 하게된다면 함수의 출구가 여러 군데 발생하게 되어 ‘짝이 되는 처리’를 놓치는 부분이 발생할 수 있다. 이를 해결하기 위해 ‘Java의 finally 문`과 ‘C++ 의 RAII 기술’ 등을 사용한다.

이름과 스코프

변수들의 주소값을 외우는 것보다는 알기 쉽게하기 위하여 변수에 이름을 붙이게 되었다. 이런 변수명은 프로그램의 하나에서 공유하게되면 충돌이 발생하는 경우가 발생하여 스코프라는 개념을 추가하였다.

  • 동적 스코프

동적 스코프는 새로운 함수에 들어갔을 때 새로운 대응표를 준비하고, 함수를 벗어날 때 그 대응표를 제거한다. 대응표를 생성할 때 원래 대응표의 바로 앞에 두고, 변수를 참조할 때 가장 가까운 대응표를 참조한다. 하지만 동적 스코프는 변수 값이 호출되는 곳에 파급되기 때문에, 어떤 값이 참조 될지는 호출처의 코드를 보지 않고선 알 수 없다는 문제가 있다.

  • 정적 스코프

정적 스코프는 새로운 함수에 들어갔을 때, 그 함수 전용의 새로운 대응표를 준비한다. 함수 내에서 변수를 참조할 때 함수 전용 대응표를 보고, 없다면 바로 전역 대응표를 확인한다. 동적 스코프의 문제점을 해결할 수 있다.

현재는 거의 많은 언어에서 동적 스코프를 사용하고 있지만, 정적 스코프도 문제점을 가지고 있다. 내포 함수의 경우 내포된 것처럼 보이는 스코프가 실제로는 내포되어 있지 않다는 문제와 내포된 스코프의 변수를 변경할 수 없다는 문제가 있다.

컴퓨터는 데이터를 비트열로 저장하고 있다. 하지만 이 저장되어 있는 비트열이 정수로 해석해야 할지 부동 소수점으로 해석해야 할지 알 수 없다. 그래서 이 값이 어떤 종류인지 정보가 별도로 저장하는데 그것이 바로 ‘형’이다. 이후에는 형 개념이 여러 곳에 응용되기 시작했다.

  • 기본적인 형을 조합해서 새로운 형을 만드는 기능
  • 사양을 나타내는 기능
    • 구조체나 클래스를 구성하는 형의 공개 유무
    • 구현 방법을 가지고 있지 않는 형(인터페이스)
  • 구성 요소의 형을 일부만 바꾸는 형(총칭형, 제네릭스, 템플릿)
  • 동적 결정형: 메모리 상에서동일한 형으로 취급되도록 설계되어있는 형
  • 형 추론: 컴파일 시에 형 체크는 하지만, 형 선언을 할 필요는 없도록하기 위해 컴퓨터가 형을 추론하도록 하는 기능

이렇게 형의 개념은 점점 확장되고, 이해하기 어려워졌다. 어떤 정보가 어디에 있고 어떤 타이밍에 사요오디는지의 관점에서 보면 이해를 도울 수 있을 것이다.

컨테이너와 문자열

  • 컨테이너: 무언가를 넣기 위한 상자
  • 배열과 연결 리스트
    • 배열: 삽입 - O(n), 탐색 - O(1)
    • 연결 리스트: 삽입 - O(1), 탐색 - O(n)
  • 사전, 해쉬, 연상 배열
    • 트리: 탐색 - O(log n) 최악 O(n)
    • 해쉬 테이블: 탐색 - O(1), 하지만 메모리 사용량이 많다
  • 만능 컨테이너는 없다. 어떤 조작이 많은가에 따라 최적의 컨테이너도 달라진다.
  • 문자
    • ASCII: 8비트
    • Unicode: 전 세계의 문자를 포함한 문자 집합
    • UTF-8: 유니코드를 인코딩하는 방법 중 하나
  • 문자열
    • C: 문자열의 길이를 따로 저장하지 않는다. 문자열 끝에 NUL 문자를 삽입한다.
    • Pascal: 문자열의 제일 앞 부분에 문자열 길이를 기록한다.
    • Java: 16비트 문자열을 사용한다. 유니코드를 사용하기 때문이다.
    • Google, Apple의 경우는 유니 코드에 이모지를 추가하도록 제안하는 등 ‘어떤 정보를 어떤 메모리에 저장하는’지에 대한 해결 방법은 다양하다.

병행 처리

  • 병행 처리: 컴퓨터에서 여러 개의 작업을 동시에 처리하는 기법
  • 병행 처리의 두가지 방법
    • 협력적 멀티태스크: 처리가 인단락되는 시점에 자발적으로 처리 교대를 하는 방법. 하지만 이 방법은 ‘모든 처리가 최적의 간격으로 교대한다’는 신뢰 관계를 가정이 있어야 성립하는 시스템이다.
    • 선점적 멀티태스크: 태스크 스케줄러가 존재하여 프로그램이 일정 시간마다 지금 실행되고 있는 처리를 강제적으로 중단시켜서 다른 프로그램이 실행될 수 있도록한다.
  • 경합 상태의 3가지 조건
    • 2가지 처리가 변수를 공유하고 있다.
    • 적어도 하나의 처리가 그 변수를 변경한다.
    • 한쪽 처리가 한 단락 마무리 되기 전에, 다른 한쪽의 처리가 끼어들 가능성이 있다.
  • 경합 상태 방지법
    • ‘2가지 처리가 변수를 공유하기 있다’를 해결 (프로세스와 액터 모델)
      • UNIX에서는 프로세스 별로 ‘사용해도 좋은 메모리 영역’ 즉 가상 주소 공간을 만들어 다른 프로세스와 메모리를 공유하지 않는 구조를 채용했다. 하지만 이는 프로세스끼리 아무것도 공유하지 못한다는 때문에 메모리를 공유하는 스레드라는 개념이 다시 도입되었다.
      • 액터 모델 병행해서 동작하는 복수의 처리가 정보를 교환하는 방법으로, ‘메모리를 공유한다’가 아닌 ‘메시지를 보낸다’로 변경된 모델이다.
    • ‘적어도 하나의 처리가 그 변수를 변경한다`를 해결
      • 공유되고 있는 변수를 변경할 수 없게 한다는 기법.
    • ‘한쪽 처리가 한 단락 마무리 되기 전에, 다른 한쪽의 처리가 끼어들 가능성이 있다.’를 해결
      • 협력적 스레드(협력적 멀티태스크)의 사용
      • 끼어들면 곤란해지는 처리에 표식을 붙인다(락, 뮤텍스, 세마포어)

        안에 들어갈 때 사용중이란 표식이 있는지 확인. 있으면 대기, 없으면 표식을 걸고 안으로 들어간다.

        • 문제점
          • 교착 상태가 발생한다: 처리A와 처리B에서 A는 ‘X를 락시키고, Y를 락시킨다’는 순서로 락을 걸어두고, B는 ‘Y를 락시키고, X를 락시킨다’는 순서로 락을 걸었을 경우에 발생한다. A가 락하고 B가 Y를 락한 상태에서 서로가 락이 풀리는 것을 기다리게 된다.
          • 합성할 수 없다: ‘값 확인’과 ‘변경’에 각각 표식을 해두어도 ‘값을 확인 후 변경’에서 끼어들기가 발생할 가능성이 있다. 이를 해결하기 위해 ‘일시적으로 별도 버전을 만들어 그것을 실행하고 실패하면 다시 고쳐서 하고, 성공하면 변경을 원본에 공유한다’는 개념의 ‘트랜잭션 메모리’라는 접근법이 나타났다. 트랜잭션 메모리는 소프트웨어, 하드웨어 두 가지 구현방법이 나타났다.

객체와 클래스

  • 객체 지향의 여러 이미
    • C++: 사용자 정의형을 만들기 위한 구조. 사용자 정의형과 상속을 사용한 프로그래밍.
    • Smalltalk: 상태를 가진 객체가 메시지를 주고 받아서 커뮤니케이션하는 프로그램
  • 변수와 함수를 합쳐서 모형을 만드는 법
    • 모듈, 패키지: 함수나 변수를 하나로 묶어서 그것에 이름을 붙일 수 있는 기능. 하지만 모듈만으로는 비슷한 사물이 복수 개 만드는 방법이 없다. 그렇기 때문에 패키지로 해쉬를 만들어 여러개의 객체를 다룰 수 있다. 그리고 객체를 생성할 대 초기화 처리도 패키지내에 넣고 관리할 수 있다.
    • 함수도 해쉬에 넣는다: 복수 개의 객체를 만들 때, 매번 함수를 만들 필요가 없을 때가 있다. 그렇기 때문에, Javascript의 경우에는 프로토타입이라는 개념을 만들어 공통되는 값이나 메소드는 프로토 타입에 넣어서 관리한다.
    • 클로저: 함수를 함수 안에 정의하고, 내포할 수 있는 정적 스코프가 있어서 함수를 반환값으로 사용하거나 변수에 대입하여 사용한다는 개념.
    • 클래스의 3가지 역할:
      • 결함체를 만드는 생성기
      • 어떤 조작이 가능한지에 대한 사양
      • 코드를 재사용하는 단위

상속을 통한 재사용

  • 상속에 관한 다양한 접근법
    • 일반화/특수화: 부모 클래스로 일반적인 기능을 구현하고, 자식 클래스로 목적에 특화된 기능을 구현한다. 이 경우 ‘ 자식 클래스는 부모 클래스의 일종입니까?라고 물으면 그 대답은 '예이다.
    • 공통 부분 추출: 복수 클래스의 공통 부분을 부모 클래스로서 추출하면 좋다는 접근법이다. 이 경우 ‘자식 클래스는 부모 클래스의 일종입니까?`라고 묻는다면 ‘아니오’가 된다.
    • 차분 구현: 상속 후 변경된 부분만을 구현하면 효율이 좋다는 접근법이다. 이 경우 ‘자시 클래스는 부모 클래스의 일종입니까?`라고 묻는다면 ‘아니오’이다.
  • 상속의 문제점 높은 자유도가 있기떄문에, 상속을 만이 사용하면 코드가 복잡해진다. 깊은 상속 트리는 어떤 객체가 어떤 메소드 X를 가지고 있다고 하면 이 메소드의 정의는 어디있는지 확인하기 어렵다. 영향 범위가 넓어 질수록 해당 변경이 문제를 일으킬지도 자신을 잃게 된다. 따라서 ‘상속은 is-a 관계가 아니면 안 된다’라는 ㅇ리스코프의 치환 원칙을 지키며 상속을 하도록한다.
  • 다중 상속: 하나의 사물이 복수의 분류에 해당하는 경우가 흔하게 발생한다. 이떄 여러 부모 클래스로부터 상속을 받는 것을 다중 상속이라고 부른다.
  • 다중 상속의 문제점: 상속 중인 부모 클래스들에 공통된 메소드가 있으면 누구의 메소드를 이용해야될지에 대한 문제가 발생한다. 아래와 같은 해결법이 있다.
    • 해결책1: 다중 상속을 금지한다.
      • 위임: 다중 상속을 금지하는 대신, 상속을 받는 거시아니라 클래스 내에 객체를 만들어서 보유하는 형태로 구현을 한다.
      • 인터페이스: Java는 다중 상속을 금지하고 있지만 인터페이스는 다중 상속이 가능하다. 인터페이스란 반드시 가지고 있어야 되는 메서드들에대한 사양만 가지고 있는 것이다. 인터페이스의 다중 상속으로 중복되는 메서드가 있다고해도, 그것을 가지고 있다는 사실 자체이기 때문에 아무 문제가 발생하지 않는다.
    • 해결책2: 메소드 해결 순서를 고민한다.
      • 깊이 우선 탐색을 할 경우 마름모 상속을 하고 있을 때, 자식 클래스에서 재정의 한것이 있음에도 부모 클래스의 메소드를 이용할 수도 있는 문제가 있다.
      • C3 선형화를 통해 깊이 우선 탐색의 문제점을 해결한다. 그 제약 조건은 다음과 같다.
        • 부모 클래스는 자식 클래스보다 먼저 탐색되지 않는다.
        • 어떤 클래스가 복수의 부모 클래스를 상속하고 있으면 먼저 만들어진 것이 우선된다.
    • 해결책3: 처리를 섞는다.

      어떤 클래스는 선조 클래스까지 도달하는 경로가 여러 개 있다는 것이 문제다. 따라서 재사용하고 싶은기능만을 모은 작은 클래스를 만들어 해당 기능을 추가하고 싶은 클래스에 섞어 넣도록하는 기법이다.

    • 해결책4: 트레이트라는 새로운 개념의 도입
      • 클래스에는 2가지의 상반되는 역할이 있다.
        • 인스턴스를 만들기 위한 것: 필요한 모든 것을 가지고 완결된 형태의 큰 클래스
        • 재사용 단위: 필요 없는 기능을 가지고 있지 않는 작은 클래스
      • 클래스가 ‘인스턴스를 만들기 위한 것’으로 사용될 때 ‘재사용 단위’라는 묶의 역할을 가지는 트레이트를 만든다. 일부 메소드를 덮어쓰기해서 새로운 트레이트를 만드는 상속과 같은 것이 가능하다. 또한 복수 트레이트를 합성해서 새로운 트레이트를 만들 수도 있다. 클래스의 역할을 분리 시켰지만, 여러 기능이 마구잡이로 섞여있다.

댓글남기기