클래스

Ordinary·2023년 6월 5일
0
post-thumbnail

클래스와 인스턴스의 개념 이해

자바스크립트 언어에서의 클래스를 다루기 전에 먼저 프로그래밍 언어에서의 클래스와 인스턴스의 일반적인 개념부터 짚고 넘어가려 합니다.

예시를 하나 들어보겠습니다.

어떤 가게에 다양한 음식을 판다고 했을 때, ‘음식’이라는 범주에 들어가는 것들을 생각해보면 과일, 고기, 채소 등 여러 가지를 생각해볼 수 있습니다. 다시 ‘과일’이라는 범주 아래에 들어가는 것을 생각해보면 배, 사과, 바나나 등의 대상들을 나열해볼 수 있을 것입니다. 이를 통해서 각각의 특징을 정리해보면 다음과 같습니다.

  • 음식, 과일 : 어떤 사물들의 공통 속성을 모아 정의한 것일 뿐, 직접 만지거나 볼 수 있는 것이 아닌 추상적인 개념
  • 배, 사과, 바나나 : 직접 만지거나 볼 수 있고 (먹을 수 있는) 구체적이고 실존하는 사물

클래스와 인스턴스의 관계는 과일과 바나나의 관계와 같습니다.

  • 클래스 = 음식, 과일
  • 인스턴스 = 배, 사과, 바나나!

즉, 클래스공통 속성을 가지고 있는 추상적인 개념이라면 인스턴스어떤 클래스의 속성을 지니고 있는 실존하는 개체를 일컫습니다. 인스턴스(instance)를 영한 사전에 기록된 “예시”라는 뜻을 그대로 가지고 와서 생각해보면 어떤 클래스의 조건을 모두 만족하는 구체적인 예시라고 생각해볼 수 있습니다.

앞선 예제에서 살펴본 음식, 과일의 관계를 집합으로 표시하면 다음과 같습니다.

  • 음식은 과일의 상위 클래스(superClass)입니다.
  • 과일은 음식의 하위 클래스(subClass)이고, 사과의 상위 클래스입니다.

여기에 “귤류”라고 하는 과일의 하위 분류가 하나 더 추가해볼 수도 있습니다.

  • 음식은 귤류의 최상위 클래스(super-superClass)입니다.
  • 귤류는 과일의 하위 클래스이고, 음식의 최하위 클래스(sub-subClass)입니다

이때 하위 클래스는 상위 개념을 포함하면서 더 구체적인 개념이 추가된다는 것을 알 수 있습니다. 음식이 단순히 ‘먹을 수 있음’이라는 개념을 가진다면, 과일은 ‘먹을 수 있음’이라는 개념에 ‘나무에서 열림’ 이라는 개념이 추가되었습니다. 귤류의 경우, 과일의 개념에 ‘말랑한 껍질 속에 달고 신맛이 나는 과육이 들어있음’이라는 개념이 추가되었다는 것을 알 수 있습니다.

현실 세계에서의 클래스와 프로그래밍 세계에서의 클래스

프로그래밍 세계에서의 클래스, 인스턴스와 현실 세계에서의 클래스, 개체는 ‘공통 요소를 지니는 집단을 분류하기 위한 개념’이라는 측면에서는 같지만, 몇몇 차이점을 가집니다.

현실 세계프로그래밍 세계
개체들이 이미 존재하는 상태에서 이들을 분류하기 위해 클래스를 도입사용자가 직접 클래스를 정의하고 이를 바탕으로 인스턴스를 생성
하나의 개체가 여러 클래스를 가질 수 있음 ex) ‘나’라는 사람은 남성이면서 직장인이고, 한국 사람이다.하나의 인스턴스가 하나의 클래스만을 바탕으로 만들어진다. 다중 상속을 지원한다고 하더라도 인스턴스를 생성할 때 호출할 수 있는 클래스는 오직 하나.
클래스는 추상적인 개념클래스는 상황에 따라 추상적인 개념이 될 수도, 구체적인 개체가 될 수도 있다.

자바스크립트의 클래스

자바스크립트는 기본적으로 프로토타입 기반 언어입니다. 이를 클래스 관점에서 해석해보면 다음과 같습니다.

  • 생성자 함수 Array(클래스)를 new 연산자와 함께 호출하면 인스턴스가 생성됩니다.
  • Array 내부 prototype 객체 요소들이 생성된 인스턴스에 상속(참조)됩니다.

또한, 생성된 인스턴스에서 사용되는 멤버인지(참조되는지) 여부에 따라 다음과 같이 분류할 수 있습니다.

  • static member
  • instance member(prototype member)

자바스크립트는 인스턴스 자체에서도 메서드를 정의할 수 있기 때문에 프로토타입에 정의한 메서드인지, 인스턴스에 정의한 메서드인지 구분을 명확히 하기 위해서 프로토타입 멤버라고 다시 이름을 붙이겠습니다.

앞서 정리한 개념을 예시로 살펴보면 다음과 같습니다.

var Rectangle = function (width, height) { //생성자
	this.width = width;
	this.height = height;
}

Rectangle.prototype.getArea = function() { //프로토타입 메서드(인스턴스 메서드)
	return this.width * this.height;
}

Rectangle.isRectangle = function(instance) { //스태틱 메서드
	return instance instanceof Rectangle &&
		instance.width > 0 && instance.height > 0;
}

var rect1 = Rectangle(4, 3);
console.log(rect1.getArea()); // 12
console.log(rect1.isRectangle(rect1)); // Error
console.log(Rectangle.isRectangle(rect1)); //true

Rectangle이라는 클래스(생성자 함수)에는 직사각형 넓이를 반환하는 getAreaprototype 객체에 등록되어 있습니다, 즉, 인스턴스에서 직접 호출할 수 있는 getArea는 프로토타입 메서드입니다.

반면, 인스턴스에서 직접 접근할 수 없고, 생성자 함수를 this로 해야만 호출할 수 있는 isRectangle은 스태틱 메서드입니다.

이렇게 생성할 인스턴스에 대한 공통 속성을 가지는 ‘틀’로써 역할을 담당할 때 클래스는 추상적인 개념이지만, 클래스 자체를 this로 지정해서 스태틱 메서드를 사용하는 경우에는 클래스 그 자체가 하나의 개체라고 말할 수 있습니다.

클래스 상속

자바스크립트에는 프로토타입 기반 언어이지만, 클래스 상속을 최대한 다른 객체 지향 언어에 익숙한 개발자들에게 친숙하게 표현하기 위해 노력해왔습니다.

기본 구현

우선, 그 시작은 프로토타입 체인입니다.

ES6에서 도입된 클래스도 prototype을 기반으로 한 것이기 때문에 자바스크립트에서의 클래스 상속은 프로토타입 체이닝을 잘 연결한 것과 같습니다.

// Grade 생성자 함수와 인스턴스
var Grade = function () {
	var args = Array.prototype.slice.call(arguments);
  	for (var i = 0; i < args.length; i++) {
    	this[i] = args[i];
    }
  this.length = args.length;
};

Grade.prototype = [];
var g = new Grade(100, 80);

Grade 생성자 함수는 내부적으로는 배열처럼 인자를 인덱싱해서 가지고 있고 배열의 메소드는 사용할 수 없는 유사배열객체입니다.

배열의 메소드를 인스턴스에서 바로 사용하기 위해서 prototype에 배열 인스턴스를 참조하도록 했습니다. 이렇게 되면 Gradeprototype[]를 참조하고 있고, []Array 생성자 함수의 인스턴스이기 때문에 Arrayprototype을 참조하고 있습니다. 즉, Array prototype에 정의된 메서드들을 프로토타입 체이닝을 이용해서 사용할 수 있는 것이죠.

하지만, 위 경우에는 2가지 문제점이 존재합니다.

  1. length프로퍼티가 삭제 가능
  2. Grade.prototype이 빈 배열을 참조
g.push(90);
console.log(g); // Grade {0: 100, 1: 80, 2: 90, length: 3}

delete g.length;
g.push(70);
console.log(g); // Grade {0: 70, 1: 80, 2: 90, length: 1}

Grade 클래스의 인스턴스 g는 배열 기능을 수행하지만 기본적으로는 일반 객체입니다. 따라서 내부 프로퍼티인 length를 삭제할 수 있습니다.

  1. push메서드의 호출로 glength를 참조하려고 하지만, 현재 glength 가 삭제된 상태입니다.
  2. g.__proto__.length를 프로토타입 체이닝을 통해 검색했을 때, 현재 빈 배열([])을 참조하고 있으므로 빈 배열의 length인 0을 읽어옵니다.
  3. 이후, 값을 할당하고, length를 1로 증가시킵니다.

서로 상속 관계에 있는 생성자 함수에 대해서도 위 방법으로 구현하면 동일한 문제가 발생합니다.

var Rectangle = function (width, height) {
	this.width = width;
    this.height = height;
};
Rectangle.prototype.getArea = function () {
	return this.width * this.height;
};
var rect = new Rectangle(3,4);
console.log(rect.getArea());  // 12

var Square = function (width) {
	Rectangle.call(this, width, width);
};

Square.prototype = new Rectangle();

var sq = new Square(5);
console.log(sq.getArea());  // 25

Rectangle클래스는 직사각형 가로, 세로 넓이를 속성으로 가지고 getArea를 통해서 전체 넓이를 반환합니다.

정사가형은 가로, 세로 넓이가 동일한 직사각형이므로 , Rectangle을 상속해서 하위 클래스로 작성할 수 있습니다. Squre클래스는 Rectangle클래스를 이용해서 생성하고, prototype에는 Rectangle의 인스턴스를 연결해서 getArea메서드를 사용할 수 있도록 했습니다.

처음 예시와 마찬가지로 다음의 문제를 가지고 있습니다.

  1. Square의 width, height 프로퍼티가 수정이 가능합니다.
  2. Square의 width, height가 삭제되고, 참조하고 있는 Rectangle 클래스(Square.prototype)의 width, height가 값을 가지고 있다면 이후, 인스턴스의 동작에 영향을 줄 수 있습니다.

이렇게 클래스의 prototype에 있는 값이 인스턴스 동작에 영향을 주는 것은 클래스의 추상성을 해치게 됩니다. 클래스의 prototype이 오로지 인스터스가 사용할 메서드를 지니는 추상적인 ‘틀’로만 동작하도록 수정이 필요합니다.

클래스가 구체적인 데이터를 지니지 않게 하는 방법

프로퍼티들을 일일이 지우기

delete Square.prototype.width;
delete Square.prototype.height;
Object.freeze(Square.prototype);

좀 더 범용적으로 함수를 만들어보면 다음과 같습니다.

var extendClass1 = function (Superclass, SubClass, subMethods) {
	SubClass.prototype = new SuperClass();
    for (var prop in SubClass.prototype) {
    	if (SubClass.prototype.hasOwnProperty(prop)) {
        	delete SubClass.prototype[prop];// SubClass의 prototype 프로퍼티 정리
        }
    }
	if (subMethods) {
		for (var method in subMethods) {
    		SubClass.prototype[method] = subMethods[method]; //SubClass의 prototype 메서드 추가
    	}
	}
	Object.freeze(SubClass.prototype); //변경할 수 없도록 객체 동결
	return SubClass;
};

var Square = extendClass1(Rectangle, function (width) {
	Rectangle.call(this, width, width);
});

프로퍼티가 없는 빈 생성자 함수를 이용하기

아무런 프로퍼티를 생성하지 않은 빈 생성자 함수(Bridge)를 통해서도 앞선 문제를 해결할 수 있습니다. 즉, Bridgeprototype을 SuperClass와 SubClass 사이에 두어 일종의 다리 역할을 하게 만드는 것입니다.

var extendClass2 = (function () {
	var Bridge = fuction () {};
    return function (SuperClass, SubClass, subMethods) {
    	Bridge.prototype = SuperClass.prototype;
        SubClass.prototype = new Bridge();
        if (subMethods) { //SubClass prototype에 담길 메서드 전달
        	for (var method in subMethods) {
            	SubClass.prototype[method] = subMethods[method];
            }
        }
        Object.freeze(SubClass.prototype);
        return SubClass;
    };
})();

SubClass는 빈 생성자 함수의 인스턴스인 Bridgeprototype으로 참조하고 있고 Bridge에는 아무런 프로퍼티가 없습니다. 이로써 프로토타입 체인 경로상에는 인스턴스를 제외하고 동작에 영향을 줄 수 있는 어떤 구체적인 데이터도 남아있지 않습니다.

또한, Bridgeprototype이 SuperClass를 참조하고 있기 때문에 SubClass에서는 프로토타입 체이닝을 통해서 SuperClass prototype 메서드를 사용할 수 있습니다.

Object.create활용

Object.create는 지정된 프로토타입을 가지고 객체를 생성합니다.

Square.prototype = Object.create(Rectangle.prototype);
Object.freeze(Square.prototype);
// (...생략)

위 예시에서는, Object.create를 통해서 Rectangleprototype을 prototype으로 가지는 새로운 객체를 만들었습니다. 이 경우, Squareprototype은 Rectangle의 prototype을 바라보지만, Rectangle의 인스턴스를 생성해서 지정한 것이 아니기 때문에 앞서 문제 상황이었던 부분으로부터 안전할 수 있습니다.

결국, 앞선 세 가지 방법의 공통점은 다음과 같습니다.

  1. SubClass의 prototype이 SuperClass의 prototype을 참조하는 것.
  2. SubClass의 prototype에는 불필요한 프로퍼티가 없어야 한다는 것.

constructor 복구하기

위 상황에서 한 가지 문제점이 더 남아있습니다. 바로 SubClass 인스턴스의 constructor가 SuperClass를 가리키고 있다는 점입니다.

var rect2 = new sq.constructor(2, 3);
console.log(rect2); // Rectangle{ width: 2, height: 3}

Square의 constructor가 아직 Rectangle을 가리키고 있기 때문에 __proto__를 타고 호출하게 되면 Square임에도 불구하고 Rectangle의 인스턴스를 생성하게 됩니다.

따라서 이를 해결하기 위해 Subclass의 prototype.constructor를 원래의 SubClass를 바라보도록 해주면 되겠습니다.

//인스턴스 생성 후, 프로퍼티를 제거하는 방법에 constructor 재설정 부분 추가
var extendClass1 = fuction (SuperClass, SubClass, subMethods) {
	SubClass.prototype = new SuperClass();
    for (var prop in SubClass.prototype) {
    	if (SubClass.prototype.hasOwnProperty(prop)) {
        	delete SubClass.prototype[prop];
        }
    }
    SubClass.prototype.constructor = SubClass;
    if (subMethods) {
    	for(var method in subMethods) {
        	SubClass.prototype[method] = suvMethods[method];
        }
    }
    Object.freeze(SubClass.prototype);
    return SubClass;
};

상위 클래스로 접근 수단 제공

다른 객체 지향 언어에서의 super처럼 상위 클래스의 프로토타입 메서드에 접근하기 위한 별도의 수단을 흉내내보고자 합니다.

var extendClass1 = fuction (SuperClass, SubClass, subMethods) {
	SubClass.prototype = Object.create(SuperClass.prototype);
	SubClass.prototype.constructor = SuperClass;
	SubClass.prototype.super = function (propName) {
		var self = this;
		if (!propName) return function () { //(1)
			SuperClass.apply(self, arguments);
		}
		var prop = SuperClass.prototype[propName];
		if (typeof prop !== 'function') return prop; //(2)
		return function () { //(3)
			return prop.apply(self, arguments);
	}}
  if (subMethods) {
  	for(var method in subMethods) {
      	SubClass.prototype[method] = suvMethods[method];
      }
  }
  Object.freeze(SubClass.prototype);
  return SubClass;
};

...

var Square = extendClass(
	Rectangle,
	function (width) {
		this.super()(width, width); //(1)
	},
	{
		getArea: function () {
			console.log('size is : ', this.super('getArea')()); //(3)
		}
	}
)
  1. 인자가 비어있는 경우, SuperClass 생성자에 접근하는 것으로 간주하여 상위 클래스를 상속받아 인스턴스를 생성합니다.

    ex) this.super()(width, width);

  2. propName에 해당하는 값이 함수가 아닌 경우, 값을 그대로 반환합니다.

    ex) this.super(propName);

  3. 함수인 경우에는 메서드에 접근해서 실행할 수 있도록 합니다.

    ex) this.super('getArea')();

ES6의 클래스 및 클래스 상속

ES6에서는 명시적으로 클래스 문법이 추가되었습니다. ES5의 프로토타입 및 생성자 함수와 ES6의 클래스를 비교해보면 다음과 같습니다.

var ES5 = function (name) {
	this.name = name;
};
ES5.staticMehod = function () {
	return this.name + 'staticMethod';
};
ES5.prototype.method = function () {
	return this.name + 'method';
};
var es5Instance = new ES5('es5'));
console.log(ES5.staticMethod()); // es5 staticMethod
console.log(es5Instace.method()); // es5 method

var ES6 = class {
	constructor (name) {
    	this.name = name;
    }
    static staticMethod() {
    	return this.name + 'staticMethod';
    }
    method () {
    	return this.name + 'method';;
    }
};
var es6Instance = new ES6('es6');
console.log(ES6.staticMethod()); // es6 staticMethod
console.log(es6Instace.method()); // es6 method

또한, ES6에서의 상속 문법은 다음과 같습니다.

var Rectangle = class {
	constructor (width, height) {
    	this.width = width;
        this.height = height;
    }
    getArea() {
    	return this.width * this.height;;
    }
};

var Square = class extends Rectangle {
	constructor (width) {
    	super(width, width);
    }
  getArea() {
  	console.log('size is : ', super.getArea());
  }
};

0개의 댓글