CHAPTER8 클래스
Class를 학습하기 전 먼저 객체지향 프로그래밍을 살짝 알고 들어가자.
객체지향 프로그래밍(OOP: Object-oriented programming)
모든 것을 객체(Object)로 보고 그 객체들끼리 역할, 책임, 협력 등 객체들의 관계를 중심으로 프로그래밍 하는 기법을 말한다.
객체지향의 4대 특성
1. 캡슐화(encapuslattion)
연관된 데이터(변수)와 기능(메소드)을 하나로 묶고, 불필요한 요소를 외부에 노출되지 않도록 설계하는 방식을 뜻한다.
2. 상속(inheritance)
기존의 클래스에 기능을 추가하거나 재정의하여 새로운 클래스를 정의하는 것을 의미한다. 상속을 이용하면 기존에 정의되어 있는 클래스의 모든 필드와 메서드를 물려받을 수 있다.
3. 추상화(abstraction)
추상화는 클래스들의 공통적인 요소를 뽑아서 상위 클래스를 만들어내는 것이다. 한 마디로 추상화는 공통적인 속성과 기능을 정의함으로써 코드의 중복을 줄이고, 클래스 간 관계를 효과적으로 설정하고, 유지보수를 용이하게 하는 것이다.
4. 다형성(polymorphism)
같은 타입이지만 실행 결과가 다양한 객체를 대입할 수 있는 성질을 말한다. 이러한 다형성을 이용하여 부모-자식 관계의 클래스들이 존재할 때, 부모 클래스로 자식 클래스들을 서로 다르게 동작시킬 수 있다.
8.1 클래스 메서드
TS는 독립 함수(standalone function)를 이해하는 것과 동일한 방식으로 메서드를 이해한다.
매개변수 타입에 타입이나 기본값을 지정하지 않으면 any 타입을 기본으로 가지며, 메서드를 호출하려면 허용 가능한 수의 인수가 필요하고, 재귀 함수가 아니라면 대부분 반환 타입을 유추한다.
아래는 인수를 지정하지 않았을 때 나는 에러다.
class Greeter {
greet(name: string) {
console.log(`Hello, ${name}!`);
}
}
new Greeter().greet('World');
new Greeter().greet()
// ~~~~~
// Error: Expected 1 arguments, but got 0.
클래스 생성자(constructor)는 매개변수와 간련하여 전형적인 클래스 메서드처럼 취급된다. TS는 메서드 호출 시 올바른 타입의 인수가 올바룬 수로 제공되는지 당연히 타입검사를 진행한다.
class Greeter {
constructor(name: string) {
console.log(`hello, ${name}!`);
}
}
new Greeter("world");
new Greeter(123)
// ~~~
// Error: Argument of type '123' is not assignable to parameter of type 'string'.
8.2 클래스 속성
TS에서 클래스의 속성을 읽거나 쓰려면 클래스에 명시적으로 선언해야 한다. 클래스 속성은 인터페이스와 동일한 구문을 사용해 선언하며 클래스 속성 이름뒤에 선택적으로 타입 애너테이션이 붙는다.
아래는 코드는 명시적으로 선언하지 않은 nonexistent 속성을 선언하지 않고 this 할당을 시도했을때 생기는 에러다.
class FieldTrip {
destination: string;
constructor(destination: string) {
this.destination = destination; // Ok
console.log(this.destination);
this.nonexistent = destination; // Error
// ~~~~~~~~~~~~~~~
// Property 'nonexistent' does not exist on type 'FieldTrip'.
}
}
const trip = new FieldTrip("Zoo");
console.log(trip.destination); // Ok
console.log(trip.nonexistent);
// ~~~~~~~~~~~~~~~
// Error: Property 'nonexistent' does not exist on type 'FieldTrip'.
8.2.1 함수 속성
클래스의 멤버를 호출 가능한 함수로 선언하는 두가지 구문이 있다.
function() {}과 같이 멤버 이름 뒤에 괄호를 붙이는 메서드 접근 방식은 함수를 클래스 프로토타입에 할당하므로 모든 클래스 인스턴스는 동일한 함수 정의를 사용한다.
class WithMethod {
method() {}
}
new WithMethod().method == new WithMethod().method; // true
다른 방식은 값이 함수인 속성을 선언하는 방식이 있다. 이렇게 하면 클래스의 인스턴스당 새로운 함수가 생성되며, 항상 클래스 인스턴스를 가리켜야 하는 화살표 함수에서 this 스코프를 사용하면 클래스 인스턴스당 새롱누 함수를 생성하는 시간과 메모리 비용 측면에서 유용할 수 있다.
class WithProperty {
myProperty: () => {};
}
new WithProperty().myProperty == new WithProperty().myProperty; // false
함수 속성에는 클래스 메서드와 독립 함수의 동일한 구문을 사용해 매개변수와 반환타입을 지정할 수 있다.
결국 함수 속성은 클래스 멤버로 할당된 값이고, 그 값은 함수이기 때문에 가능하다.
아래 코드는
class WithPropertyParameter {
takesParameter = (input: boolean) => (input ? "yes" : "no");
}
const instance = new WithPropertyParameter();
instance.takesParameter(true); // 'yes'
instance.takesParameter(123); // Error
// ~~~
// Error: Argument of type '123' is not assignable to parameter of type 'boolean'.
8.2.2 초기화 검사
엄격한 컴파일러를 설정한 프로젝트 진행시 undefined 값이 할당되지 않으면 초기화 에러가 나타난다.
class WithValue {
immediate = 0;
later: number;
mayBeUndefined: number | undefined;
unused: number;
//~~~~~~
// Error: Property 'unused' has no initializer and is not definitely assigned in the constructor.
constructor() {
this.later = 123;
}
}
이를 막기 위해서 다음과 같이 !(어서션)를 추가해 검사를 비활성화 할 수 있다.
class WithValue {
immediate = 0;
later: number;
mayBeUndefined: number | undefined;
unused!: number;
constructor() {
this.later = 123;
}
}
8.2.3 선택적 속성
인터페이스와 마찬가지로 이름 뒤에 ?를 추가해 | undefined를 포함하는 유니언 타입을 사용할 수 있다.
다음 코드예시를 확인해 보자.
class MissingInitializer {
property?: string;
}
new MissingInitializer().property?.length; // Ok
new MissingInitializer().property.length; // Error
// ~~~~~~~~
// Error: Object is possibly 'undefined'.
8.2.4 읽기 전용 속성
인터페이스와 마찬가지로 클래스도 선언된 속성 이름 앞에 readonly를 사용할 수 있으며, 또한 타입 시스템에만 존재하는 것도 같다.
생성자 내부에서는 값을 수정할 수 있다.
원시 타입의 초깃값을 갖는 readonly로 선언된 속성은 다른속성과는 다르다. 아래 코드에 implicit는 원시 값이 아닌 좁혀진 리터럴 타입으로 유추된다.
TS는 값이 나중에 변경되지 않는다는 것을 알기 때문에 더 공격적인 초기타입 내로잉을 편하게? 느낀다고 한다.
아래 예제를 살펴보면 implicit는 타입이 좁혀진 것을 볼 수 있다.
class RandomQuote {
readonly explicit: string = "나는 string이다";
readonly implicit = "나도 string이다";
constructor() {
this.explicit = "string이다"; // Ok
this.implicit = "나는 리터럴이다";
// ~~~~~~~~~~~~~
// Type '"나는 리터럴이다"' is not assignable to type '"나도 string이다"'.
}
}
new RandomQuote().explicit;
// 타입 : string
new RandomQuote().implicit)
// 타입 : '나도 string이다'
8.3 타입으로서의 클래스
타입 시스템에서의 클래스는 클래스 선언이 런타임 값(클래스 자체)과 타입 애너테이션에서 사용할 수 있는 타입을 모두 생성한다는 점에서 상대적으로 독특하다.
아래 Teacher 클래스의 이름은 teacher 변수에 주석을 다는 데 사용된다. teacher 변수에는 Teacher 클래스의 자체 인스턴스처럼 Teacher 클래스에 할당할 수 있는 값만 할당해야 함을 TS에 알려준다.
class Teacher {
sayHello() {
console.log("Hello, how are you?");
}
}
let teacher: Teacher;
teacher = new Teacher();
teacher = "asd";
//~~~~~~
// Error: Type 'string' is not assignable to type 'Teacher'.
TS는 클래스의 동일한 멤버를 모두 포함하는 모든 객체 타입을 클래스에 할당할 수 있는 것으로 간주한다. TS의 구조적 타이핑이 선언되는 방식이 아닌 객체의 형태만 고려하기 때문이라고 한다.
아래 코드를 보면 SchoolBus 클래스에 getAbilities 메서드에 타입을 string[]으로 유추하여 다른 타입은 사용할 수 없게 하는 것을 볼 수 있다.
class SchoolBus {
getAbilities() {
return ["drive", "honk"];
}
}
function withSchoolBus(bus: SchoolBus) {
console.log(bus.getAbilities());
}
withSchoolBus(new SchoolBus());
// ["drive", "honk"]
withSchoolBus({ getAbilities: () => ["fly"] }); // Ok
// ["fly"]
withSchoolBus({ getAbilities: () => 123 }); // Error
// ~~~
// Error: Type 'number' is not assignable to type 'string'.
8.4 클래스와 인터페이스
클래스 이름 뒤에 implements 키워드와 인터페이스를 추가함으로써 클래스의 해당 인스턴스가 인터페이스를 준수한다고 선언할 수 있다. 이렇게 하면 클래스를 각 인터페이스에 할당해야하며 불일치에 대해 오류가 발생한다.
interface Learner {
name: string;
study(hours: number): void;
}
class Student implements Learner {
name: string;
constructor(name:string){
this.name = name;
}
study(hours: number) {
console.log(`${this.name} studied for ${hours} hours`);
}
}
class Slacker implements Learner {
// ~~~~~~~
// Error: Class 'Slacker' incorrectly implements interface 'Learner'.
// Property 'name' is missing in type 'Slacker' but required in type 'Learner'.
}
8.4.1 다중 인터페이스 구현
클래스는 다중 인터페이스를 구현할 수 있는데, 인터페이스 사이에 쉼표를 넣고 사용하며, 갯수 제한 없이 사용할 수 있다.
implements 했던 인터페이스를 전부 준수해야 한다.
interface Graded {
grade: number[];
}
interface Reporter {
report: () => void;
}
class ReportCard implements Graded, Reporter {
grade: number[] = [100, 80, 90];
report() {
console.log("Report card generated");
}
}
같은 이름의 인터페이스 멤버가 있을 경우 에러가 발생한다.
interface AgeIsNum {
age: number;
}
interface AgeIsString {
age: string;
}
class AsNumber implements AgeIsNum, AgeIsString {
age = 0;
// Error: Property 'age' in type 'AsNumber' is not assignable to the same property in base type 'AgeIsString'.
}
class AsString implements AgeIsNum, AgeIsString {
age = "0";
// Error: Property 'age' in type 'AsString' is not assignable to the same property in base type 'AgeIsNum'.
}
8.5 클래스 확장
클래스를 확장하거나 하위 클래스를 만드는 JS 개념에 타입 검사를 추가한다. 이는 파생 클래스라고도 하는 하위 클래스에서 사용할 수 있다.
class Teacher {
teach() {
console.log("Teaching");
}
}
class Student extends Teacher {
learn() {
console.log("Learning");
}
}
const teacher = new Student();
teacher.teach(); // Ok
teacher.learn(); // Ok
8.5.1 할당 가능성 확장
하위 클래스도 기본 클래스의 멤버를 상속한다. 하위 클래스의 인스턴스는 기본 클래스의 모든 멤버를 가지므로 기본 클래스의 인스턴스가 필요한 모든 곳에서 사용할 수 있다.
Lesson 클래스의 인스턴스는 파생된 OnLineLesson 인스턴스가 필요한 곳에서 사용할 수 없지만, 파생된 인스턴스는 기본 클래스 또는 하위 클래스를 충족하는 데 사용할 수 있다.
class Lesson {
subject: string;
constructor(subject: string) {
this.subject = subject;
}
}
class OnLineLesson extends Lesson {
url: string;
constructor(subject: string, url: string) {
super(subject);
this.url = url;
}
}
let lesson: Lesson;
lesson = new Lesson("Math"); // Ok
lesson = new OnLineLesson("Math", "http://example.com"); // Ok
let onLine: OnLineLesson;
onLine = new OnLineLesson("Math", "http://example.com"); // Ok
onLine = new Lesson("Math"); // Error
// Error: Type 'Lesson' is not assignable to type 'OnLineLesson'.
8.5.2 재정의된 생성자
J하위 클래스는 자체 생성자를 정의할 필요가 없으며, 자체 생성자가 없을 경우 암묵적으로 부모 클래스에 생성자를 사용한다.
하위 클래스가 자체 생성자를 선언하면 super 키워드를 통해 부모 클래스 생성자를 호출해야한다.
그렇지 않는 다면 타입 오류가 발생한다.
class GradeAnnouncer {
message: string;
constructor(grade: number) {
this.message = grade >= 65 ? "안돼" : "돼";
}
}
class PassingAnnouncer extends GradeAnnouncer {
constructor() {
super(100);
}
}
class FailingAnnouncer extends GradeAnnouncer {
constructor() { }
//~~~~~~~~~~~
// Error: 'FailingAnnouncer' must call super() in constructor.
}
this 또는 super에 접근하기 전에 반드시 부모 클래스의 생성자를 호출해야 한다. super()를 호출하기 전에 this또는 super에 접근하려고 하는 경우 타입 오류를 발생한다.
class GradeAnnouncer {
message: string;
constructor(grade: number) {
this.message = grade >= 65 ? "안돼" : "돼";
}
}
class PassingAnnouncer extends GradeAnnouncer {
constructor() {
// super(100) // super()를 먼저 선언하면 Ok 아니면 아래와 같은 에러
this.message = "Passing grade";
// ~~~~
// Error: this.message cannot be accessed before calling super()
}
}
8.5.3 재정의 된 메서드
자식 클래스의 메서드가 부모 클래스의 메서드에 할당될 수 있는 한 자식 클래스는 부모 클래스와 동일한 이름으로 새 메서드를 선언할 수 있다.
기본 클래스를 사용하는 모든 곳에 자식 클래스를 사용할 수 있으므로 새 메서드의 타입도 부모 메서드 대신 사용할 수 있어야 한다.
다음 예제에서 FailureCounter의 countGrades 메서드는 기본 GradeCounter의 countGrades메서드의 반환 타입과 첫 번째 매개변수가 동일하기 때문에 허용된다.
AnyFailureChecker의 countGrades는 잘못된 반환 타입을 가지고 있어 타입 에러가 발생한다.
class GradeCounter {
countGrades(grades: string[], letter: string) {
return grades.filter((grade) => grade === letter).length;
}
}
class FailureCounter extends GradeCounter {
countGrades(grades: string[]) {
return super.countGrades(grades, "F");
}
}
class AnyFailureChecker extends GradeCounter {
countGrades(grades: string[]) {
//~~~~~~~~~~~
// Error: Property 'countGrades' in type 'AnyFailureChecker' is not assignable to the same property in base type 'GradeCounter'.
// Type '(grades: string[]) => number' is not assignable to type '(grades: string[], letter: string) => number'.
return super.countGrades(grades, "F") !== 0;
}
}
8.5.4 재정의된 속성
자식 클래스는 새 타입을 부모 클래스의 타입에 할당할 수 있는 한 동일한 이름으로 기본 클래스의 속성을 명시적으로 다시 선언할 수 있다.
재정의된 메서드와 마찬가지로 하위 클래스는 기본 클래스와 구조적으로 일치해야 한다.
속성을 다시 선언하는 대부분의 자식 클래스는 해당 속성을 유니언 타입의 더 구체적인 하위 집합으로 만든다.
다음 코드는 부모 클래스인 Assignment의 grade속성이 number | undefined로 선언되었고, 자식 클래스에선 number로 타입을 줄이는 코드다.
class Assignment {
grade?: number;
}
class GradeAssignment extends Assignment {
grade: number;
constructor(grade: number) {
super();
this.grade = grade;
}
}
8.6 추상 클래스
추상 클래스는 추상 메서드를 하나 이상 포함한 클래스이며, 정의되지 않은 추상 메소드를 포함하고 있으며, 인스턴스를 생성할 수 없고 상속만 가능하다.
추상 메서드란 부모 클래스에서 정의하며, 반드시 자식 클래스에서 오버라이딩해야만 사용할 수 있기때문에 구현부는 없고 선언부만 존재한다.
TS에서 사용하려면 추상 클래스를 사용할 클래스와 메서드 앞에 abstract 키워드를 추가해주면 된다.
추상 메서드 선언은 추상화 부모 클래스에서 메서드의 본문(구현부)를 건너뛰고, 인터페이스와 동일한 방식으로 선언하면 된다.
abstract class School {
readonly name: string;
constructor(name: string) {
this.name = name;
}
abstract getSchoolTypes(): string[];
}
class Preschool extends School {
getSchoolTypes() {
return ["Preschool"];
}
}
8.7 멤버 접근성
JS에선 클래스 멤버 이름 앞에 #을 추가해 private 클래스 멤버임을 나타내었다.
TS의 클래스 지원은 JS의 # 프라이버시보다 먼저 만들어 졌다고한다.
또한 TS는 private 클래스 멤버를 지원하지만, 타입 시스템에만 존재하는 클래스 메서드와 속성에 대해 조금 더 미묘한 프라이버시 정의 집합을 허용한다고 한다.
TS 멤버 접근성은 아래 키워드 중 하나를 추가해 만들 수 있다.
public(기본 값) : 모든 곳에서 누구나 접근 가능하며 인스턴스 생성 가능
protected : 클래스 내부 또는 하위 클래스에서만 접근 가능하며 인스턴스 생성 X
private : 클래스 내부에서만 접근 가능하며 인스턴스 생성 X
위 키워드는 타입 시스템에서만 존재하며, JS로 컴파일 되면 사라진다.
TS의 멤버 접근성과 JS의 #키워드는 함께 사용할 수 없다
아래 코드에서 확인해보면 protected는 자식에서 접근 가능하지만 인스턴스는 생성이 안되며, #과 private는 둘다 안되는 것을 확인할 수 있다.
class Subclass extends Base {
example() {
this.isPublic1; // Ok
this.isPublic2; // Ok
this.isProtected; // Ok
this.isPrivate1; // Error
// ~~~~~~~~~~
// Property 'isPrivate1' is private and only accessible within class 'Base'.
// 'isPrivate1' 속성은 private이며 'Base' 클래스 내에서만 액세스할 수 있습니다.
this.#isPrivate2; // Error
// ~~~~~~~~~~~
// Property '#isPrivate2' is not accessible outside class 'Base' because it has a private identifier.
// '#ispPrivate2' 속성은 private 식별자를 가지고 있기 때문에 'Base' 클래스 외부에서 액세스할 수 없습니다.
}
}
const base = new Base();
base.isPublic1; // Ok
base.isPublic2; // Ok
base.isProtected; // Error
// ~~~~~~~~~~~
// Property 'isProtected' is protected and only accessible within class 'Base'.
// 'isProtected' 속성은 보호된 속성이며 'Base' 클래스 내에서만 액세스할 수 있습니다.
base.isPrivate1; // Error
// ~~~~~~~~~~
// Property 'isPrivate1' is private and only accessible within class 'Base'.
// 'isPrivate1' 속성은 private이며 'Base' 클래스 내에서만 액세스할 수 있습니다.
base.#isPrivate2; // Error
// ~~~~~~~~~~~
// Property '#isPrivate2' is not accessible outside class 'Base' because it has a private identifier.
// '#ispPrivate2' 속성은 private 식별자를 가지고 있기 때문에 'Base' 클래스 외부에서 액세스할 수 없습니다.
접근성 제한자는 readonly와 함께 표시할 수 있는데, 명시적 접근성 키워드로 멤버를 선언하려면 접근성 키워드를 먼저 적으면 된다.
8.7.1 정적 필드 제한자
JS는 static 키워드를 사용해 클래스 자체에서 멤버를 선언한다.
static 키워드를 단독으로 사용하거나 readonly와 접근성 키워드를 함께 사용할 수 있도록 지원한다.
다음 Question 클래스는 protected, static, readonly를 모두 사용해 prompt와 answer 속성을 만든다.
class Question {
protected static readonly answer: "bash";
protected static readonly prompt: "What is the best shell?";
guess(getAnswer: (prompt: string) => string) {
const answer = getAnswer(Question.prompt);
if(answer === Question.answer) {
console.log("Correct!");
} else {
console.log("Try again!");
}
}
}
Question.answer = "zsh";
// ~~~~~~~~~~~
// Error: Cannot assign to 'answer' because it is a read-only property.
// 'answer' 속성은 보호된 속성이며 'Question' 클래스 내에서만 액세스할 수 있습니다.
static 클래스 필드에 대해 readonly와 접근성 제한자를 사용하면 해당 필드가 해당 클래스 외부에서 접근되거나 수정되는 것을 제한하는데 유용하다고 한다.
'항해99 플러스 > TypeScript 스터디' 카테고리의 다른 글
[TypeScript 스터디] 4주차 CHAPTER9 타입 제한자 (0) | 2025.02.27 |
---|---|
[TypeScript 스터디] 3주차 CHAPTER5 ~ 7 (0) | 2025.02.18 |
[TypeScript 스터디] 2주차 CHAPTER3 ~ 4 (1) | 2025.02.13 |
[TypeScript 스터디] 1주차 CHAPTER1 ~ 2 (1) | 2025.02.02 |