Cài đặt quan hệ thứ tự trong C++
Bài viết dưới đây mô tả một giải pháp để cài đặt các quan hệ giữa các cấu trúc dữ liệu (CTDL) khác nhau trong C++. Ứng dụng của quan hệ giữa các đối tượng đóng vai trò quan trọng trong các thuật toán, dưới đây có thể nêu lên 1 số ví dụ:
- Total Order (Quan hệ thứ tự): Đây là một trong những quan hệ quen thuộc nhất, có vai trò quan trọng trong các thuật toán sắp xếp.
- Equivalence relation (Quan hệ tương đương): được sử dụng trong các thuật toán hashing.
Tuy nhiên trong bài này thay vì đề cập trực tiếp đến quan hệ thứ tự (Total Order),
ta thử sử dụng quan hệ tổng quát hơn chút weak order. Điều khác biệt duy nhất
của Weak Order so với Total Order nằm ở chỗ weak order không có tính đối xứng.
Và trong “thực tế đời sống”, Weak Order chính là dấu <
.
Vậy có điều gì khó khăn khi phải cài đặt dấu <
này? Giả sử ta có một hàm như
sau:
template<typename T> bool f(const T& lhs, const T& rhs) { return lsh<rhs; }
Giả sử ta viết một thư viện trong đó chứa hàm f
và mong muốn bất kì ai sử
dụng cũng phải cài đặt dấu <
cho CTDL của mình. Bởi đôi khi, dấu <
mặc
định của C++ chưa chắc mang ngữ nghĩa đúng, hay bất kì một CTDL tự tạo nào
không chứa dấu <
cũng gặp lỗi. Vậy nếu có cơ chế nào giúp trình biên dịch
phát hiện được LTV quên cài đặt dấu này. Cụ thể hơn ta, muốn có:
template<typename T>
bool f(const T& lhs, const T& rhs) {
// Error if T does not have `<` operator
// otherwise
return lsh<rhs;
}
Trong C++11, ta có 1 cơ chế khá giống như vậy:
template<typename T>
bool f(const T& lhs, const T& rhs) {
static_assert(has_less_operator<T>::value, "Please implement < operator");
return lsh<rhs;
}
Kỹ thuật sử dụng struct<T>::value
được gọi là type traits,
được sử dụng rất
phổ biến trong STL, metaprogramming, các thư viện như BOOST, dlib. Tiếp, ::value
ở đây phải là static, không có gì phải bàn. Đồng thời, ::value
trả về yes/no
khi lớp T chứa/ hoặc không chứa <
, nên kiểu phải là bool
. Do giá trị này được
kiểm tra lúc trình biên
dịch thực thi, nên hẳn là constexpr
. Xong tạm thời ta có 1 cài đặt:
template<typename T>
class has_less_operator{
private:
// stuff
public:
static constexpr bool value = // and other stuff;
};
Và giờ đến phần khoai nhất khi cài đặt. Ta buộc phải gán “cái gì đó” vào value
,
nhưng ngay tại thời điểm cài đặt ta còn không biết nó có giá trị gì, và hoàn toàn
phụ thuật vào CTDL T
nào đó do LTV khác viết. Hay nói một cách khác, chính giá
trị boolean cũng bị tham số hóa theo kiểu. May mắn thay, STL có hỗ trợ vụ này:
#include <type_traits>
using namespace std;
typedef true_type yes;
typedef false_type no;
Lúc này ta có thể viết :
static constexpr bool value = is_same<yes, /* X */>::value;
Như vậy value
sẽ phụ thuộc vào is_same<yes, X>::value
. Vấn đề X ở đây là gì?
Ta xem trong STL, is_same
nhận 2 datatype làm input. Vậy X chính là 1 datatype
khác. Nhưng cụ thể hơn nó có thể là yes/no để value
trở thành yes/no.
Tới đây là một đoạn cực kì fancy, nhưng mình không biết giải thích trước thế nào nhưng trước hết cứ viết ra đã:
static constexpr bool value = is_same<yes,
decltype(test<T>(nullptr))
>::value;
decltype
trả về kiểu dữ liệu return type
của hàm test
và ta cố tình đưa
toàn bộ cài đặt vào trong test
này. Bởi toán tử <
trong C++ yêu cầu 1 tham
số đầu vào nên ta để 1 tham số hàm test
làm dummy. Như vậy ta biết chắc chắn
test<T>
trả về 1 trong 2 kiểu dữ liệu : yes
hoặc no
như được typedef phía
trên. Đồng thời, ta biết rằng mặc định mọi kiểu dữ liệu T
sẽ trả về no
.
Tức ta có :
template<typename> static no test(...);
Xong cho phần sai. Giờ làm thế nào để trả về yes
khi dữ liệu thực sự đã cài
đặt <
. Ta có 1 hàm test thứ 2:
template<typename P> static auto test (P *p)
-> decltype(declval<T>()<(*p), yes());
WTF =))) Điều gì đang xảy ra ở đây? Nhờ một khái niệm SFINAE, chúng ta được chứng kiến sự điên rồ của C++.
Hãy phân tích câu lệnh trên.
Phần template<typename T> static
không có gì khác với test
của no
. Kết
quả trả về là auto
để “nhường” phần quyết định cho decltype
. decltype
làm
chuyện như sau, nếu biểu thức declval<T>()<(*p), yes()
biên dịch được thì
auto
sẽ trở thành yes
nhờ vào “comma-separated list”: trả về kết quả của term
cuối cùng trong list. Vậy vấn đề nằm ở declval
. Hàm nào giúp ta gọi được
<
mà không cần thông qua constructor. Chính xác hơn declval<T>()
là
template function với tham số kiểu dữ liệu T
, trả về kiểu reference X nào đó,
nhờ đó ta gọi được <(*p)
: chính là phép <
thần thánh của chúng ta. Nếu như
không tồn tại <
, biểu thức này fail nhưng sẽ không bị báo lỗi biên dịch mà thay
vào đó trình biên dịch gán test
cho static no test(...)
.
Tổng kết lại ta có code như sau:
#include <iostream>
#include <type_traits>
using namespace std;
typedef std::true_type yes;
typedef std::false_type no;
template<typename T>
class has_less_operator {
private:
template<typename P> static auto test(P *p) -> decltype(std::declval<P>()<(*p), yes());
template<typename> static no test(...);
public:
static constexpr bool value = is_same<
yes,
decltype(test<T>(nullptr))
>::value;
};
template<typename T>
bool f(const T& lsh, const T& rhs) {
static_assert(has_less_operator<T>::value, "Please implement <");
return lsh<rhs;
}
struct s1 {
int i;
s1(int _i): i(_i) { }
inline bool operator<(const s1& other) const {
return i < other.i;
}
};
struct s2 {
int i;
s2(int _i): i(_i) { }
};
int main(int argc, char const* argv[])
{
s1 a(10);
s1 b(20);
f(a, b);
/* s2 c(10), d(20); */
/* f(c, d); */
return 0;
}
Nếu ta uncomment s2 và f bên dưới, ta có thông báo lỗi như sau:
a.cpp: In instantiation of ‘bool f(const T&, const T&) [with T = s2]’:
a.cpp:47:11: required from here
a.cpp:24:5: error: static assertion failed: Please implement <
static_assert(has_less_operator<T>::value, "Please implement <");
^~~~~~~~~~~~~
Thông báo cực kì dễ hiểu. Với việc sử dụng một số IDE thông minh, thậm chí thông
báo lỗi sẽ được thông báo ngay lúc cài đặt s2
và gọi f
. Quan trọng hơn,
nhờ đó ta có thể viết mã theo “Design by Contract” hay theo phong cách interface
của Java và C++. Sắp tới C++ sẽ giới thiệu khái niệm concept
và theo tác
giả của C++ là sẽ giải quyết vấn đề suốt 35 năm qua của Generic Programming
(link).
Các toán tử khác
Sau khi cài đặt toán tử <
, thông thường ta sẽ có nhu cầu cài đặt các toán
tử so sánh khác như >
, <=
, >=
. May mắn thay ta có thể quy đổi các toán tử
này dễ dàng thông qua <
:
>
: chính làrhs<lsh
.<=
: là!(rhs<lsh)
.>=
:!(lhs<rhs)
.
Trong thâm ta ta có cảm giác rằng thể nào cũng có rất nhiều CTDL ta cài đặt
kiểu này: chỉ cần định nghĩa <
và 3 toán tử còn lại tự nội suy. Nhờ vào kỹ
thuật Curiously recurring template pattern,
ta có một code khá gọn:
template<typename T>
struct order {
virtual bool operator<( const T& rhs) const = 0;
bool operator>( const T& rhs) const
{return rhs<static_cast<const T&>(*this);}
bool operator>=( const T& rhs) const
{return !((*this)<rhs); }
bool operator<=( const T& rhs) const
{return !(rhs< static_cast<const T&>(*this));}
};
Như vậy chỉ cần kế thừa order
và cài đặt <
, ta sẽ có luôn 3 toán tử còn lại.
Điều duy nhất mình ức chê là phải sử dụng static_cast
.