Skip to content

Conversation

@angryPodo
Copy link
Contributor

@angryPodo angryPodo commented Apr 1, 2025

New

  • 그래핌 글자수
  • 아스키 얼리리턴
  • 그래핌 단위 뒤집기

최대한 최적화 해봤어요 🙇🏻‍♂️

@angryPodo angryPodo requested a review from chattymin April 1, 2025 17:23
@angryPodo angryPodo self-assigned this Apr 1, 2025
Copy link
Owner

@chattymin chattymin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다. 한번만 더 확인 부탁드릴게요 :)

Comment on lines 11 to 12
fun String.countGraphemes(trim: Boolean = false): Int {
val target = if (trim) this.trim() else this
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trim을 제외한 글자 수를 세는것 까지가 이 함수의 역할일까요?
글자를 Grapheme 단위로 세는것까지가 이 함수의 역할이라고 생각이 됩니다.

그렇기에 trim여부를 외부에서 받아오는 것이 아닌, 외부에서 trim을 하고 해당 함수를 사용하는 것이 이상적이라 생각됩니다.
trim과 관련된 부분은 삭제하면 좋을 것 같습니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

책임이 과한 느낌이 있으니 이 부분은 제거하겠습니다

}

// 그래핌 단위로 카운팅된 총 글자수 리턴
fun String.countGraphemes(trim: Boolean = false): Int {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

count 라는 네이밍은 String 내부에서 특정 Char의 갯수를 셀 때 주로 사용하는 네이밍입니다.
현재 함수의 목적은 길이를 구하는 것이기에 length라는 네이밍이 더 어울릴 것 같네요

Suggested change
fun String.countGraphemes(trim: Boolean = false): Int {
fun String.graphemeLength(): Int {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀하신 것처럼.length 처럼 익숙한 네이밍으로 가는게 좋아 보여요. 반영하겠습니다

Comment on lines 17 to 25
// ThreadLocal.get()은 값이 설정되지 않았을 경우 null을 반환할 수 있어서 nullable 타입으로 추론하는듯.
// 그래서 iterator가 null safety하지 않다고 경고하는디...
// val iterator = localBreakIterator.get() ?:
// BreakIterator.getCharacterInstance(Locale.ROOT).also {
// localBreakIterator.set(it)
// }
// 이렇게 null 처리를 하는건 어떨지???
val iterator = localBreakIterator.get()
iterator.setText(target)
Copy link
Owner

@chattymin chattymin Apr 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nullable한 상태로 사용하는 것은 문제가 있는 것 같습니다.
제안해주신 방향성의 경우 추후 localBreakIterator를 사용할 때 마다 적어줘야 하기에 불편함이 클 것 같습니다.

제가 제안하는 방법은 get 했을 때 nullable하다면 자체적으로 set 하는 변수를 만들어 백킹프로퍼티를 활용하는 방향입니다. 최상단의 선언 부분만 수정해준다면, 아래에서 활용할 때에 기존과 비슷하고 get을 매번 써주지 않아도 되는 방향으로 사용할 수 있을 것 같네요
보신 후 의견 제안해주시면 감사하겠습니다.

private val LocalBreakIterator = ThreadLocal<BreakIterator>().apply {
    set(BreakIterator.getCharacterInstance(Locale.ROOT))
}

private val localBreakIterator: BreakIterator = LocalBreakIterator.get() ?: initBreakIterator()

private fun initBreakIterator() = BreakIterator.getCharacterInstance(Locale.ROOT).also {
    LocalBreakIterator.set(it)
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

자제척으로 set을 하는 방향은 생각을 못했었네요!
제안하신 방법으로 사용한다면 val iterator = localBreakIterator 처럼 사용이 간단해서 좋을 것 같습니다. 추가적으로 찾아본 방법은 아래와 같은데 이걸 사용하는건 어떻게 생각하시나요? 현업의 시선이 궁금합니다ㅎㅎ

private val localBreakIterator = ThreadLocal.withInitial {
    BreakIterator.getCharacterInstance(Locale.ROOT)
}

withInitial을 사용하면 ThreadLocal에 접근할 때마다 초기값이 자동제공 됩니다. 때문에 별도 초기화 로직이 필요가 없어지지만 아래의 걸림돌이 생기네요.

  1. API 26이상에서만 지원함
  2. Java의 ThreadLocal.get() 메서드에는 nullability 어노테이션이 없어서 Kotlin에서는 반환 타입을 nullable로 추론
  3. 2.의 여파로 localBreakIterator가 실제 실행에서는 항상 non-null임을 보장받지만 IDE상에서 경고로 표시됨

제약이 좀 있는편이라 좋은 방법은 아닌것 같지만 선언이 간단하다? 의 이점이 있을것 같네요.

Copy link
Owner

@chattymin chattymin Apr 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지원하는 api가 너무 높습니다. 현재 프로젝트의 경우 api 21부터 지원하고 있기에 해당 방법을 사용하기엔 무리가 있다고 생각됩니다.
현재는 프로젝트의 규모가 작고, 해당 기능의 컨텍스트를 파악하고 있지만 추후 규모가 커지고 외부에서 작업을 한다면 non-null이 보장받는 다는것을 파악하는데 리소스가 필요할 것 같습니다.

더 간단한 로직도 좋지만, 리스크를 줄이는 방향이 더 좋을 것 같습니다 :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의견 감사합니다ㅎ 해당 부분은 제안해주신 방안으로 수정했습니다 👍

}

// 아스키 문자열일 경우 얼리리턴 용도
private fun String.isLikelyAscii(): Boolean {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isAscii만 해도 의미 전달은 충분할 것 같습니다.

Suggested change
private fun String.isLikelyAscii(): Boolean {
private fun String.isAscii(): Boolean {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영하겠습니다 :)

var i = 0
val len = length
while (i < len) {
if (this[i].code > 0x7F) return false
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0x7F 와 같은 문자는 상수로 빼서 관리하면 좋을 것 같습니다.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i 라는 변수를 사용하기 보다는 String 전체를 순회하는 방법을 사용하면 불필요한 변수 할당을 줄일 수 있을 것 같네요

Suggested change
if (this[i].code > 0x7F) return false
this.forEach {
if (it.code > 0x7F) return false
}

Copy link
Contributor Author

@angryPodo angryPodo Apr 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. 상수로 관리하겠습니다!
  2. forEach같은 경우 내부적으로 람다를 사용하는걸로 알고있습니다. 물론 인라인 함수로 구현되어 람다 오버헤드가 적다는것도 알고있지만, 깡성능에서 while 또는 for 루프에 비해 불필요한 람다 호출 가능성이 높다고 생각했어요. 물론 엄~청 미세한 오버헤드 차이라고 생각합니다. forEach사용도 가독성 측면에선 더 좋다고 생각해요. 어떤 방식을 더 선호하시나요??

Copy link
Owner

@chattymin chattymin Apr 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 성능에 큰 차이가 있지 않다면 가독성을 더 챙기는 것을 선호합니다 :)
말씀해주신 부분을 더욱 신경쓰고 싶다면 이런 방법은 어떤가요? @angryPodo

for (input in this) {
        if (input.code > 0x7F) return false
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for문으로 좀더 간결성을 챙기는게 좋아보여 반영하겠습니다!

}

// 그래핌 단위의 문자열 뒤집기(이모지 포함)
fun String.reverseWithGraphemes(): String {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fun String.reverseWithGraphemes(): String {
fun String.graphemeReversed(): String {

Copy link
Contributor Author

@angryPodo angryPodo Apr 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 👍 👍 👍


// 그래핌 단위의 문자열 뒤집기(이모지 포함)
fun String.reverseWithGraphemes(): String {
if (this.isEmpty()) return ""
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

공백만 있는 경우에도 연산을 수행하기 보다 바로 리턴하는게 더 좋을 것 같습니다.
그리고 임의의 값을 리턴하기 보다는 자기 자신을 리턴하는게 더 안전할 것 같네요

Suggested change
if (this.isEmpty()) return ""
if (this.isBlank()) return this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영하겠습니다ㅎㅎ

Comment on lines 57 to 73
val graphemes = mutableListOf<String>()

var start = 0
var end = iterator.next()

while (end != BreakIterator.DONE) {
graphemes.add(this.substring(start, end))
start = end
end = iterator.next()
}

val result = StringBuilder(length)
for (i in graphemes.size - 1 downTo 0) {
result.append(graphemes[i])
}

return result.toString()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

조금 더 최적화 할 방법이 있을 것 같네요.
반복문 하나로 해결할 수 있을 것 같습니다. 이건 한번 고민해보시면 좋을 것 같아요

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요게 처음 버전은 아래와 같았는데요

fun String.reverseWithGraphemes(): String {
    if (this.isEmpty()) return ""
    if (this.length == 1) return this
    if (this.isLikelyAscii()) return this.reversed()

    val iterator = localBreakIterator.get()
    iterator.setText(this)
    val graphemes = mutableListOf<String>()
    var start = 0
    var end = iterator.next()

    while (end != BreakIterator.DONE) {
        graphemes.add(this.substring(start, end))
        start = end
        end = iterator.next()
    }

    return graphemes.reversed().joinToString("")
}

.joinToString("")의 내부로직을 보니까 결합하는 과정에서 중간 컬렉션이 생성되어서 메모리 최적화를 해볼까 싶었어요. 그래서 StringBuilder를 사용해 객체 생성을 줄여봤습니다.
이전 버전이 나을까요? 가독성은 더 좋은 것 같습니다.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

테스트해보니 String.reversed 사용해도 잘 뒤집히네요. 해당 함수 삭제 해주시면 감사하겠습니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 함수는 제거했습니다 😢

@chattymin
Copy link
Owner

chattymin commented Apr 2, 2025

그리고 함수 위에있는 주석은 지우거나, 영어로 작성하는 것으로 변경 부탁드립니다.
@angryPodo

- Rename `countGraphemes` to `graphemeLength`.
- Rename `isLikelyAscii` to `isAscii`.
- Use `ASCII_MAX` constant for ASCII range check.
- Remove trim option in `graphemeLength`.
- Improve efficiency by using `isAscii` for early return.
- Use `initBreakIterator` for initializing `localBreakIterator`.
- Improve comments.
Copy link
Owner

@chattymin chattymin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 👍

@chattymin chattymin changed the title [FEAT] 그래핌 관련 확장함수 구현 [FEAT] lengthGrapheme String Extension with LocalBreakIterator Apr 3, 2025
@chattymin chattymin changed the title [FEAT] lengthGrapheme String Extension with LocalBreakIterator [FEAT] graphemeLength String Extension with LocalBreakIterator Apr 3, 2025
@chattymin chattymin merged commit d6bfbc2 into main Apr 3, 2025
@chattymin chattymin deleted the feat-graphems-ext branch April 3, 2025 00:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants