왜 디버깅으로 초기화 실행 흐름을 확인하게 되었나?
과제로 자바의 정석 연습 문제를 풀다가 객체를 그림으로 그려보고, '상속 관계인 두 클래스에 중복된 멤버가 있을 때 어느 클래스 타입 참조변수로 접근하느냐에 따라 값이 달라질 수 있다'를 이해는 하고 있었느나, 문제를 풀어보니 생각한 답과 코드 실행결과가 달랐다. 초기화 순서는 제대로 이해한 상태이다.
내가 생각한 답은 1000, 실행결과는 200 이다.
문제
[ 연습문제 7장 7번 ]
다음 코드의 실행했을 때 호출되는 생성자의 순서와 실행결과를 적으시오.
package CH07_OOP2.Excercise;
class Parent {
int x = 100;
Parent() {
this(200);
}
Parent(int x) {
super();
this.x = x;
}
int getX() { return x;}
}
class Child extends Parent{
int x = 3000;
Child() {
this(1000);
}
Child(int x) {
super();
this.x = x;
}
}
public class Excercise7_7 {
public static void main(String[] args) {
Child c = new Child();
System.out.println("x = " + c.getX());
}
}
답을 생각하게 된 생각 흐름
자바의 정석 CH06. 객체지향프로그래밍1 에서 변수의 초기화 과정 및 순서는 아래와 같다고 배웠다.
초기화 순서
- 클래스 변수 초기화 -> 인스턴스 변수 초기화
초기화 과정
1. 자동 초기화 ( 기본형 변수 , 참조형 변수 null인 default 값으로 초기화)
2. 간단 초기화 (대입 연산자 = )
3. 복잡 초기화 (초기화 블럭{}, static {}, 생성자)
- 초기화 블럭 후 생성자 실행
초기화 순서를 위와 같이 이해했는데,
1) 컴파일러의 기능,
2) 상속받은 메서드를 자손 클래스에서 그대로 가진 경우
를 간과 했다.
컴파일러가 하는 일
1. 파싱
- 단어로 쪼갬
2. 문법 체크
- if문 덩어리
3. 의미 체크
4. 소스코드 최적화
① (간단한) 계산
② 코드 자동 추가
컴파일러 원서의 목차의 컴파일러의 구조를 보면 수업에서 배운 컴파일러의 하는 일이 작성되어 있는 걸 확인할 수 있었다.
Lexical Analysis == 1. 파싱 (단어로 쪼갬),
Syntax Analysis == 2. 문법체크,
Semantic Analysis == 3. 의미 체크,
Code Optimization == 4.코드 최적화,
Code Generation == ② 코드 자동 추가 이 대응된다 .
[놓친 부분 1] 컴파일러의 코드 자동 추가
여기서 필자가 놓친 컴파일러의 기능은 "코드 자동 추가 "이다. 클래스의 멤버 변수 선언 부분에는 {}, static{} 부분이 생략되어 보인다. 그래서 디버깅을 했을 때, Parent(int x) 생성자에서 생략된 super(); Object() 생성자를 호출한 이후에 필자는 바로 Parent(int x) 에서 this.x를 초기화 할 줄 알았다.
다음 명령문을 실행해보니, this.x가 아닌 int x = 10; 이 실행되었다. 즉, 컴파일러가 자동으로 초기화 블럭{}이 생성한 것이다. 그럼 { int x = 100; } 을 넣으면 될까 싶어 아래와 같이 코드를 작성했다 .
컴파일은 에러가 나지 않았지만, 실행 에러(런타임 에러)가 발생했다. 초기화 블럭을 먼저 초기화한다고 해서 작성했는데 x 를 찾을 수 없다는 에러가 나왔다. 변수 선언과 값초기화를 초기화 블럭 {} 에 넣어서 지역변수가 되었기 때문이다. 다른 메서드인 생성자에서 사용할 수가 없었던 것이다.
앞서 말한 것처럼 컴파일러의 기능인 코드 자동추가에서 필드의 선언과 초기화를 분리하여 초기화 블럭을 생성한다.
클래스의 멤버 변수 초기화를 int x = 100;이라 작성을 하면 컴파일러는 자동으로 desugaring( sugar 구문 제거) 기능을 해 필드 선언과 초기화를 분리한다. 초기화 블럭을 명시적으로 작성할 때 주의해야한다.
즉, Parent(int x)에서 super()로 Object() 생성자 호출 완료 후 다음 실행하는 명령문은 int x = 100; 이다. 그러면서 Child 객체에서 Parent의 멤버변수 x 에 100이 초기화 된 후 Parent(int x) 내부의 this.x = x; 가 실행된다. 부모 멤버 변수 x에는 200이 최종적으로 저장된다.
[놓친 부분2] 정적 바인딩
자손 클래스가 조상 클래스 멤버 함수를 상속 받는다.
자손 타입의 참조변수로 자손 타입 객체에 존재하는 조상 멤버 함수 호출을 한다.
조상 메서드 구현부에서 자손 클래스와 조상 클래스에 중복된 멤버 변수가 있을 때, 조상 메서드는 어떤 멤버 변수에 접근하나?
return x;의 x는 자손의 멤버인지 조상의 멤버인지 헷갈린 것이다. Child 클래스가 Parent 클래스를 상속하기 때문에 getX()함수, 조상 x 멤버변수까지 Child 객체 안에 존재하기 때문이다.
참조변수와 인스턴스 연결의 중복된 메서드 부분에서 헷갈렸던 것이다.
1. 해당 문제에서는 자손 클래스에서는 중복된 메서드가 없다
2. 중복된 멤버 변수가 문제 있을 때랑 혼동했다.
3. 자손 객체에서 부모의 중복된 멤버변수가 사라지니까 getX()호출 시 자손 멤버변수를 가리킬 것이다.
참조변수와 인스턴스 연결
조상 클래스와 자손 클래스에 동일한 이름의 멤버변수와, 멤버함수가 존재하는 경우
<메서드>
1. 메서드 조상 타입 참조변수.중복메서드명()
- 참조변수 타입에 관계없이 실제 인스턴스 타입인 클래스에 정의된 메서드 호출
2, 자손 타입 참조변수.중복메서드명()
- 참조변수 타입에 관계없이 실제 인스턴스 타입인 클래스에 정의된 메서드 호출
<멤버변수>
1. 멤버변수 조상 타입 참조변수.중복멤버변수명
- 조상 타입 멤버 변수 참조
2. 자손 타입 참조변수.중복멤버변수명
- 자손 타입 멤버 변수 참조
Child 클래스의 메서드에서 getX()를 오버라이딩 하니 x = 1000;이 출력되었다. 이를 통해 메서드의 변수는 해당 메서드가 속해있는 클래스의 멤버 변수를 접근함을 알게 되었다.
어떤 원리가 적용되는지 찾아보니 정적 바인딩 (static binding) 원칙에 의해 '자바에서의 필드 접근은 컴파일 시점에 결정'된다는 것이다. 메서드 내부의 'x'변수는 해당 메서드가 속한 클래스의 멤버 변수를 가리킨다. 즉, 부모 클래스의 getX()에 나오는 x는 컴파일 시점에 부모 멤버변수를 가리키도록 결정된 것이다.
그래서 자손 클래스에도 중복된 멤버(x)가 있고 부모의 멤버 변수(x)가 숨겨진 필드가 되어도, getX() 메서드 호출 시 자손 클래스의 멤버 변수 (x =1000)을 참조하지 않게 된 것이다.
배운 점
- 생각한 대로 되지 않을 때, 컴파일러가 뭘 추가한 것이 아닌가 의심을 해보는 것이 필요하다.
- 컴파일 시점에 실행되는 일과, 실행시점에 실행되는 것을 구분해서 알아야한다.
- 자바의 심화 내용을 아는 것이 중요하다.
'Java' 카테고리의 다른 글
예술적인 코드 모음 (계속 업데이트) (0) | 2025.04.29 |
---|---|
[Java] 바이트코드로 분석하는 new T()가 안되는 이유 (0) | 2025.04.27 |
ArrayList 클래스의 remove() 메서드 파해치기 (0) | 2025.04.15 |
[커널아카데미] 백엔드 12기 2주차 - 자바의 정석 CH06.객체지향프로그래밍1 (0) | 2025.04.06 |