2023. 5. 6. 01:58ㆍJavaScript
1. 객체지향 프로그래밍
: 객체의 집합으로 프로그램을 표현하려는 프로그래밍 패러다임
실세계의 실체가 수많은 속성 중 특정 속성을 통해 구별될 수 있는 것처럼, 프로그램의 객체도 이러한 속성을 갖는다.
여기서 여러 속성 중 필요한 속성만 간추려내는 것을 추상화라 한다.
객체지향 프로그래밍에서 객체는 상태 데이터와 동작을 하나의 논리적인 단위로 묶어 놓은 복합적 자료구조이다. 객체의 상태 데이터를 프로퍼티, 동작을 메소드라 한다.
2. 상속과 프로토타입
상속 : 어떤 객체의 프로퍼티 또는 메소드를 다른 객체가 상속받아 그대로 사용할 수 있는 것
→ 불필요한 중복 제거가 가능하다 (개발 비용 낮춤)
객체를 생성하는 방법
- 객체 리터럴에 의한 객체 생성
- 생성자 함수에 의한 객체 생성
// 생성자 함수
function Circle(r) {
this.radius = r;
this.getDiameter = function() {
return 2 * this.radius;
}
}
const circle1 = new Circle(1); // 객체 인스턴스 생성
생성자 함수를 통해 객체를 생성할 때 프로퍼티는 모든 인스턴스가 다르지만, 메소드는 모두가 동일한 메소드를 공유하기 때문에 인스턴스마다 getDiameter 메소드를 중복으로 가지고 있는 것은 비효율적이다. (메모리 낭비)
→ 상속을 통해 불필요한 중복을 제거할 수 있다. 자바스크립트는 프로토타입을 기반으로 상속을 구현한다.
function Circle(r) {
this.radius = r;
}
// 모든 인스턴스가 동일하게 가질 메소드는 따로 뺀다.
// 생성자 함수 인스턴스의 부모인 프로토타입에게 따로 넘겨주고,
// 자식 인스턴스를 생성할 때마다 부모로부터 메소드를 상속받는다.
Circle.prototype.getDiameter = function(){
return 2 * this.radius;
}
Circle 생성자 함수가 생성한 인스턴스들의 부모 = Circle.protoype 프로토타입
메소드가 생성자 함수 안에 정의되어 있는 경우에는 인스턴스를 생성할 때마다 메소드가 여러 번 생성되어 중복으로 소유하는 비효율적인 문제가 있었는데, 이를 프로토타입의 메소드로 정의하여 메소드가 하나만 있어도 인스턴스들이 이를 상속받아 재사용할 수 있다.
즉, 모든 인스턴스는 하나의 메소드를 공유하게 된다.
→ 정리하자면, 내용이 동일한 메소드는 상속을 통해 공유하여 사용할 수 있다.
3. 프로토타입 객체
: 객체 간 상속을 구현하기 위해 사용한다.
모든 객체의 프로토타입은 [[Prototype]] 내부 슬롯에 그 참조값이 저장돼있다.
1) 객체 리터럴에 의해 생성되었느냐 2) 생성자 함수에 의해 생성되었느냐에 따라 프로토타입이 결정된다.
1)의 경우, 프로토타입 객체는 Object.prototype이다. (프로토타입 체인의 최상의 객체)
2)의 경우, 프로토타입 객체는 생성자 함수의 prototype 프로퍼티에 바인딩되어 있는 객체이다.
Circle.prototype // 생성자 함수 Circle로 생성된 인스턴스의 프로토타입 객체
프로토타입에 접근하는 방법은 크게 두 가지가 있다.
__proto__ 접근자 프로퍼티
: Object.prototype의 프로퍼티로, 모든 객체가 이를 상속받아 자신의 프로토타입에 간접적으로 접근할 수 있다. (콘솔에 객체를 찍어보면 __proto__ 프로퍼티를 가지고 있다.)
__proto__는 ‘접근자’ 프로퍼티로 어떠한 값을 자체적으로 가지지 않고, [[Get]], [[Set]] 프로퍼티 어트리뷰트만 가지고 있어 프로퍼티의 값을 읽거나 저장만 할 수 있다.
다만 직접 상속을 통해 Object.prototype을 상속받지 않는 객체도 존재하므로 __proto__프로퍼티를 상속받아 사용하지 못하는 경우도 있기 때문에 이를 개발자가 직접 사용하는 것은 권장되지 않는다. 대신에 Object.getPrototypeOf(obj)
, Object.setPrototypeOf(obj, 교체할프로토타입)
를 사용하는 것을 권장한다.
함수 객체의 .prototype 프로퍼티
: 생성자 함수가 생성할 인스턴스의 프로토타입을 가리킨다.
단, 생성자 함수가 아닌 non-constructor(화살표 함수, ES6 메서드 축약표현) 함수들은 .prototype 프로퍼티를 소유하지 않고, 프로토타입을 생성하지 않는다.
__proto__ 접근자 프로퍼티와 생성자 함수의 .prototype 프로퍼티는 동일한 프로토타입을 가리킨다. 다만 이를 사용하는 주체가 다를 뿐이다. __proto__는 모든 객체가, .prototype 프로퍼티는 생성자 함수가 사용한다.
function Circle(r) {
this.radius = r;
}
const circle1 = new Circle(2);
// 사용하는 주체가 다를 뿐 같은 프로토타입을 가리킨다.
console.log(Circle.prototype === circle1.__proto__) // true
프로토타입의 .constructor 프로퍼티
생성자 함수가 .prototype 프로퍼티를 통해 프로토타입을 가리킨다면, 반대로 프로토타입은 .constructor 프로퍼티를 통해 생성자 함수를 가리킨다. (둘은 쌍으로 존재한다.)
해당 프로토타입을 상속받는 객체, 즉 생성자 함수로부터 생성된 인스턴스는 .constructor 프로퍼티를 상속받아 생성자 함수를 가리킬 수 있다.
4. 리터럴 표기법에 의해 생성된 객체의 생성자 함수와 프로토타입
그렇다면 객체 리터럴에 의해 생성된 프로토타입도 생성자 함수와 연결이 되어있을까?
→ (엄밀히 말하면 아니지만) 그렇게 봐도 큰 무리는 없다.
객체 리터럴에 의해 생성된 객체는 생성자 함수로부터 생성된 것이 아니지만, 이 객체도 Object 생성자 함수와 .constructor 프로퍼티로 연결되어 있다.
프로토타입은 언제나 생성자 함수와 더불어 생성된다. 객체리터럴에 의해 생성된 객체도 상속을 위해 프로토타입이 필요한데, 프로토타입을 가지려면 이를 위한 생성자 함수도 필요하기 때문에 객체리터럴에 의한 객체도 생성자 함수와 constructor프로퍼티로 연결돼있는 것이다.
따라서 객체 리터럴에 의한 객체와 생성자 함수에 의한 객체는 본질적인 면에서 큰 차이는 없기 때문에 객체 리터럴에 의한 객체의 constructor가 가리키는 생성자 함수를 그 객체 리터럴에 의한 객체를 생성한 생성자 함수로 봐도 큰 무리는 없다.
5. 프로토타입의 생성 시점
앞서 살펴본 것처럼 객체 리터럴에 의해 생성된 객체도 생성자 함수와 연결되어있으므로, 결국 모든 객체는 생성자 함수와 연결되어 있다.
앞서 말했듯이 프로토타입은 생성자 함수가 생성되는 시점에 더불어 생성되므로, 프로토타입의 생성 시점은 함수의 생성 시점과 같다.
사용자가 정의한 생성자 함수의 경우
: non-constructor가 아닌 함수, 즉 constructor 함수가 정의되어 함수 객체가 생성되는 시점에 프로토타입이 더불어 생성된다.
함수 호이스팅
생성자 함수 선언문 가장 먼저 평가 → 런타임 이전에 함수 객체 생성, 프로토타입도 더불어 생성
따라서 생성자 함수처럼 프로토타입도 코드 어느 위치에서든 접근할 수 있다.
빌트인 생성자 함수의 경우
: 전역 객체 window가 생성되는 시점에 생성된다.
따라서 인스턴스가 생성되기 전에 생성자 함수와 프로토타입은 이미 객체화되어 존재한다.
6. 객체 생성 방식과 프로토타입의 결정
객체를 생성하는 방법:
- 객체 리터럴
- Object 생성자 함수
- 사용자 정의 생성자 함수
- Object.create 메소드
- 클래스 (ES6)
위 방법들은 모두 추상 연산 OrdinaryObjectCreate
에 의해 생성된다는 공통점이 있다.
OrdinaryObjectCreate는 생성할 객체의 프로토타입을 필수적으로 인수로 전달받는다. 빈 객체를 생성한 후, 객체에 추가할 프로퍼티 목록을 인수로 전달받았다면 이를 추가하고, 인수로 전달받은 프로토타입을 생성한 객체의 [[Prototype]] 내부 슬롯에 할당한 다음, 생성한 객체를 반환한다.
따라서 프로토타입은 OrdinaryObjectCreate에 전달되는 인수에 의해 결정되며 이 인수는 객체 생성 방식에 의해 결정된다.
즉, 객체 생성 방식에 따라 프로토타입이 다르다.
객체 리터럴에 의해
프로토타입: Object.prototype
…이 추상연산 OrdinaryObjectCreate의 인수로 전달된다.
위의 경우 obj는 Object.prototype을 상속받기 때문에 constructor프로퍼티, hasOwnProperty 메소드 등을 자유롭게 재사용할 수 있다. (프로토타입은 상속을 구현하기 위해 사용한다!)
Object 생성자 함수에 의해
프로토타입: Object.prototype
…이 추상연산 OrdinaryObjectCreate의 인수로 전달된다.
객체 리터럴에 의한 생성 방식과 동일한 구조를 갖는다.
사용자 정의 생성자 함수에 의해
프로토타입: 생성자 함수.prototype
(생성자함수의 prototype 프로퍼티에 바인딩 돼있는 객체)
사용자가 정의한 생성자 함수와 더불어 생성된 프로토타입은 가장 기본 프로퍼티인 constructor 프로퍼티 뿐이다.
Person.prototype.sayHello = function() {
console.log('Hi! My name is ${this.name}');
}
const me = new Person('Lee');
me.sayHello(); // Hi! My name is Lee
이렇게 개발자가 직접 프로토타입에 프로퍼티를 추가할 수 있다.
7. 프로토타입 체인
객체의 프로퍼티(메소드 포함)에 접근 시도
→ 해당 객체에 프로퍼티 없다면
→ [[Prototype]] 내부 슬롯의 참조를 따라 자신의 부모 역할을 하는 프로토타입에서 검색
→ 찾을 때까지 이를 순차적으로 계속 올라가면서 시도
→ 프로토타입 체인 종점에는 Object.prototype (여기에도 없으면 undefined 반환)
(위 과정은 사실상 한 객체가 상속을 받은 프로퍼티나 메소드를 사용했을 때 자바스크립트가 작동하는 방식이다.)
프로토타입 체인의 종점에는 Object.prototype이므로, 모든 객체는 이 프로토타입으로부터 상속을 받는다.
프로토타입 체인은 상속과 프로퍼티 검색을 위한 메커니즘이다.
스코프 체인은 식별자 검색을 위한 메커니즘이다.
둘은 서로 협력하여 식별자와 프로퍼티를 검색하는 데 사용된다.
me.hasOwnProperty('name'); // ~~스코프에서 식별자 검색하고, 프로퍼티 검색하고, ...
8. 오버라이딩과 프로퍼티 섀도잉
const Person = (function () {
// 생성자 함수
function Person(name) {
this.name = name;
}
// '프로토타입 메소드' 생성
Person.prototype.sayHello = function() {
console.log('from Prototype');
}
return Person;
}());
const me = new Person('Lee');
// '인스턴스 메소드' 생성
me.sayHello = function() {
console.log('from Instance');
};
// 1번
me.sayHello(); // from Instance
// 인스턴스 메소드가 삭제된다
delete me.sayHello;
me.sayHello(); // from Prototype
// 프로토타입 메소드를 삭제하려면 직접 접근해서 삭제한다
delete Person.prototype.sayHello;
me.sayHello(); // TypeError
1번의 경우 me 인스턴스 메소드
를 오버라이딩하지 않았다면 Person.prototype 으로부터 프로토타입 메소드
를 상속받아 사용해 ‘from Prototype’ 이 출력되었을 것이다.
여기서 오버라이딩이란 프로토타입의 프로퍼티를 덮어쓴다는 의미가 아니라, 상위 객체가 가지고 있는 메소드를 하위 객체가 재정의하여 사용하는 방식이다. 즉 같은 이름으로 인스턴스의 프로퍼티가 추가되어 프로토타입의 프로퍼티는 가려지는 것이다. = 섀도잉
삭제의 경우도 마찬가지이다. 2번의 경우 오버라이딩된 메소드가 먼저 삭제되므로 sayHello메소드를 호출하면 인스턴스 메소드
는 삭제되었으므로 프로토타입 메소드
가 호출된다.
이처럼 하위 객체를 통해 프로토타입의 프로퍼티를 변경하거나 삭제하는 것은 불가능하다. (즉 자식이 부모 객체를 조작할 수는 없다.) get 액세스는 허용되나 set 액세스는 허용되지 않는 것이다.
프로토타입 프로퍼티
를 변경하거나 삭제하려면 하위 객체를 통해 프로토타입 체인으로 접근하는 것이 아니라, 생성자함수.prototype으로 직접 접근해야 한다.
9. 프로토타입의 교체
프로토타입은 임의의 다른 객체로 변경할 수 있다.
이를 통해 객체 간의 상속 관계를 동적으로 변경할 수 있다.
생성자 함수에 의해
// 생성자함수.prototype으로 프로토타입에 접근하여
Person.prototype = {
sayHello() { ... }
}; // 직접 교체해줄 수 있다.
단 이렇게 교체된 프로토타입에는 constructor 프로퍼티가 없어서, 인스턴스 객체의 생성자 함수를 검색하면 Person이 아닌 Object 생성자 함수가 나온다. constructor 프로퍼티를 검색하는 과정에서 프로토타입 체인을 타고 Object.prototype까지 올라가 constructor를 찾기 때문이다.
따라서 생성자 함수에 의해 프로토타입을 교체하는 경우 아래처럼 constructor 프로퍼티를 추가해서 생성자 함수와 프로토타입 간의 연결을 되살릴 수 있다.
Person.prototype = {
constructor: Person, // 이렇게 직접 constructor를 넣어줘야 한다.
sayHello() { ... }
};
인스턴스에 의해
__proto__ 접근자 프로퍼티 (=Object.setPropertyOf) 를 통해 교체해줄 수 있다.
const newPrototype = {
sayHello() { ... }
};
Object.setPrototypeOf(me, newPrototype); // me.__proto__ = newPrototype 과 동일
이 경우 역시 constructor 프로퍼티를 별도로 추가해주지 않으면 생성자 함수와 연결이 끊긴다.
게다가 이 경우는 생성자 함수도 prototype 프로퍼티로 프로토타입에 접근을 할 수 없게 된다.
따라서 아래처럼 수정하면 생성자 함수와 프로토타입 서로 간의 연결을 되살릴 수 있다.
const newPrototype = {
constructor: Person, // 이렇게 직접 constructor를 넣어줘야 한다.
sayHello() { ... }
};
Person.prototype = parent;
Object.setPrototypeOf(me, parent);
이처럼 프로토타입 교체를 통해 객체 간의 상속 관계를 동적으로 변경하는 것은 번거롭기 때문에, 직접 교체하지 않는 것이 좋다.
상속 관계를 인위적으로 설정하고 싶다면 직접 상속이 더 편리하다.
10. instanceof 연산자
객체 instanceof 생성자 함수
: 객체의 프로퍼티 체인 상에 생성자 함수.prototype 객체가 존재하는가
프로토타입.constructor가 가리키는 생성자 함수를 찾는 것이 아니라, 생성자 함수.prototype가 가리키는 프로토타입이 프로토타입 체인 상에 존재하는지를 확인.
따라서 프로토타입이 인스턴스에 의해 교체된 경우 객체 instanceof 생성자함수
는 false가 된다. 생성자함수에 의해 생성된 객체인 것은 맞더라도, 생성자함수와 연결이 끊겨 생성자함수.prototype은 더 이상 프로토타입 체인 상에 있지 않기 때문이다.
11. 직접 상속
앞서 모든 객체가 Object.prototype을 상속받는 것은 아니기 때문에 __proto__를 개발자가 직접 사용하는 것은 바람직하지 않다고 했다. null을 프로토타입으로 직접 상속받는 객체가 생성될 경우, 그 객체는 프로토타입이 null이므로 그 객체는 프로토타입 체인의 종점에 위치하기 때문에 Object.prototype을 상속받지 않는다. 이렇게 Object.prototype을 상속받지 않는 경우도 있기 때문에 Object.prototype의 빌트인 메소드나 프로퍼티를 직접 호출하는 것은 바람직하지 않다. (Function.prototype.call
을 통해 간접적으로 호출하는 것이 좋다: 22장)
직접 상속이란 무엇인가?
Object.create에 의해
Object.create 메소드로 프로토타입을 지정하는 것은 새로운 객체를 생성하는 방식 중 하나다. 지정한 프로토타입을 상속받는 새로운 객체가 생성된다. 즉, 객체를 생성하면서 직접적으로 상속을 구현한다.
/**
* 지정된 프로토타입 및 프로퍼티를 갖는 새로운 객체를 생성하여 반환한다.
* @param {Object} prototype 생성할 객체의 프로토타입으로 지정할 객체
* @param {Object} [propertiesObject] 생성할 객체의 프로퍼티를 갖는 객체
* @returns {Object} 지정된 프로토타입 및 프로퍼티를 갖는 새로운 객체
*/
Object.create(prototype[, propertiesObject])
프로토타입으로 null을 지정한 경우, 이렇게 생성된 객체는 프로토타입 체인의 종점에 위치한다.
(물론 프로토타입을 Object.prototype으로 지정하면 Object.prototype을 상속받는다.)
// obj -> null (obj의 프로토타입은 null, 즉 obj는 프로토타입 체인 종점에 위치)
let obj = Object.create(null); // null을 상속받는 새로운 객체 반환
console.log(Object.getPrototypeOf(obj)); //null
//obj -> Object.prototype
obj = Object.create(Object.prototype); // Object.prototype을 상속받는 새로운 객체 반환
console.log(Object.getPrototypeOf(obj)); //Object.prototype
// 프로퍼티를 정의하고 싶다면 두 번째 인수로 정의해야 한다.
obj = Object.create(Object.prototype, {
x: { value: 1, writable: true, enumerable: true, configurable: true }
}); // Object.prototype을 상속받고 { x: 1 } 프로퍼티를 갖는 새로운 객체 반환
장점은,
- new 연산자 없이 객체 생성 가능
- 프로토타입을 직접 지정하면서 객체 생성 가능
- 객체 리터럴에 의해 생성된 객체도 상속받을 수 있다.
객체 리터럴 내부에서 __proto__에 의해
const myProto = { x: 10 };
// 객체 리터럴에 의해 객체를 생성하면서 프로토타입 지정하여 직접 상속
const obj = {
y: 20,
__proto__: myProto
};
// Object.create메소드에 의해 객체를 생성하면서 프로토타입 지정하여 직접 상속
const obj = Object.create(myProto, {
y: { value: 20, writable: true, enumerable: true, configurable: true }
});
위 두 방법은 동일하다.
다만 객체리터럴에 의해 객체를 생성하며 프로토타입을 지정하여 직접 상속하는 방식이 훨씬 편리하다.
12. 정적 프로퍼티/메서드
: 생성자 함수로 인스턴스를 생성하지 않아도 참조/호출할 수 있는 프로퍼티/메서드
function Person(name) {
this.name = name;
}
// 정적 프로퍼티
Person.staticProp = 'static prop';
// 정적 메서드
Person.staticMethod = function() {
console.log('staticMethod');
};
Person.staticMethod(); // staticMethod
console.log(Person.staticProp); //static prop
// 인스턴스는 정적 프로퍼티/메소드를 참조/호출할 수 없다.
// 인스턴스의 프로토타입 체인에 해당 프로퍼티/메소드가 존재하지 않기 때문이다.
// 정적 프로퍼티/메소드는 생성자 함수 아래에 있다.
const me = new Person('Lee');
me.staticMethod(); // TypeError: me.staticMethod is not a function
프로토타입 프로퍼티/메서드는 정적 프로퍼티/메소드와 달리 호출하려면 인스턴스를 생성하여 인스턴스를 통해 호출해야 한다. 반면 정적 프로퍼티/메소드는 인스턴스 없이도 호출할 수 있다. (인스턴스가 정적 프로퍼티/메소드를 호출할 수 없다.)
13. 프로퍼티 존재 확인
in 연산자
key in object
const person = {
name: 'Lee',
};
console.log('name' in person); // true
console.log('toString' in person); // true
// ES6, Reflect.has 도 동일하게 동작
console.log(Reflect.has(person, 'name')); // true
console.log(Reflect.has(person, 'toString')); // true
key는 프로퍼티 키를 나타내는 문자열이다. object는 확인 대상 객체이다.
in 연산자는 확인 대상 객체가 상속받은 모든 프로토타입의 프로퍼티까지 확인하므로 주의해야 한다. 위의 경우 toString은 Object.prototype의 메소드로, person객체가 이를 상속받았기 때문에 true를 반환한다.
Object.prototype.hasOwnProperty 메소드
console.log(person.hasOwnProperty('name')); // true
console.log(person.hasOwnProperty('toString')); // false
Object.prototype.hasOwnProperty의 경우 in연산자와 달리 상속받은 프로퍼티 키를 찾는 경우에는 false를 반환한다. 즉, 상속받지 않은 고유의 프로퍼티만을 대상으로 한다.
14. 프로퍼티 열거
for … in 문
for (변수 선언문 in 객체) { ... }
const person = {
name: 'Lee',
address: 'Seoul',
__proto__: { prototypeKey: 'prototypeProperty' }
};
for (const key in person) {
console.log(key + ':' + person[key]);
}
// name: Lee
// address: Seoul
// prototypeKey: prototypeProperty
객체의 모든 프로퍼티를 순회하며 열거할 때 사용한다.
프로토타입으로부터 상속받은 프로퍼티까지 열거한다. 하지만 Object.prototype의 toString 메서드는 열거할 수 없도록 정의되어있기 때문에 열거되지 않는다. (Object.prototype.string 프로퍼티의 프로퍼티 어트리뷰터 [[Enumerable]] 이 false)
즉, 객체의 프로토타입 체인 상에 존재하는 모든 프로토타입 프로퍼티 중에서 프로퍼티 어트리뷰트 [[Enumerable]] 값이 true인 프로퍼티만 순회하며 열거한다.
상속받은 것이 아닌 자신의 프로퍼티만 순회하고 싶다면 반복문 안에서 Object.prototype.hasOwnProperty를 사용하여 자신의 프로퍼티인지 체크해야 한다.
cf> 배열의 경우, 배열도 객체이기 때문에 for … in 문을 사용하면 배열의 요소만이 아니라 배열 객체의 프로퍼티까지 모두 열거하게 되어 예상과 다른 결과가 나타날 수 있다. 따라서 배열의 경우 일반 for문, for … of문, Array.prototype.forEach메소드를 사용하는 것이 권장된다.
Object.keys/values/entries 메서드
앞서 for…in문을 사용하려면 상속받은 것까지 열거하기 때문에 Object.prototype.hasOwnProperty를 통해 확인을 해야 한다고 했다.
Object.keys/values/entries를 사용하면 자신의 프로퍼티만을 취급한다.
스터디 교재
'JavaScript' 카테고리의 다른 글
[JS스터디] 21. 빌트인 객체 (0) | 2023.05.14 |
---|---|
[JS스터디] 20. strict mode (0) | 2023.05.14 |
[JS스터디] 18. 함수와 일급객체 (0) | 2023.05.06 |
[JS스터디] 17. 생성자 함수에 의한 객체 생성 (0) | 2023.04.13 |
[JS스터디] 프로퍼티 어트리뷰트 (0) | 2023.04.13 |