Implement DataFrame.__array_ufunc__
For some cases, this will preserve extension types of arrays by calling
the ufunc blockwise.

```python
In [1]: import pandas as pd; import numpy as np
In [2]: df = pd.DataFrame({"A": pd.array([0, 1], dtype="Sparse")})

In [3]: np.sin(df).dtypes
Out[3]:
A    Sparse[float64, nan]
dtype: object
```

Implementation-wise, this was done by moving `Series.__array_ufunc__` to `NDFrame` and making it generic for Series / DataFrame. The DataFrame implementation goes through `BlockManager.apply(ufunc)`.

We don't currently handle the multi-input case well for dataframes (aside from ufuncs that are implemented as dunder ops like `np.add`). For these, we fall back to the old implementation of converting to an ndarray and wrapping the result. This loses extension types.

We also don't currently handle multi-output ufuncs (like `np.modf`). This would require a `BlockManager.apply` that returns a `Tuple[BlockManager]`, `nout` per input block. Maybe someday, but that's low priority.

closes https://github.com/pandas-dev/pandas/issues/23743