코딩마을방범대
코틀린 기초 (3) - 코틀린에서의 FP 본문
코틀린에서 배열과 컬렉션을 다루는 방법
1. 배열
- JAVA :
int[] array = {100, 200};
for (int i=0; i<array.length; i++){
System.out.println("%s %s", i, array[i]);
}
- 코틀린:
val array = arrayOf(100, 200)
// 인덱스만 받기
for (i in array.indices){
println("${i} ${array[i]}")
}
// 인덱스와 value 같이 받기
for((idx, value) in array.withIndex()){
println("$idx $value")
}
// 배열에 값 추가
array.plus(300)
2. 컬렉션
① 리스트
* 가변(Mutable) 컬렉션: 컬렉션에 요소를 추가하거나 삭제할 수 있다.
* 불변 컬렉션: 컬렉션이 생성된 후에는 요소를 추가하거나 삭제할 수 없다.
- Reference Type인 Element의 필드는 바꿀 수 있다.
// 가상의 Product 클래스 data class Product(var name: String, var price: Int) // 불변 리스트 생성 val products = listOf(Product("Apple", 100), Product("Banana", 200)) // 불변 리스트 자체는 변경 불가 // products.add(Product("Orange", 300)) // 컴파일 오류 // 하지만 리스트 내부 객체의 필드는 변경 가능 products[0].price = 150 println(products[0].price) // 150 출력
- JAVA:
// 가변
List<Integer> numbers = Arrays.asList(100,200);
// 불변
List<Integer> immutableNumbers1 = Collections.unmodifiableList(numbers);
List<Integer> immutableNumbers2 = List.of(100,200);
// 하나를 가져오기
numbers.get(0);
// for Each
for(int number : numbers){
...
}
// 전통적인 for 문
for (int i=0; i<numbers.size(); i++){
...
}
- 코틀린:
// 가변
val numbers = mutableListOf(100, 200)
numbers.add(300) // 자바와 사용 방법 동일
// 불변
val numbers = listOf(100, 200)
// 하나 가져오기
numbers.get(0)
// 대괄호를 통해서도 가능
numbers[0]
// for Each
for(number in numbers){
...
}
// 전통적인 for 문
for ((idx, value) in numbers.withIndex()){
...
}
// 빈 리스트 선언: 타입 추론이 불가하기 때문에 타입을 직접 명시해줘야함.
val emptyList = emptyList<Int>()
// 타입 추론 가능하다면 생략 가능
fun main(){
useNumbers(emptyList())
}
private fun useNumbers(numbers: List<Int>){}
② Set
// 가변
val numbers = mutableSetOf(100, 200)
// 불변
val numbers = setOf(100, 200)
// for each
for (number in numbers){
...
}
// 전통적인 for 문
for ((index, number) in numbers.withIndex()){
...
}
③ Map
- JAVA:
// JDK 8까지
Map<Integer, String> map = new HashMap<>();
map.put(1, "MONDAY");
map.put(2, "TUESDAY");
// JDK 9부터
Map.of(1, "MONDAY", 2, "TUESDAY");
// key 가져오기
for (int key : map.keySet()){
map.get(key);
}
// key와 value 함께 가져오기
for (Map.Entry<Integer, String> entry : map.entrySet()){
entry.getKey();
entry.getValue();
}
- 코틀린:
val map = mutableMapOf<Int, String>()
map.put(1, "MONDAY")
// 아래도 동일한 명령어임
map[1] = "MONDAY"
mapOf(1 to "MONDAY", 2 to "TUESDAY")
// key 가져오기
for (key in map.keys){
map.get(key)
map[key]
}
// key와 value 함께 가져오기
for ((key, value) in map.entries){
key
value
}
3. 컬렉션의 null 가능성, JAVA와 함께 사용하기
- List<Int?>: 리스트에 null이 들어갈 수 있지만, 리스트는 절대 null이 아님
- List<Int>?: 리스트에 null이 들어갈 수 없지만, 리스트는 null일 수 있음
- List<Int?>?: 리스트에 null이 들어갈 수 있고, 리스트는 절대 null일 수 있음
💡 자바는 읽기 전용 컬렉션과 변경 가능 컬렉션을 구분하지 않는다.
코틀린 쪽의 컬렉션이 자바에서 호출되면 컬렉션 내용이 변할 수 있음을 감안해야 한다.
코틀린 쪽에서 방어 로직을 짠다거나, Collections.unmodifableXXX() 를 활용하면 변경 자체를 막을 수는 있다.
- 코틀린에서 만든 불변 리스트를 자바가 가져다 쓸 때 요소를 추가하게 되면 코틀린에서는 오동작을 일으킬 수 있음
- 코틀린에 null 이 들어갈 수 없는 리스트에 자바가 쓸 때 null 을 추가할 경우, 코틀린은 오동작을 일으킬 수 있음
- 코틀린에서 자바 컬렉션을 가져다 사용할 때 플랫폼 타입을 신경써야 한다.
- ex. List<Int?> 인지 List<Int>? 인지 List<Int?>? 인지
코틀린에서 다양한 함수를 다루는 방법
1. 확장함수
기존에 있는 클래스의 코드를 직접 수정하지 않고도 새로운 메서드를 추가하는 기능이다.
자바나 다른 언어로 만들어진 라이브러리를 사용할 때, 그 클래스에 기능을 추가하고 싶지만 원본 코드를 수정할 수 없을 때 유용하다.
확장 함수의 원리
- 클래스 외부에서 정의: 클래스 외부에서 함수 코드 작성
- .(점)을 사용하여 호출: 마치 클래스 내부에 있는 멤버 함수처럼 .(점)을 사용하여 호출
- this를 통한 접근: 함수 내부에서는 this 키워드를 통해 클래스의 인스턴스에 접근할 수 있음
// String 클래스 확장
fun String.lastChar(): Char {
// 함수 안에서는 this를 통해 인스턴스에 접근 가능함
return this[this.length - 1]
}
// 호출
"Hello".lastChar()
// 예시
// this=수신객체, 확장하려는 클래스=수신객체 타입
fun 확장하려는클래스.함수이름(파라미터): 리턴타입{
// this를 이용해 실제 클래스 안의 값에 접근
}
의문점
1. 확장함수가 public이고, 확장함수에서 수신객체클래스의 private 함수를 가져오면 캡슐화가 깨지는거 아닌가?
- 확장함수는 클래스에 있는 private, protected 멤버를 가져올 수 없다
2. 멤버함수와 확장함수의 시그니처가 같다면?
- 멤버함수가 우선적으로 호출됨
// String 클래스에 이미 존재하는 멤버 함수
// fun String.plus(other: Any?): String
// 우리가 정의한 확장 함수
fun String.plus(other: Any?): String {
return this + " " + other
}
fun main() {
val myString = "Hello"
val result = myString.plus("Kotlin") // 멤버 함수와 확장 함수의 시그니처가 같음
println(result) // 결과: HelloKotlin
}
3. 확장함수가 override 될 경우?
- 현재 타입을 기준으로 호출됨
open class Train(
val name: String = "새마을기차",
val price: Int = 5_000
)
fun Train.isExpensive(): Boolean {
println("Train의 확장함수")
return this.price >= 10000
}
class Srt : Train("SRT", 40_000)
fun Str.isExpensive(): Boolean {
println("Srt의 확장함수")
return this.price >= 10000
}
// 호출
val train: Train = Train()
train.isExpensive() // Train의 확장함수 호출
val str1: Train = Srt()
str1.isExpensive() // Train의 확장함수 호출
val str2: Srt = Srt()
srt2.isExpensive() // Srt의 확장함수 호출
4. 자바에서 코틀린 확장함수를 가져다 사용할 수 있나?
- 정적 메소드를 부르는 것처럼 사용 가능
// StringUtils.kt 파일
fun String.lastChar(): Char {
return this[this.length - 1]
}
// 자바 코드
StringUtilsKt.lastChar("ABC") //로 사용 가능
5. 확장 프로퍼티 원리는 확장함수 + custom getter와 동일하다
코틀린 컴파일러가 확장 프로퍼티를 내부적으로 확장 함수처럼 처리한다는 뜻이다.
즉, 확장 프로퍼티는 실제 필드를 추가하는 것이 아니라, 기존 클래스에 새로운 '가상의 프로퍼티'를 만들어주는 문법적인 편리함이라고 볼 수 있다.
여기서 중요한 점은 val String.lastChar는 실제 필드를 추가하는 것이 아니라, get() 블록에 정의된 로직을 실행하는 함수처럼 작동한다는 것이다.
- 확장 함수: str.lastChar()를 호출하면 lastChar() 함수가 실행됩니다.
- 확장 프로퍼티: str.lastChar를 호출하면 컴파일러가 자동으로 get() 블록의 코드를 실행하여 값을 반환합니다.
// 확장 함수
fun String.lastChar(): Char {
return this[this.length - 1]
}
// 확장 프로퍼티와 커스텀 게터
val String.lastChar: Char
get() = this[this.length - 1]
2. infix 함수 (중위함수)
- 함수를 호출하는 새로운 방법
- downTo, step 도 중위 호출 함수이다.
- '변수.함수이름(argument)' 대신 '변수 함수이름 argument'로 사용
// ex. 해당 객체 이름이 test일 경우
// 확장 함수
fun Int.add(other: Int): Int {
return this + other
}
// 중위함수
infix fun Int.add2(other: Int): Int{
return this + other
}
// 확장 함수 호출
test.add(4)
// 중위 함수 호출
test.add2(4)
test add2 4
3. inline 함수
- 함수가 호출되는 대신, 함수를 호출한 지점에 함수 본문을 그대로 복붙하고 싶은 경우
- 인라인 함수는 컴파일 시점에 호출된 곳에 함수 본문이 직접 삽입되어 오버헤드를 줄이는 특징을 가지고 있다.
fun main() {
3.add(4)
}
inline fun Int.add(other: Int): Int {
return this + other
}
// 바이트 코드로 변경 시
public static final void main(){
byte $this$add$iv = 3;
int other$iv = 4;
int $i$f$add = false;
int var10000 = $this$add$iv + other$iv;
}
4. 지역함수
- 함수 안에 함수를 선언할 수 있다. (이 함수를 지금 함수 내에서만 사용하고 싶을 때)
- depth가 깊어지기도 하고, 코드가 그렇게 깔끔하지는 않다.
"depth(깊이)가 깊어진다"는 것은 복잡한 중첩 구조를 의미하며, 이는 코드의 가독성을 떨어뜨리는 주요 원인 중 하나이다.
코틀린에서 다양한 람다를 다루는 방법
* 람다: 이름이 없는 함수
람다(Lambda)와 익명 함수(Anonymous Function)의 차이
코틀린은 람다를 우선적으로 사용하여 코드를 간결하게 작성하도록 권장한다.
특징 람다 익명 함수 문법 a, b -> a + b fun(a, b): Int { return a + b } return사용 return을 사용하면 함수를 감싼 외부 함수에서 반환합니다. return을 사용하면 익명 함수 자체에서 반환합니다. 호출 방식 { }안에 코드를 작성하고 매개변수와 반환 타입을 추론합니다. fun키워드를 사용하고 매개변수와 반환 타입을 명시합니다.
하지만 람다의 return 규칙이 혼란스러울 수 있는 경우에는 익명 함수를 사용하여 명확성을 높이는 것이 좋다.
1. JAVA에서 람다를 다루기 위한 노력
1. 익명 함수를 이용한 방법
// 인터페이스
public interface FruitFilter {
boolean isSelected(Fruit fruit);
}
// 검증 메소드
private List<Fruit> filterFruits(List<Fruit> fruits, FruitFilter fruitFilter){
List<Fruit> results = new ArrayList<>();
for (Fruit fruit : fruits) {
if (fruitFilter.isSelected(fruit)){
results.add(fruit);
}
}
return results;
}
// 인터페이스를 구현하는 익명함수
filterFruits(fruits, new FruitFilter(){
@Override
public boolean isSelected(Fruit fruit){
return Arrays.asList("사과", "바나나").contains(fruit.getName()) && fruit.getPrice() > 5_000;
}
}
2. Predicate 를 이용한 방법
// 검증 메소드
private List<Fruit> filterFruits(List<Fruit> fruits, Predicate<Fruit> fruitFilter){
List<Fruit> results = new ArrayList<>();
for (Fruit fruit : fruits) {
if (fruitFilter.test(fruit)){
results.add(fruit);
}
}
return results;
}
// 함수 호출
filterFruits(fruits, fruit -> fruit.getName().equals("사과"));
// Predicate와 Collectors(스트림) 를 이용한 방법
// 검증 메소드
private static List<Fruit> filterFruits(List<Fruit> fruits, Predicate<Fruit> fruitFilter){
return fruits.stream()
.filter(fruitFilter)
.collect(Collectors.toList());
}
// 함수 호출(메소드 레퍼런스 활용)
// 메소드 자체를 직접 넘겨주는 것처럼 사용할 수 있다.
filterFruits(fruits, Fruit::isApple);
자바의 함수는 변수에 할당되거나 파라미터로 전달할 수 없다. (자바에선 함수를 2급 시민으로 간주)
1급 시민: 객체와 동등하게 다뤄짐
2급 시민: 객체와 다르게 다뤄짐
2. 코틀린에서의 람다
- 코틀린에서는 함수가 그 자체로 값이 될 수 있다.(변수, 파라미터에 할당 가능 = 1급 시민이기 때문)
var fruits = listOf(
Fruit("사과", 1_000),
Fruit("바나나", 2_000)
)
// 람다 함수로 선언 (함수명만 빠짐)
// isApple 은 Fruit를 파라미터 타입으로 받아 Boolean을 반환하는 함수
// 함수의 타입 표기 방법 = (파라미터 타입...) -> 반환 타입
// val isApple: (Fruit) -> Boolean
val isApple = fun(fruit: Fruit): Boolean{
return fruit.name == "사과"
}
// 람다 함수 화살표로 선언 (중괄호와 화살표 이용)
val isApple2 = { fruit: Fruit -> fruit.name == "사과" }
// 함수 호출
isApple(fruits[0])
isApple.invoke(fruits[0])
* invoke의 핵심 요약
함수처럼 호출 가능: invoke 덕분에 sum(3, 5)처럼 괄호만 사용해서 함수 객체를 실행할 수 있다. / 함수 객체를 일반 함수처럼 호출할 수 있게 해주는 특별한 연산자
생략 가능: 코틀린 컴파일러가 invoke 호출을 자동으로 처리해주기 때문에, 개발자가 직접 sum.invoke()를 쓸 필요는 거의 없다.
연산자 오버로딩: invoke는 연산자 오버로딩의 한 종류이다. 덕분에 클래스의 인스턴스를 함수처럼 호출하는 것도 가능하다.
자바의 filterFruit 따라하기
var fruits = listOf(
Fruit("사과", 1_000),
Fruit("바나나", 2_000)
)
// 검증 함수
private fun filterFruits(fruits: List<Fruit>, filter: (Fruit) -> Boolean): List<Fruit> {
val results = mutableListOf<Fruit>()
for (fruit in fruits){
if (filter(fruit)){
results.add(fruit)
}
}
return results
}
// 필터 함수
val isApple = fun(fruit: Fruit): Boolean{
return fruit.name == "사과"
}
// 함수 호출
filterFruits(fruits, isApple)
filterFruits(fruits, { fruit: Fruit -> fruit.name == "사과" })
// 화살표 함수를 넘길 때 함수가 파라미터의 마지막 위치에 있다면 소괄호 밖으로 빼기 가능
filterFruits(fruits) { fruit: Fruit -> fruit.name == "사과" }
// 타입 추론 가능하므로 Fruit 키워드도 생략 가능
filterFruits(fruits) { fruit -> fruit.name == "사과" }
// 람다를 여러줄 작성 가능하며, return을 붙이지 않아도 마지막 줄의 결과가 람다의 반환값이다.
filterFruits(fruits) { fruit ->
println("사과만 받는다..!")
fruit.name == "사과"
}
// 익명 함수 사용 시 파라미터가 한 개라면, 파라미터명과 화살표 생략 후 it 으로 명시 가능
filterFruits(fruits) { it.name == "사과" }
3. Closure
- JAVA: 람다를 쓸 때 사용할 수 있는 변수 제약이 있다: final 인 변수 혹은 실질적으로 final인 변수만 사용 가능
- 'Variable used in lambda expression should be final or effectively final' 오류 발생
// 바나나를 수박으로 바꿨기 때문에 불가능
String targetFruitName = "바나나";
targetFruitName = "수박";
filterFruits(fruits, (fruit) -> targetFruitName.equals(fruit.getName()));
- 코틀린: 오류가 발생하지 않음
- 코틀린에서는 람다가 시작하는 지점에 참조하고 있는 변수들을 모두 포획하여 그 정보를 가지고 있다.
이렇게 해야만 람다를 진정한 일급 시민으로 간주할 수 있다. 이 데이터 구조를 Closure 라고 부른다.
- 코틀린에서는 람다가 시작하는 지점에 참조하고 있는 변수들을 모두 포획하여 그 정보를 가지고 있다.
var targetFruitName = "바나나"
targetFruitName = "수박"
filterFruits(fruits) { it.name == targetFruitName }
4. try with resources
fun readFile(path: String) {
BufferedReader(FileReader(path)).use { reader ->
println(reader.readLine())
}
}
use 함수 파헤치기
public inline fun <T : Closeable?, R> T.use(block: (T) -> R): R {
- Closeable 구현체에 대한 확장 함수이다.
- use는 java.io.Closeable 인터페이스를 구현한 객체(예: BufferedReader, InputStream)에 대해서만 사용할 수 있다.
use 함수의 시그니처를 보면 <T : Closeable?, R> T.use(...)로 되어 있는데, 이는 T 타입이 Closeable의 하위 타입이어야 함을 의미한다.
- use는 java.io.Closeable 인터페이스를 구현한 객체(예: BufferedReader, InputStream)에 대해서만 사용할 수 있다.
- inline 함수이다.
- use 함수가 호출될 때, 함수의 본문(body)이 호출 위치에 직접 복사된다.
이는 함수 호출에 대한 오버헤드를 줄여 성능을 향상시킨다.
- use 함수가 호출될 때, 함수의 본문(body)이 호출 위치에 직접 복사된다.
- 람다를 받게 만들어진 함수이다. (받고 있는 파라미터가 block 이라는 이름을 가진 함수)
- use 함수는 block: (T) -> R이라는 매개변수를 받는다.
이는 T 타입의 객체를 인수로 받고 R을 반환하는 람다 함수를 받는다는 의미이다.
개발자는 이 람다 안에 리소스(reader)를 사용하는 코드를 작성할 수 있다.
use 함수는 이 람다를 실행한 후, 람다의 실행이 끝나면 리소스를 자동으로 닫아준다.
- use 함수는 block: (T) -> R이라는 매개변수를 받는다.
use함수의 동작 원리
BufferedReader와 같은 리소스는 사용 후 반드시 닫아주어야 메모리 누수나 파일 손상을 막을 수 있다.
자바에서는 이를 위해 try-with-resources 문법을 사용한다.
코틀린의 use 함수는 이 과정을 훨씬 더 간결하게 만들어 준다.
코틀린에서 컬렉션을 함수형으로 다루는 방법
* fruits 변수는 리스트라는 가정 하에 테스트 코드 작성
1. 필터와 맵
ex1. 사과만 주세요
val apples = fruits.filter { fruit -> fruit.name == "사과" }
ex2. 필터에 인덱스가 필요한 경우
val apples = fruits.filterIndexed { idx, fruit -> fruit.name == "사과" }
ex3. 사과의 가격들을 알려주세요
val apples = fruits.filter { fruit -> fruit.name == "사과" }
.map { fruit - > fruit.currentPrice }
ex4. 맵에서 인덱스가 필요하다면?
val apples = fruits.filter { fruit -> fruit.name == "사과" }
.mapIndexed { idx, fruit ->
fruit.currentPrice
}
ex5. Mapping의 결과가 null이 아닌 것만 가져오고 싶다면?
val apples = fruits.filter { fruit -> fruit.name == "사과" }
.mapNotNull { fruit -> fruit.nullOrValue() }
2. 다양한 컬렉션 처리 기능
- all : 조건을 모두 만족하면 true, 아니면 false
val isAllApple = fruits.all { fruit -> fruit.name == "사과" }
- none : 조건을 모두 불만족하면 true, 아니면 false
val isNoApple = fruits.none { fruit -> fruit.name == "사과" }
- any : 조건을 하나라도 만족하면 true, 아니면 false
val isNoApple = fruits.any { fruit -> fruit.factoryPrice >= 10_000 }
- count : 개수를 센다
val fruitCount = fruits.count()
- sortedBy : (오름차순) 정렬을 한다
val fruitCount = fruits.sortedBy { fruit -> fruit.currentPrice }
- sortedByDescending : (오름차순) 정렬을 한다
val fruitCount = fruits.sortedByDescending { fruit -> fruit.currentPrice }
- distinctBy : 변형된 값을 기준으로 중복을 제거한다.
// name 중복값을 제거한 후, map 을 사용해 이름만 남김
val distinctFruitNames = fruits.distinctBy { fruit -> fruit.name }
.map { fruit -> fruit.name }
- first : 첫번째 값을 가져온다 (무조건 Null이 아니어야함)
fruits.first()
- firstOrNull : 첫번째 값 또는 null 을 가져온다
fruits.firstOrNull()
- last : 마지막 값을 가져온다 (무조건 Null이 아니어야함)
fruits.last()
- lastOrNull : 마지막 값 또는 null 을 가져온다
fruits.lastOrNull()
3. List를 Map으로
- groupBy : 주어진 값을 키로 설정하여 value가 리스트인 Map으로 생성됨
val map: Map<String, List<Fruit>> = fruits.groupBy { fruit -> fruit.name }
* value를 지정하고 싶은 경우
val map: Map<String, List<Long>> = fruits .groupBy({ fruit -> fruit.name }, { fruit -> fruit.factoryPrice })
- associateBy : 주어진 값을 키로 설정하여 value가 단일객체인 Map이 생성됨
중복되지 않는 키를 가지고 Map을 만들 때 사용
val map: Map<Long, Fruit> = fruits.associateBy { fruit -> fruit.id }
* value를 지정하고 싶은 경우
val map: Map<Long, Long> = fruits .associateBy({ fruit -> fruit.id }, { fruit -> fruit.factoryPrice })
- Map에서도 Filter 사용 가능
val map: Map<String, List<Fruit>> = fruits.groupBy { fruit -> fruit.name }
.filter{ (key, value) -> key == "사과" }
4. 중첩된 컬렉션 처리
* flatMap vs. map
- map: 각 요소에 함수를 적용하여 새로운 동일한 구조의 컬렉션을 만듦
- flatMap: 각 요소에 함수를 적용한 후, 그 결과를 하나의 평평한 컬렉션으로 합침
val nestedList = listOf(listOf(1, 2), listOf(3, 4)) // map => List<List> val mappedList = nestedList.map { it.map { num -> num + 1 } } // mappedList 결과: [[2, 3], [4, 5]] // flatMap => List val flatMappedList = nestedList.flatMap { it.map { num -> num + 1 } } // flatMappedList 결과: [2, 3, 4, 5]
- flatten : 중첩돼있는 컬렉션을 중첩 해제 시킴
ex. 출고가와 현재가가 동일한 과일을 골라주세요!
// 조건 = fruitsInList: List<List<Fruit>>
val samePriceFruits = fruitsInList.flatMap { list ->
list.filter { fruit -> fruit.factoryPrice == fruit.currentPrice }
}
// 리팩토링 시
val samePriceFruits = fruitsInList.flatMap { list -> list.samePriceFilter }
val List<Fruit>.samePriceFilter: List<Fruit>
get() = this.filter(Fruit::isSamePrice)
data class Fruit(
val id: Long,
val name: String,
val factoryPrice: Long,
val currentPrice: Long
) {
val isSamePrice: Boolean
get() = factoryPrice == currentPrice
}
'💡 백엔드 > Kotlin' 카테고리의 다른 글
| 코틀린 기초 (4) - 추가적으로 알아두어야 할 코틀린 특성 (2) | 2025.08.17 |
|---|---|
| 코틀린 기초 (2) - 코틀린에서의 OOP (5) | 2025.08.12 |
| 코틀린 기초 (1) - 코틀린의 변수와 코드 제어 (5) | 2025.08.10 |