[Java와 비교하는 Kotlin] 기본 개념 #3
1. 자원 관리
Java에서 자원 관리에는 Java 7부터 도입된 기능으로 try-with-resources 문을 사용한다. AutoCloseable 또는 Closeable 인터페이스를 구현한 객체에 대해 자원 해제를 자동으로 처리할 수 있도록 한다.
Kotlin에서는 use 확장 함수를 제공하여 자원을 자동으로 닫는 기능을 간결하게 구현할 수 있다. 이 방식은 Java의 try-with-resources 구문과 유사하지만, Kotlin의 확장 함수를 활용해 더 간결하고 직관적으로 작성할 수 있다.
- 간결성: Kotlin의 use 함수는 확장 함수로 제공되어, 자원을 자동으로 닫는 코드가 매우 간결하고 직관적이다. 자원 해제 처리를 명시적으로 작성할 필요 없이 use 블록 안에서만 자원을 사용할 수 있다.
- 안전성: use 함수는 내부적으로 try-finally 블록을 사용하여 예외가 발생하더라도 자원이 자동으로 닫히도록 보장한다. 이는 자원 해제를 잊을 수 있는 실수를 줄여준다.
- 명시적 자원 해제 처리 불필요: Java에서는 try-with-resources 구문을 사용해야 하며, 자원 해제를 직접 관리해야 한다. 반면 Kotlin에서는 use 블록 내에서 자원을 사용하고, 블록을 벗어나면 자동으로 자원이 해제된다.
import java.io.*;
public class ResourceManagementExample {
public static void main(String[] args) {
// try-with-resources 구문
try (BufferedReader reader = new BufferedReader(new FileReader("test.txt"))) {
String line = reader.readLine();
System.out.println(line);
} catch (IOException e) {
e.printStackTrace();
}
// BufferedReader는 try 블록을 벗어나면 자동으로 close() 메서드가 호출됨
}
}
import java.io.*
fun main() {
// use 확장 함수를 사용하여 자원을 자동으로 닫음
try {
FileReader("test.txt").buffered().use { reader ->
val line = reader.readLine()
println(line)
}
} catch (e: IOException) {
e.printStackTrace()
}
// FileReader와 BufferedReader는 use 블록을 벗어나면 자동으로 close() 메서드가 호출됨
}
use 확장 함수의 작동 방식
inline fun <T : Closeable?, R> T.use(block: (T) -> R): R {
try {
return block(this) // 자원 사용
} finally {
this?.close() // 자원 닫기
}
}
- use 함수는 Closeable 타입의 객체에 대해 사용되며, 객체가 블록을 벗어날 때 close()가 자동으로 호출된다.
- 자원을 사용한 후 반드시 close()가 호출되므로, 예외가 발생하더라도 자원은 자동으로 해제된다.
2. 스마트 캐스트(Smart Cast)
스마트 캐스트는 Kotlin에서 특정 조건을 만족하는 객체를 자동으로 지정된 타입으로 변환해주는 기능이다. 주로 is 연산자와 결합되어 사용되며, 객체가 특정 타입인지 검사한 후, 그 타입으로 자동 변환된다. 이 방식은 명시적인 타입 변환을 하지 않아도 변환을 처리해주기 때문에 코드가 간결하고 안전하다.
- is 연산자를 사용하여 객체가 특정 타입인지 확인하면, 그 이후에는 해당 타입으로 스마트 캐스트가 자동으로 적용된다.
- null 체크와 결합하면 더욱 안전하게 작동한다.
- 명시적인 캐스트 필요 없음: 타입 변환을 명시적으로 작성할 필요 없이, 타입을 안전하게 변환할 수 있다.
public class Main {
public static void main(String[] args) {
Object input = "Hello, World!";
if (input instanceof String) {
// input을 String으로 명시적으로 캐스트
String str = (String) input;
System.out.println(str.length()); // String의 length 메서드를 사용할 수 있음
} else {
System.out.println("Not a String");
}
}
}
fun handleInput(input: Any) {
if (input is String) {
// input은 String으로 스마트 캐스트됨
println(input.length) // String의 메서드인 length를 안전하게 호출할 수 있음
} else {
println("Not a String")
}
}
스마트 캐스트가 유용한 경우
when 구문을 사용하거나 여러 타입을 처리할 때 스마트 캐스트를 활용하면 코드가 더 직관적이고 오류를 방지할 수 있다.
아래 예시를 보면 when 구문을 사용하여 타입에 따라 다르게 처리할 수 있으며, 각 타입에 대해 명시적으로 캐스트하지 않아도 된다. Kotlin은 is 연산자 후 자동으로 타입을 캐스트한다.
fun printLength(input: Any) {
when (input) {
is String -> println(input.length) // String으로 스마트 캐스트됨
is Int -> println(input.toString()) // Int로 스마트 캐스트됨
else -> println("Unknown type")
}
}
3. 동등성(Equality)과 동일성( Identity)
동등성은 두 객체가 "동일한 값"을 가지는지를 비교하는 개념이다. 즉, 객체가 동일한 데이터를 가지고 있는지를 판단하는 것이다. 두 객체가 서로 다른 메모리 위치에 있을 수 있지만, 그 값이 같다면 동등하다고 간주된다.
Kotlin에서는 동등성 비교를 == 연산자를 사용하여 처리한다. Kotlin에서는 == 연산자가 실제로 equals() 메서드를 호출하도록 구현되어 있다.
import java.util.Objects;
class Person {
String name;
int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Person person = (Person) obj;
return age == person.age && Objects.equals(name, person.name);
}
}
public class Main {
public static void main(String[] args) {
Person person1 = new Person("Alice", 30);
Person person2 = new Person("Alice", 30);
System.out.println(person1.equals(person2)); // true
}
}
data class Person(val name: String, val age: Int)
fun main() {
val person1 = Person("Alice", 30)
val person2 = Person("Alice", 30)
println(person1 == person2) // true
}
동일성은 두 객체가 "같은 객체"인지 비교하는 개념이다. 즉, 두 객체가 메모리 상에서 동일한 객체인지, 즉 같은 참조를 가리키고 있는지 판단한다. 동일성 비교는 객체가 같은 메모리 위치를 참조하는지 확인하는 작업이다.
Kotlin에서는 === 연산자를 사용하여 동일성을 비교한다. === 연산자는 두 객체가 같은 참조를 가지고 있는지를 확인한다.
public class Main {
public static void main(String[] args) {
Person person1 = new Person("Alice", 30);
Person person2 = person1;
System.out.println(person1 == person2); // true, person1과 person2는 같은 객체를 참조
}
}
fun main() {
val person1 = Person("Alice", 30)
val person2 = person1
println(person1 === person2) // true, person1과 person2는 같은 객체를 참조
}
4. 문자열 인터폴레이션(String Interpolation)
문자열 인터폴레이션은 문자열 내에서 변수를 쉽게 사용할 수 있는 방법이다. Kotlin은 "$변수명" 또는 ${표현식}을 사용하여 문자열 안에 변수를 넣을 수 있도록 지원한다. 이 기능은 문자열을 작성할 때 변수나 표현식을 직접 삽입할 수 있어 코드가 간결하고 가독성이 높다.
public class Main {
public static void main(String[] args) {
String name = "Alice";
int age = 30;
String greeting = "Hello, my name is " + name + " and I am " + age + " years old.";
System.out.println(greeting);
}
}
fun main() {
val name = "Alice"
val age = 30
val greeting = "Hello, my name is $name and I am $age years old."
println(greeting)
}
// ...
fun main() {
val price = 100
val discount = 0.1
println("Final price is: ${price * (1 - discount)}")
}
5. 여러 줄 문자열(Multi-line Strings)
Kotlin에서는 """를 사용하여 여러 줄에 걸쳐 문자열을 작성할 수 있으며, 줄바꿈도 자동으로 처리된다. 이 방식은 코드 내에서 문자열을 여러 줄에 걸쳐 작성해야 할 때 매우 유용하다.
참고로 Java 13 이상에서부터 동일하게 텍스트 블록(""")을 사용하여 여러 줄 문자열을 좀 더 직관적으로 처리할 수 있다.
public class Main {
public static void main(String[] args) {
String text = "This is a multi-line string.\n" +
"It spans multiple lines.\n" +
"Java requires manual line breaks.";
System.out.println(text);
}
}
fun main() {
val text = """
This is a multi-line string.
It spans multiple lines.
Kotlin makes it easy to handle.
"""
println(text)
}
1. 변성: 파라미터화한 타입과 하위 타입
변성은 파라미터화된 타입이 하위 타입(subtype)과 어떻게 관계를 맺을 것인지 결정하는 방식이다. 예를 들어, List<Cat>와 List<Animal>이 있을 때, Cat이 Animal을 상속하는 관계일 경우, List<Cat>이 List<Animal>의 하위 타입이 되어야 하는 상황에서 변성이 중요해진다.
제네릭 타입을 사용할 때, 특정 타입 간의 관계가 직관적이지 않거나 불안정할 수 있다. 예를 들어, List<Cat>은 List<Animal>로 대체될 수 있는지, 혹은 List<Animal>이 List<Cat>로 대체될 수 있는지에 대한 문제는 변성에 대한 이해 없이는 잘못된 타입 캐스팅이나 런타임 오류를 일으킬 수 있다.
공변성은 제네릭 타입의 하위 타입 관계가 유지되도록 보장하는 특성이다. 즉, List<Cat>은 List<Animal>의 하위 타입이 되어야 할 경우 공변성을 사용한다. 공변성은 주로 출력이 중요한 상황에서 유용하다.
Kotlin에서는 공변성을 out 키워드를 사용하여 표시한다. out은 "제네릭 타입의 상위 타입에 대해서만 읽을 수 있다."라는 제한을 두어 타입의 안정성을 보장한다. out은 출력만 허용하고, 입력을 허용하지 않는다.
import java.util.List;
class Animal {}
class Cat extends Animal {}
public class Main {
public static void main(String[] args) {
List<Cat> cats = List.of(new Cat(), new Cat());
printFirstAnimal(cats); // List<Cat>를 List<? extends Animal>로 사용할 수 있음
}
public static void printFirstAnimal(List<? extends Animal> animals) {
System.out.println(animals.get(0));
}
}
- List<? extends Animal>은 List<Cat>뿐만 아니라 List<Dog> 같은 다른 하위 타입도 받을 수 있다.
- 공변성은 출력만 허용하는 제네릭 타입으로, List<Cat>에서 값을 읽을 수 있지만, 값을 추가할 수는 없다.
open class Animal
class Cat : Animal()
fun printFirstAnimal(animals: List<out Animal>) {
println(animals.first())
}
fun main() {
val cats: List<Cat> = listOf(Cat(), Cat())
printFirstAnimal(cats) // List<Cat>를 List<out Animal>으로 사용 가능
}
- List<out Animal>은 List<Cat>와 같은 하위 타입도 받을 수 있다.
- out을 사용하면 List<Cat>을 List<Animal>처럼 사용할 수 있다. 이는 출력을 위한 제네릭이다. out을 사용하면 List<Cat>에서 값을 읽을 수는 있지만, 값을 넣을 수는 없다.
반공변성은 공변성과 반대되는 개념으로, 제네릭 타입에서 입력이 중요한 경우에 사용된다. 예를 들어, List<Animal>이 List<Cat>의 상위 타입이 되어야 하는 경우, List<Animal>에 Cat을 입력하려면 반공변성을 사용해야 한다.
Kotlin에서는 반공변성을 in 키워드를 사용하여 표현한다. in은 입력만 허용하고, 출력은 허용하지 않는다.
import java.util.List;
class Animal {}
class Cat extends Animal {}
public class Main {
public static void main(String[] args) {
List<Animal> animals = List.of(new Animal(), new Animal());
addCat(animals); // List<Animal>을 List<? super Cat>로 사용 가능
}
public static void addCat(List<? super Cat> animals) {
animals.add(new Cat()); // List<? super Cat>에 Cat 추가 가능
}
}
- List<? super Cat>은 List<Animal>뿐만 아니라 List<Object>와 같은 더 일반적인 상위 타입도 허용한다.
- 반공변성은 입력만 허용하는 제네릭 타입으로, Cat 객체를 입력할 수 있다.
open class Animal
class Cat : Animal()
fun addCat(animals: MutableList<in Cat>) {
animals.add(Cat()) // MutableList<in Cat>에 Cat 추가 가능
}
fun main() {
val animals: MutableList<Animal> = mutableListOf()
addCat(animals) // MutableList<in Cat>는 MutableList<Animal>의 하위 타입
}
- MutableList<in Cat>은 MutableList<Animal>의 상위 타입이 된다.
- 입력만 허용하는 제네릭이므로 addCat 함수는 Cat 객체를 입력할 수 있다.\
Kotlin과 Java 모두 변성을 사용지점 변성(Use-site Variance)과 선언지점 변성(Declaration-site Variance) 두 가지 방식으로 다룰 수 있다.
선언지점 변성은 제네릭 타입을 정의할 때 변성을 지정하는 방식이다. Kotlin에서 out이나 in 키워드를 사용하는 방식이 이에 해당한다.
// 선언지점 변성 (Declaration-site Variance)
fun printFirstAnimal(animals: List<out Animal>) {
println(animals.first())
}
- List<out Animal>에서 변성은 List를 선언할 때 지정된다.
사용지점 변성은 제네릭 타입을 사용하는 사용 지점에서 변성을 지정하는 방식이다. Kotlin에서는 List<Animal>을 사용할 때 변성을 지정하는 방식을 사용지점 변성이라고 한다.
// 사용지점 변성 (Use-site Variance)
fun <T> printFirst(animals: List<T>) {
if (animals is List<*>) {
println(animals.first())
}
}
- List<T>의 변성은 사용할 때 지정된다.