Skip to content
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

Fantasy land update #166

Merged
merged 15 commits into from
Feb 8, 2018
Merged

Conversation

nordfjord
Copy link
Collaborator

@nordfjord nordfjord commented Jan 26, 2018

This PR:

  1. uses the newer fantasy-land namespaced methods
  2. deprecates the the old fantasy-land methods
  3. adds a pipe method to ease the transition from old to new fantasy-land for end users
  4. adds a index.d.ts file with Typescript types for flyd and modules

The PR also exposes chain, of, and ap as methods on the flyd object. This is intended for them to be as operators in pipe

Before I consider this complete I will need to:

  • update documentation
  • provide JSDoc annotations on new methods
  • get back to 100% coverage

Any help with the above will be greatly appreciated

@coveralls
Copy link

Coverage Status

Coverage decreased (-10.6%) to 89.431% when pulling 38cbdb3 on nordfjord:fantasy-land-update into 500e85a on paldepind:master.

2 similar comments
@coveralls
Copy link

Coverage Status

Coverage decreased (-10.6%) to 89.431% when pulling 38cbdb3 on nordfjord:fantasy-land-update into 500e85a on paldepind:master.

@coveralls
Copy link

Coverage Status

Coverage decreased (-10.6%) to 89.431% when pulling 38cbdb3 on nordfjord:fantasy-land-update into 500e85a on paldepind:master.

@coveralls
Copy link

coveralls commented Jan 26, 2018

Coverage Status

Coverage remained the same at 100.0% when pulling 6f715c4 on nordfjord:fantasy-land-update into 3afb73a on paldepind:master.

@nordfjord
Copy link
Collaborator Author

I'm not sure what the consensus is on deprecating Promise swallowing.
In my view it is worth it, the functionality belongs in a helper IMHO.

Migration will be as simple as:

- stream(promise)
+ flyd.fromPromise(promise)

@StreetStrider
Copy link
Contributor

deprecating Promise swallowing

Does this include promise handling in combine, map, etc?

@nordfjord
Copy link
Collaborator Author

nordfjord commented Jan 27, 2018

@StreetStrider ah, forgot about that. Yes, I'm talking about deprecating the whole shebang.

The benefit of the promise swallowing mechanic has always eluded me.
Are you using it? and if so can you elaborate how?

Since this pr makes stream monads I don't see how promise swallowing is of value any more.

Consider:

const {stream} = require('flyd');
const getResults = filter => requestPromise('https://example.com/search?q=' + filter);

const filter$ = stream('');
const result$ = filter$.map(getResults)

What you have there is a Stream string being mapped into a Stream List Result.

With the features added in this PR

const {stream, chain, fromPromise} = require('flyd');
const getResults = filter => requestPromise('https://example.com/search?q=' + filter);

const filter$ = stream('');
const result$ = filter$
  .pipe(chain(f => fromPromise(getResults(f))));

// or
const result$ = filter$
  .pipe(map(getResults))
  .pipe(chain(fromPromise))

Furthermore the fantasy-land spec explicitly says for Applicatives:

No parts of a should be checked

link

So we can't have a fantasy-land monad without either

  1. Deprecating and removing promise swallowing
  2. Creating a specific of method that functions differently to flyd.stream in that it doesn't swallow promises

In my mind option 1. is the better one, it involves less gotchas, and less magic.

@StreetStrider
Copy link
Contributor

@nordfjord hello.
I used to use them from time to time. The basic scenario is to map through some async function without need of additional hassle.
Some of my observations:

  • I found this feature very practical, since there's no much sense (if any) in having Promise to flow inside Stream. Most of the time in such case we just want to unwrap value from Promise and pass it to Stream. So promise swallowing is a good way to solve 98% of communication promise → stream.
  • Other streaming libs does not implement this for some reasons (maybe because of the spec compability, as itt).
  • Since flyd does not handle errors, there's no straightforward strategy to handle Promise rejections. I spent some of my recent time solving similar tasks. Should rejections be ignored? Should rejections pass down stream? Should rejections end stream?
  • As far as I understood, there's no guarantee in ordering. When we map through async function, outputs may occur in different order than inputs. fromPromise would not handle this either, but at least the issue became more explicit.

So, before, for me this feature is a good way to handle promises in simple scenarios without diving into the whole fantasy-land business. Sad, but if it blocks implementation of this specs, it should be removed, especially considering some magic behind it, corresponding to my notes above.

@nordfjord
Copy link
Collaborator Author

nordfjord commented Jan 27, 2018

I see what you’re saying.

I’m going to give a couple of observations WRT streams and promises.

  1. promises have 3 states
  • Loading
  • Resolved a
  • Rejected b

To properly handle all cases I have used this helper function before

function fromPromise(p) {
  const s = stream({state: “loading”});
  p
    .then(value => ({state “resolved”, value}))
    .catch(error => ({state: “rejected”, error})
    .then(s)
    .then(()=> s.end(true);
  return s;
}
  1. When you chain using fromPronise the ordering is actually preserved. It might skip a result if a new value is pushed to the parent stream. This is because a new stream is returned from the mapping function every time and we stop listening to the old stream immediately

lib/index.js Outdated
if (arguments.length > 0) s(initialValue);
return s;
}
// fantasy-land Applicative
flyd.of = flyd.stream;
Copy link
Owner

@paldepind paldepind Jan 28, 2018

Choose a reason for hiding this comment

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

Are these two lines necessary? They're not required for FL and flyd.stream does the same?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

good catch, will remove

@paldepind
Copy link
Owner

Migration will be as simple as:

- stream(promise)
+ flyd.fromPromise(promise)

Not in all cases as far as I can see. For instance this example

streamX
  .map(doSomeAsyncStuff)
  .map(doMoreAsyncStuff)
  .map((result) => result.done ? true : doEvenMoreAsyncStuff(result.workLeft));

The basic idea behind promise shallowing is based on what @StreetStrider wrote:

there's no much sense (if any) in having Promise to flow inside Stream.

It can bring some nice simplifications to just handle promises as "delayed" pushes to the child streams. Additionally, the implementation of the feature is very simple.

I think the removal of the inbuilt handling of promises should be discussed in a separate issue. The rest of this PR are really great improvements and they're much less "controversial".

@nordfjord
Copy link
Collaborator Author

Very well, I'll remove the deprecation. I'll open up a separate issue to discuss the promise handling mechanics.

@nordfjord
Copy link
Collaborator Author

Ok, I've removed the promise swallowing deprecation.

But I've also had to remove the line in the readme that says flyd implements the fantasy-land monad specification. Because it doesn't, because of promise swallowing.

@nordfjord
Copy link
Collaborator Author

I've also opened a seperate issue to discuss promise swallowing. #167

@nordfjord nordfjord changed the title WIP: Fantasy land update Fantasy land update Jan 28, 2018
This was referenced Jan 28, 2018
@nordfjord nordfjord mentioned this pull request Jan 28, 2018
@dmitriz
Copy link
Contributor

dmitriz commented Jan 28, 2018

Considering the above example

// this function returns Promise, which is not FL compliant
const getResults = filter => requestPromise('https://example.com/search?q=' + filter);

const result$ = filter$
  .pipe(map(getResults))
  .pipe(chain(fromPromise))  // chaining non-FL-compliant function

Would the following syntax not be simpler and FL-compliant:

// convert to FL-compliant stream right away
// a -> Stream b
const getResultsStream = filter => fromPromise(
    requestPromise('https://example.com/search?q=' + filter)
)

// Get Stream b by chaining Stream a ~> (a -> Stream b) -> Stream b
const result$ = filter$
    // use the ordinary monadic `chain` on Streams
  .chain(getResultsStream)

@nordfjord
Copy link
Collaborator Author

@dmitriz We should move the promise discussion to #167 I'll provide my answer there

@dmitriz
Copy link
Contributor

dmitriz commented Jan 28, 2018

@nordfjord
It is also about providing direct methods on streams such as stream.map.

Since this PR proposes to use stream.pipe(map(f)), I wonder how about also enabling
stream.map(f) as a shortcut?

@nordfjord
Copy link
Collaborator Author

nordfjord commented Jan 28, 2018

@dmitriz you can get that behaviour by doing:

const FL = require('fantasy-land');
FL.map = 'map';
FL.chain = 'chain';
FL.ap = 'ap';
FL.of = 'of';

or:

Object.assign(FL, Object.keys(FL).reduce((p,k)=> {
  p[k] = k;
  return p;
}, {}));

In your own code

@dmitriz
Copy link
Contributor

dmitriz commented Jan 28, 2018

@nordfjord You mean, mutating FL like that will provide the instance methods on the stream objects? Could you explain how this happens?

@nordfjord
Copy link
Collaborator Author

Yes, to support the fantasy-land spec we require('fantasy-land') which exposes an object with a mapping from the "normal" method names e.g. map to the fantasy-land prefixed version e.g. fantasy-land/map.

In the flyd code we do:

s[FL.map] = boundMap;

So if you mutate FL.map to "map" that's what the method will be called on the stream instance.

@nordfjord
Copy link
Collaborator Author

An example:

import * as FL from 'fantasy-land';
FL.chain = 'chain';
import * as flyd from 'flyd';

const s = flyd.stream(1)
  .chain(()=> flyd.stream(2));

s() // 2;

README.md Outdated
@@ -426,6 +424,57 @@ __Example__
var numbers = flyd.stream(0);
var squaredNumbers = flyd.map(function(n) { return n*n; }, numbers);
```
### flyd.chain(fn, s)
Given a stream of streams, returns a single stream of merged values from the created streams.
Copy link
Contributor

Choose a reason for hiding this comment

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

Isn't s here just Stream a rather than stream of streams?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, that's true, I copied this from that flatMap readme, I'll fix

@@ -426,6 +424,57 @@ __Example__
var numbers = flyd.stream(0);
var squaredNumbers = flyd.map(function(n) { return n*n; }, numbers);
```
### flyd.chain(fn, s)
Copy link
Contributor

Choose a reason for hiding this comment

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

What about the FL-standard s.chain(fn) or the static chain(fn)(s)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

What about them? can you elaborate?

Copy link
Contributor

Choose a reason for hiding this comment

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

Whether I can use them instead?

Copy link
Contributor

Choose a reason for hiding this comment

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

I have never seen the syntax like here in this context. Any motivation for it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

There's actually no FL-standard s.chain(fn).

If you read the spec, there's a requirement for a prefixed fantasy-land/chain to comply with the spec.

flyd.chain can be used in a number of ways:

  1. directly
flyd.chain(fn, s);
  1. through pipe
s.pipe(flyd.chain(fn));
  1. bound to the stream as fantasy-land/chain
s['fantasy-land/chain'](fn);

All of these ways are functionally equivalent.

Copy link
Contributor

Choose a reason for hiding this comment

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

I mean here:
https://github.com/fantasyland/fantasy-land#chain-method

m.chain(f)

As a monad, flyd.stream object has to implement the .chain method.

I didn't mean the prefixed methods.
They are in addition, more for the library creators and the situation
when you have low level libraries used by higher level ones.
However, there is no higher level library for flyd.

I can see the other ways, but none of them is really as usable,
they are more for a low level library.

So I would need to write a micro-wrapper of my own,
which I shall do if the direct methods won't be supported out of the box,
but I've thought it might be useful for more people than just me.

Copy link
Collaborator Author

@nordfjord nordfjord Jan 28, 2018

Choose a reason for hiding this comment

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

the part of the spec I linked says:

Further in this document unprefixed names are used just to reduce noise.

Meaning it's the prefixed methods that matter. Unprefixed methods are not part of the spec and as such can for the most part be treated as having undefined behaviour.

Now we really have 2 camps when it comes to FP in JS, there's the Haskell camp, and the Scala camp.

The Scala camp prefers method chaining and the Haskell camp prefers function composition.

To me, the pipe method should make both camps happy.

The function composition camp can write:

import {stream} from 'flyd';
import {chain, map, compose, prop} from 'ramda';

const filter$ = stream('');
const result$ = compose(
  map(prop('content')),
  chain(compose(fromPromise, getResult))
)(filter$);

and the method chaining camp can write:

import {stream} from 'flyd';
import {chain, map, prop} from 'ramda';

const filter$ = stream('');
const result$ = filter$
  .pipe(chain(compose(fromPromise, getResult)))
  .pipe(map(prop('content')));

Note: chain and map could also be imported from flyd, but I wanted to show that other libraries supporting the fantasy-land spec can be used too.

@dmitriz
Copy link
Contributor

dmitriz commented Jan 28, 2018

@nordfjord
Thanks, it works indeed with your code, even if feels somewhat magic.

The code is getting some implicit flavor when we are mutating what we don't see,
which is what you usually want to avoid in any functional code ;)

From the usability point of view, wouldn't it be easier for most users simply enabling it by default?

Otherwise I would have to maintain a wrapper doing that,
but then what about other packages depending on FL?

@nordfjord
Copy link
Collaborator Author

nordfjord commented Jan 28, 2018

The code is getting some implicit flavor when we are mutating what we don't see,
which is what you usually want to avoid in any functional code ;)

You only need to mutate once, which you can do in something like your main.js file.

From the usability point of view, wouldn't it be easier for most users simply enabling it by default?

The cons of providing both map and fantasy-land/map are:

  • it would violate The Zen of Python (python -c 'import this' | grep 'only one');
  • method chaining is at odds with an emphasis on function composition.

Sanctuary had a similar discussion here

I remember @paldepind saying somewhere he felt disgusted having to provide methods just to support the fantasy-land spec.

but then what about other packages depending on FL?

They would be effected in the same way. i.e. their exports would have map in stead of fantasy-land/map

@dmitriz
Copy link
Contributor

dmitriz commented Jan 28, 2018

There are also more comments there and arguments against these Cons, many different opinions.

But my point here is merely practical.
Chaining methods is very common and widespread among JS users,
and simplicity and familiarity is always good to attract more people using the library.

Any additional complication to the syntax will go the opposite way,
make it harder, less attractive, and ultimately fewer people will be interested
to come and contribute to keep it live.

The Node's Stream syntax had been notably criticised by TJ:
https://medium.com/@tjholowaychuk/im-not-sure-they-re-misunderstood-they-re-honestly-just-not-very-well-implemented-1822a5948895

@nordfjord
Copy link
Collaborator Author

nordfjord commented Jan 28, 2018

But my point here is merely practical.
Chaining methods is very common and widespread among JS users,
and simplicity and familiarity is always good to attract more people using the library.

Agreed. I just think pipe will serve the method chaining population better than direct references on the stream prototype.

Why do I think this? It provides a uniform way to interact with stream for flyd functions and modules.

example:

const filter = require('flyd/module/filter');
const {stream, map} = require('flyd');
const numbers = stream(1);
const evenNumbersSquared = numbers
  .pipe(filter(d => d % 2 === 0))
  .pipe(map(d => d * d));

Using map, and filter is now actually the same syntax.

Take a look at rx-js and their decision to move to pipe in stead of direct patching of the Observable prototype here.

They provide some really great arguments about why pipe is superior to patching. Some of them are specific to rxjs, but others can be applied to flyd too.

Any additional complication to the syntax will go the opposite way

Agreed. Are we not in agreement that s.chain, s.map and s.ap are additional complications to the syntax?

The Node's Stream syntax had been notably criticised by TJ

I'm not familiar with TJ or why his comment is notable. I don't see how to lift his comment into this discussion.

@dmitriz
Copy link
Contributor

dmitriz commented Jan 29, 2018

I see where you come from and the arguments.
There are pluses and minuses in any solution, so it all depends on your priorities.

My highest priority is to reduce the coding and maintenance time by reducing the code's complexity.
The functional approach aimed for by FL and going back to Category Theory and implemented by Haskell is to make most of your code universal by conforming to the algebraic data type interfaces.

What it means in practice, my code should work universally e.g. for any Monad:

let newMonad = oldMonad.chain(f)

or

let newMonad = chain(f)(oldMonad)

Note that I consider these two expressions conceptually identical.
That is, a.chain(f) is the pointed syntax and chain(f)(a) or chain(f,a) is the point-free syntax
for exactly the same operation -- chain a Monad with a function valued in the same Monad.

With that principle in mind, I can freely switch between the two syntaxes, whatever makes my code more readable. And it works for any Monad, whether it is a stream or Either or anything else. So I don't need to have any separate code just to deal with streams.

The problem with using .pipe, it does not conform to the Monadic interface anymore, so the code will only work for the objects implementing .pipe. This means, you code is not universal anymore, you have to maintain separate modules for streams (with .pipe) and then again for other Monads with .chain attached directly. That will result in code duplication, which is exactly what we try to avoid with the FL-conformal algebraic interfaces.

In the ideal world, chain should be as native as map on the arrays.
It should not be considered "patched" as you wouldn't call the map so.

What is even worse, since .pipe is used by other libraries and even by Nodejs itself, this may run into conflicts. Even if I do everything correctly, I have to think hard whether this .pipe is the one by Node or the one by flyd or something else.

Concerning Rx, they don't seem to embrace the functional approach, so would have different aims and priorities. For instance, their worry about tree-shaking is more acute for them simply because of the (enormous) size of their library. flyd is tiny in comparison, so this is not the problem we would have here.

Having said that, I still find it great what you are doing here and it is ok to have different priorities.
Ultimately, it is up to @paldepind to decide what to accept.

@nordfjord
Copy link
Collaborator Author

I agree, I'm not explicitly against having these methods unprefixed as well.

Let's get @paldepind's input on this.

@paldepind
Copy link
Owner

@nordfjord

But I've also had to remove the line in the readme that says flyd implements the fantasy-land monad specification. Because it doesn't, because of promise swallowing.

Yes, you're right. When I implemented the promise handling I didn't consider how it would affect the implementation of the "standard" structures. But with promise swallowing a Flyd stream is, strictly speaking, not even a functor. I'll have more to say about that in the other thread.

I remember @paldepind saying somewhere he felt disgusted having to provide methods just to support the fantasy-land spec.

Yeah, I remember saying something like that when prefixing the FL methods was discussed in FL. I've since changed my opinions. I used to think that methods were "non-functional" and I tried to avoid them. These days I consider methods to be just a form of functions with some different properties (the syntax for invoking them is different, they're namespaced on the object) that gives different pros and cons.

What matters is whether the function or method is pure. If it's pure it's functional enough for me 😄 Currently methods compose in a nice way syntactically when using chaining. That gives them an advantage to functions. I'm looking forward to the pipe operator changing that.

@dmitriz

Hi dmitriz. Long time, no chat. Nice to see you, I hope you're doing well 😄

What it means in practice, my code should work universally e.g. for any Monad:

let newMonad = oldMonad.chain(f)

or

let newMonad = chain(f)(oldMonad)

Note that I consider these two expressions conceptually identical.
That is, a.chain(f) is the pointed syntax and chain(f)(a) or chain(f,a) is the point-free syntax
for exactly the same operation -- chain a Monad with a function valued in the same Monad.

I think you raise some very good points. Using pipe definitely loses some generality—the presence of pipe is not guaranteed on a monad. But, if we use the Fantasy Land specification for monads then oldMonad.chain(f) is not guaranteed to work for any monad either. Fantasy Land specifies that only oldMonad["fantasy-land/chain"](f) is guaranteed to work for a monad. So if you want to write code compatible with any FL monad then we can't use the chain method directly. That's consequence of Fantasy Land. I personally wish that FL didn't have the prefixes but we probably can't change that.

Now, there's nothing that prevents Flyd from having unprefixed methods as well. I think doing that would be fine. But those can only be there for convenience and not for writing universal monad code.

index.d.ts Outdated

pipe<V>(operator: (input: Stream<T>) => Stream<V>): Stream<V>;

['fantasy-land/map']<V>(accessor: Morphism<T, V>): Stream<V>;
Copy link
Owner

Choose a reason for hiding this comment

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

Is there a reason for using Morphism instead of just (a: A) => B? I think the latter would be a bit more "mainstream"? Maybe even more idiomatic.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

No particular reason. Leftovers from other typings in my project.

I think accessor is the wrong name for the argument as well. How about project?

@StreetStrider
Copy link
Contributor

These days I consider methods to be just a form of functions
What matters is whether the function or method is pure
Currently methods compose in a nice way syntactically when using chaining
pipe operator

I think exactly the same way.

As for chain, I think I know the ultimate reason why FL people did that. It seemes that to achieve true polymorphism and do not break anything you need to instrument your objects via symbols and not public names. In generic, using names like chain to implement third-party APIs is to violate public interface of an object. We got no bad side-effects using public names in JS just because of name collisions is not so often in such situation and because of people already used to such form of polymorphism/duck-typing. Using public names means to use that name in sense of that object domain (like chain or pipe can, if present, have another meaning for that object).

Instead one should register export const chain = Symbol('third-party-module/chain') for overloading. Only in that case nothing breaks at all. (like Symbol.species)

In practical sense it creates additional work for programmer to implement compability, so people still using naive implementations all around.

I'm sure this was discussed somewhere in FL or elsewhere.

@dmitriz
Copy link
Contributor

dmitriz commented Jan 30, 2018

Hi @paldepind

Hi dmitriz. Long time, no chat. Nice to see you, I hope you're doing well 😄

Thanks, good to hear from you too.
Everything well, been doing some heavy math as of late 😄
Just noticed some unanswered discussion we had on Gitter,
and wrote some belated answers, hope you can find them.
Was really looking forward having this new functionality with stream.chain(f).

I think you raise some very good points. Using pipe definitely loses some generality—the presence of pipe is not guaranteed on a monad. But, if we use the Fantasy Land specification for monads then oldMonad.chain(f) is not guaranteed to work for any monad either. Fantasy Land specifies that only oldMonad"fantasy-land/chain" is guaranteed to work for a monad. So if you want to write code compatible with any FL monad then we can't use the chain method directly. That's consequence of Fantasy Land. I personally wish that FL didn't have the prefixes but we probably can't change that.

Now, there's nothing that prevents Flyd from having unprefixed methods as well. I think doing that would be fine. But those can only be there for convenience and not for writing universal monad code.

I see that my interpretation of the FL compatibility is outdated :(
It goes back to the time when there was no prefixed methods.
I do understand the implementation reasons and
practical issues not to break the existing non compliant methods.

I can see the pipe is a general way of writing f(x) as x.pipe(f),
which is cool and making m.pipe(chain(f)) consistent with chain(f)(m).
On the other hand, having the extra operator and parentheses feels
noisy comparing to the simpler m.chain(f) just as the Haskell's m >>= f
that I would expect from any monadic type I'd use
(and would write a wrapper if it doesn't).
So yes, supporting both ways would be great imo.

@nordfjord
Copy link
Collaborator Author

I've removed the deprecations of map ap, and of as stream methods.

I've also added chain unnamespaced.

I did leave the old behaviour of ap intact. This means we support both fantasy-land@1.0.0 and fantasy-land@3.0.0 spec.

After our discussion in #167 I've added a deprecation notice to Promise swallowing and opened #168 to remove promise swallowing (that PR depends on this one)

I think the best way moving forward is to Remove promise swallowing in one fell swoop with no intermediate release, because the helper methods we want to provide flattenPromise and fromPromise are somewhat limited in a promise swallowing flyd, namely flattenPromise will not work.

I none the less wanted to keep the door open for an intermediate release should you desire that, so I've made it seperate PRs for that reason.

@paldepind paldepind merged commit 7b4f33f into paldepind:master Feb 8, 2018
@paldepind
Copy link
Owner

Great! This is now on npm 🎉

Now it's time to break things.

paldepind added a commit that referenced this pull request Feb 20, 2018
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.

5 participants