Skip to content

[LBP] 현정빈 로또 미션 1단계 제출합니다. #83

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Mar 12, 2025

Conversation

JeongBeanHyun
Copy link

@JeongBeanHyun JeongBeanHyun commented Feb 19, 2025

  • 리뷰어: 김우진님
  • 리뷰이: 현정빈
  • 페어: 정상화님, 윤준석님

우진님 안녕하세요! 로또 미션 1단계 제출합니다!
이번 미션에서는 MVC 패턴과 SRP(단일 책임 원칙) 을 준수하려고 노력했지만, 아직 미숙한 부분이 많은 것 같습니다..!!
네이밍도 최대한 가독성이 좋도록 페어 프로그래밍을 하며 고민하면서 작성했는데, 피드백 부탁드립니다!

로또 번호 생성 방식은 다음과 같이 구현하였습니다.

  • Collections.shuffle()을 사용하여 리스트 요소를 무작위로 섞은 후

  • subList()로 6개의 숫자를 선택하고

  • Collections.sort()를 사용하여 오름차순 정렬

처음에는 Random과 Set을 활용하여 로또 번호를 생성하는 방식도 시도해보았는데, 두 방식의 차이점이 단순히 중복을 방지하는 것인지, 성능이나 코드 구조 등의 다른 차이점도 있는지 궁금합니다.


질문

  • Collections.shuffle() + subList() 방식과 Random + Set 방식을 비교했을 때, 어떤 방식이 더 효율적인지 궁금합니다.

  • MVC 패턴과 SRP 원칙을 적절하게 준수했는지 리뷰 부탁드립니다. 개선할 부분이 있을까요?

  • 현재 코드에서는 중복을 방지한다고 생각하여 힌트에 있는 contains() 메서드를 사용하지 않았는데, 별도의 중복 예외처리 로직을 추가하는 것이 더 적절했을까요?

Copy link

@woogym woogym left a comment

Choose a reason for hiding this comment

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

1단계 미션 수고하셨어요 정빈! 👏👏

MVC의 책임과 SRP를 준수하고자 하는 노력이 보여서 코드가 잘 읽혔던 것 같아요
이게 대해서 리뷰 남겨놨으니 확인 부탁드려요

Q&A

Collections.shuffle() + subList() 방식과 Random + Set 방식을 비교했을 때, 어떤 방식이 더 효율적인지 궁금합니다.

찾아보면 알아볼 수 있을겁니다 (이런 질문은 구글링 해봅시다) 서로의 시간은 소중하니까요
다만 이번 미션에서는 요구사항으로 주어지지 않고 힌트로 주어졌기에 사용함에 있어서는 어색하지 않았어요 이런 방식에 대한 고민도 훌륭하고요
각각의 장단점이 분명히 존재하고 요구사항과 프로그램 구조에 따라서 더욱더 유리한 조건을 채택하면 될 것 같습니다.

MVC 패턴과 SRP 원칙을 적절하게 준수했는지 리뷰 부탁드립니다. 개선할 부분이 있을까요?

이 점은 리뷰에 남겨드렸어요

현재 코드에서는 중복을 방지한다고 생각하여 힌트에 있는 contains() 메서드를 사용하지 않았는데, 별도의 중복 예외처리 로직을 추가하는 것이 더 적절했을까요?

어디까지나 요구사항이 아닌 힌트이기에 정빈이가 사용하지 않아도 적절했다고 생각합니다
해당 미션들에 있어서 예외처리는 본인이 필요하다면 구현하는게 맞습니다
어디까지의 범위는 구체적으로 정해진 것이 없습니다.
잘못된 입력이 주어지면 예외를 발생시킨다에서 잘못된 입력의 범위를 구성하는 것은 미션을 수행하는 본인의 역할이지 않을까 생각해요
정빈이가 작성된 코드에 대해서 더 이해하고자 하면 이 질문에 대한 답변을 찾을 수 있을 것 같아요

Comment on lines 14 to 18
public static final List<Integer> LOTTO_NUMBER_POOL =
IntStream
.rangeClosed(LOTTO_MIN_NUMBER,LOTTO_MAX_NUMBER)
.boxed()
.collect(Collectors.toList());
Copy link

Choose a reason for hiding this comment

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

개행을 수정해봅시다!

Copy link
Author

Choose a reason for hiding this comment

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

개행을 일관성 있게 정리하여 수정하였습니다. 기존에는 IntStream을 개별 줄에 나누어 작성했지만, 가독성을 높이기 위해 한 줄에서 시작하도록 변경했습니다. 피드백 감사드립니다!

public static final List<Integer> LOTTO_NUMBER_POOL =
            IntStream.rangeClosed(LOTTO_MIN_NUMBER,LOTTO_MAX_NUMBER)
                    .boxed()
                    .collect(Collectors.toList());

Comment on lines 48 to 53
public List<Integer> getNumbers() {
List<Integer> sortedNumbers = new ArrayList<>(numbers);
Collections.sort(sortedNumbers);

return sortedNumbers;
}
Copy link

Choose a reason for hiding this comment

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

getNumbers()의 이름말고도 더 많은 역할을 수행하고 있는 것 같아요~
네이밍은 자세할수록 좋아요~

Copy link
Author

Choose a reason for hiding this comment

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

네 현재 getNumbers()는 정렬된 리스트를 반환해주는 역할을 하고 있습니다. 보다 명확한 의미를 전달하기 위해getSortedNumbers() 로 수정하였습니다. 피드백 감사합니다!

public List<Integer> getSortedNumbers() {
        List<Integer> sortedNumbers = new ArrayList<>(numbers);
        Collections.sort(sortedNumbers);

        return sortedNumbers;
    }

Comment on lines 22 to 24
public static int getTicketCount(int purchaseAmount){
return purchaseAmount / LOTTO_PRICE;
}
Copy link

Choose a reason for hiding this comment

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

만약 1000원단위로 금액이 입력되지 못하면 어쩌죠?

Copy link
Author

Choose a reason for hiding this comment

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

기능 요구사항에서 로또1장의 가격은 1000원이기 때문에, 1000원 단위 금액이 입력되지 않은 경우는 예외 처리가 필요합니다. 또한, 1000원 미만의 구매 금액이 입력되었을 경우에도 예외처리가 필요합니다. 피드백 반영하여 리팩토링 진행하였습니다. 감사합니다!

public void validatePurchaseAmount(int purchaseAmount) {
        if (purchaseAmount < Lotto.LOTTO_PRICE) {
            throw new IllegalArgumentException("구매 금액은 1000원 이상이어야 합니다.");
        }

        if (purchaseAmount % Lotto.LOTTO_PRICE != 0) {
            throw new IllegalArgumentException("구매 금액은 1000원 단위여야 합니다.");
        }
    }

Comment on lines 38 to 46
public static List<Lotto> generateLottoTickets(int ticketCount){
List<Lotto> tickets = new ArrayList<>();

for (int i = 0; i < ticketCount; i++){
tickets.add(new Lotto());
}

return tickets;
}
Copy link

@woogym woogym Feb 21, 2025

Choose a reason for hiding this comment

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

  1. 해당 메서드는 Lotto 객체의 책임이 벗어나 있어요
  • generateLottoTickets()Lotto객체의 생성과 컬렉션 리스트 관리까지의 책임을 담당하고 있네요
    Lotto라는 객체만을 생각했을때는 로또 번호만을 생성하는 역할만 가져야 자연스럽다고 생각해요
    여러개의 로또를 관리하는 것이 Lotto라는 객체의 역할과 부합해보이지 않아요

  • Lotto 객체가 자기 자신을 여러개 생성하는 구조
    만약 객체가 자신을 여러개 생성하는 구조라면 외부에서 직접 관리하기 어려워지고, 객체 생성 방식이 변경되면 그에 따른 Lotto클래스의 내부도 수정이 필연적입니다

이 부분에 있어서는 많이 찾아보시고 공부해보시고 고민해봅시다!

  1. 정적 메서드가 인스턴스의 상태를 제어하고 있어요
    정적 메서드는 클래스 레벨에서 실행되는 메서드에요, 즉 특정 객체에 속하지 않죠 그렇다면 정적 메서드가 인스턴스의 상태를 제어하거나 생성하는 역할은 정적 메서드가 해서는 안될 역할이라고 볼 수 있어요
    이유는 해당 리뷰가 도움 될 수 있을 것 같아요~

  2. 마찬가지로 외부에서 List<Lotto> lottos = getTicketCount(6) 이러한 형태로
    오용될 가능성도 가지고 있네요

찾아보고, 고민해보고, 개선해봅시다!

Copy link
Author

@JeongBeanHyun JeongBeanHyun Feb 24, 2025

Choose a reason for hiding this comment

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

  1. Lotto가 직접 여러 개의 객체를 생성하면, 로또 번호 생성로또 리스트 관리라는 두 가지 역할을 동시에 수행하는 것이 되겠군요..! 이렇게 되면 객체의 책임이 모호해지고 유지보수가 어려워질 것 같습니다.

Lotto는 하나의 로또를 생성하고 저장해야하는데, Lotto 클래스 자체를 로또자판기로 생각하면서 작성했었습니다. 그래서 객체의 역할이 명확해지도록 클래스를 나눌 필요가 있을 것 같습니다. LottogenerateLottoTickets() 메서드는 여러 개의 Lotto를 생성하고 리스트로 관리하는 역할까지 포함하고 있었습니다. 그래서 이 역할은 LottoTickets 클래스로 분류하였습니다. Lotto는 로또 번호 6개를 생성하는 역할만 담당하도록 수정했습니다.

  1. 기존 코드에서는 generateLottoTickets()가 정적(static) 메서드로 정의되어 있었고, 내부에서 new Lotto()를 호출하여 객체를 직접 생성했습니다. 이렇게 하면 정적 메서드가 인스턴스를 관리하는 역할을 하게 되어 객체지향 원칙에 어긋난다는 것을 알게되었습니다.
    static 메서드였던 generateLottoTickets()를 삭제하고, 대신 LottoTickets에서 객체를 생성하도록 변경했습니다.

  2. getTicketCount()는 원래 로또 개수를 계산하는 메서드인데, 마치 여러 개의 로또를 생성하는 메서드처럼 보일 수도 있었습니다. getTicketCount()는 그대로 두고, 여러 개의 로또를 생성하는 기능은 LottoTickets 클래스에서 담당하도록 변경했습니다.

좋은 피드백 감사합니다!

public static int getPurchaseAmount(){
System.out.println("구입금액을 입력해 주세요.");
int purchaseAmount = scanner.nextInt();
scanner.close();
Copy link

Choose a reason for hiding this comment

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

입력이 끝난 시점에서 바로 닫아주는 거 좋아용👍

Comment on lines 6 to 15
public static void printOrderTickets(int ticketCount){
System.out.println();
System.out.println(ticketCount + "개를 구매했습니다.");
}

public static void printTickets(int ticketCount ,List<String> formattedTickets){
for (String ticket : formattedTickets) {
System.out.println(ticket);
}
}
Copy link

Choose a reason for hiding this comment

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

mvc 패턴에 대한 이해도가 높이진게 티가 나네요👍

System.out.println(ticketCount + "개를 구매했습니다.");
}

public static void printTickets(int ticketCount ,List<String> formattedTickets){
Copy link

Choose a reason for hiding this comment

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

구입한 티켓을 추력하는 것으로 보이는데, 좀 더 자세하게 네이밍 해봅시다
요구사항에는 네이밍을 축약하지 않는다고 되어 있으니 더 준수해봅시다!
네이밍은 자세할수록 좋습니다!

Copy link
Author

Choose a reason for hiding this comment

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

네이밍을 좀더 자세하게 작성할 필요가 있을 것 같습니다! 앞으로의 과제에서도 명심하겠습니다.
구입한 로또 티켓을 출력한다는 의미를 명확히 표현하기 위해 printPurchasedLottoTickets()로 수정하였습니다.
좋은 피드백 감사합니다!

Comment on lines 23 to 33
public List<String> formatTickets(List<Lotto> tickets) {
List<String> formattedTickets = tickets.stream()
.map(lotto -> lotto.getNumbers()
.stream()
.sorted()
.map(String::valueOf)
.collect(Collectors.joining(",","[","]")))
.toList();

return formattedTickets;
}
Copy link

Choose a reason for hiding this comment

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

  1. 접근 제어자에 대한 부분을 자주 놓치고 있는 것 같아요
    접근 제어자를 지정하는 것은
    private -> protected -> public순서로 고민해보며 지정하면 좋은 습관 만들어 갈 수 있을 것 같아요

  2. 또한 stream이 중첩되어 있는 구조인데 SRP를 준수하기에는 조금 미흡하지 않나 생각이 들어요
    메서드 분리를 통해서 개선해봅시다!

Copy link
Author

Choose a reason for hiding this comment

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

  1. formatTickets()는 외부에서 직접 호출될 필요가 없는 내부 메서드이므로, public에서 private으로 변경했습니다.
    접근 제어자는 최소한의 공개 범위를 설정하는 것이 중요하다는 점을 다시 한번 확인했습니다.
    앞으로는 private → protected → public 순서로 접근 제어자를 고민하는 습관을 들이겠습니다!

  2. 로또 번호를 문자열로 변환하는 역할을 별도의 메서드(convertLottoToString())로 분리하여 SRP를 준수하도록 개선했습니다.

  • formatTickets()는 티켓 리스트를 문자열 리스트로 변환하는 역할만 수행
  • convertLottoToString()은 하나의 로또 번호를 문자열로 변환하는 역할만 수행

소중한 시간 내어 피드백 해주셔서 항상 감사드립니다!

@JeongBeanHyun JeongBeanHyun requested a review from woogym February 24, 2025 11:17
Copy link

@woogym woogym left a comment

Choose a reason for hiding this comment

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

리팩토링 고생 많으셨어요 정빈👍👍

전반적으로 코드가 많이 개선된 것 같아요 좋습니다ㅎㅎ
고민한 흔적도 느껴지기도 하네요 많이 고민하고 많이 얻어갔으면 좋겠어요!

코멘트 남겨두었으니 확인부탁드려요
다음 미션도 화이팅👍👍


ResultView.printOrderTickets(ticketCount);
ResultView.printPurchasedLottoTickets(formatTickets(lottoTickets.getTickets()));
}catch (IllegalArgumentException e){
Copy link

Choose a reason for hiding this comment

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

공백이 필요해보여요

Copy link
Author

Choose a reason for hiding this comment

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

catch 앞에 공백을 추가하고, 메서드 호출문 사이에 공백을 추가하여 가독성을 높였습니다. 좋은 피드백 감사합니다!

            ResultView.printOrderTickets(ticketCount);

            ResultView.printPurchasedLottoTickets(formatTickets(lottoTickets.getTickets()));
            
        } catch (IllegalArgumentException e) {
        

throw new IllegalArgumentException("구매 금액은 1000원 단위여야 합니다.");
}
}

Copy link

Choose a reason for hiding this comment

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

불필요한 공백이 있어요

Copy link
Author

Choose a reason for hiding this comment

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

불필요한 공백을 삭제하였습니다. 좋은 피드백 감사합니다!

Comment on lines 42 to 50
private void validatePurchaseAmount(int purchaseAmount) {
if (purchaseAmount < Lotto.LOTTO_PRICE) {
throw new IllegalArgumentException("구매 금액은 1000원 이상이어야 합니다.");
}

if (purchaseAmount % Lotto.LOTTO_PRICE != 0) {
throw new IllegalArgumentException("구매 금액은 1000원 단위여야 합니다.");
}
}
Copy link

Choose a reason for hiding this comment

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

입력값 검증에 대해서는 정말 의견이 분분해요
정빈이는 model, view, controller중 어느 계층에서 검증이 이루어지는게 적절하다고 생각하나요?
정빈이 생각이 궁금해요

Copy link
Author

Choose a reason for hiding this comment

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

저는 검증은 Controller에서 이루어지는 것이 적절하다고 생각합니다.

  • Model은 데이터를 담당하므로 독립성을 유지해야 합니다.
    데이터 무결성을 보장하는 최소한의 검증만 Model에서 수행하고, 입력값 검증은 Controller에서 처리하는 것이 적절합니다.

  • View는 UI를 담당하므로 검증까지 포함하면 가독성이 떨어질 수 있습니다.
    View는 화면 출력과 입력만 담당해야 하며, 검증까지 맡으면 코드가 복잡해질 수 있습니다.

  • Controller는 Model과 View를 연결하는 역할을 하므로 검증을 담당하는 것이 적절합니다.
    Controller에서 입력값을 검증하여 Model에 올바른 데이터를 전달하면 유지보수성과 유연성이 높아집니다.

따라서, Controller에서 검증을 수행하여 상황에 맞는 데이터를 Model에 전달하는 것이 가장 적절한 구조라고 판단됩니다!

Comment on lines 21 to 23
public static int getTicketCount(int purchaseAmount){
return purchaseAmount / LOTTO_PRICE;
}
Copy link

Choose a reason for hiding this comment

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

입력값에 따른 티켓 갯수를 반환하는것은 Lotto의 책임에 어색한 느낌을 받아요
로또 1장을 관리하는 model 안에 구입할 로또 티켓의 갯수를 계산하는 것 또한 어색해보이지 않았나요?
저번 리뷰에서 말씀 드린 내용과 비슷하게 Lotto 여러장을 관리하기 위한 동적 배열(List<Lotto>)의 갯수를 Lotto에서 관리하고 있는 느낌이에요

정빈이는 어떻게 생각해요?
고민해보고 정빈이의 생각을 알려주세요~

Copy link
Author

Choose a reason for hiding this comment

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

입력값에 따른 티켓 개수를 반환하는 것 역시 Lotto의 책임이 아닌 것 같습니다.

Lotto는 한 장의 로또를 관리하는 객체여야 합니다.
Lotto 객체는 단순히 6개의 숫자를 생성하고 관리하는 역할을 수행해야 합니다. 하지만 구매 금액을 기반으로 티켓 개수를 계산하는 로직이 포함되면, 단일 책임 원칙(SRP)이 깨질 수 있을 것 같습니다.

여러 개의 로또 티켓을 관리하고 개수를 계산하는 역할은 LottoTickets 에서 수행하는 것이 적절합니다. 이를 통해 Lotto는 한 장의 로또 관리에 집중하고, LottoTickets는 여러 장을 관리하는 역할을 분리할 수 있습니다.

만약 로또 구매 방식이 변하거나 추가 규칙이 생긴다면, 티켓 개수를 계산하는 로직은 LottoTickets에서만 수정하면 됩니다. 이렇게 하면 Lotto 클래스는 불필요한 변경 없이 안정적으로 유지할 수 있습니다.

따라서, Lotto는 한 장의 로또를 관리하는 역할만 수행하고, 티켓 개수 계산은 LottoTickets에서 담당하는 것이 더 적절하다고 생각합니다. 좋은 피드백 감사합니다!

Comment on lines +25 to +27
public Lotto(){
this.numbers = createLottoNumbers();
}
Copy link

Choose a reason for hiding this comment

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

자바 스타일 가이드를 참고하면

  1. 중첩 클래스
  2. 필드(멤버 변수)
  3. 생성자
  4. 메서드

순서를 제시하고 있어요 생성자의 순서가 조금 어색하죠? 수정해봅시다ㅎㅎ

Copy link
Author

Choose a reason for hiding this comment

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

초기화는 생성자에서 이루어지는 것이 적절하다고 생각합니다.

멤버 변수(numbers)는 생성자에서 초기화됩니다.

Lotto 클래스에서 numbers는 객체가 생성될 때마다 새로운 로또 번호를 가져야 하므로, 생성자 내부에서 this.numbers = createLottoNumbers(); 와 같이 초기화하는 것이 적절합니다.
초기화 로직은 별도의 메서드에서 처리하는 것이 가독성과 유지보수 측면에서 유리합니다.

createLottoNumbers() 메서드를 통해 초기화 로직을 분리하면, 생성자 내부가 간결해지고 코드의 역할이 명확해집니다.

불필요한 초기화를 제거하고 생성자에서 멤버 변수를 초기화하는 것이 자바 스타일 가이드에도 적합하며, 유지보수성과 가독성을 높일 수 있는 방법이라고 생각합니다.

좋은 피드백 감사합니다!

Comment on lines 22 to 24
public List<Lotto> getTickets() {
return tickets;
}
Copy link

Choose a reason for hiding this comment

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

불변성 유지의 중요성에 대해서 알아가봅시다

List<Lotto>는 객체의 참조를 반환하고 있어요, 이렇게 반환된 객체의 참조는 다른 곳에서 수정될 가능성을 열어두고 있어요. 이에 대해서 방어적 복사 키워드에 대해서 공부해봅시다

충분히 고민, 공부해보고 왜 수정될 가능성을 열어두면 안되는지 정빈이의 생각을 알려주세요

Copy link
Author

@JeongBeanHyun JeongBeanHyun Mar 2, 2025

Choose a reason for hiding this comment

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

방어적 복사는 원본 데이터를 보호하고 불변성을 유지하기 위한 중요한 기법입니다.

현재 List의 참조를 직접 반환하면 외부에서 리스트를 수정할 가능성이 있습니다.
이렇게 되면 객체의 무결성이 깨질 수 있으며, 예기치 않은 오류가 발생할 가능성이 있습니다.
이를 방지하기 위해, 새로운 복사본을 만들어 반환하면 원본 데이터가 변경되지 않고 안전하게 보호됩니다.

public List<Lotto> getTickets() {
    return new ArrayList<>(tickets);
}

<방어적 복사의 장점>

  • 원본 데이터 보호 -> 외부에서 반환된 리스트를 변경하더라도, 원본 리스트는 변경되지 않습니다.

  • 예기치 않은 오류 방지 -> 데이터가 의도치 않게 변경되는 것을 방지하여, 디버깅을 쉽게 만들고 유지보수를 용이하게 합니다.

  • 멀티스레드 환경에서도 안전 -> 여러 스레드에서 같은 객체를 공유하더라도, 원본 데이터가 변경되지 않기 때문에 동기화 문제가 발생하지 않습니다.

좋은 피드백 감사합니다!

Comment on lines 17 to 19
public static void printErrorMessage(String message) {
System.out.println(message);
}
Copy link

Choose a reason for hiding this comment

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

에러메세지를 출력하는건 ResultView라는 클래스 즉 결과출력이라는 네이밍과 부합하게 보기에는 살짝 어색해보여요
정빈이는 어떻게 생각해요?

Copy link
Author

Choose a reason for hiding this comment

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

ResultView는 결과출력을 담당하기 때문에, 에러메세지 출력과는 거리가 있는 것 같습니다. ErrorView 라는 클래스로 분류하여 리팩토링 하였습니다!

@JeongBeanHyun JeongBeanHyun requested a review from woogym March 2, 2025 06:33
@JeongBeanHyun JeongBeanHyun changed the base branch from main to jeongbeanhyun March 11, 2025 02:48
@boorownie boorownie merged commit 5e29f83 into next-step:jeongbeanhyun Mar 12, 2025
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