nueijeel

[Kotlin 이론] 0517 Companion과 Generic, Null 처리 본문

Kotlin

[Kotlin 이론] 0517 Companion과 Generic, Null 처리

nueijeel 2023. 6. 1. 15:30

2023.05.17

 

 

1. Companion

 

Companion

: 클래스를 정의할 때 멤버를 정적 요소로 만들어주는 키워드로, companion 요소는 객체를 생성하지 않아도 클래스 이름을 사용해 접근이 가능하다

 

자바에서의 static 키워드와 동일한 역할을 한다.

 

fun main(){
    println("Companion 변수 a2 : ${TestClass.a2}")
    println("Companion 변수 a3 : ${TestClass.a3}")
    TestClass.testMethod2()
    
    val t1 = TestClass()
    t1.testMethod1()
}
class TestClass{
    var a1 = 100
    
    fun testMethod1(){
    	println("a1: $a1")
    	println("a2: $a2")
    }
    
    companion object{
    	var a2 = 200
        var a3 = 300
        
        fun testMethod2(){
            println("a2 + a3 : ${a2+a3}")
        }
    }
}

 

TestClass에서 일반 멤버 변수인 a1과 일반 멤버 메서드인 testMethod1()을 선언하고, companion 요소인 변수 a2, a3와 메서드 testMethod2()를 선언했다. 

 

Companion 요소들은 main 함수에서 객체가 생성되기 전에 클래스명으로 접근이 가능했고, 일반 요소는 객체 생성 후 객체를 통해서만 접근이 가능했다.

 

일반 메서드인 testMethod1()에서 companion요소인 a2 변수를 출력한 것을 볼 수 있다. 이렇게 호출 할 수 있는 이유는 객체가 생성되어야 값이 메모리에 저장되는 일반 요소의 호출 시점에 companion 요소의 메모리 저장 여부를 보장받을 수 있기 때문이다. 반대로 companion 요소 내에서는 일반 요소의 메모리 저장 여부를 보장받지 못하므로 사용이 불가하다.

 

companion object{
    @JvmStatic var kotlin1 = 10
    @JvmStatic var kotlin2 = 20
    
    @Jvmstatic fun kotlinMethod(){
    	println("kotlin1 : $kotlin1")
        println("kotlin2 : $kotlin2")
    }
}

 

코틀린과 자바 사이에서 서로의 정적 요소를 사용할 수 있다.

자바의 static 요소는 코틀린에서 그대로 사용할 수 있는 반면, 코틀린의 companion요소는 위 예제와 같이 @JvmStatic 이라는 어노테이션을 사용해 자바에서 static 요소로 인식할 수 있도록 해줘야한다.

 

어노테이션이란 일종의 주석인데, 컴파일러에게 정보를 제공하거나 프로그램 실행 시 특정 코드를 자동으로 생성해주도록 도와주는 역할을 한다.

 

 


2. Generic

 

Generic

: 제네릭은 클래스 내부에서 사용할 자료형을 객체 생성 시에 지정할 수 있게 하는 개념이다.

 

제네릭을 적용한 클래스는 선언 시 <>안에 알파벳 문자를 넣어준다. 보통 대문자 알파벳 한 글자를 넣어준다. 이 알파벳은 아직 결정되지 않은 타입을 의미하는데, 이를 형식 매개변수라고 하고 자료형 입력 시 null 허용 형태의 자료형으로 선언된다.

 

fun main(){
    val t1 = TestClass<String>()
    t1.testMethod("안녕하세요")
    
    val t2 = TestClass<Int>()
    t2.testMethod(123)
}
class TestClass<T>{
    fun testMethod(a1:T){
    	println("a1: $a1")
    }
}

 

 

main 메서드에서 TestClass의 객체를 생성하면서 제네릭에 사용할 타입을 지정해준다. t1은 문자열 변수를 매개변수로 갖는 메서드를 호출했고, t2는 정수형 변수를 매개변수로 갖는 메서드를 호출했다.

 

fun main(){
    val t1 = TestClass1<String>("Hello")
    t1.testMethod1("World")
    
    val t2 = TestClass1<Int>(100)
    t2.testMethod1(200)
    
    val t3 = TestClass2<Int, Double, Char, String>(10, 3.14)
    t3.testMethod2('A', "pple")
}
class TestClass1<T>(var a1:T){
    fun testMethod1(a2: T){
    	println("a1: $a1")
        println("a2: $a2")
    }
}
class TestClass2<A, B, C, D>(var a1:A, var a2:B){
    fun testMethod2(a3: C, a4: D){
    	println("a1: $a1")
        println("a2: $a2")
        println("a3: $a3")
        println("a4: $a4")
    }
}

 

TestClass1과 같이 한 형식 매개변수를 클래스 주 생성자와 메서드 매개변수에서 동일하게 사용할 수도 있고, TestClass2처럼 여러 형식 매개변수를 한 클래스에 지정해 클래스 내에서 각각 다르게 지정할 수도 있다.

 

 


 

 

가변성

: 형식 매개변수가 클래스 계층에 영향을 주는 것

 

제네릭의 가변성을 지정하지 않으면 형식 매개변수에 상/하 관계의 클래스가 지정되어도 자료형이 변환되지 않는다.

 

fun main(){
    val super1 : Super = Sub()	//가능
    val sub1 : Sub = Super()	//불가
    
    //자료형 불일치
    val super2 : TestClass<Super> = TestClass<Sub>()	
    val sub2 : TestClass<Sub> = TestClass<Super>()		
}
open class Super
class Sub : Super()
class TestClass<T>

 

super2와 sub2에서는 타입으로 설정한 클래스에 제네릭 가변성이 지정되지 않았으므로 자료형 불일치 오류가 발생한다.

 

 


 

 

불변성

: 제네릭의 객체를 생성할 때 같은 타입의 제네릭이 설정된 변수에만 담을 수 있는 성질

 

fun main(){
    val t1 : TestClass<SuperClass1> = TestClass<SuperClass1>()
    val t2 : TestClass<SubClass1> = TestClass<SubClass1>()
    
    val t3 : TestClass<SubClass1> = TestClass<SuperClass1>()
    val t4 : TestClass<SuperClass1> = TestClasss<SubClass1>()
}
open class SuperClass1
open class SubClass1 : SuperClass1()
class SubClass2 : SubClass1()

class TestClass<A>()

 

제네릭의 불변성은 제네릭 선언 시 별다른 키워드를 붙이지 않으면 설정된다. 이렇게 불변성이 적용된 제네릭 클래스는 객체를 생성했을 때, 해당 제네릭 타입의 변수에만 담을 수 있다.

따라서 t1, t2에는 정상적으로 설정한 제네릭 타입의 객체가 참조되고, t3, t4에는 오류가 발생한다.

 

 


 

 

공변성

: 제네릭의 객체를 생성할 때 사용한 제네릭과 같은 타입일 뿐만 아니라 사용한 제네릭의 부모 클래스인 경우에도 변수에 담을 수 있다.

 

fun main(){
    val t1:TestClass<SubClass1> = TestClass<SubClass1>()
    val t2:TestClass<SuperClass1> = TestClass<SubClass1>()
    val t3:TestClass<SubClass1> = TestClass<SubClass2>()
    
    val t4:TestClass<SubClass2> = TestClass<SubClass1>()
    val t5:TestClass<SubClass1> = TestClass<SuperClass1>()
}
open class SuperClass1
open class SubClass1 : SuperClass1()
class SubClass2 : SubClass1()

class TestClass<out A>()

 

제네릭에 공변성을 설정하기 위해서는 out 키워드를 이용한다.

 

부모 클래스를 형식 매개변수로 설정한 객체 참조 변수 t2, t3 에 자식 클래스를 형식 매개변수로 지정한 객체가 정상적으로 담기는 것을 확인할 수 있다. 반면, 자식 클래스를 형식 매개변수로 한 객체 참조변수에 부모 클래스를 형식 매개변수로 한 객체를 담으면 오류가 발생한다.

 

이 경우에는 반 공변성을 활용하면 된다.

 

 


 

 

반 공변성

: 제네릭의 객체를 생성할 때 사용한 제네릭과 같은 타입일 뿐만 아니라 사용한 제네릭의 자식 클래스인 경우에도 변수에 담을 수 있다.

 

fun main(){
    val t1:TestClass<SubClass2> = TestClass<SubClass1>()
    val t2:TestClass<SubClass1> = TestClass<SuperClass1>()
}
open class SuperClass1
open class SubClass1 : SuperClass1()
class SubClass2 : SubClass1()

class TestClass<in A>()

 

제네릭에 반 공변성을 설정하기 위해서는 in 키워드를 이용한다.

 

SubClass1을 형식 매개변수로 지정한 객체를 생성하여 SubClass1의 자식 클래스인 SubClass2를 형식 매개변수로 지정한 객체 참조 변수 t1에 담는다. 마찬가지로 부모 클래스인 SuperClass1을 형식 매개변수로 하는 객체를  자식 클래스인 SubClass1을 형식 매개변수로 하는 객체 참조 변수 t2에 담을 수 있다.

 

 

 


3. Null 처리

 

코틀린의 언어 특성 중 null safe가 있다. null 값이 들어있는 참조 변수를 통해 객체에 접근할 때 발생하는 NullPointerException 오류를 방지하기 위해 코틀린이 제공하는 방법을 사용할 수 있다.

 

 

- !! 연산자

!! 연산자는 null 허용 변수에 담긴 객체 ID 값을 null을 허용하지 않는 형태의 값으로 변환하는 연산자이다.

 

fun main(){
    testFun1("안녕하세요")
    testFun1(null)
}

fun testFun1(str1: String?){
    val str2 = str1!!
    println("str2 : $str2")
}

 

testFun1 메서드에서 매개 변수로 받은 null 허용 문자열을 !! 연산자를 사용해 null 허용하지 않는 형태의 변수에 담고 출력한다. 이 과정에서 전달된 매개변수 값이 null이라면 오류가 발생한다. 

 

!! 연산자를 사용하면 값의 타입을 null 허용하지 않는 타입으로 바꿔주지만 값 자체가 null일 경우 오류가 발생해 null safe를 보장받지는 못한다.

 

 

- ? 연산자

? 연산자는 null 허용 변수에 담긴 객체 ID 값이 null이면 지정한 기본값을 반환하고, 아니면 ID값을 반환하는 연산자이다.

 

fun main(){
    testFun2(null)
    testFun2("지정 문자열")
}
fun testFun2(str1: String?){
    val str2 : String = str1 ?:"기본 문자열"
    println("str2 : $str2")
}

 

testFun2 메서드에서는 매개변수로 받은 str1 값이 null 값이라면 지정한 "기본 문자열"을 출력하고, null 값이 아니라면 입력한 문자열을 출력한다.

 

null일 경우의 기본값을 지정해줘야 하지만 이 기본값을 지정해놓기 때문에 NullPointerException 오류가 발생하진 않는다.

 

 

- 확실한 null 처리 방법

 

if문을 통해 변수의 값이 null인 경우 코드가 동작하지 않도록 처리해주면 null safe를 보장받을 수 있다.

 

fun main(){
    testFun3(null)
    testFun3("안녕")
}
fun testFun3(str1 : String?){
    if(str1!=null){
        val str2 : String = str1
        println("str2: $str2")
    }
}

 

위 예제는 if 문을 통해 변수 값이  null인 경우 코드가 동작하지 않도록 작성되었다. 

이 방법을 사용하면 null 허용 변수인 str1을 null 허용하지 않는 변수처럼 자유롭게 사용이 가능하다.

 

 

fun main(){
	val t1 = TestClass1(100, "hello")
    
    testFunction1(t1)
    testFunction1(null)
    
    testFunction2(t1)
    testFunction2(null)
    
    testFunction3(t1)
    testFunction3(null)
}
class TestClass1(var v1: Int, var v2: String){
    fun testFun1(){
        println("TestClass1의 testFun1")
    }
}
fun testFunction1(t: TestClass1?){
    println("t.v1: ${t!!.v1}")
    println("t.v2: ${t!!.v2}")
    t1!!.testFun1()
}
fun testFunction2(t: TestClass1?){
    println("t.v1: ${t?.v1}")
    println("t.v2: ${t?.v2}")
    t1?.testFun1()
}
fun testFunction3(t: TestClass1?){
    if(t != null){
        println("t.v1: ${t.v1}")
    	println("t.v2: ${t.v2}")
    	t.testFun1()
    }
}

위 예제에서는 멤버 변수와 메서드를 가지는 TestClass1을 생성하고, null을 허용하는 TestClass1형 객체를 매개변수로 받는 testFunction1, testFunction2, testFunction3을 생성했다. testFunction1에는 객체에 !! 연산자를, testFunction2에는 ? 연산자를, testFunction3은 if 문을 통한 null 처리를 적용했다.

 

모든 메서드에 정상적인 객체 값을 전달하면 정상적으로 출력이 이루어진다. 하지만 null 값을 전달하면 testFunction1에서는 오류가 발생한다. testFunction2에서 ? 연산자는 객체 참조 변수가 null 값을 가졌을 때, 멤버 변수는 null 값을 반환하게 하고 멤버 메서드는 호출되지 않게 한다. testFunction3 에서는 객체 참조 변수가 null 값일 경우 수행할 코드가 없으므로 아무런 수행이 되지 않는다.

 

null 허용 프로퍼티를 사용할 때는 항상 NullPointerException이 발생하지 않게 잘 처리해줘야 한다. 오류가 발생했을 때의 처리 방법은 이후에 다룰 예외처리 부분을 참고하면 좋을 것 같다.

728x90